From c1ba4526704089886c3844a0c3f2f2a4d5204bbe Mon Sep 17 00:00:00 2001 From: autonomy Date: Mon, 23 Feb 2026 02:07:02 +0000 Subject: [PATCH 1/2] feat: implement paginated bounties listing endpoint --- packages/api/src/__tests__/bounties.test.ts | 98 ++++++++++ packages/api/src/app.ts | 2 + packages/api/src/routes/bounties.ts | 192 ++++++++++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 packages/api/src/__tests__/bounties.test.ts create mode 100644 packages/api/src/routes/bounties.ts diff --git a/packages/api/src/__tests__/bounties.test.ts b/packages/api/src/__tests__/bounties.test.ts new file mode 100644 index 0000000..815e871 --- /dev/null +++ b/packages/api/src/__tests__/bounties.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { findManyMock } = vi.hoisted(() => ({ + findManyMock: vi.fn(), +})); + +vi.mock('../db', () => ({ + db: { + query: { + bounties: { + findMany: findManyMock, + }, + }, + }, +})); + +import { createApp } from '../app'; + +const baseBountyRow = { + id: 'bounty-1', + githubIssueId: 13, + repoOwner: 'ubounty-app', + repoName: 'ubounty-demo', + title: 'Video e2e demo', + description: 'Create a 20s demo.', + amountUsdc: '10.0', + techTags: ['typescript'], + difficulty: 'beginner', + status: 'open', + deadline: null, + creatorId: 'creator-1', + assigneeId: null, + createdAt: new Date('2026-02-23T00:00:00.000Z'), + updatedAt: new Date('2026-02-23T00:00:00.000Z'), +}; + +describe('GET /bounties', () => { + const app = createApp(); + + beforeEach(() => { + findManyMock.mockReset(); + }); + + it('returns paginated bounties with meta envelope', async () => { + findManyMock.mockResolvedValue([ + baseBountyRow, + { + ...baseBountyRow, + id: 'bounty-2', + title: 'Second bounty', + createdAt: new Date('2026-02-22T00:00:00.000Z'), + }, + ]); + + const res = await app.request( + '/bounties?limit=1&tech_stack=typescript,node&amount_min=5&amount_max=20&difficulty=beginner&status=open', + ); + + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.data).toHaveLength(1); + expect(body.meta.has_more).toBe(true); + expect(typeof body.meta.next_cursor).toBe('string'); + expect(findManyMock).toHaveBeenCalledTimes(1); + expect(findManyMock.mock.calls[0][0].limit).toBe(2); + }); + + it('returns 400 for invalid cursor', async () => { + const res = await app.request('/bounties?cursor=not-a-valid-cursor'); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('cursor is invalid'); + expect(findManyMock).not.toHaveBeenCalled(); + }); + + it('returns 400 when amount range is invalid', async () => { + const res = await app.request('/bounties?amount_min=30&amount_max=10'); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('amount_min cannot be greater than amount_max'); + expect(findManyMock).not.toHaveBeenCalled(); + }); + + it('returns has_more=false and next_cursor=null when page is complete', async () => { + findManyMock.mockResolvedValue([baseBountyRow]); + + const res = await app.request('/bounties?limit=10'); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toHaveLength(1); + expect(body.meta.has_more).toBe(false); + expect(body.meta.next_cursor).toBeNull(); + }); +}); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index c3b84d9..a3aac4c 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import auth from './routes/auth'; +import bounties from './routes/bounties'; /** * Creates and configures the Hono application with all routes and middleware. @@ -33,6 +34,7 @@ export function createApp() { // API Routes app.route('/auth', auth); + app.route('/bounties', bounties); app.get('/health', (c) => { return c.json({ status: 'ok' }); diff --git a/packages/api/src/routes/bounties.ts b/packages/api/src/routes/bounties.ts new file mode 100644 index 0000000..eece359 --- /dev/null +++ b/packages/api/src/routes/bounties.ts @@ -0,0 +1,192 @@ +import { Hono } from 'hono'; +import { and, eq, gte, lte, lt, or, sql, type SQL } from 'drizzle-orm'; +import { db } from '../db'; +import { difficultyEnum, statusEnum } from '../db/schema'; + +type Difficulty = (typeof difficultyEnum.enumValues)[number]; +type BountyStatus = (typeof statusEnum.enumValues)[number]; + +type CursorPayload = { + created_at: string; + id: string; +}; + +const DEFAULT_LIMIT = 10; +const MAX_LIMIT = 50; +const difficultyValues = new Set(difficultyEnum.enumValues); +const statusValues = new Set(statusEnum.enumValues); + +function parseLimit(raw: string | undefined): number | null { + if (raw === undefined) { + return DEFAULT_LIMIT; + } + + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_LIMIT) { + return null; + } + + return parsed; +} + +function parseAmount(raw: string | undefined): number | null { + if (raw === undefined) { + return null; + } + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + + return parsed; +} + +function parseCursor(raw: string | undefined): { createdAt: Date; id: string } | null { + if (!raw) { + return null; + } + + try { + const decoded = Buffer.from(raw, 'base64url').toString('utf8'); + const parsed = JSON.parse(decoded) as Partial; + if (typeof parsed.created_at !== 'string' || typeof parsed.id !== 'string') { + return null; + } + + const createdAt = new Date(parsed.created_at); + if (Number.isNaN(createdAt.getTime())) { + return null; + } + + return { createdAt, id: parsed.id }; + } catch { + return null; + } +} + +function encodeCursor(createdAt: Date, id: string): string { + return Buffer.from( + JSON.stringify({ + created_at: createdAt.toISOString(), + id, + }), + 'utf8', + ).toString('base64url'); +} + +const bountiesRoute = new Hono(); + +bountiesRoute.get('/', async (c) => { + const limit = parseLimit(c.req.query('limit')); + if (limit === null) { + return c.json({ error: `limit must be an integer between 1 and ${MAX_LIMIT}` }, 400); + } + + const difficultyRaw = c.req.query('difficulty'); + let difficulty: Difficulty | undefined; + if (difficultyRaw !== undefined) { + if (!difficultyValues.has(difficultyRaw as Difficulty)) { + return c.json({ error: `difficulty must be one of: ${difficultyEnum.enumValues.join(', ')}` }, 400); + } + difficulty = difficultyRaw as Difficulty; + } + + const statusRaw = c.req.query('status'); + let status: BountyStatus | undefined; + if (statusRaw !== undefined) { + if (!statusValues.has(statusRaw as BountyStatus)) { + return c.json({ error: `status must be one of: ${statusEnum.enumValues.join(', ')}` }, 400); + } + status = statusRaw as BountyStatus; + } + + const amountMinRaw = c.req.query('amount_min') ?? c.req.query('min_amount'); + const amountMaxRaw = c.req.query('amount_max') ?? c.req.query('max_amount'); + const amountMin = parseAmount(amountMinRaw); + const amountMax = parseAmount(amountMaxRaw); + + if (amountMinRaw !== undefined && amountMin === null) { + return c.json({ error: 'amount_min must be a non-negative number' }, 400); + } + if (amountMaxRaw !== undefined && amountMax === null) { + return c.json({ error: 'amount_max must be a non-negative number' }, 400); + } + if (amountMin !== null && amountMax !== null && amountMin > amountMax) { + return c.json({ error: 'amount_min cannot be greater than amount_max' }, 400); + } + + const techStack = (c.req.query('tech_stack') ?? '') + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); + + const cursorRaw = c.req.query('cursor'); + const cursor = parseCursor(cursorRaw); + if (cursorRaw !== undefined && cursor === null) { + return c.json({ error: 'cursor is invalid' }, 400); + } + + try { + const rows = await db.query.bounties.findMany({ + where: (table) => { + const conditions: SQL[] = []; + + if (difficulty !== undefined) { + conditions.push(eq(table.difficulty, difficulty)); + } + if (status !== undefined) { + conditions.push(eq(table.status, status)); + } + if (amountMin !== null) { + conditions.push(gte(table.amountUsdc, amountMin.toString())); + } + if (amountMax !== null) { + conditions.push(lte(table.amountUsdc, amountMax.toString())); + } + if (techStack.length > 0) { + conditions.push(sql`${table.techTags} @> ${JSON.stringify(techStack)}::jsonb`); + } + if (cursor !== null) { + const cursorCondition = or( + lt(table.createdAt, cursor.createdAt), + and(eq(table.createdAt, cursor.createdAt), lt(table.id, cursor.id)), + ); + if (cursorCondition) { + conditions.push(cursorCondition); + } + } + + if (conditions.length === 0) { + return undefined; + } + return and(...conditions); + }, + orderBy: (table, { desc }) => [desc(table.createdAt), desc(table.id)], + limit: limit + 1, + }); + + const hasMore = rows.length > limit; + const data = hasMore ? rows.slice(0, limit) : rows; + const lastItem = data[data.length - 1]; + const lastCreatedAt = + lastItem?.createdAt instanceof Date ? lastItem.createdAt : new Date(lastItem?.createdAt ?? ''); + const nextCursor = + hasMore && lastItem && !Number.isNaN(lastCreatedAt.getTime()) + ? encodeCursor(lastCreatedAt, lastItem.id) + : null; + + return c.json({ + data, + meta: { + next_cursor: nextCursor, + has_more: hasMore, + }, + }); + } catch (error) { + console.error('GET /bounties failed:', error); + return c.json({ error: 'Failed to fetch bounties' }, 500); + } +}); + +export default bountiesRoute; From f8a7e68577794b6a403965a4909415d8c5131cba Mon Sep 17 00:00:00 2001 From: autonomy Date: Mon, 23 Feb 2026 02:32:11 +0000 Subject: [PATCH 2/2] test: prove numeric amount filter predicates --- packages/api/src/__tests__/bounties.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/api/src/__tests__/bounties.test.ts b/packages/api/src/__tests__/bounties.test.ts index 815e871..324618f 100644 --- a/packages/api/src/__tests__/bounties.test.ts +++ b/packages/api/src/__tests__/bounties.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PgDialect } from 'drizzle-orm/pg-core'; const { findManyMock } = vi.hoisted(() => ({ findManyMock: vi.fn(), @@ -15,6 +16,7 @@ vi.mock('../db', () => ({ })); import { createApp } from '../app'; +import { bounties } from '../db/schema'; const baseBountyRow = { id: 'bounty-1', @@ -84,6 +86,24 @@ describe('GET /bounties', () => { expect(findManyMock).not.toHaveBeenCalled(); }); + it('builds numeric SQL predicates for amount filters', async () => { + findManyMock.mockResolvedValue([]); + + const res = await app.request('/bounties?amount_min=20&amount_max=100'); + + expect(res.status).toBe(200); + expect(findManyMock).toHaveBeenCalledTimes(1); + + const where = findManyMock.mock.calls[0][0].where as (table: typeof bounties) => unknown; + const whereSql = where(bounties); + const query = new PgDialect().sqlToQuery(whereSql as Parameters[0]); + + expect(query.sql).toContain('"bounties"."amount_usdc" >= '); + expect(query.sql).toContain('"bounties"."amount_usdc" <= '); + expect(query.sql.toLowerCase()).not.toContain('::text'); + expect(query.params).toEqual(['20', '100']); + }); + it('returns has_more=false and next_cursor=null when page is complete', async () => { findManyMock.mockResolvedValue([baseBountyRow]);