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
193 changes: 193 additions & 0 deletions packages/api/src/__tests__/bounties_detail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { describe, it, expect, beforeAll, vi, beforeEach } from 'vitest';
import { createApp } from '../app';
import { verify } from 'hono/jwt';
import { db } from '../db';

vi.mock('hono/jwt', () => ({
verify: vi.fn(),
}));

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

const createSelectResultMock = <T>(rows: T[]) => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(rows),
}),
});

describe('GET /api/bounties/:id', () => {
let app: ReturnType<typeof createApp>;

beforeAll(() => {
app = createApp();

vi.mocked(verify).mockResolvedValue({
sub: 'test-user-id',
username: 'testuser',
exp: Math.floor(Date.now() / 1000) + 3600,
});

process.env.JWT_PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nfake\n-----END PUBLIC KEY-----';
});

beforeEach(() => {
vi.clearAllMocks();
});

it('should return 404 when bounty does not exist', async () => {
vi.mocked(db.query.bounties.findFirst).mockResolvedValue(null as never);

const res = await app.request('/api/bounties/missing-id', {
headers: {
Authorization: 'Bearer valid.token',
},
});

expect(res.status).toBe(404);
expect(await res.json()).toEqual({ error: 'Bounty not found' });
});

it('should return full bounty detail including creator, application count, and assignee', async () => {
vi.mocked(db.query.bounties.findFirst).mockResolvedValue({
id: 'bounty-1',
title: 'Test bounty',
description: 'Test description',
creatorId: 'creator-1',
assigneeId: 'assignee-1',
status: 'assigned',
} as never);

vi.mocked(db.select)
.mockReturnValueOnce(createSelectResultMock([{ count: 3 }]) as never)
.mockReturnValueOnce(createSelectResultMock([
{ id: 'creator-1', username: 'alice', avatarUrl: 'https://img/creator.png' },
{ id: 'assignee-1', username: 'bob', avatarUrl: 'https://img/assignee.png' },
]) as never);

const res = await app.request('/api/bounties/bounty-1', {
headers: {
Authorization: 'Bearer valid.token',
},
});

expect(res.status).toBe(200);
const body = await res.json();

expect(body.id).toBe('bounty-1');
expect(body.status).toBe('assigned');
expect(body.applicationCount).toBe(3);
expect(body.creator).toEqual({
id: 'creator-1',
username: 'alice',
avatarUrl: 'https://img/creator.png',
});
expect(body.assignee).toEqual({
id: 'assignee-1',
username: 'bob',
avatarUrl: 'https://img/assignee.png',
});
expect(db.select).toHaveBeenCalledTimes(2);
});

it('should return assignee as null when bounty is unassigned', async () => {
vi.mocked(db.query.bounties.findFirst).mockResolvedValue({
id: 'bounty-2',
title: 'Unassigned bounty',
description: 'Test description',
creatorId: 'creator-2',
assigneeId: null,
status: 'open',
} as never);

vi.mocked(db.select)
.mockReturnValueOnce(createSelectResultMock([{ count: 0 }]) as never)
.mockReturnValueOnce(createSelectResultMock([
{ id: 'creator-2', username: 'charlie', avatarUrl: null },
]) as never);

const res = await app.request('/api/bounties/bounty-2', {
headers: {
Authorization: 'Bearer valid.token',
},
});

expect(res.status).toBe(200);
const body = await res.json();

expect(body.applicationCount).toBe(0);
expect(body.creator).toEqual({
id: 'creator-2',
username: 'charlie',
avatarUrl: null,
});
expect(body.assignee).toBeNull();
expect(db.select).toHaveBeenCalledTimes(2);
});

it('should return 500 when the creator record is missing', async () => {
vi.mocked(db.query.bounties.findFirst).mockResolvedValue({
id: 'bounty-3',
title: 'Broken bounty',
description: 'Test description',
creatorId: 'creator-3',
assigneeId: null,
status: 'open',
} as never);

vi.mocked(db.select)
.mockReturnValueOnce(createSelectResultMock([{ count: 1 }]) as never)
.mockReturnValueOnce(createSelectResultMock([]) as never);

const res = await app.request('/api/bounties/bounty-3', {
headers: {
Authorization: 'Bearer valid.token',
},
});

expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: 'Bounty creator not found' });
});

it('should return assignee as null when the assignee record is missing', async () => {
vi.mocked(db.query.bounties.findFirst).mockResolvedValue({
id: 'bounty-4',
title: 'Missing assignee',
description: 'Test description',
creatorId: 'creator-4',
assigneeId: 'assignee-4',
status: 'assigned',
} as never);

vi.mocked(db.select)
.mockReturnValueOnce(createSelectResultMock([{ count: 2 }]) as never)
.mockReturnValueOnce(createSelectResultMock([
{ id: 'creator-4', username: 'dana', avatarUrl: 'https://img/creator-4.png' },
]) as never);

const res = await app.request('/api/bounties/bounty-4', {
headers: {
Authorization: 'Bearer valid.token',
},
});

expect(res.status).toBe(200);
const body = await res.json();

expect(body.creator).toEqual({
id: 'creator-4',
username: 'dana',
avatarUrl: 'https://img/creator-4.png',
});
expect(body.assignee).toBeNull();
});
});
47 changes: 43 additions & 4 deletions packages/api/src/routes/bounties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ 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, users } from '../db/schema';
import { eq, and, gte, lte, sql, desc, or, lt, inArray } from 'drizzle-orm';

const bountiesRouter = new Hono<{ Variables: Variables }>();

const toPublicUser = (user: { id: string; username: string | null; avatarUrl: string | null }) => ({
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
});

/**
* GET /api/bounties
* Paginated listing of bounties with filters.
Expand Down Expand Up @@ -85,7 +91,7 @@ bountiesRouter.get('/', async (c) => {
)
)
);
} catch (e) {
} catch {
return c.json({ error: 'Invalid cursor' }, 400);
}
}
Expand Down Expand Up @@ -136,7 +142,40 @@ bountiesRouter.get('/:id', async (c) => {
return c.json({ error: 'Bounty not found' }, 404);
}

return c.json(bounty);
const userIds = bounty.assigneeId && bounty.assigneeId !== bounty.creatorId
? [bounty.creatorId, bounty.assigneeId]
: [bounty.creatorId];

const [[applicationCountResult], relatedUsers] = await Promise.all([
db
.select({ count: sql<number>`cast(count(*) as int)` })
.from(applications)
.where(eq(applications.bountyId, id)),
db
.select({
id: users.id,
username: users.username,
avatarUrl: users.avatarUrl,
})
.from(users)
.where(inArray(users.id, userIds)),
]);

const usersById = new Map(relatedUsers.map((user) => [user.id, user]));
const creator = usersById.get(bounty.creatorId);

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

const assignee = bounty.assigneeId ? usersById.get(bounty.assigneeId) ?? null : null;

return c.json({
...bounty,
creator: toPublicUser(creator),
applicationCount: applicationCountResult?.count ?? 0,
assignee: assignee ? toPublicUser(assignee) : null,
});
});

/**
Expand Down