diff --git a/docs/development/testing.md b/docs/development/testing.md index a72662d..0e88860 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -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`). diff --git a/src/__tests__/project-recommendations-api.test.ts b/src/__tests__/project-recommendations-api.test.ts new file mode 100644 index 0000000..b816a64 --- /dev/null +++ b/src/__tests__/project-recommendations-api.test.ts @@ -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 + ); + }); +}); diff --git a/src/__tests__/stars-api.test.ts b/src/__tests__/stars-api.test.ts new file mode 100644 index 0000000..81c2c8a --- /dev/null +++ b/src/__tests__/stars-api.test.ts @@ -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); + }); +});