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..5fa2f24 --- /dev/null +++ b/packages/api/src/__tests__/bounty_detail.test.ts @@ -0,0 +1,149 @@ +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(), + }, + }, + select: 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); + + 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 applicationCount', 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', + }, + } 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: { + 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', + avatarUrl: 'https://img/creator.png', + }); + expect(body.assignee).toEqual({ + id: 'u-2', + username: 'assignee_user', + avatarUrl: 'https://img/assignee.png', + }); + expect(body.applicationCount).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, + } 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.applicationCount).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/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 2f4f6e6..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 { bounties } 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 }>(); @@ -128,15 +128,39 @@ bountiesRouter.get('/', async (c) => { */ bountiesRouter.get('/:id', async (c) => { const id = c.req.param('id'); + const bounty = await db.query.bounties.findFirst({ where: eq(bounties.id, id), + with: { + creator: { + columns: { + username: true, + avatarUrl: true, + }, + }, + assignee: { + columns: { + id: true, + username: true, + avatarUrl: true, + }, + }, + }, }); if (!bounty) { return c.json({ error: 'Bounty not found' }, 404); } - return c.json(bounty); + const [{ count: applicationCount }] = await db + .select({ count: count() }) + .from(applications) + .where(eq(applications.bountyId, id)); + + return c.json({ + ...bounty, + applicationCount, + }); }); /**