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
138 changes: 90 additions & 48 deletions packages/api/src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
@@ -1,78 +1,120 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { createApp } from '../app';

describe('API App', () => {
const mockDb = {
execute: vi.fn(),
};

let app: ReturnType<typeof createApp>;

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

// ── Health Endpoint ──────────────────────────────────────────────

describe('GET /health', () => {
it('should return 200 with status ok', async () => {
const res = await app.request('/health');

expect(res.status).toBe(200);

const body = await res.json();
expect(body).toEqual({ status: 'ok' });
});
});

// ── Gemini Endpoint ──────────────────────────────────────────────

describe('POST /api/gemini', () => {
it('should return 200 with valid prompt', async () => {
const res = await app.request('/api/gemini', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: 'Hello, AI!' }),
});

expect(res.status).toBe(200);

describe('GET /bounties/:id', () => {
it('should return 400 for invalid bounty ID format', async () => {
const res = await app.request('/bounties/not-a-valid-uuid');
expect(res.status).toBe(400);
const body = await res.json();
expect(body).toEqual({
message: 'Request received securely on backend',
status: 'success',
});
expect(body.error).toBe('Invalid bounty ID format');
});

it('should return 400 when prompt is missing', async () => {
const res = await app.request('/api/gemini', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});

expect(res.status).toBe(400);
it('should return 404 when bounty not found', async () => {
mockDb.execute.mockResolvedValueOnce({ rows: [] });
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 mockRow = {
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,
};

mockDb.execute.mockResolvedValueOnce({ rows: [mockRow] });

const res = await app.request('/bounties/11111111-1111-1111-1111-111111111111');
expect(res.status).toBe(200);
const body = await res.json();
expect(body.error).toBe('Prompt is required and must be a non-empty string');
});

it('should return 400 when prompt is empty string', async () => {
const res = await app.request('/api/gemini', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: ' ' }),
expect(body).toMatchObject({
id: '11111111-1111-1111-1111-111111111111',
repoOwner: 'acme',
repoName: 'repo',
title: 'Test bounty',
amountUsdc: '100',
applicationCount: 7,
creator: {
username: 'creator',
},
assignee: {
username: 'assignee',
},
});

expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe('Prompt is required and must be a non-empty string');
// ensure internal IDs are not exposed
expect(body.creator.id).toBeUndefined();
expect(body.assignee.id).toBeUndefined();
});

it('should return 400 when prompt is not a string', async () => {
const res = await app.request('/api/gemini', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: 123 }),
});

expect(res.status).toBe(400);
it('should return bounty details with null assignee if unassigned', async () => {
const mockRow = {
id: '11111111-1111-1111-1111-111111111111',
github_issue_id: 123,
repo_owner: 'acme',
repo_name: 'repo',
title: 'Unassigned Bounty',
description: 'Desc',
amount_usdc: '100',
tech_tags: ['ts'],
difficulty: 'beginner',
status: 'open',
deadline: null,
creator_id: '22222222-2222-2222-2222-222222222222',
assignee_id: null,
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: null,
assignee_avatar_url: null,
application_count: 3,
};

mockDb.execute.mockResolvedValueOnce({ rows: [mockRow] });

const res = await app.request('/bounties/11111111-1111-1111-1111-111111111111');
expect(res.status).toBe(200);
const body = await res.json();
expect(body.assignee).toBeNull();
});
});
});
112 changes: 91 additions & 21 deletions packages/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,26 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { sql } from 'drizzle-orm';

/**
* 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 type DbLike = {
execute: (query: any) => Promise<{ rows: any[] }>;
};

export type CreateAppDeps = {
db: DbLike;
};

export function createApp(deps?: Partial<CreateAppDeps>) {
const app = new Hono();

// Global middleware
app.use('*', logger());
app.use('*', cors());

// Rate limiter stub middleware
app.use('*', async (_c, next) => {
// TODO(#1): Implement a robust rate limiter (e.g., using `@hono/rate-limiter`).
// For now, checks are skipped
await next();
});

// Error handler
app.onError((err, c) => {
console.error('App Error:', err);
if (process.env.NODE_ENV === 'production') {
Expand All @@ -30,17 +29,11 @@ export function createApp() {
return c.json({ error: 'Internal server error', message: err.message }, 500);
});

// API Routes
app.get('/health', (c) => {
return c.json({ status: 'ok' });
});
app.get('/health', (c) => c.json({ status: 'ok' }));

app.post('/api/gemini', async (c) => {
const apiKey = process.env.GEMINI_API_KEY;

if (!apiKey) {
throw new Error('Gemini API key not configured on server');
}
if (!apiKey) throw new Error('Gemini API key not configured on server');

const body = await c.req.json();
const { prompt } = body;
Expand All @@ -50,10 +43,87 @@ export function createApp() {
}

console.log('Received prompt:', prompt);
return c.json({ message: 'Request received securely on backend', status: 'success' });
});

// --- Bounties ---

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

// Validate UUID to avoid DB errors for malformed inputs
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(bountyId)) {
return c.json({ error: 'Invalid bounty ID format' }, 400);
}

const db = deps?.db;
if (!db) 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);

// Public response: omit internal user UUIDs
return c.json({
message: 'Request received securely on backend',
status: 'success'
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: {
username: row.creator_username,
avatarUrl: row.creator_avatar_url,
},
applicationCount: row.application_count ?? 0,
assignee: row.assignee_id
? {
username: row.assignee_username,
avatarUrl: row.assignee_avatar_url,
}
: null,
});
});

Expand Down
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
Loading