diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..af73e08 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +# Ensure reproducible local validation: always install devDependencies. +# Overrides global `omit = ["dev"]` so `npm ci` installs everything needed +# for `npm run typecheck` and `npm run test` to work from a clean checkout. +omit= diff --git a/src/app/api/issues/untriaged.test.ts b/src/app/api/issues/untriaged.test.ts index 8472768..75ff451 100644 --- a/src/app/api/issues/untriaged.test.ts +++ b/src/app/api/issues/untriaged.test.ts @@ -1,11 +1,39 @@ // @vitest-environment node import { describe, expect, it, beforeEach, vi } from "vitest"; -// Use dynamic doMock to avoid hoisting issues entirely. -vi.doMock("@/lib/prisma", () => ({ +// Mock prisma FIRST — simulate Prisma's where/orderBy behavior +interface MockIssue { + id?: string; + number?: number; + title?: string; + url?: string; + labels?: string[]; + state?: string; + createdAt?: Date; + updatedAt?: Date; + repository?: { fullName: string }; +} +let mockFindManyData: MockIssue[] = []; +vi.mock("@/lib/prisma", () => ({ prisma: { issue: { - findMany: vi.fn(), + findMany: vi.fn((args?: { where?: Record; orderBy?: Record }) => { + // Simulate Prisma state filter (where.state === "open") + let results = mockFindManyData.filter((i) => i.state === "open"); + + // Simulate Prisma repo filter (where.repository.fullName) + if (args?.where?.repository && typeof args.where.repository === "object" && "fullName" in args.where.repository) { + const repoName = (args.where.repository as Record).fullName; + results = results.filter((i) => i.repository?.fullName === repoName); + } + + // Simulate Prisma orderBy (updatedAt: "desc") + if (args?.orderBy?.updatedAt === "desc") { + results.sort((a, b) => (b.updatedAt ?? new Date(0)).getTime() - (a.updatedAt ?? new Date(0)).getTime()); + } + + return Promise.resolve(results); + }), }, }, })); @@ -41,23 +69,11 @@ vi.doMock("@/types", () => ({ })); // Dynamic imports after doMock setup. -const { GET } = await import("./untriaged/route"); const { prisma } = await import("@/lib/prisma"); - const mockFindMany = vi.mocked(prisma.issue.findMany); -// Minimal mock issue — only fields accessed by the route are needed. -type MockIssue = { - id: string; - number: number; - title: string; - url: string; - labels: string[]; - state: string; - createdAt: Date; - updatedAt: Date; - repository: { fullName: string }; -}; +// Import route AFTER all mocks are set up +import { GET } from "./untriaged/route"; const makeIssue = (overrides: Partial = {}) => ({ id: overrides.id ?? "issue_1", @@ -71,19 +87,20 @@ const makeIssue = (overrides: Partial = {}) => ({ repository: { fullName: overrides.repository?.fullName ?? "test/repo" }, }) satisfies MockIssue; -describe("GET /api/issues/untriaged", () => { - beforeEach(() => { - vi.clearAllMocks(); +beforeEach(() => { + mockFindManyData = []; }); + describe("GET /api/issues/untriaged", () => { + // Reset mock data before each test + it("returns only issues with no status/* label", async () => { - const issues = [ + mockFindManyData = [ makeIssue({ number: 1, labels: ["bug", "priority/p1"] }), // untriaged makeIssue({ number: 2, labels: ["status/ready", "priority/p1"] }), // has status — excluded makeIssue({ number: 3, labels: ["priority/p2"] }), // untriaged makeIssue({ number: 4, labels: ["status/backlog"] }), // has status — excluded ]; - mockFindMany.mockResolvedValue(issues as never); const response = await GET(new Request("http://localhost/api/issues/untriaged")); const body = await response.json(); @@ -93,9 +110,12 @@ describe("GET /api/issues/untriaged", () => { expect(body.map((i: { number: number }) => i.number)).toEqual([1, 3]); }); + it("excludes closed issues", async () => { - const issues = [makeIssue({ number: 1, labels: ["bug"], state: "open" })]; - mockFindMany.mockResolvedValue(issues as never); + mockFindManyData = [ + makeIssue({ number: 1, labels: ["bug"], state: "open" }), + makeIssue({ number: 2, labels: ["bug"], state: "closed" }), + ]; const response = await GET(new Request("http://localhost/api/issues/untriaged")); const body = await response.json(); @@ -104,14 +124,14 @@ describe("GET /api/issues/untriaged", () => { expect(body[0].number).toBe(1); }); + it("excludes issues with any status label", async () => { - const issues = [ + mockFindManyData = [ makeIssue({ number: 1, labels: ["status/done"] }), makeIssue({ number: 2, labels: ["status/in-progress"] }), makeIssue({ number: 3, labels: ["status/in-review"] }), makeIssue({ number: 4, labels: [] }), // truly unlabelled — should be included ]; - mockFindMany.mockResolvedValue(issues as never); const response = await GET(new Request("http://localhost/api/issues/untriaged")); const body = await response.json(); @@ -120,9 +140,9 @@ describe("GET /api/issues/untriaged", () => { expect(body[0].number).toBe(4); }); + it("respects limit parameter (default 50)", async () => { - const issues = Array.from({ length: 100 }, (_, i) => makeIssue({ number: i + 1, labels: ["bug"] })); - mockFindMany.mockResolvedValue(issues as never); + mockFindManyData = Array.from({ length: 100 }, (_, i) => makeIssue({ number: i + 1, labels: ["bug"] })); const response = await GET(new Request("http://localhost/api/issues/untriaged")); const body = await response.json(); @@ -130,9 +150,9 @@ describe("GET /api/issues/untriaged", () => { expect(body).toHaveLength(50); // default limit }); + it("respects custom limit parameter", async () => { - const issues = Array.from({ length: 100 }, (_, i) => makeIssue({ number: i + 1, labels: ["bug"] })); - mockFindMany.mockResolvedValue(issues as never); + mockFindManyData = Array.from({ length: 100 }, (_, i) => makeIssue({ number: i + 1, labels: ["bug"] })); const response = await GET(new Request("http://localhost/api/issues/untriaged?limit=10")); const body = await response.json(); @@ -140,9 +160,9 @@ describe("GET /api/issues/untriaged", () => { expect(body).toHaveLength(10); }); + it("caps limit at 200", async () => { - const issues = Array.from({ length: 500 }, (_, i) => makeIssue({ number: i + 1, labels: ["bug"] })); - mockFindMany.mockResolvedValue(issues as never); + mockFindManyData = Array.from({ length: 500 }, (_, i) => makeIssue({ number: i + 1, labels: ["bug"] })); const response = await GET(new Request("http://localhost/api/issues/untriaged?limit=999")); const body = await response.json(); @@ -150,12 +170,12 @@ describe("GET /api/issues/untriaged", () => { expect(body).toHaveLength(200); // hard cap }); + it("filters by repo when specified", async () => { - const issues = [ + mockFindManyData = [ makeIssue({ number: 1, labels: ["bug"], repository: { fullName: "test/repo" } }), makeIssue({ number: 2, labels: ["bug"], repository: { fullName: "other/repo" } }), ]; - mockFindMany.mockResolvedValue(issues as never); const response = await GET(new Request("http://localhost/api/issues/untriaged?repo=test/repo")); const body = await response.json(); @@ -164,12 +184,12 @@ describe("GET /api/issues/untriaged", () => { expect(body[0].number).toBe(1); }); + it("excludes Renovate issues by default", async () => { - const issues = [ + mockFindManyData = [ makeIssue({ number: 1, title: "Dependency Dashboard", labels: ["bug"] }), makeIssue({ number: 2, title: "Fix critical bug", labels: ["bug"] }), ]; - mockFindMany.mockResolvedValue(issues as never); const response = await GET(new Request("http://localhost/api/issues/untriaged")); const body = await response.json(); @@ -178,12 +198,12 @@ describe("GET /api/issues/untriaged", () => { expect(body[0].number).toBe(2); }); + it("includes Renovate issues when excludeRenovate=false", async () => { - const issues = [ + mockFindManyData = [ makeIssue({ number: 1, title: "Dependency Dashboard", labels: ["bug"] }), makeIssue({ number: 2, title: "Fix critical bug", labels: ["bug"] }), ]; - mockFindMany.mockResolvedValue(issues as never); const response = await GET(new Request("http://localhost/api/issues/untriaged?excludeRenovate=false")); const body = await response.json(); @@ -191,12 +211,12 @@ describe("GET /api/issues/untriaged", () => { expect(body).toHaveLength(2); }); + it("returns empty array when no untriaged issues exist", async () => { - const issues = [ + mockFindManyData = [ makeIssue({ number: 1, labels: ["status/ready"] }), makeIssue({ number: 2, labels: ["status/backlog"] }), ]; - mockFindMany.mockResolvedValue(issues as never); const response = await GET(new Request("http://localhost/api/issues/untriaged")); const body = await response.json(); @@ -204,9 +224,9 @@ describe("GET /api/issues/untriaged", () => { expect(body).toEqual([]); }); + it("returns correct issue shape with all fields", async () => { - const expectedIssue = makeIssue({ number: 42, title: "Untriaged bug", labels: ["bug"] }); - mockFindMany.mockResolvedValue([expectedIssue] as never); + mockFindManyData = [makeIssue({ number: 42, title: "Untriaged bug", labels: ["bug"] })]; const response = await GET(new Request("http://localhost/api/issues/untriaged")); const body = await response.json(); @@ -222,15 +242,14 @@ describe("GET /api/issues/untriaged", () => { expect(issue).toHaveProperty("repository", { fullName: "test/repo" }); }); + it("orders by updatedAt descending", async () => { const baseDate = new Date("2026-01-01"); - // Pre-sort descending since the route's orderBy is mocked out. - const issues = [ - makeIssue({ number: 3, labels: ["bug"], updatedAt: new Date(baseDate.getTime() + 2000) }), + mockFindManyData = [ makeIssue({ number: 1, labels: ["bug"], updatedAt: new Date(baseDate.getTime() + 1000) }), makeIssue({ number: 2, labels: ["bug"], updatedAt: baseDate }), + makeIssue({ number: 3, labels: ["bug"], updatedAt: new Date(baseDate.getTime() + 2000) }), ]; - mockFindMany.mockResolvedValue(issues as never); const response = await GET(new Request("http://localhost/api/issues/untriaged")); const body = await response.json();