From cc03576de11be7634d2ab2193f5b58022342b784 Mon Sep 17 00:00:00 2001 From: Jujubee-LLM Date: Mon, 2 Mar 2026 11:29:05 +0800 Subject: [PATCH 1/2] feat(api): enrich GET /bounties/:id detail response --- .../api/src/__tests__/bounties_detail.test.ts | 150 ++++++++++++++++++ packages/api/src/routes/bounties.ts | 48 +++++- 2 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 packages/api/src/__tests__/bounties_detail.test.ts diff --git a/packages/api/src/__tests__/bounties_detail.test.ts b/packages/api/src/__tests__/bounties_detail.test.ts new file mode 100644 index 0000000..0bed439 --- /dev/null +++ b/packages/api/src/__tests__/bounties_detail.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeAll, vi, beforeEach } from 'vitest'; +import { createApp } from '../app'; +import { verify } from 'hono/jwt'; +import { db } from '../db'; + +vi.mock('hono/jwt', () => ({ + verify: vi.fn(), +})); + +vi.mock('../db', () => ({ + db: { + query: { + bounties: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + }, + select: vi.fn(), + }, +})); + +describe('GET /api/bounties/:id', () => { + let app: ReturnType; + + beforeAll(() => { + app = createApp(); + + vi.mocked(verify).mockResolvedValue({ + sub: 'test-user-id', + username: 'testuser', + exp: Math.floor(Date.now() / 1000) + 3600, + }); + + process.env.JWT_PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nfake\n-----END PUBLIC KEY-----'; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return 404 when bounty does not exist', async () => { + vi.mocked(db.query.bounties.findFirst).mockResolvedValue(null as never); + + const res = await app.request('/api/bounties/missing-id', { + headers: { + Authorization: 'Bearer valid.token', + }, + }); + + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Bounty not found' }); + }); + + it('should return full bounty detail including creator, application count, and assignee', async () => { + vi.mocked(db.query.bounties.findFirst).mockResolvedValue({ + id: 'bounty-1', + title: 'Test bounty', + description: 'Test description', + creatorId: 'creator-1', + assigneeId: 'assignee-1', + status: 'assigned', + } as never); + + vi.mocked(db.select) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 3 }]), + }), + } as never) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([ + { id: 'creator-1', username: 'alice', avatarUrl: 'https://img/creator.png' }, + ]), + }), + } as never) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([ + { id: 'assignee-1', username: 'bob', avatarUrl: 'https://img/assignee.png' }, + ]), + }), + } as never); + + const res = await app.request('/api/bounties/bounty-1', { + headers: { + Authorization: 'Bearer valid.token', + }, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.id).toBe('bounty-1'); + expect(body.status).toBe('assigned'); + expect(body.applicationCount).toBe(3); + expect(body.creator).toEqual({ + id: 'creator-1', + username: 'alice', + avatar: 'https://img/creator.png', + }); + expect(body.assignee).toEqual({ + id: 'assignee-1', + username: 'bob', + avatar: 'https://img/assignee.png', + }); + }); + + it('should return assignee as null when bounty is unassigned', async () => { + vi.mocked(db.query.bounties.findFirst).mockResolvedValue({ + id: 'bounty-2', + title: 'Unassigned bounty', + description: 'Test description', + creatorId: 'creator-2', + assigneeId: null, + status: 'open', + } as never); + + vi.mocked(db.select) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 0 }]), + }), + } as never) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([ + { id: 'creator-2', username: 'charlie', avatarUrl: null }, + ]), + }), + } as never); + + const res = await app.request('/api/bounties/bounty-2', { + headers: { + Authorization: 'Bearer valid.token', + }, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.applicationCount).toBe(0); + expect(body.creator).toEqual({ + id: 'creator-2', + username: 'charlie', + avatar: null, + }); + expect(body.assignee).toBeNull(); + }); +}); diff --git a/packages/api/src/routes/bounties.ts b/packages/api/src/routes/bounties.ts index 2f4f6e6..27190c2 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, applications, users } from '../db/schema'; import { eq, and, gte, lte, sql, desc, or, lt } from 'drizzle-orm'; const bountiesRouter = new Hono<{ Variables: Variables }>(); @@ -85,7 +85,7 @@ bountiesRouter.get('/', async (c) => { ) ) ); - } catch (e) { + } catch { return c.json({ error: 'Invalid cursor' }, 400); } } @@ -136,7 +136,49 @@ bountiesRouter.get('/:id', async (c) => { return c.json({ error: 'Bounty not found' }, 404); } - return c.json(bounty); + const [applicationCountResult] = await db + .select({ count: sql`cast(count(*) as int)` }) + .from(applications) + .where(eq(applications.bountyId, id)); + + const [creator] = await db + .select({ + id: users.id, + username: users.username, + avatarUrl: users.avatarUrl, + }) + .from(users) + .where(eq(users.id, bounty.creatorId)); + + const [assignee] = bounty.assigneeId + ? await db + .select({ + id: users.id, + username: users.username, + avatarUrl: users.avatarUrl, + }) + .from(users) + .where(eq(users.id, bounty.assigneeId)) + : [null]; + + return c.json({ + ...bounty, + creator: creator + ? { + id: creator.id, + username: creator.username, + avatar: creator.avatarUrl, + } + : null, + applicationCount: applicationCountResult?.count ?? 0, + assignee: assignee + ? { + id: assignee.id, + username: assignee.username, + avatar: assignee.avatarUrl, + } + : null, + }); }); /** From 885d4e81f732ae6c7d6b212beda6a520993975f2 Mon Sep 17 00:00:00 2001 From: Jujubee-LLM Date: Mon, 2 Mar 2026 23:01:22 +0800 Subject: [PATCH 2/2] perf(api): optimize bounty detail lookups and harden edge cases --- .../api/src/__tests__/bounties_detail.test.ts | 111 ++++++++++++------ packages/api/src/routes/bounties.ts | 61 +++++----- 2 files changed, 106 insertions(+), 66 deletions(-) diff --git a/packages/api/src/__tests__/bounties_detail.test.ts b/packages/api/src/__tests__/bounties_detail.test.ts index 0bed439..00f6e58 100644 --- a/packages/api/src/__tests__/bounties_detail.test.ts +++ b/packages/api/src/__tests__/bounties_detail.test.ts @@ -19,6 +19,12 @@ vi.mock('../db', () => ({ }, })); +const createSelectResultMock = (rows: T[]) => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(rows), + }), +}); + describe('GET /api/bounties/:id', () => { let app: ReturnType; @@ -62,25 +68,11 @@ describe('GET /api/bounties/:id', () => { } as never); vi.mocked(db.select) - .mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ count: 3 }]), - }), - } as never) - .mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([ - { id: 'creator-1', username: 'alice', avatarUrl: 'https://img/creator.png' }, - ]), - }), - } as never) - .mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([ - { id: 'assignee-1', username: 'bob', avatarUrl: 'https://img/assignee.png' }, - ]), - }), - } as never); + .mockReturnValueOnce(createSelectResultMock([{ count: 3 }]) as never) + .mockReturnValueOnce(createSelectResultMock([ + { id: 'creator-1', username: 'alice', avatarUrl: 'https://img/creator.png' }, + { id: 'assignee-1', username: 'bob', avatarUrl: 'https://img/assignee.png' }, + ]) as never); const res = await app.request('/api/bounties/bounty-1', { headers: { @@ -97,13 +89,14 @@ describe('GET /api/bounties/:id', () => { expect(body.creator).toEqual({ id: 'creator-1', username: 'alice', - avatar: 'https://img/creator.png', + avatarUrl: 'https://img/creator.png', }); expect(body.assignee).toEqual({ id: 'assignee-1', username: 'bob', - avatar: 'https://img/assignee.png', + avatarUrl: 'https://img/assignee.png', }); + expect(db.select).toHaveBeenCalledTimes(2); }); it('should return assignee as null when bounty is unassigned', async () => { @@ -117,18 +110,10 @@ describe('GET /api/bounties/:id', () => { } as never); vi.mocked(db.select) - .mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ count: 0 }]), - }), - } as never) - .mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([ - { id: 'creator-2', username: 'charlie', avatarUrl: null }, - ]), - }), - } as never); + .mockReturnValueOnce(createSelectResultMock([{ count: 0 }]) as never) + .mockReturnValueOnce(createSelectResultMock([ + { id: 'creator-2', username: 'charlie', avatarUrl: null }, + ]) as never); const res = await app.request('/api/bounties/bounty-2', { headers: { @@ -143,7 +128,65 @@ describe('GET /api/bounties/:id', () => { expect(body.creator).toEqual({ id: 'creator-2', username: 'charlie', - avatar: null, + avatarUrl: null, + }); + expect(body.assignee).toBeNull(); + expect(db.select).toHaveBeenCalledTimes(2); + }); + + it('should return 500 when the creator record is missing', async () => { + vi.mocked(db.query.bounties.findFirst).mockResolvedValue({ + id: 'bounty-3', + title: 'Broken bounty', + description: 'Test description', + creatorId: 'creator-3', + assigneeId: null, + status: 'open', + } as never); + + vi.mocked(db.select) + .mockReturnValueOnce(createSelectResultMock([{ count: 1 }]) as never) + .mockReturnValueOnce(createSelectResultMock([]) as never); + + const res = await app.request('/api/bounties/bounty-3', { + headers: { + Authorization: 'Bearer valid.token', + }, + }); + + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'Bounty creator not found' }); + }); + + it('should return assignee as null when the assignee record is missing', async () => { + vi.mocked(db.query.bounties.findFirst).mockResolvedValue({ + id: 'bounty-4', + title: 'Missing assignee', + description: 'Test description', + creatorId: 'creator-4', + assigneeId: 'assignee-4', + status: 'assigned', + } as never); + + vi.mocked(db.select) + .mockReturnValueOnce(createSelectResultMock([{ count: 2 }]) as never) + .mockReturnValueOnce(createSelectResultMock([ + { id: 'creator-4', username: 'dana', avatarUrl: 'https://img/creator-4.png' }, + ]) as never); + + const res = await app.request('/api/bounties/bounty-4', { + headers: { + Authorization: 'Bearer valid.token', + }, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.creator).toEqual({ + id: 'creator-4', + username: 'dana', + avatarUrl: 'https://img/creator-4.png', }); expect(body.assignee).toBeNull(); }); diff --git a/packages/api/src/routes/bounties.ts b/packages/api/src/routes/bounties.ts index 27190c2..1e8eb5b 100644 --- a/packages/api/src/routes/bounties.ts +++ b/packages/api/src/routes/bounties.ts @@ -3,10 +3,16 @@ import { Variables } from '../middleware/auth'; import { ensureBountyCreator, ensureBountyAssignee } from '../middleware/resource-auth'; import { db } from '../db'; import { bounties, applications, users } from '../db/schema'; -import { eq, and, gte, lte, sql, desc, or, lt } from 'drizzle-orm'; +import { eq, and, gte, lte, sql, desc, or, lt, inArray } from 'drizzle-orm'; const bountiesRouter = new Hono<{ Variables: Variables }>(); +const toPublicUser = (user: { id: string; username: string | null; avatarUrl: string | null }) => ({ + id: user.id, + username: user.username, + avatarUrl: user.avatarUrl, +}); + /** * GET /api/bounties * Paginated listing of bounties with filters. @@ -136,48 +142,39 @@ bountiesRouter.get('/:id', async (c) => { return c.json({ error: 'Bounty not found' }, 404); } - const [applicationCountResult] = await db - .select({ count: sql`cast(count(*) as int)` }) - .from(applications) - .where(eq(applications.bountyId, id)); - - const [creator] = await db - .select({ - id: users.id, - username: users.username, - avatarUrl: users.avatarUrl, - }) - .from(users) - .where(eq(users.id, bounty.creatorId)); + const userIds = bounty.assigneeId && bounty.assigneeId !== bounty.creatorId + ? [bounty.creatorId, bounty.assigneeId] + : [bounty.creatorId]; - const [assignee] = bounty.assigneeId - ? await db + const [[applicationCountResult], relatedUsers] = await Promise.all([ + db + .select({ count: sql`cast(count(*) as int)` }) + .from(applications) + .where(eq(applications.bountyId, id)), + db .select({ id: users.id, username: users.username, avatarUrl: users.avatarUrl, }) .from(users) - .where(eq(users.id, bounty.assigneeId)) - : [null]; + .where(inArray(users.id, userIds)), + ]); + + const usersById = new Map(relatedUsers.map((user) => [user.id, user])); + const creator = usersById.get(bounty.creatorId); + + if (!creator) { + return c.json({ error: 'Bounty creator not found' }, 500); + } + + const assignee = bounty.assigneeId ? usersById.get(bounty.assigneeId) ?? null : null; return c.json({ ...bounty, - creator: creator - ? { - id: creator.id, - username: creator.username, - avatar: creator.avatarUrl, - } - : null, + creator: toPublicUser(creator), applicationCount: applicationCountResult?.count ?? 0, - assignee: assignee - ? { - id: assignee.id, - username: assignee.username, - avatar: assignee.avatarUrl, - } - : null, + assignee: assignee ? toPublicUser(assignee) : null, }); });