Files
projectbrain-Public/backend/test/database-repository-sync.test.ts

153 lines
8.8 KiB
TypeScript

import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { loadEnv } from "../src/config/env.js";
import { prisma } from "../src/db/prisma.js";
import { createProject } from "../src/repositories/projectsRepository.js";
import { reconcilePortfolioRepositories } from "../src/services/portfolioControlPlane.js";
import { PROJECT_REPOSITORY_PROVISION_ACTION, provisionProjectRepository } from "../src/services/projectRepositoryProvisioning.js";
const databaseUrl = process.env.TEST_DATABASE_URL;
if (databaseUrl) process.env.DATABASE_URL = databaseUrl;
const databaseDescribe = databaseUrl ? describe : describe.skip;
const owner = "ProjectBrainSyncProof";
const createdProjectSlugs: string[] = [];
const env = () => loadEnv({
NODE_ENV: "test",
LOG_LEVEL: "silent",
DATABASE_URL: databaseUrl,
PROJECTBRAIN_DATA_SOURCE: "database",
PROJECTBRAIN_OWNER_USERNAME: "database-owner",
PROJECTBRAIN_OWNER_PASSWORD: "database-owner-password-value",
PROJECTBRAIN_AUTH_SECRET: "database-test-secret-with-at-least-thirty-two-characters",
PROJECTBRAIN_GITEA_URL: "https://gitea.example.com",
PROJECTBRAIN_GITEA_TOKEN: "database-gitea-token-with-twenty-characters",
PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: "true",
PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: "60",
});
const repositoryPayload = (name: string) => ({
id: name.length * 100,
name,
full_name: `${owner}/${name}`,
description: `Repository sync proof for ${name}`,
private: true,
archived: false,
default_branch: "main",
html_url: `https://gitea.example.com/${owner}/${name}`,
updated_at: "2026-09-02T00:00:00Z",
owner: { login: owner },
permissions: { admin: true, push: true, pull: true },
});
const removeProofData = async () => {
await prisma.portfolioRepository.deleteMany({ where: { owner } });
if (createdProjectSlugs.length) await prisma.project.deleteMany({ where: { slug: { in: createdProjectSlugs } } });
};
databaseDescribe("bidirectional ProjectBrain and Gitea repository sync", () => {
beforeAll(removeProofData);
afterEach(() => {
vi.unstubAllGlobals();
});
afterAll(async () => {
await removeProofData();
await prisma.$disconnect();
});
it("provisions and links one initialized Gitea repository for a new ProjectBrain project", async () => {
const slug = `pb-to-gitea-${Date.now().toString(36)}`;
createdProjectSlugs.push(slug);
await createProject({
id: slug,
name: "ProjectBrain to Gitea proof",
description: "Verifies durable repository provisioning from a ProjectBrain project.",
primaryGoal: "Create exactly one linked repository",
});
const remote = repositoryPayload(slug);
let repositoryExists = false;
const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input instanceof Request ? input.url : input);
if (url.endsWith("/api/v1/user")) return new Response(JSON.stringify({ id: 1, login: owner }), { status: 200, headers: { "content-type": "application/json" } });
if (url.endsWith("/api/v1/user/repos") && init?.method === "POST") {
repositoryExists = true;
return new Response(JSON.stringify(remote), { status: 201, headers: { "content-type": "application/json" } });
}
if (url.endsWith(`/api/v1/repos/${owner}/${slug}`) && repositoryExists) return new Response(JSON.stringify(remote), { status: 200, headers: { "content-type": "application/json" } });
return new Response(JSON.stringify({ message: "not found" }), { status: 404, headers: { "content-type": "application/json" } });
});
vi.stubGlobal("fetch", fetcher);
const first = await provisionProjectRepository(env(), slug, "database:provisioning-proof");
const second = await provisionProjectRepository(env(), slug, "database:provisioning-proof");
expect(first).toMatchObject({ status: "ready", created: true, repository: { fullName: `${owner}/${slug}`, isPrimary: true } });
expect(second).toMatchObject({ status: "ready", created: false, repository: { fullName: `${owner}/${slug}` } });
expect(fetcher.mock.calls.filter(([input, init]) => String(input).endsWith("/api/v1/user/repos") && init?.method === "POST")).toHaveLength(1);
const project = await prisma.project.findUniqueOrThrow({ where: { slug } });
expect(await prisma.giteaRepositoryLink.count({ where: { projectId: project.id } })).toBe(1);
expect(await prisma.externalOperation.count({ where: { projectId: project.id, action: PROJECT_REPOSITORY_PROVISION_ACTION, status: "succeeded" } })).toBe(1);
expect(await prisma.portfolioRepository.findUnique({ where: { owner_repository: { owner, repository: slug } } })).toMatchObject({ projectId: project.id, status: "onboarded" });
}, 15_000);
it("discovers a new Gitea repository and creates its linked ProjectBrain project", async () => {
const name = `gitea-to-pb-${Date.now().toString(36)}`;
createdProjectSlugs.push(name);
const remote = repositoryPayload(name);
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
const url = String(input instanceof Request ? input.url : input);
if (url.includes("/api/v1/user/repos")) {
const page = new URL(url).searchParams.get("page");
return new Response(JSON.stringify(page === "1" ? [remote] : []), { status: 200, headers: { "content-type": "application/json" } });
}
if (url.endsWith(`/api/v1/repos/${owner}/${name}`)) return new Response(JSON.stringify(remote), { status: 200, headers: { "content-type": "application/json" } });
return new Response(JSON.stringify({ message: "not found" }), { status: 404, headers: { "content-type": "application/json" } });
}));
const reconciliation = await reconcilePortfolioRepositories(env(), "database:discovery-proof", {
dryRun: false,
repositoryFullNames: [`${owner}/${name}`],
});
expect(reconciliation).toMatchObject({ candidateCount: 1, onboardedCount: 1, failedCount: 0 });
const project = await prisma.project.findUniqueOrThrow({ where: { slug: name } });
expect(await prisma.giteaRepositoryLink.findUnique({ where: { owner_repository: { owner, repository: name } } })).toMatchObject({ projectId: project.id, syncStatus: "ready" });
expect(await prisma.portfolioRepository.findUnique({ where: { owner_repository: { owner, repository: name } } })).toMatchObject({ projectId: project.id, status: "onboarded" });
}, 15_000);
it("resumes an ambiguous create without issuing a duplicate repository mutation", async () => {
const slug = `pb-retry-${Date.now().toString(36)}`;
createdProjectSlugs.push(slug);
await createProject({
id: slug,
name: "ProjectBrain repository retry proof",
description: "Verifies that an uncertain Gitea create is resumed without a duplicate repository.",
primaryGoal: "Resume one durable provisioning operation",
});
const remote = repositoryPayload(slug);
let repositoryExists = false;
const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input instanceof Request ? input.url : input);
if (url.endsWith("/api/v1/user")) return new Response(JSON.stringify({ id: 1, login: owner }), { status: 200, headers: { "content-type": "application/json" } });
if (url.endsWith("/api/v1/user/repos") && init?.method === "POST") {
repositoryExists = true;
return new Response(JSON.stringify({ message: "upstream result unknown" }), { status: 503, headers: { "content-type": "application/json" } });
}
if (url.endsWith(`/api/v1/repos/${owner}/${slug}`) && repositoryExists) return new Response(JSON.stringify(remote), { status: 200, headers: { "content-type": "application/json" } });
return new Response(JSON.stringify({ message: "not found" }), { status: 404, headers: { "content-type": "application/json" } });
});
vi.stubGlobal("fetch", fetcher);
await expect(provisionProjectRepository(env(), slug, "database:retry-proof")).rejects.toMatchObject({ status: 503, code: "GITEA_HTTP_503" });
const resumed = await provisionProjectRepository(env(), slug, "database:retry-proof");
expect(resumed).toMatchObject({ status: "ready", created: false, repository: { fullName: `${owner}/${slug}` } });
expect(fetcher.mock.calls.filter(([input, init]) => String(input).endsWith("/api/v1/user/repos") && init?.method === "POST")).toHaveLength(1);
const project = await prisma.project.findUniqueOrThrow({ where: { slug } });
expect(await prisma.externalOperation.count({ where: { projectId: project.id, action: PROJECT_REPOSITORY_PROVISION_ACTION, status: "failed" } })).toBe(1);
expect(await prisma.externalOperation.count({ where: { projectId: project.id, action: PROJECT_REPOSITORY_PROVISION_ACTION, status: "succeeded" } })).toBe(1);
expect(await prisma.giteaRepositoryLink.count({ where: { projectId: project.id } })).toBe(1);
}, 15_000);
});