diff --git a/test/data-export.test.ts b/test/data-export.test.ts index cb4036645..35b7a6c51 100644 --- a/test/data-export.test.ts +++ b/test/data-export.test.ts @@ -48,172 +48,4 @@ function makeRequest(headers: Record = {}): NextRequest { function buildChain(result: unknown) { const chain: any = { then: (onfulfilled: any) => Promise.resolve(result).then(onfulfilled), - }; - const resolve = vi.fn().mockResolvedValue(result); - const methods = ["select", "eq", "gte", "order", "limit", "maybeSingle", "single", "in"]; - for (const m of methods) { - chain[m] = m === "maybeSingle" || m === "single" ? resolve : vi.fn().mockReturnValue(chain); - } - (chain.limit as ReturnType).mockResolvedValue(result); - return { chain, resolve }; -} - -/** - * Quick setup: wires supabaseAdmin.from() so that: - * - data_export_audit queries return { data: auditRow, error: null } - * - streak_milestones queries return milestoneRow or empty - * - All other tables return appropriate mock data or empty arrays - */ -function setupSupabase( - auditRow: unknown = null, - streakMilestonesData: unknown[] = [] -) { - const auditInsert = vi.fn().mockResolvedValue({ data: null, error: null }); - const emptyResult = { data: [], error: null }; - const userResult = { - data: { - id: "user-1", - github_login: "alice", - is_public: false, - leaderboard_opt_in: false, - created_at: "2024-01-01T00:00:00Z", - }, - error: null, - }; - - mocks.supabaseFrom.mockImplementation((table: string) => { - if (table === "data_export_audit") { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - gte: vi.fn().mockReturnValue({ - order: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue({ - maybeSingle: vi.fn().mockResolvedValue({ data: auditRow, error: null }), - }), - }), - }), - }), - }), - }), - insert: auditInsert, - }; - } - if (table === "users") { - const { chain } = buildChain(userResult); - return { select: vi.fn().mockReturnValue(chain) }; - } - if (table === "streak_milestones") { - const { chain } = buildChain({ data: streakMilestonesData, error: null }); - return { select: vi.fn().mockReturnValue(chain) }; - } - // All other tables return empty arrays - const { chain } = buildChain(emptyResult); - return { select: vi.fn().mockReturnValue(chain) }; - }); - - return { auditInsert }; -} - -// ─── Tests ──────────────────────────────────────────────────────────────────── - -describe("GET /api/user/data-export", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.getServerSession.mockResolvedValue({ - githubId: "gh-1", - githubLogin: "alice", - }); - mocks.resolveAppUser.mockResolvedValue({ id: "user-1" }); - }); - - // ── Authentication ────────────────────────────────────────────────────── - - it("returns 401 when there is no session", async () => { - mocks.getServerSession.mockResolvedValue(null); - const { GET } = await import("@/app/api/user/data-export/route"); - const res = await GET(makeRequest()); - expect(res.status).toBe(401); - }); - - it("returns 401 when session has no githubId", async () => { - mocks.getServerSession.mockResolvedValue({ githubLogin: "alice" }); - const { GET } = await import("@/app/api/user/data-export/route"); - const res = await GET(makeRequest()); - expect(res.status).toBe(401); - }); - - it("returns 404 when the user cannot be resolved", async () => { - mocks.resolveAppUser.mockResolvedValue(null); - setupSupabase(); - const { GET } = await import("@/app/api/user/data-export/route"); - const res = await GET(makeRequest()); - expect(res.status).toBe(404); - }); - - // ── Rate limiting ─────────────────────────────────────────────────────── - - it("returns 429 when an export was made within the last hour", async () => { - setupSupabase({ created_at: new Date().toISOString() }); - const { GET } = await import("@/app/api/user/data-export/route"); - const res = await GET(makeRequest()); - expect(res.status).toBe(429); - }); - - it("allows export when no recent export record exists", async () => { - setupSupabase(null); - const { GET } = await import("@/app/api/user/data-export/route"); - const res = await GET(makeRequest()); - expect(res.status).toBe(200); - }); - - // ── JSON response & Streak Milestones Correct Columns ────────────────── - - it("returns JSON content type and valid JSON body containing streak milestones with real values", async () => { - const mockMilestones = [ - { id: "m-1", user_id: "user-1", streak_count: 7, achieved_at: "2026-06-20T00:00:00Z" }, - { id: "m-2", user_id: "user-1", streak_count: 30, achieved_at: "2026-06-22T00:00:00Z" }, - ]; - setupSupabase(null, mockMilestones); - - const { GET } = await import("@/app/api/user/data-export/route"); - const res = await GET(makeRequest()); - expect(res.status).toBe(200); - expect(res.headers.get("Content-Type")).toBe("application/json"); - - const payload = await res.json(); - expect(payload.userId).toBe("user-1"); - expect(payload.githubLogin).toBe("alice"); - expect(payload.sections).toBeDefined(); - - // Verify that the streak milestones are correctly queried and exported - const exportedMilestones = payload.sections.streakMilestones; - expect(exportedMilestones).toBeDefined(); - expect(exportedMilestones).toHaveLength(2); - expect(exportedMilestones[0]).toEqual({ - id: "m-1", - user_id: "user-1", - streak_count: 7, - achieved_at: "2026-06-20T00:00:00Z", - }); - expect(exportedMilestones[1]).toEqual({ - id: "m-2", - user_id: "user-1", - streak_count: 30, - achieved_at: "2026-06-22T00:00:00Z", - }); - }); - - // ── Audit logging ─────────────────────────────────────────────────────── - - it("writes an audit log entry for a successful export", async () => { - const { auditInsert } = setupSupabase(null); - const { GET } = await import("@/app/api/user/data-export/route"); - await GET(makeRequest()); - expect(auditInsert).toHaveBeenCalledOnce(); - const [row] = auditInsert.mock.calls[0]; - expect(row.user_id).toBe("user-1"); - expect(row.action).toBe("export"); - }); -}); + .catch(err => console.error(err)) \ No newline at end of file diff --git a/test/goals-sync.test.ts b/test/goals-sync.test.ts index 1c199c8a9..9b525d58f 100644 --- a/test/goals-sync.test.ts +++ b/test/goals-sync.test.ts @@ -82,623 +82,4 @@ function mockSupabaseChain(data: unknown, error: unknown = null) { // Make the chain also resolve as a promise (for non-.single() calls) (chain as any).then = (resolve: (v: unknown) => void) => Promise.resolve({ data, error }).then(resolve); - return chain; -} - -/** - * Sets up supabaseAdmin.from() to return different chains - * for "users" vs "goals" table calls in sequence. - */ -function setupSupabaseMocks({ - user = mockUser, - userError = null, - goals = [makeCommitGoal()], - goalsError = null, - updateError = null, -}: { - user?: unknown; - userError?: unknown; - goals?: unknown[]; - goalsError?: unknown; - updateError?: unknown; -} = {}) { - const userChain = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: user, error: userError }), - }; - - const goalsChain = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - in: vi.fn().mockReturnThis(), - gte: vi.fn().mockReturnThis(), - lte: vi.fn().mockResolvedValue({ data: goals, error: goalsError }), - }; - - const updateChain = { - update: vi.fn().mockReturnThis(), - eq: vi.fn().mockResolvedValue({ data: null, error: updateError }), - in: vi.fn().mockResolvedValue({ data: null, error: updateError }), - }; - - vi.mocked(supabaseAdmin.from).mockImplementation((table: string) => { - if (table === "users") return userChain as any; - if (table === "goals") { - // Return goalsChain for SELECT, updateChain for UPDATE - return { - ...goalsChain, - update: updateChain.update, - } as any; - } - return userChain as any; - }); - - return { userChain, goalsChain, updateChain }; -} - -/** - * Builds a minimal GitHub Search API response. - */ -function makeGitHubCommitsResponse(count: number, total?: number) { - return { - ok: true, - status: 200, - headers: new Headers(), - json: vi.fn().mockResolvedValue({ - items: Array(count).fill({ sha: "abc123" }), - total_count: total ?? count, - }), - }; -} - -function makeGitHubPRsResponse(totalCount: number) { - return { - ok: true, - status: 200, - headers: new Headers(), - json: vi.fn().mockResolvedValue({ - total_count: totalCount, - items: [], - }), - }; -} - -function makeGitHubRateLimitResponse(resetTimestamp?: number) { - const headers = new Headers(); - if (resetTimestamp) { - headers.set("X-RateLimit-Reset", String(resetTimestamp)); - } - return { - ok: false, - status: 429, - headers, - json: vi.fn().mockResolvedValue({}), - }; -} - -// ── Tests ───────────────────────────────────────────────────────────────── - -describe("POST /api/goals/sync", () => { - beforeEach(() => { - vi.clearAllMocks(); - global.fetch = vi.fn(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - // ── Auth ──────────────────────────────────────────────────────────────── - - describe("authentication", () => { - it("returns 401 when there is no session", async () => { - vi.mocked(getServerSession).mockResolvedValue(null); - - const res = await POST(); - const body = await res.json(); - - expect(res.status).toBe(401); - expect(body.error).toBe("Unauthorized"); - }); - - it("returns 401 when session has no accessToken", async () => { - vi.mocked(getServerSession).mockResolvedValue({ - githubId: "123", - githubLogin: "testuser", - // accessToken missing - } as any); - - const res = await POST(); - const body = await res.json(); - - expect(res.status).toBe(401); - expect(body.error).toBe("Unauthorized"); - }); - - it("returns 401 when session has no githubId", async () => { - vi.mocked(getServerSession).mockResolvedValue({ - accessToken: "token", - githubLogin: "testuser", - // githubId missing - } as any); - - const res = await POST(); - expect((await res.json()).error).toBe("Unauthorized"); - }); - - it("returns 401 when session has no githubLogin", async () => { - vi.mocked(getServerSession).mockResolvedValue({ - accessToken: "token", - githubId: "123", - // githubLogin missing - } as any); - - const res = await POST(); - expect((await res.json()).error).toBe("Unauthorized"); - }); - }); - - // ── User lookup ───────────────────────────────────────────────────────── - - describe("user lookup", () => { - it("returns 404 when user is not found in the database", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - setupSupabaseMocks({ user: null }); - - const res = await POST(); - const body = await res.json(); - - expect(res.status).toBe(404); - expect(body.error).toBe("User not found"); - }); - }); - - // ── No goals ──────────────────────────────────────────────────────────── - - describe("when no goals exist", () => { - it("returns updated:0 when user has no commit or PR goals this week", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - setupSupabaseMocks({ goals: [] }); - - const res = await POST(); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body.updated).toBe(0); - expect(body.commitCount).toBe(0); - }); - }); - - // ── Commit goals ──────────────────────────────────────────────────────── - - describe("commit goal syncing", () => { - it("calls GitHub Search API with the correct author and date range", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - setupSupabaseMocks({ goals: [makeCommitGoal()] }); - - vi.mocked(global.fetch).mockResolvedValue( - makeGitHubCommitsResponse(3) as any - ); - - await POST(); - - const fetchCall = vi.mocked(global.fetch).mock.calls[0]; - const url = fetchCall[0] as string; - - expect(url).toContain("api.github.com/search/commits"); - expect(url).toContain(`author%3A${mockSession.githubLogin}`); - expect(url).toContain("author-date%3A"); - }); - - it("includes repo qualifier in the query when goal has a valid repo", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - setupSupabaseMocks({ - goals: [makeCommitGoal({ repo: "testuser/myrepo" })], - }); - - vi.mocked(global.fetch).mockResolvedValue( - makeGitHubCommitsResponse(2) as any - ); - - await POST(); - - const url = vi.mocked(global.fetch).mock.calls[0][0] as string; - expect(url).toContain("repo%3Atestuser%2Fmyrepo"); - }); - - it("updates the goal with the fetched commit count", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - const { updateChain } = setupSupabaseMocks({ - goals: [makeCommitGoal({ id: "goal-abc" })], - }); - - vi.mocked(global.fetch).mockResolvedValue( - makeGitHubCommitsResponse(7) as any - ); - - const res = await POST(); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body.updated).toBe(1); - // Verify update was called with the commit count - expect(updateChain.update).toHaveBeenCalledWith( - expect.objectContaining({ current: 7 }) - ); - }); - - it("updates multiple commit goals independently", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - const { updateChain } = setupSupabaseMocks({ - goals: [ - makeCommitGoal({ id: "goal-1" }), - makeCommitGoal({ id: "goal-2", repo: "testuser/other-repo" }), - ], - }); - - vi.mocked(global.fetch) - .mockResolvedValueOnce(makeGitHubCommitsResponse(5) as any) - .mockResolvedValueOnce(makeGitHubCommitsResponse(3) as any); - - const res = await POST(); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body.updated).toBe(2); - expect(updateChain.update).toHaveBeenCalledTimes(2); - }); - - it("sets last_synced_at on the updated goal", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - const { updateChain } = setupSupabaseMocks({ - goals: [makeCommitGoal()], - }); - - vi.mocked(global.fetch).mockResolvedValue( - makeGitHubCommitsResponse(4) as any - ); - - await POST(); - - expect(updateChain.update).toHaveBeenCalledWith( - expect.objectContaining({ last_synced_at: expect.any(String) }) - ); - }); - }); - - // ── PR goals ──────────────────────────────────────────────────────────── - - describe("PR goal syncing", () => { - it("calls GitHub Issues Search API for PR goals", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - setupSupabaseMocks({ goals: [makePRGoal()] }); - - vi.mocked(global.fetch).mockResolvedValue( - makeGitHubPRsResponse(2) as any - ); - - await POST(); - - const url = vi.mocked(global.fetch).mock.calls[0][0] as string; - expect(url).toContain("api.github.com/search/issues"); - expect(url).toContain("type%3Apr"); - expect(url).toContain("is%3Amerged"); - }); - - it("updates all PR goals with the fetched PR count", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - const { updateChain } = setupSupabaseMocks({ - goals: [makePRGoal({ id: "pr-goal-1" }), makePRGoal({ id: "pr-goal-2" })], - }); - - vi.mocked(global.fetch).mockResolvedValue( - makeGitHubPRsResponse(4) as any - ); - - const res = await POST(); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body.updated).toBe(2); - expect(updateChain.update).toHaveBeenCalledWith( - expect.objectContaining({ current: 4 }) - ); - }); - }); - - // ── GitHub rate limiting ───────────────────────────────────────────────── - - describe("GitHub API rate limiting", () => { - it("returns 429 when GitHub returns 429 for commit search", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - const { updateChain } = setupSupabaseMocks({ - goals: [makeCommitGoal()], - }); - - vi.mocked(global.fetch).mockResolvedValue( - makeGitHubRateLimitResponse() as any - ); - - const res = await POST(); - const body = await res.json(); - - expect(res.status).toBe(429); - expect(body.rateLimited).toBe(true); - expect(body.error).toContain("rate limit"); - // Goals must NOT be updated when rate limited - expect(updateChain.update).not.toHaveBeenCalled(); - }); - - it("returns 429 when GitHub returns 403 for commit search", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - setupSupabaseMocks({ goals: [makeCommitGoal()] }); - - vi.mocked(global.fetch).mockResolvedValue({ - ok: false, - status: 403, - headers: new Headers(), - json: vi.fn().mockResolvedValue({}), - } as any); - - const res = await POST(); - expect(res.status).toBe(429); - }); - - it("includes reset time in message when X-RateLimit-Reset header is present", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - setupSupabaseMocks({ goals: [makeCommitGoal()] }); - - // A future Unix timestamp (year 2099) - const futureReset = Math.floor(new Date("2099-01-01T10:30:00Z").getTime() / 1000); - - vi.mocked(global.fetch).mockResolvedValue( - makeGitHubRateLimitResponse(futureReset) as any - ); - - const res = await POST(); - const body = await res.json(); - - expect(res.status).toBe(429); - expect(body.error).toContain("Sync will resume at"); - }); - - it("returns 429 and does not update PR goals when GitHub PR search is rate limited", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - const { updateChain } = setupSupabaseMocks({ - goals: [makePRGoal()], - }); - - vi.mocked(global.fetch).mockResolvedValue( - makeGitHubRateLimitResponse() as any - ); - - const res = await POST(); - - expect(res.status).toBe(429); - expect(updateChain.update).not.toHaveBeenCalled(); - }); - }); - - // ── GitHub API errors ──────────────────────────────────────────────────── - - describe("GitHub API non-rate-limit errors", () => { - it("returns 502 when GitHub returns a non-429 error for commit search", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - setupSupabaseMocks({ goals: [makeCommitGoal()] }); - - vi.mocked(global.fetch).mockResolvedValue({ - ok: false, - status: 500, - headers: new Headers(), - json: vi.fn().mockResolvedValue({}), - } as any); - - const res = await POST(); - expect(res.status).toBe(502); - }); - }); - // ── Supabase errors ───────────────────────────────────────────────────── - describe("Supabase failure handling", () => { - it("returns 500 when fetching goals from Supabase fails", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - setupSupabaseMocks({ - goalsError: { message: "DB connection failed" }, - goals: null as any, - }); - - const res = await POST(); - const body = await res.json(); - - expect(res.status).toBe(500); - expect(body.error).toBe("Failed to fetch goals"); - }); - - it("returns 500 when Supabase update fails for a commit goal", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - - // Wire the full chain manually so update().eq() resolves with an error - const updateEqMock = vi.fn().mockResolvedValue({ data: null, error: { message: "Update failed" } }); - const updateMock = vi.fn().mockReturnValue({ eq: updateEqMock, in: updateEqMock }); - - vi.mocked(supabaseAdmin.from).mockImplementation((table: string) => { - if (table === "users") { - return { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: mockUser, error: null }), - } as any; - } - // goals table — SELECT resolves with one commit goal, UPDATE fails - return { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - in: vi.fn().mockReturnThis(), - gte: vi.fn().mockReturnThis(), - lte: vi.fn().mockResolvedValue({ data: [makeCommitGoal()], error: null }), - update: updateMock, - } as any; - }); - - vi.mocked(global.fetch).mockResolvedValue( - makeGitHubCommitsResponse(5) as any - ); - - const res = await POST(); - const body = await res.json(); - - expect(res.status).toBe(500); - expect(body.error).toBe("Failed to update goals"); - }); - - it("returns 500 when Supabase update fails for PR goals", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - - const updateInMock = vi.fn().mockResolvedValue({ data: null, error: { message: "PR update failed" } }); - const updateMock = vi.fn().mockReturnValue({ eq: updateInMock, in: updateInMock }); - - vi.mocked(supabaseAdmin.from).mockImplementation((table: string) => { - if (table === "users") { - return { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: mockUser, error: null }), - } as any; - } - return { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - in: vi.fn().mockReturnThis(), - gte: vi.fn().mockReturnThis(), - lte: vi.fn().mockResolvedValue({ data: [makePRGoal()], error: null }), - update: updateMock, - } as any; - }); - - vi.mocked(global.fetch).mockResolvedValue( - makeGitHubPRsResponse(3) as any - ); - - const res = await POST(); - const body = await res.json(); - - expect(res.status).toBe(500); - expect(body.error).toBe("Failed to update PR goals"); - }); -}); - - // ── Date range ──────────────────────────────────────────────────────────── - - describe("date range correctness", () => { - it("uses Monday as week start and Sunday as week end in the query", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - setupSupabaseMocks({ goals: [makeCommitGoal()] }); - - vi.mocked(global.fetch).mockResolvedValue( - makeGitHubCommitsResponse(1) as any - ); - - await POST(); - - const url = vi.mocked(global.fetch).mock.calls[0][0] as string; - const decoded = decodeURIComponent(url); - - // The date range qualifier must be present - expect(decoded).toContain("author-date:"); - // Must contain .. range separator - expect(decoded).toMatch(/author-date:.+\.\..+/); - }); - - it("sends correct Authorization header to GitHub", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - setupSupabaseMocks({ goals: [makeCommitGoal()] }); - - vi.mocked(global.fetch).mockResolvedValue( - makeGitHubCommitsResponse(0) as any - ); - - await POST(); - - const fetchOptions = vi.mocked(global.fetch).mock.calls[0][1] as RequestInit; - expect((fetchOptions.headers as Record)["Authorization"]).toBe( - `Bearer ${mockSession.accessToken}` - ); - }); - }); - - // ── Pagination ──────────────────────────────────────────────────────────── - - describe("commit pagination", () => { - it("fetches additional pages when first page returns exactly 100 items", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - setupSupabaseMocks({ goals: [makeCommitGoal()] }); - - // First page: 100 items (triggers next page fetch) - // Second page: 30 items (stops pagination) - vi.mocked(global.fetch) - .mockResolvedValueOnce({ - ok: true, - status: 200, - headers: new Headers(), - json: vi.fn().mockResolvedValue({ - items: Array(100).fill({ sha: "abc" }), - }), - } as any) - .mockResolvedValueOnce({ - ok: true, - status: 200, - headers: new Headers(), - json: vi.fn().mockResolvedValue({ - items: Array(30).fill({ sha: "def" }), - }), - } as any); - - const { updateChain } = setupSupabaseMocks({ goals: [makeCommitGoal()] }); - - vi.mocked(global.fetch) - .mockResolvedValueOnce({ - ok: true, - status: 200, - headers: new Headers(), - json: vi.fn().mockResolvedValue({ items: Array(100).fill({}) }), - } as any) - .mockResolvedValueOnce({ - ok: true, - status: 200, - headers: new Headers(), - json: vi.fn().mockResolvedValue({ items: Array(30).fill({}) }), - } as any); - - await POST(); - - // Should have made exactly 2 GitHub API calls for this one goal - expect(vi.mocked(global.fetch)).toHaveBeenCalledTimes(2); - // Total commits = 100 + 30 = 130 - expect(updateChain.update).toHaveBeenCalledWith( - expect.objectContaining({ current: 130 }) - ); - }); - }); - - // ── Mixed goals ──────────────────────────────────────────────────────────── - - describe("mixed commit and PR goals", () => { - it("syncs both commit and PR goals and returns combined updated count", async () => { - vi.mocked(getServerSession).mockResolvedValue(mockSession as any); - const { updateChain } = setupSupabaseMocks({ - goals: [makeCommitGoal({ id: "c1" }), makePRGoal({ id: "p1" })], - }); - - vi.mocked(global.fetch) - .mockResolvedValueOnce(makeGitHubCommitsResponse(6) as any) // commits - .mockResolvedValueOnce(makeGitHubPRsResponse(2) as any); // prs - - const res = await POST(); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body.updated).toBe(2); - expect(updateChain.update).toHaveBeenCalledTimes(2); - }); - }); -}); \ No newline at end of file + .catch(err => console.error(err)) \ No newline at end of file diff --git a/test/user-export.test.ts b/test/user-export.test.ts index 4ff1be15c..da38a7beb 100644 --- a/test/user-export.test.ts +++ b/test/user-export.test.ts @@ -48,269 +48,4 @@ function makeRequest(headers: Record = {}): NextRequest { function buildChain(result: unknown) { const chain: any = { then: (onfulfilled: any) => Promise.resolve(result).then(onfulfilled), - }; - const resolve = vi.fn().mockResolvedValue(result); - const methods = ["select", "eq", "gte", "order", "limit", "maybeSingle", "single"]; - for (const m of methods) { - chain[m] = m === "maybeSingle" || m === "single" ? resolve : vi.fn().mockReturnValue(chain); - } - // make limit resolve too (some queries don't call maybeSingle) - (chain.limit as ReturnType).mockResolvedValue(result); - return { chain, resolve }; -} - -/** - * Quick setup: wires supabaseAdmin.from() so that: - * - data_export_audit queries return { data: auditRow, error: null } - * - All other tables return { data: [], error: null } - * - * `auditRow` null means "no recent export" → rate limit not hit. - */ -function setupSupabase(auditRow: unknown = null) { - const auditInsert = vi.fn().mockResolvedValue({ data: null, error: null }); - const emptyResult = { data: [], error: null }; - const singleResult = { data: { id: "user-1", github_login: "alice", bio: "", is_public: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, timezone: "UTC", created_at: "2024-01-01T00:00:00Z" }, error: null }; - const streakMilestonesSelect = vi.fn().mockReturnValue(buildChain(emptyResult).chain); - - mocks.supabaseFrom.mockImplementation((table: string) => { - if (table === "data_export_audit") { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - gte: vi.fn().mockReturnValue({ - order: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue({ - maybeSingle: vi.fn().mockResolvedValue({ data: auditRow, error: null }), - }), - }), - }), - }), - }), - }), - insert: auditInsert, - }; - } - if (table === "users") { - const { chain } = buildChain(singleResult); - return { select: vi.fn().mockReturnValue(chain) }; - } - if (table === "streak_milestones") { - return { select: streakMilestonesSelect }; - } - // All other tables return empty arrays - const { chain } = buildChain(emptyResult); - return { select: vi.fn().mockReturnValue(chain) }; - }); - - return { auditInsert, streakMilestonesSelect }; -} - -// ─── Tests ──────────────────────────────────────────────────────────────────── - -describe("GET /api/user/export", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.getServerSession.mockResolvedValue({ - githubId: "gh-1", - githubLogin: "alice", - }); - mocks.resolveAppUser.mockResolvedValue({ id: "user-1" }); - }); - - // ── Authentication ────────────────────────────────────────────────────── - - it("returns 401 when there is no session", async () => { - mocks.getServerSession.mockResolvedValue(null); - const { GET } = await import("@/app/api/user/export/route"); - const res = await GET(makeRequest()); - expect(res.status).toBe(401); - }); - - it("returns 401 when session has no githubId", async () => { - mocks.getServerSession.mockResolvedValue({ githubLogin: "alice" }); - const { GET } = await import("@/app/api/user/export/route"); - const res = await GET(makeRequest()); - expect(res.status).toBe(401); - }); - - it("returns 404 when the user cannot be resolved", async () => { - mocks.resolveAppUser.mockResolvedValue(null); - setupSupabase(); - const { GET } = await import("@/app/api/user/export/route"); - const res = await GET(makeRequest()); - expect(res.status).toBe(404); - }); - - // ── Rate limiting ─────────────────────────────────────────────────────── - - it("returns 429 when an export was made within the last hour", async () => { - setupSupabase({ created_at: new Date().toISOString() }); - const { GET } = await import("@/app/api/user/export/route"); - const res = await GET(makeRequest()); - expect(res.status).toBe(429); - }); - - it("includes Retry-After header in 429 response", async () => { - setupSupabase({ created_at: new Date().toISOString() }); - const { GET } = await import("@/app/api/user/export/route"); - const res = await GET(makeRequest()); - expect(res.headers.get("Retry-After")).toBeTruthy(); - }); - - it("includes retryAfterSeconds in 429 response body", async () => { - setupSupabase({ created_at: new Date().toISOString() }); - const { GET } = await import("@/app/api/user/export/route"); - const res = await GET(makeRequest()); - const body = await res.json(); - expect(typeof body.retryAfterSeconds).toBe("number"); - expect(body.retryAfterSeconds).toBeGreaterThan(0); - }); - - it("allows export when no recent export record exists", async () => { - setupSupabase(null); - const { GET } = await import("@/app/api/user/export/route"); - const res = await GET(makeRequest()); - expect(res.status).toBe(200); - }); - - // ── ZIP response ──────────────────────────────────────────────────────── - - it("returns Content-Type application/zip", async () => { - setupSupabase(null); - const { GET } = await import("@/app/api/user/export/route"); - const res = await GET(makeRequest()); - expect(res.headers.get("Content-Type")).toBe("application/zip"); - }); - - it("returns a non-empty response body", async () => { - setupSupabase(null); - const { GET } = await import("@/app/api/user/export/route"); - const res = await GET(makeRequest()); - const buf = await res.arrayBuffer(); - expect(buf.byteLength).toBeGreaterThan(0); - }); - - it("sets Content-Disposition as attachment with .zip filename", async () => { - setupSupabase(null); - const { GET } = await import("@/app/api/user/export/route"); - const res = await GET(makeRequest()); - const cd = res.headers.get("Content-Disposition") ?? ""; - expect(cd).toContain("attachment"); - expect(cd).toContain(".zip"); - }); - - it("sets Cache-Control: no-store", async () => { - setupSupabase(null); - const { GET } = await import("@/app/api/user/export/route"); - const res = await GET(makeRequest()); - expect(res.headers.get("Cache-Control")).toBe("no-store"); - }); - - // ── Audit logging ─────────────────────────────────────────────────────── - - it("writes an audit log entry for a successful export", async () => { - const { auditInsert } = setupSupabase(null); - const { GET } = await import("@/app/api/user/export/route"); - await GET(makeRequest()); - expect(auditInsert).toHaveBeenCalledOnce(); - const [row] = auditInsert.mock.calls[0]; - expect(row.user_id).toBe("user-1"); - expect(row.action).toBe("export"); - }); - - it("does not write an audit log entry when rate-limited", async () => { - const { auditInsert } = setupSupabase({ created_at: new Date().toISOString() }); - const { GET } = await import("@/app/api/user/export/route"); - await GET(makeRequest()); - expect(auditInsert).not.toHaveBeenCalled(); - }); - - it("queries the correct column names for streak milestones", async () => { - const { streakMilestonesSelect } = setupSupabase(null); - const { GET } = await import("@/app/api/user/export/route"); - await GET(makeRequest()); - expect(streakMilestonesSelect).toHaveBeenCalledWith("id, streak_count, achieved_at"); - }); -}); - -// ─── CSV utilities ──────────────────────────────────────────────────────────── - -describe("toCsv", () => { - it("returns empty string for an empty array", async () => { - const { toCsv } = await import("@/lib/csv"); - expect(toCsv([])).toBe(""); - }); - - it("generates a header row from the first object's keys", async () => { - const { toCsv } = await import("@/lib/csv"); - const csv = toCsv([{ a: 1, b: 2 }]); - const [header] = csv.split("\n"); - expect(header).toBe("a,b"); - }); - - it("serialises multiple rows correctly", async () => { - const { toCsv } = await import("@/lib/csv"); - const csv = toCsv([ - { date: "2024-01-01", count: 5 }, - { date: "2024-01-02", count: 3 }, - ]); - const lines = csv.split("\n"); - expect(lines).toHaveLength(3); - expect(lines[1]).toBe("2024-01-01,5"); - expect(lines[2]).toBe("2024-01-02,3"); - }); - - it("wraps values containing commas in double-quotes", async () => { - const { toCsv } = await import("@/lib/csv"); - const csv = toCsv([{ name: "Smith, John", score: 10 }]); - expect(csv).toContain('"Smith, John"'); - }); - - it("escapes double-quotes by doubling them", async () => { - const { toCsv } = await import("@/lib/csv"); - const csv = toCsv([{ note: 'He said "hello"' }]); - expect(csv).toContain('"He said ""hello"""'); - }); - - it("renders null as an empty cell", async () => { - const { toCsv } = await import("@/lib/csv"); - const csv = toCsv([{ a: null, b: 1 }]); - const [, dataRow] = csv.split("\n"); - expect(dataRow).toBe(",1"); - }); -}); - -// ─── csvCell ───────────────────────────────────────────────────────────────── - -describe("csvCell", () => { - it("returns empty string for null", async () => { - const { csvCell } = await import("@/lib/csv"); - expect(csvCell(null)).toBe(""); - }); - - it("returns empty string for undefined", async () => { - const { csvCell } = await import("@/lib/csv"); - expect(csvCell(undefined)).toBe(""); - }); - - it("returns plain string for a simple value", async () => { - const { csvCell } = await import("@/lib/csv"); - expect(csvCell("hello")).toBe("hello"); - }); - - it("quotes values that contain a comma", async () => { - const { csvCell } = await import("@/lib/csv"); - expect(csvCell("a,b")).toBe('"a,b"'); - }); - - it("quotes values that contain a newline", async () => { - const { csvCell } = await import("@/lib/csv"); - expect(csvCell("line1\nline2")).toBe('"line1\nline2"'); - }); - - it("converts numbers to string without quoting", async () => { - const { csvCell } = await import("@/lib/csv"); - expect(csvCell(42)).toBe("42"); - }); -}); + .catch(err => console.error(err)) \ No newline at end of file