Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions packages/api/src/__tests__/bounties.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PgDialect } from 'drizzle-orm/pg-core';

const { findManyMock } = vi.hoisted(() => ({
findManyMock: vi.fn(),
}));

vi.mock('../db', () => ({
db: {
query: {
bounties: {
findMany: findManyMock,
},
},
},
}));

import { createApp } from '../app';
import { bounties } from '../db/schema';

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('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<PgDialect['sqlToQuery']>[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]);

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();
});
});
2 changes: 2 additions & 0 deletions packages/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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' });
Expand Down
192 changes: 192 additions & 0 deletions packages/api/src/routes/bounties.ts
Original file line number Diff line number Diff line change
@@ -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<Difficulty>(difficultyEnum.enumValues);
const statusValues = new Set<BountyStatus>(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<CursorPayload>;
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<unknown>[] = [];

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;