Skip to content
Merged
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
3 changes: 3 additions & 0 deletions docs/development/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

- **Runner:** Vitest 4 with v8 coverage. Config in `vitest.config.ts`.
- **Location:** `src/__tests__/` (one file per module under test).
- Route tests (`stars-api`, `project-recommendations-api`, `similar-repos-api`)
cover auth, empty-result fallbacks, and parameterized SQL at the HTTP
boundary — ranking helpers stay in their own lib tests.
- **Coverage thresholds:** 80% lines/functions/statements, 70% branches on core
logic modules (`github-projects`, `project-recommendations`, `search`, and
`starboard-rag-documents`).
Expand Down
129 changes: 129 additions & 0 deletions src/__tests__/project-recommendations-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { NextRequest } from 'next/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
auth: vi.fn(),
execute: vi.fn(),
retrieveProjectIntelligence: vi.fn(),
}));

vi.mock('@/lib/auth', () => ({ auth: mocks.auth }));
vi.mock('@/db', () => ({ db: { execute: mocks.execute } }));
vi.mock('@/lib/project-intelligence', () => ({
retrieveProjectIntelligence: mocks.retrieveProjectIntelligence,
}));

import { GET } from '@/app/api/projects/[slug]/recommendations/route';

const connectedRow = {
id: 42,
name: 'app',
full_name: 'acme/app',
owner_login: 'acme',
owner_avatar: 'https://example.com/avatar.png',
html_url: 'https://github.com/acme/app',
description: 'A TypeScript application',
language: 'TypeScript',
stargazers_count: 100,
archived: 0,
topics: '["nextjs"]',
connected_at: '2026-08-08T00:00:00Z',
ai_summary: null,
ai_category: null,
ai_keywords: '[]',
tools: '[]',
};

const emptyIntelligence = {
similarProjects: [],
recommendedTools: [],
fallback: true,
context: { language: null, topics: [], tools: [] },
retrieval: {
mode: 'fallback' as const,
candidateCount: 0,
semanticCandidates: 0,
lexicalCandidates: 0,
structuredCandidates: 0,
},
};

describe('GET /api/projects/[slug]/recommendations', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.auth.mockResolvedValue({ user: { githubId: 'user-1' } });
mocks.execute.mockResolvedValue({ rows: [connectedRow] });
mocks.retrieveProjectIntelligence.mockResolvedValue(emptyIntelligence);
});

it('refuses guests and sessions without githubId', async () => {
mocks.auth.mockResolvedValueOnce(null);

const guest = await GET(new NextRequest('http://localhost/api/projects/42/recommendations'), {
params: Promise.resolve({ slug: '42' }),
});

expect(guest.status).toBe(401);
expect(await guest.json()).toEqual({ error: 'Unauthorized' });
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.retrieveProjectIntelligence).not.toHaveBeenCalled();

mocks.auth.mockResolvedValueOnce({ user: {} });

const unsigned = await GET(
new NextRequest('http://localhost/api/projects/42/recommendations'),
{ params: Promise.resolve({ slug: '42' }) }
);

expect(unsigned.status).toBe(401);
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.retrieveProjectIntelligence).not.toHaveBeenCalled();
});

it('returns empty peer and tool lists when retrieval has no matches', async () => {
const response = await GET(
new NextRequest('http://localhost/api/projects/42/recommendations'),
{ params: Promise.resolve({ slug: '42' }) }
);
const payload = (await response.json()) as {
similarProjects: unknown[];
recommendedTools: unknown[];
fallback: boolean;
};

expect(response.status).toBe(200);
expect(payload.similarProjects).toEqual([]);
expect(payload.recommendedTools).toEqual([]);
expect(payload.fallback).toBe(true);
expect(mocks.retrieveProjectIntelligence).toHaveBeenCalledWith(
expect.objectContaining({ id: 42, fullName: 'acme/app' }),
24
);
});

it('clamps the requested peer limit at the route boundary', async () => {
const oversized = await GET(
new NextRequest('http://localhost/api/projects/42/recommendations?limit=200'),
{ params: Promise.resolve({ slug: '42' }) }
);

expect(oversized.status).toBe(200);
expect(mocks.retrieveProjectIntelligence).toHaveBeenCalledWith(
expect.objectContaining({ id: 42 }),
50
);

mocks.retrieveProjectIntelligence.mockClear();

const undersized = await GET(
new NextRequest('http://localhost/api/projects/42/recommendations?limit=0'),
{ params: Promise.resolve({ slug: '42' }) }
);

expect(undersized.status).toBe(200);
expect(mocks.retrieveProjectIntelligence).toHaveBeenCalledWith(
expect.objectContaining({ id: 42 }),
24
);
});
});
157 changes: 157 additions & 0 deletions src/__tests__/stars-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { NextRequest } from 'next/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
auth: vi.fn(),
execute: vi.fn(),
batch: vi.fn(),
searchStarboardRagOrEmpty: vi.fn(),
trackSearchOutcome: vi.fn(),
}));

vi.mock('@/lib/auth', () => ({ auth: mocks.auth }));
vi.mock('@/db', () => ({
db: {
execute: mocks.execute,
batch: mocks.batch,
},
}));
vi.mock('@/lib/knowledgebase', () => ({
searchStarboardRagOrEmpty: mocks.searchStarboardRagOrEmpty,
}));
vi.mock('@/lib/analytics', () => ({
trackSearchOutcome: mocks.trackSearchOutcome,
}));

import { GET } from '@/app/api/stars/route';

const starRow = {
id: 11,
name: 'next.js',
full_name: 'vercel/next.js',
owner_login: 'vercel',
owner_avatar: 'https://example.com/vercel.png',
html_url: 'https://github.com/vercel/next.js',
description: 'The React Framework',
language: 'TypeScript',
stargazers_count: 130_000,
archived: 0,
topics: '["react","nextjs"]',
repo_created_at: '2016-10-25T00:00:00Z',
repo_updated_at: '2026-08-01T00:00:00Z',
list_id: null,
collection_ids: '[]',
notes: null,
starred_at: '2026-07-01T00:00:00Z',
is_starred: 1,
is_saved: 0,
};

function queryArg(call: unknown): { sql: string; args: unknown[] } {
return call as { sql: string; args: unknown[] };
}

describe('GET /api/stars', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.auth.mockResolvedValue({ user: { githubId: 'user-1' } });
mocks.searchStarboardRagOrEmpty.mockResolvedValue([]);
mocks.execute.mockResolvedValue({ rows: [starRow] });
mocks.batch.mockResolvedValue([
{ rows: [{ total: 1 }] },
{ rows: [{ language: 'TypeScript', count: 1 }] },
{ rows: [{ id: 7, name: 'Frontend', color: '#111111', count: 1 }] },
]);
});

it('returns 401 when the session has no githubId', async () => {
mocks.auth.mockResolvedValueOnce(null);

const response = await GET(new NextRequest('http://localhost/api/stars'));

expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: 'Unauthorized' });
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.batch).not.toHaveBeenCalled();
expect(mocks.searchStarboardRagOrEmpty).not.toHaveBeenCalled();
});

it('treats a signed-in session without githubId as unauthorized', async () => {
mocks.auth.mockResolvedValueOnce({ user: {} });

const response = await GET(
new NextRequest('http://localhost/api/stars?q=nextjs&sort=relevance')
);

expect(response.status).toBe(401);
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.searchStarboardRagOrEmpty).not.toHaveBeenCalled();
});

it('binds the signed-in user and filters as SQL parameters', async () => {
const response = await GET(
new NextRequest('http://localhost/api/stars?language=TypeScript&limit=10&offset=5')
);

expect(response.status).toBe(200);
const mainQuery = queryArg(mocks.execute.mock.calls[0]?.[0]);
expect(mainQuery.sql).toContain('ur.user_id = ?');
expect(mainQuery.sql).toContain('r.language IN (SELECT CAST(value AS TEXT) FROM json_each(?))');
expect(mainQuery.sql).not.toContain('user-1');
expect(mainQuery.args).toEqual(['user-1', JSON.stringify(['TypeScript']), 10, 5]);

const batched = mocks.batch.mock.calls[0]?.[0] as Array<{ sql: string; args: unknown[] }>;
expect(batched[0].sql).toContain('ur.user_id = ?');
expect(batched[0].args).toEqual(['user-1', JSON.stringify(['TypeScript'])]);
expect(batched[1].args).toEqual(['user-1']);
expect(batched[2].args).toEqual(['user-1']);

await expect(response.json()).resolves.toMatchObject({
repos: [{ id: 11, full_name: 'vercel/next.js' }],
total: 1,
facets: {
languages: [['TypeScript', 1]],
lists: [{ id: 7, name: 'Frontend', count: 1 }],
},
});
expect(mocks.searchStarboardRagOrEmpty).not.toHaveBeenCalled();
expect(mocks.trackSearchOutcome).not.toHaveBeenCalled();
});

it('falls back to lexical matches when knowledgebase RAG is empty', async () => {
mocks.execute.mockImplementation(async (statement: { sql: string; args: unknown[] }) => {
if (statement.sql.includes('repos_fts MATCH')) {
return { rows: [{ id: 11 }] };
}
return { rows: [starRow] };
});

const response = await GET(
new NextRequest('http://localhost/api/stars?q=nextjs&sort=relevance')
);

expect(response.status).toBe(200);
expect(mocks.searchStarboardRagOrEmpty).toHaveBeenCalledWith('user-1', 'nextjs nextjs', 500);

const lexicalQuery = queryArg(
mocks.execute.mock.calls.find((call) =>
queryArg(call[0]).sql.includes('repos_fts MATCH')
)?.[0]
);
expect(lexicalQuery.args).toEqual(['user-1', 'nextjs*', 'user-1', 'nextjs*']);

const mainQuery = queryArg(
mocks.execute.mock.calls.find((call) =>
queryArg(call[0]).sql.includes('LIMIT ? OFFSET ?')
)?.[0]
);
expect(mainQuery.sql).toContain('r.id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))');
expect(mainQuery.sql).toContain('CASE r.id WHEN 11 THEN 0');
expect(mainQuery.sql).not.toContain('0 = 1');
expect(mainQuery.args).toEqual(['user-1', JSON.stringify([11]), 50, 0]);

const payload = (await response.json()) as { repos: Array<{ id: number }>; total: number };
expect(payload.repos).toEqual([expect.objectContaining({ id: 11 })]);
expect(mocks.trackSearchOutcome).toHaveBeenCalledWith('semantic', 1);
});
});
Loading