import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { mkdtemp, rm, access } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { AssetRepository } from "../src/gatekeeper/repository.js"; import { GitCommitter } from "../src/autoext/git-committer.js"; import { AutoExtensionService } from "../src/autoext/auto-extension.js"; import { createLogger } from "../src/audit/log.js"; import type { AlertSender, AlertEvent } from "../src/alerts/types.js"; import type { AssetDraft } from "../src/autoext/artifact-writer.js"; const exec = promisify(execFile); const log = createLogger("silent"); class CapturingSender implements AlertSender { events: AlertEvent[] = []; async send(e: AlertEvent): Promise { this.events.push(e); } } const draft = (over: Partial = {}): AssetDraft => ({ type: "workflow-n8n", name: "Backup NC", version: "1.0.0", riskTier: "privileged", summary: "Sauvegarde.", content: '{"a":1}', ...over, }); let root: string; let repo: AssetRepository; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "chlova-git-")); await exec("git", ["init", "-q"], { cwd: root }); await exec("git", ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q", "-m", "init"], { cwd: root }); repo = new AssetRepository(":memory:"); }); afterEach(async () => { repo.close(); await rm(root, { recursive: true, force: true }); }); function service(alerts: AlertSender): AutoExtensionService { return new AutoExtensionService(repo, new GitCommitter(root), alerts, log, root); } describe("AutoExtensionService.propose", () => { it("écrit, commit, enregistre BLOQUÉ (privilégié) et alerte", async () => { const sender = new CapturingSender(); const res = await service(sender).propose(draft()); expect(res.asset.status).toBe("bloqué"); // privilégié → aucun sursis expect(res.commit).toMatch(/^[0-9a-f]{7,}$/); expect(res.asset.commitLink).toBe(res.commit); expect(res.asset.docLink).toBe(res.docPath); // fichiers présents + commités await expect(access(join(root, res.artifactPath))).resolves.toBeUndefined(); const { stdout } = await exec("git", ["log", "--oneline"], { cwd: root }); expect(stdout).toContain("need-review"); // asset persistant + alerte expect(repo.get(res.asset.id)?.status).toBe("bloqué"); expect(sender.events.map((e) => e.kind)).toContain("asset_created"); }); it("réversible → PROVISOIRE", async () => { const res = await service(new CapturingSender()).propose(draft({ riskTier: "reversible", name: "RO tool", type: "tool" })); expect(res.asset.status).toBe("provisoire"); expect(res.asset.expiresAt).not.toBeNull(); }); it("refuse un doublon (même id)", async () => { const svc = service(new CapturingSender()); await svc.propose(draft()); await expect(svc.propose(draft())).rejects.toThrow(/existant/); }); });