import { beforeEach, describe, expect, it, vi } from "vitest"; import { loadEnv } from "../src/config/env.js"; import { createPortfolioAnalysisJob } from "../src/repositories/analysisJobRepository.js"; import { refreshPortfolioCatalog } from "../src/services/portfolioControlPlane.js"; import { registerAnalysisJobRoutes } from "../src/routes/analysisJobs.js"; import Fastify from "fastify"; const mocks = vi.hoisted(() => ({ inventory: vi.fn(), links: vi.fn(), findJob: vi.fn(), findFirstJob: vi.fn(), createJob: vi.fn(), catalog: vi.fn(), upsert: vi.fn(), unavailable: vi.fn(), })); vi.mock("../src/integrations/gitea/service.js", () => ({ getGiteaClient: () => ({ listRepositories: mocks.inventory }), listLinkedRepositories: mocks.links, })); vi.mock("../src/db/prisma.js", () => ({ prisma: { analysisJob: { findUnique: mocks.findJob, findFirst: mocks.findFirstJob, create: mocks.createJob }, portfolioRepository: { findMany: mocks.catalog, upsert: mocks.upsert, updateMany: mocks.unavailable }, } })); const env = loadEnv({ NODE_ENV: "test", PROJECTBRAIN_DATA_SOURCE: "database", DATABASE_URL: "postgresql://fixture:fixture@localhost:5432/fixture", PROJECTBRAIN_OWNER_USERNAME: "fixture-owner", PROJECTBRAIN_OWNER_PASSWORD: "fixture-only-password", PROJECTBRAIN_AUTH_SECRET: "fixture-only-auth-secret-at-least-32-characters" }); const repositories = (count: number) => Array.from({ length: count }, (_, index) => ({ id: index + 1, name: `repo-${index}`, full_name: `Owner/repo-${index}`, owner: { login: "Owner" }, default_branch: "main", permissions: { pull: true }, })); beforeEach(() => { vi.clearAllMocks(); mocks.links.mockResolvedValue([]); mocks.catalog.mockResolvedValue([]); mocks.findJob.mockResolvedValue(null); mocks.findFirstJob.mockResolvedValue(null); mocks.createJob.mockImplementation(async ({ data }) => ({ id: "fixture-job", status: "queued", progressTotal: data.progressTotal, progressCurrent: 0, })); }); describe("bounded complete portfolio analysis", () => { it.each([58, 200, 201])("enforces the same %i-selection boundary at the project HTTP route", async (count) => { mocks.links.mockResolvedValue(repositories(count).map((repository) => ({ id: String(repository.id), projectId: "fixture-project", projectSlug: "fixture-project", projectName: "Fixture", owner: "Owner", repository: repository.name, fullName: repository.full_name, defaultBranch: "main", accessMode: "read_only", }))); const app = Fastify(); app.decorateRequest("authUser", null); app.addHook("onRequest", async (request) => { request.authUser = { id: "fixture-owner", username: "fixture-owner", role: "owner", credentialVersion: 1 }; }); await registerAnalysisJobRoutes(app, { env }); try { const response = await app.inject({ method: "POST", url: "/api/projects/fixture-project/analysis-jobs", payload: { repositoryIds: Array.from({ length: count }, (_, index) => String(index + 1)) } }); if (count <= 200) { expect(response.statusCode).toBe(202); expect(response.json().data.job.progressTotal).toBe(count); expect(mocks.createJob.mock.calls[0][0].data.items.create).toHaveLength(count); } else { expect(response.statusCode).toBeGreaterThanOrEqual(400); expect(mocks.createJob).not.toHaveBeenCalled(); } } finally { await app.close(); } }); it.each([51, 58, 200])("persists all %i targets without truncation", async (count) => { mocks.inventory.mockResolvedValue(repositories(count)); const result = await createPortfolioAnalysisJob(env, "fixture-actor", {}); expect(result.job.progressTotal).toBe(count); const data = mocks.createJob.mock.calls[0][0].data; expect(data.items.create).toHaveLength(count); expect(data.requestJson.repositoryIds).toHaveLength(count); expect(new Set(data.items.create.map((item: { sequence: number }) => item.sequence)).size).toBe(count); }); it("rejects 201 targets before job persistence", async () => { mocks.inventory.mockResolvedValue(repositories(201)); await expect(createPortfolioAnalysisJob(env, "fixture-actor", {})).rejects.toThrow("ANALYSIS_REPOSITORY_LIMIT_EXCEEDED"); expect(mocks.createJob).not.toHaveBeenCalled(); }); it("does not persist a job or mark cached repositories unavailable after incomplete inventory", async () => { mocks.inventory.mockRejectedValue(new Error("GITEA_INVENTORY_LIMIT_EXCEEDED")); await expect(createPortfolioAnalysisJob(env, "fixture-actor", {})).rejects.toThrow("GITEA_INVENTORY_LIMIT_EXCEEDED"); await expect(refreshPortfolioCatalog(env)).rejects.toThrow("GITEA_INVENTORY_LIMIT_EXCEEDED"); expect(mocks.createJob).not.toHaveBeenCalled(); expect(mocks.upsert).not.toHaveBeenCalled(); expect(mocks.unavailable).not.toHaveBeenCalled(); }); });