Files
projectbrain-Public/backend/test/analysis-job-capacity.test.ts
T

94 lines
4.8 KiB
TypeScript

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();
});
});