From 37df634128377f34e8bb2a9a090b3efe25abf48e Mon Sep 17 00:00:00 2001 From: automaton365-sys Date: Tue, 3 Mar 2026 19:48:31 +0000 Subject: [PATCH 1/3] feat(api): enrich GET /bounties/:id with creator, assignee, application count and tests --- .../api/src/__tests__/bounty_detail.test.ts | 138 ++++++++++++++++++ packages/api/src/routes/bounties.ts | 69 ++++++++- 2 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/__tests__/bounty_detail.test.ts diff --git a/packages/api/src/__tests__/bounty_detail.test.ts b/packages/api/src/__tests__/bounty_detail.test.ts new file mode 100644 index 0000000..262c12a --- /dev/null +++ b/packages/api/src/__tests__/bounty_detail.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { verify } from 'hono/jwt'; + +vi.mock('hono/jwt', () => ({ + verify: vi.fn(), +})); + +vi.mock('../db', () => ({ + db: { + query: { + bounties: { + findMany: vi.fn(), + findFirst: vi.fn(), + }, + }, + update: vi.fn(), + }, +})); + +import { createApp } from '../app'; +import { db } from '../db'; + +describe('GET /api/bounties/:id', () => { + const app = createApp(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(verify).mockResolvedValue({ sub: 'user-id' } as any); + }); + + it('returns bounty detail with creator, assignee and application_count', async () => { + vi.mocked((db.query as any).bounties.findFirst).mockResolvedValue({ + id: 'b-1', + githubIssueId: 123, + repoOwner: 'owner', + repoName: 'repo', + title: 'Fix endpoint', + description: 'Do work', + amountUsdc: '250.0000000', + techTags: ['ts', 'api'], + difficulty: 'intermediate', + status: 'assigned', + deadline: null, + creatorId: 'u-1', + assigneeId: 'u-2', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + creator: { + id: 'u-1', + username: 'creator_user', + avatarUrl: 'https://img/creator.png', + }, + assignee: { + id: 'u-2', + username: 'assignee_user', + avatarUrl: 'https://img/assignee.png', + }, + applications: [{ id: 'a1' }, { id: 'a2' }, { id: 'a3' }], + } as any); + + const res = await app.request('/api/bounties/b-1', { + method: 'GET', + headers: { + Authorization: 'Bearer test-token', + }, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.id).toBe('b-1'); + expect(body.creator).toEqual({ + id: 'u-1', + username: 'creator_user', + avatar: 'https://img/creator.png', + }); + expect(body.assignee).toEqual({ + id: 'u-2', + username: 'assignee_user', + avatar: 'https://img/assignee.png', + }); + expect(body.application_count).toBe(3); + expect(body.status).toBe('assigned'); + }); + + it('returns assignee as null when not assigned', async () => { + vi.mocked((db.query as any).bounties.findFirst).mockResolvedValue({ + id: 'b-2', + githubIssueId: 456, + repoOwner: 'owner', + repoName: 'repo', + title: 'Open bounty', + description: 'Still open', + amountUsdc: '100.0000000', + techTags: ['go'], + difficulty: 'beginner', + status: 'open', + deadline: null, + creatorId: 'u-1', + assigneeId: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + creator: { + id: 'u-1', + username: 'creator_user', + avatarUrl: 'https://img/creator.png', + }, + assignee: null, + applications: [], + } as any); + + const res = await app.request('/api/bounties/b-2', { + method: 'GET', + headers: { + Authorization: 'Bearer test-token', + }, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.assignee).toBeNull(); + expect(body.application_count).toBe(0); + }); + + it('returns 404 when bounty is not found', async () => { + vi.mocked((db.query as any).bounties.findFirst).mockResolvedValue(null); + + const res = await app.request('/api/bounties/does-not-exist', { + method: 'GET', + headers: { + Authorization: 'Bearer test-token', + }, + }); + + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Bounty not found' }); + }); +}); diff --git a/packages/api/src/routes/bounties.ts b/packages/api/src/routes/bounties.ts index 2f4f6e6..256c490 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 { applications, bounties, users } from '../db/schema'; import { eq, and, gte, lte, sql, desc, or, lt } from 'drizzle-orm'; const bountiesRouter = new Hono<{ Variables: Variables }>(); @@ -125,18 +125,83 @@ bountiesRouter.get('/', async (c) => { /** * GET /api/bounties/:id * Publicly accessible route to get bounty details + * + * Returns: + * - base bounty fields + * - creator info (username, avatar) + * - application_count + * - assignee info (username, avatar) when assigned */ bountiesRouter.get('/:id', async (c) => { const id = c.req.param('id'); + const bounty = await db.query.bounties.findFirst({ where: eq(bounties.id, id), + columns: { + id: true, + githubIssueId: true, + repoOwner: true, + repoName: true, + title: true, + description: true, + amountUsdc: true, + techTags: true, + difficulty: true, + status: true, + deadline: true, + creatorId: true, + assigneeId: true, + createdAt: true, + updatedAt: true, + }, + with: { + creator: { + columns: { + id: true, + username: true, + avatarUrl: true, + }, + }, + assignee: { + columns: { + id: true, + username: true, + avatarUrl: true, + }, + }, + applications: { + columns: { + id: true, + }, + }, + }, }); if (!bounty) { return c.json({ error: 'Bounty not found' }, 404); } - return c.json(bounty); + const { applications: bountyApplications, creator, assignee, ...base } = bounty; + + return c.json({ + ...base, + creator: creator + ? { + id: creator.id, + username: creator.username, + avatar: creator.avatarUrl, + } + : null, + assignee: assignee + ? { + id: assignee.id, + username: assignee.username, + avatar: assignee.avatarUrl, + } + : null, + application_count: bountyApplications.length, + status: base.status, + }); }); /** From 67f92846a0c4f7c932e88eac5f20edbefbef9515 Mon Sep 17 00:00:00 2001 From: automaton365-sys Date: Tue, 3 Mar 2026 20:47:36 +0000 Subject: [PATCH 2/3] feat(api): enrich bounty detail with creator/assignee and application count --- packages/api/src/db/schema.ts | 33 +++++++++++++++++ packages/api/src/routes/bounties.ts | 57 ++++------------------------- 2 files changed, 41 insertions(+), 49 deletions(-) diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index 3d81532..01b0fcf 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -1,5 +1,6 @@ import { pgTable, text, timestamp, varchar, bigint, jsonb, decimal, integer, uuid, pgEnum, index, uniqueIndex, check } from 'drizzle-orm/pg-core'; import { sql, desc } from 'drizzle-orm'; +import { relations } from 'drizzle-orm'; export const difficultyEnum = pgEnum('difficulty', ['beginner', 'intermediate', 'advanced']); @@ -185,3 +186,35 @@ export const transactions = pgTable('transactions', { stellarTxHashIdx: uniqueIndex('transactions_stellar_tx_hash_idx').on(table.stellarTxHash), }; }); + + +export const usersRelations = relations(users, ({ many }) => ({ + createdBounties: many(bounties, { relationName: 'bounty_creator' }), + assignedBounties: many(bounties, { relationName: 'bounty_assignee' }), + applications: many(applications), +})); + +export const bountiesRelations = relations(bounties, ({ one, many }) => ({ + creator: one(users, { + fields: [bounties.creatorId], + references: [users.id], + relationName: 'bounty_creator', + }), + assignee: one(users, { + fields: [bounties.assigneeId], + references: [users.id], + relationName: 'bounty_assignee', + }), + applications: many(applications), +})); + +export const applicationsRelations = relations(applications, ({ one }) => ({ + bounty: one(bounties, { + fields: [applications.bountyId], + references: [bounties.id], + }), + applicant: one(users, { + fields: [applications.applicantId], + references: [users.id], + }), +})); diff --git a/packages/api/src/routes/bounties.ts b/packages/api/src/routes/bounties.ts index 256c490..3a4efa4 100644 --- a/packages/api/src/routes/bounties.ts +++ b/packages/api/src/routes/bounties.ts @@ -2,8 +2,8 @@ import { Hono } from 'hono'; import { Variables } from '../middleware/auth'; import { ensureBountyCreator, ensureBountyAssignee } from '../middleware/resource-auth'; import { db } from '../db'; -import { applications, bounties, users } from '../db/schema'; -import { eq, and, gte, lte, sql, desc, or, lt } from 'drizzle-orm'; +import { bounties, applications } from '../db/schema'; +import { eq, and, gte, lte, sql, desc, or, lt, count } from 'drizzle-orm'; const bountiesRouter = new Hono<{ Variables: Variables }>(); @@ -125,39 +125,15 @@ bountiesRouter.get('/', async (c) => { /** * GET /api/bounties/:id * Publicly accessible route to get bounty details - * - * Returns: - * - base bounty fields - * - creator info (username, avatar) - * - application_count - * - assignee info (username, avatar) when assigned */ bountiesRouter.get('/:id', async (c) => { const id = c.req.param('id'); const bounty = await db.query.bounties.findFirst({ where: eq(bounties.id, id), - columns: { - id: true, - githubIssueId: true, - repoOwner: true, - repoName: true, - title: true, - description: true, - amountUsdc: true, - techTags: true, - difficulty: true, - status: true, - deadline: true, - creatorId: true, - assigneeId: true, - createdAt: true, - updatedAt: true, - }, with: { creator: { columns: { - id: true, username: true, avatarUrl: true, }, @@ -169,11 +145,6 @@ bountiesRouter.get('/:id', async (c) => { avatarUrl: true, }, }, - applications: { - columns: { - id: true, - }, - }, }, }); @@ -181,26 +152,14 @@ bountiesRouter.get('/:id', async (c) => { return c.json({ error: 'Bounty not found' }, 404); } - const { applications: bountyApplications, creator, assignee, ...base } = bounty; + const [{ count: applicationCount }] = await db + .select({ count: count() }) + .from(applications) + .where(eq(applications.bountyId, id)); return c.json({ - ...base, - creator: creator - ? { - id: creator.id, - username: creator.username, - avatar: creator.avatarUrl, - } - : null, - assignee: assignee - ? { - id: assignee.id, - username: assignee.username, - avatar: assignee.avatarUrl, - } - : null, - application_count: bountyApplications.length, - status: base.status, + ...bounty, + applicationCount, }); }); From 1bdf7c85e071c330bb1183ed71708c142b3c3c21 Mon Sep 17 00:00:00 2001 From: automaton365-sys Date: Tue, 3 Mar 2026 21:05:30 +0000 Subject: [PATCH 3/3] test(api): update bounty detail tests for count query response shape --- .../api/src/__tests__/bounty_detail.test.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/api/src/__tests__/bounty_detail.test.ts b/packages/api/src/__tests__/bounty_detail.test.ts index 262c12a..5fa2f24 100644 --- a/packages/api/src/__tests__/bounty_detail.test.ts +++ b/packages/api/src/__tests__/bounty_detail.test.ts @@ -13,6 +13,7 @@ vi.mock('../db', () => ({ findFirst: vi.fn(), }, }, + select: vi.fn(), update: vi.fn(), }, })); @@ -26,9 +27,15 @@ describe('GET /api/bounties/:id', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(verify).mockResolvedValue({ sub: 'user-id' } as any); + + vi.mocked((db as any).select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 0 }]), + }), + }); }); - it('returns bounty detail with creator, assignee and application_count', async () => { + it('returns bounty detail with creator, assignee and applicationCount', async () => { vi.mocked((db.query as any).bounties.findFirst).mockResolvedValue({ id: 'b-1', githubIssueId: 123, @@ -55,9 +62,14 @@ describe('GET /api/bounties/:id', () => { username: 'assignee_user', avatarUrl: 'https://img/assignee.png', }, - applications: [{ id: 'a1' }, { id: 'a2' }, { id: 'a3' }], } as any); + vi.mocked((db as any).select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 3 }]), + }), + }); + const res = await app.request('/api/bounties/b-1', { method: 'GET', headers: { @@ -72,14 +84,14 @@ describe('GET /api/bounties/:id', () => { expect(body.creator).toEqual({ id: 'u-1', username: 'creator_user', - avatar: 'https://img/creator.png', + avatarUrl: 'https://img/creator.png', }); expect(body.assignee).toEqual({ id: 'u-2', username: 'assignee_user', - avatar: 'https://img/assignee.png', + avatarUrl: 'https://img/assignee.png', }); - expect(body.application_count).toBe(3); + expect(body.applicationCount).toBe(3); expect(body.status).toBe('assigned'); }); @@ -106,7 +118,6 @@ describe('GET /api/bounties/:id', () => { avatarUrl: 'https://img/creator.png', }, assignee: null, - applications: [], } as any); const res = await app.request('/api/bounties/b-2', { @@ -119,7 +130,7 @@ describe('GET /api/bounties/:id', () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.assignee).toBeNull(); - expect(body.application_count).toBe(0); + expect(body.applicationCount).toBe(0); }); it('returns 404 when bounty is not found', async () => {