From 9987fb7dcb07336ec278aaabf18534d88d365a71 Mon Sep 17 00:00:00 2001 From: gavin-openops Date: Sun, 1 Mar 2026 20:53:49 +0000 Subject: [PATCH 1/2] feat(api): enrich bounty detail endpoint response --- .../api/src/__tests__/bounties_list.test.ts | 61 +++++++++++++++++++ packages/api/src/routes/bounties.ts | 42 ++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/packages/api/src/__tests__/bounties_list.test.ts b/packages/api/src/__tests__/bounties_list.test.ts index 7030900..2e17c50 100644 --- a/packages/api/src/__tests__/bounties_list.test.ts +++ b/packages/api/src/__tests__/bounties_list.test.ts @@ -14,8 +14,13 @@ vi.mock('../db', () => ({ query: { bounties: { findMany: vi.fn(), + findFirst: vi.fn(), + }, + users: { + findFirst: vi.fn(), }, }, + select: vi.fn(), }, })); @@ -161,4 +166,60 @@ describe('GET /api/bounties', () => { const body = await res.json(); expect(body.error).toBe('Invalid cursor'); }); + + it('should return detailed bounty information for GET /api/bounties/:id', async () => { + const bountyId = 'bounty-1'; + vi.mocked(db.query.bounties.findFirst).mockResolvedValue({ + id: bountyId, + title: 'Bounty 1', + status: 'open', + creatorId: 'creator-1', + assigneeId: 'assignee-1', + } as any); + + vi.mocked(db.query.users.findFirst) + .mockResolvedValueOnce({ username: 'creatorUser', avatarUrl: 'https://avatar/creator.png' } as any) + .mockResolvedValueOnce({ username: 'assigneeUser', avatarUrl: 'https://avatar/assignee.png' } as any); + + vi.mocked(db.select as any).mockReturnValue({ + from: () => ({ + where: async () => [{ count: 3 }], + }), + } as any); + + const res = await app.request(`/api/bounties/${bountyId}`, { + headers: { + 'Authorization': 'Bearer valid.token' + } + }); + + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.id).toBe(bountyId); + expect(body.creator).toEqual({ + username: 'creatorUser', + avatar: 'https://avatar/creator.png', + }); + expect(body.assignee).toEqual({ + username: 'assigneeUser', + avatar: 'https://avatar/assignee.png', + }); + expect(body.applicationCount).toBe(3); + }); + + it('should return 404 when bounty is not found for GET /api/bounties/:id', async () => { + vi.mocked(db.query.bounties.findFirst).mockResolvedValue(null as any); + + const res = await app.request('/api/bounties/non-existent', { + headers: { + 'Authorization': 'Bearer valid.token' + } + }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe('Bounty not found'); + }); + }); diff --git a/packages/api/src/routes/bounties.ts b/packages/api/src/routes/bounties.ts index 2f4f6e6..da6809c 100644 --- a/packages/api/src/routes/bounties.ts +++ b/packages/api/src/routes/bounties.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono'; import { Variables } from '../middleware/auth'; import { ensureBountyCreator, ensureBountyAssignee } from '../middleware/resource-auth'; import { db } from '../db'; -import { bounties } from '../db/schema'; +import { bounties, users, applications } from '../db/schema'; import { eq, and, gte, lte, sql, desc, or, lt } from 'drizzle-orm'; const bountiesRouter = new Hono<{ Variables: Variables }>(); @@ -136,7 +136,45 @@ bountiesRouter.get('/:id', async (c) => { return c.json({ error: 'Bounty not found' }, 404); } - return c.json(bounty); + const [creator, assignee, applicationCount] = await Promise.all([ + db.query.users.findFirst({ + where: eq(users.id, bounty.creatorId), + columns: { + username: true, + avatarUrl: true, + }, + }), + bounty.assigneeId + ? db.query.users.findFirst({ + where: eq(users.id, bounty.assigneeId), + columns: { + username: true, + avatarUrl: true, + }, + }) + : Promise.resolve(null), + db + .select({ count: sql`count(*)` }) + .from(applications) + .where(eq(applications.bountyId, id)), + ]); + + return c.json({ + ...bounty, + creator: creator + ? { + username: creator.username, + avatar: creator.avatarUrl, + } + : null, + assignee: assignee + ? { + username: assignee.username, + avatar: assignee.avatarUrl, + } + : null, + applicationCount: Number(applicationCount[0]?.count ?? 0), + }); }); /** From 1ac589d7454eb96770b92222a0ffb84824177fbd Mon Sep 17 00:00:00 2001 From: gavin-openops Date: Sun, 1 Mar 2026 21:24:09 +0000 Subject: [PATCH 2/2] test(api): cover unassigned assignee case for bounty detail endpoint --- .../api/src/__tests__/bounties_list.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/api/src/__tests__/bounties_list.test.ts b/packages/api/src/__tests__/bounties_list.test.ts index 2e17c50..cac4126 100644 --- a/packages/api/src/__tests__/bounties_list.test.ts +++ b/packages/api/src/__tests__/bounties_list.test.ts @@ -208,6 +208,45 @@ describe('GET /api/bounties', () => { expect(body.applicationCount).toBe(3); }); + + it('should return null assignee when bounty has no assignee for GET /api/bounties/:id', async () => { + const bountyId = 'bounty-no-assignee'; + + vi.mocked(db.query.bounties.findFirst).mockResolvedValue({ + id: bountyId, + title: 'Bounty 2', + status: 'open', + creatorId: 'creator-1', + assigneeId: null, + } as any); + + vi.mocked(db.query.users.findFirst) + .mockResolvedValueOnce({ username: 'creatorUser', avatarUrl: 'https://avatar/creator.png' } as any); + + vi.mocked(db.select as any).mockReturnValue({ + from: () => ({ + where: async () => [{ count: 1 }], + }), + } as any); + + const res = await app.request(`/api/bounties/${bountyId}`, { + headers: { + 'Authorization': 'Bearer valid.token' + } + }); + + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.id).toBe(bountyId); + expect(body.creator).toEqual({ + username: 'creatorUser', + avatar: 'https://avatar/creator.png', + }); + expect(body.assignee).toBeNull(); + expect(body.applicationCount).toBe(1); + }); + it('should return 404 when bounty is not found for GET /api/bounties/:id', async () => { vi.mocked(db.query.bounties.findFirst).mockResolvedValue(null as any);