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
58 changes: 56 additions & 2 deletions packages/api/src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { createApp } from '../app';
import { createApp, type DbLike } from '../app';

describe('API App', () => {
let app: ReturnType<typeof createApp>;

const mockDb: DbLike = {
execute: async () => ({ rows: [] }),
};

beforeAll(() => {
app = createApp();
app = createApp({ db: mockDb });
});

// ── Health Endpoint ──────────────────────────────────────────────
Expand Down Expand Up @@ -75,4 +79,54 @@ describe('API App', () => {
expect(res.status).toBe(400);
});
});

// ── Bounties Endpoint ───────────────────────────────────────────

describe('GET /bounties/:id', () => {
it('should return 404 when bounty not found', async () => {
const res = await app.request('/bounties/00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
});

it('should return bounty details when found', async () => {
const foundDb: DbLike = {
execute: async () => ({
rows: [
{
id: '11111111-1111-1111-1111-111111111111',
github_issue_id: 123,
repo_owner: 'acme',
repo_name: 'repo',
title: 'Test bounty',
description: 'Desc',
amount_usdc: '100',
tech_tags: ['ts'],
difficulty: 'beginner',
status: 'open',
deadline: null,
creator_id: '22222222-2222-2222-2222-222222222222',
assignee_id: '33333333-3333-3333-3333-333333333333',
created_at: '2026-02-19T00:00:00.000Z',
updated_at: '2026-02-19T00:00:00.000Z',
creator_username: 'creator',
creator_avatar_url: 'https://example.com/c.png',
assignee_username: 'assignee',
assignee_avatar_url: 'https://example.com/a.png',
application_count: 7,
},
],
}),
};

const app2 = createApp({ db: foundDb });
const res = await app2.request('/bounties/11111111-1111-1111-1111-111111111111');
expect(res.status).toBe(200);
const body = await res.json();

expect(body.id).toBe('11111111-1111-1111-1111-111111111111');
expect(body.creator.username).toBe('creator');
expect(body.applicationCount).toBe(7);
expect(body.assignee.username).toBe('assignee');
});
});
});
96 changes: 95 additions & 1 deletion packages/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { sql } from 'drizzle-orm';

export type DbLike = {
execute: (query: any) => Promise<{ rows: any[] }>;
};

export type CreateAppDeps = {
db: DbLike;
};

/**
* Creates and configures the Hono application with all routes and middleware.
* Extracted from index.ts to enable testing without triggering server startup
* or environment variable validation side effects.
*/
export function createApp() {
export function createApp(deps?: Partial<CreateAppDeps>) {
const app = new Hono();

// Global middleware
Expand Down Expand Up @@ -57,5 +66,90 @@ export function createApp() {
});
});

// --- Bounties ---

app.get('/bounties/:id', async (c) => {
const bountyId = c.req.param('id');

if (!bountyId || typeof bountyId !== 'string') {
return c.json({ error: 'Invalid bounty id' }, 400);
}

const db = deps?.db;
if (!db) {
// Keep app.ts test-friendly: the real db must be injected by the server entrypoint.
throw new Error('Database dependency not provided');
}

const q = sql`
SELECT
b.id,
b.github_issue_id,
b.repo_owner,
b.repo_name,
b.title,
b.description,
b.amount_usdc,
b.tech_tags,
b.difficulty,
b.status,
b.deadline,
b.creator_id,
b.assignee_id,
b.created_at,
b.updated_at,
creator.username AS creator_username,
creator.avatar_url AS creator_avatar_url,
assignee.username AS assignee_username,
assignee.avatar_url AS assignee_avatar_url,
(
SELECT COUNT(*)::int
FROM applications a
WHERE a.bounty_id = b.id
) AS application_count
FROM bounties b
JOIN users creator ON creator.id = b.creator_id
LEFT JOIN users assignee ON assignee.id = b.assignee_id
WHERE b.id = ${bountyId}
LIMIT 1;
`;

const result = await db.execute(q);
const row = result.rows?.[0];

if (!row) {
return c.json({ error: 'Bounty not found' }, 404);
}

return c.json({
id: row.id,
githubIssueId: row.github_issue_id,
repoOwner: row.repo_owner,
repoName: row.repo_name,
title: row.title,
description: row.description,
amountUsdc: row.amount_usdc,
techTags: row.tech_tags,
difficulty: row.difficulty,
status: row.status,
deadline: row.deadline,
createdAt: row.created_at,
updatedAt: row.updated_at,
creator: {
id: row.creator_id,
username: row.creator_username,
avatarUrl: row.creator_avatar_url,
},
applicationCount: row.application_count ?? 0,
assignee: row.assignee_id
? {
id: row.assignee_id,
username: row.assignee_username,
avatarUrl: row.assignee_avatar_url,
}
: null,
});
});

return app;
}
3 changes: 2 additions & 1 deletion packages/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { serve } from '@hono/node-server';
import dotenv from 'dotenv';
import { createApp } from './app';
import { db } from './db';

// Load environment variables
dotenv.config();
Expand Down Expand Up @@ -55,7 +56,7 @@ if (missingOptional.length > 0) {
);
}

const app = createApp();
const app = createApp({ db });
const port = Number(process.env.PORT) || 3001;

console.log(`Server is running on http://localhost:${port}`);
Expand Down