94 lines
4.8 KiB
TypeScript
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();
|
|
});
|
|
});
|