diff --git a/packages/api/src/__tests__/app.test.ts b/packages/api/src/__tests__/app.test.ts index b4cf75f..3c66faf 100644 --- a/packages/api/src/__tests__/app.test.ts +++ b/packages/api/src/__tests__/app.test.ts @@ -1,11 +1,15 @@ import { describe, it, expect, beforeAll } from 'vitest'; -import { createApp } from '../app'; +import { createApp, type DbLike } from '../app'; describe('API App', () => { let app: ReturnType; + const mockDb: DbLike = { + execute: async () => ({ rows: [] }), + }; + beforeAll(() => { - app = createApp(); + app = createApp({ db: mockDb }); }); // ── Health Endpoint ────────────────────────────────────────────── @@ -75,4 +79,54 @@ describe('API App', () => { expect(res.status).toBe(400); }); }); + + // ── Bounties Endpoint ─────────────────────────────────────────── + + describe('GET /bounties/:id', () => { + it('should return 404 when bounty not found', async () => { + const res = await app.request('/bounties/00000000-0000-0000-0000-000000000000'); + expect(res.status).toBe(404); + }); + + it('should return bounty details when found', async () => { + const foundDb: DbLike = { + execute: async () => ({ + rows: [ + { + id: '11111111-1111-1111-1111-111111111111', + github_issue_id: 123, + repo_owner: 'acme', + repo_name: 'repo', + title: 'Test bounty', + description: 'Desc', + amount_usdc: '100', + tech_tags: ['ts'], + difficulty: 'beginner', + status: 'open', + deadline: null, + creator_id: '22222222-2222-2222-2222-222222222222', + assignee_id: '33333333-3333-3333-3333-333333333333', + created_at: '2026-02-19T00:00:00.000Z', + updated_at: '2026-02-19T00:00:00.000Z', + creator_username: 'creator', + creator_avatar_url: 'https://example.com/c.png', + assignee_username: 'assignee', + assignee_avatar_url: 'https://example.com/a.png', + application_count: 7, + }, + ], + }), + }; + + const app2 = createApp({ db: foundDb }); + const res = await app2.request('/bounties/11111111-1111-1111-1111-111111111111'); + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.id).toBe('11111111-1111-1111-1111-111111111111'); + expect(body.creator.username).toBe('creator'); + expect(body.applicationCount).toBe(7); + expect(body.assignee.username).toBe('assignee'); + }); + }); }); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 13365cf..f64479c 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -1,13 +1,22 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; +import { sql } from 'drizzle-orm'; + +export type DbLike = { + execute: (query: any) => Promise<{ rows: any[] }>; +}; + +export type CreateAppDeps = { + db: DbLike; +}; /** * Creates and configures the Hono application with all routes and middleware. * Extracted from index.ts to enable testing without triggering server startup * or environment variable validation side effects. */ -export function createApp() { +export function createApp(deps?: Partial) { const app = new Hono(); // Global middleware @@ -57,5 +66,90 @@ export function createApp() { }); }); + // --- Bounties --- + + app.get('/bounties/:id', async (c) => { + const bountyId = c.req.param('id'); + + if (!bountyId || typeof bountyId !== 'string') { + return c.json({ error: 'Invalid bounty id' }, 400); + } + + const db = deps?.db; + if (!db) { + // Keep app.ts test-friendly: the real db must be injected by the server entrypoint. + throw new Error('Database dependency not provided'); + } + + const q = sql` + SELECT + b.id, + b.github_issue_id, + b.repo_owner, + b.repo_name, + b.title, + b.description, + b.amount_usdc, + b.tech_tags, + b.difficulty, + b.status, + b.deadline, + b.creator_id, + b.assignee_id, + b.created_at, + b.updated_at, + creator.username AS creator_username, + creator.avatar_url AS creator_avatar_url, + assignee.username AS assignee_username, + assignee.avatar_url AS assignee_avatar_url, + ( + SELECT COUNT(*)::int + FROM applications a + WHERE a.bounty_id = b.id + ) AS application_count + FROM bounties b + JOIN users creator ON creator.id = b.creator_id + LEFT JOIN users assignee ON assignee.id = b.assignee_id + WHERE b.id = ${bountyId} + LIMIT 1; + `; + + const result = await db.execute(q); + const row = result.rows?.[0]; + + if (!row) { + return c.json({ error: 'Bounty not found' }, 404); + } + + return c.json({ + id: row.id, + githubIssueId: row.github_issue_id, + repoOwner: row.repo_owner, + repoName: row.repo_name, + title: row.title, + description: row.description, + amountUsdc: row.amount_usdc, + techTags: row.tech_tags, + difficulty: row.difficulty, + status: row.status, + deadline: row.deadline, + createdAt: row.created_at, + updatedAt: row.updated_at, + creator: { + id: row.creator_id, + username: row.creator_username, + avatarUrl: row.creator_avatar_url, + }, + applicationCount: row.application_count ?? 0, + assignee: row.assignee_id + ? { + id: row.assignee_id, + username: row.assignee_username, + avatarUrl: row.assignee_avatar_url, + } + : null, + }); + }); + return app; } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 6ec17c1..3fbd97c 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,6 +1,7 @@ import { serve } from '@hono/node-server'; import dotenv from 'dotenv'; import { createApp } from './app'; +import { db } from './db'; // Load environment variables dotenv.config(); @@ -55,7 +56,7 @@ if (missingOptional.length > 0) { ); } -const app = createApp(); +const app = createApp({ db }); const port = Number(process.env.PORT) || 3001; console.log(`Server is running on http://localhost:${port}`);