From 157729ea953cb24a31b59eb8760a2d18a59af578 Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Sun, 12 Apr 2026 23:57:07 +0000 Subject: [PATCH] test(skills): add service-level tests for scan prune path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the pruning logic in scanProjectWorkspaces with integration tests using embedded Postgres and mocked GitHub HTTP layer: - skill removed from source → detached from agents → deleted from DB - skill removed with no agent references → deleted without warnings - source fetch failure → no pruning, warning emitted Addresses review feedback on PR #3351 requesting test coverage for the destructive prune/delete behavior. Co-Authored-By: Paperclip --- .../__tests__/company-skills-prune.test.ts | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 server/src/__tests__/company-skills-prune.test.ts diff --git a/server/src/__tests__/company-skills-prune.test.ts b/server/src/__tests__/company-skills-prune.test.ts new file mode 100644 index 00000000..87834039 --- /dev/null +++ b/server/src/__tests__/company-skills-prune.test.ts @@ -0,0 +1,282 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + agents, + companies, + companySkills, + createDb, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +// Mock the adapter layer so usage() never tries to call real adapters +vi.mock("../adapters/index.js", () => ({ + findActiveServerAdapter: () => null, +})); + +// Mock ghFetch so readUrlSkillImports never makes real HTTP calls. +// gitHubApiBase and resolveRawGitHubUrl are pure functions — pass them through. +const mockGhFetch = vi.fn(); +vi.mock("../services/github-fetch.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + ghFetch: (...args: unknown[]) => mockGhFetch(...args), + }; +}); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping company-skills prune tests: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("scanProjectWorkspaces prune path", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + const companyId = randomUUID(); + const agentId = randomUUID(); + const skillKeepId = randomUUID(); + const skillPruneId = randomUUID(); + const projectId = randomUUID(); + + const sourceLocator = "https://github.com/test-org/test-skills"; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-skills-prune-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + beforeEach(async () => { + // Seed company + await db.insert(companies).values({ + id: companyId, + name: "Test Co", + issuePrefix: `T${Date.now().toString(36).slice(-4).toUpperCase()}`, + }); + + // Seed project (no workspaces — local scan phase will find nothing) + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Test Project", + }); + + // Seed agent with both skills in desired config + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Builder", + adapterType: "claude_local", + adapterConfig: { + paperclipSkillSync: { + desiredSkills: [ + `test-org/test-skills/keep-skill`, + `test-org/test-skills/prune-skill`, + ], + }, + }, + }); + + // Seed two GitHub-sourced skills from the same repo + await db.insert(companySkills).values([ + { + id: skillKeepId, + companyId, + key: "test-org/test-skills/keep-skill", + slug: "keep-skill", + name: "Keep Skill", + markdown: "# Keep Skill", + sourceType: "github", + sourceLocator, + sourceRef: "abc123", + metadata: { sourceKind: "github", owner: "test-org", repo: "test-skills", ref: "abc123", trackingRef: "main" }, + }, + { + id: skillPruneId, + companyId, + key: "test-org/test-skills/prune-skill", + slug: "prune-skill", + name: "Prune Skill", + markdown: "# Prune Skill", + sourceType: "github", + sourceLocator, + sourceRef: "abc123", + metadata: { sourceKind: "github", owner: "test-org", repo: "test-skills", ref: "abc123", trackingRef: "main" }, + }, + ]); + }); + + afterEach(async () => { + mockGhFetch.mockReset(); + await db.delete(companySkills).where(eq(companySkills.companyId, companyId)); + await db.delete(projects).where(eq(projects.companyId, companyId)); + await db.delete(agents).where(eq(agents.companyId, companyId)); + await db.delete(companies).where(eq(companies.id, companyId)); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + /** + * Helper: configure mockGhFetch to simulate a GitHub repo that contains + * only the specified skill slugs (each with a SKILL.md in its directory). + */ + function stubGitHubSource(slugs: string[]) { + const sha = "deadbeef".repeat(5); + const tree = slugs.map((slug) => ({ + path: `${slug}/SKILL.md`, + type: "blob", + })); + + mockGhFetch.mockImplementation(async (url: string, init?: RequestInit) => { + const u = url; + + // GET /repos/{owner}/{repo} → default branch + if (u.match(/\/repos\/test-org\/test-skills$/)) { + return new Response(JSON.stringify({ default_branch: "main" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + // GET /repos/{owner}/{repo}/commits/{ref} → pinned SHA + if (u.includes("/commits/")) { + return new Response(JSON.stringify({ sha }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + // GET /repos/{owner}/{repo}/git/trees/{sha}?recursive=1 → file tree + if (u.includes("/git/trees/")) { + return new Response(JSON.stringify({ tree }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + // GET raw content for SKILL.md files + if (u.includes("raw.githubusercontent.com") || u.includes("/raw/")) { + const match = u.match(/\/([^/]+)\/SKILL\.md$/); + const slug = match?.[1] ?? "unknown"; + const markdown = [ + "---", + `name: ${slug}`, + `slug: ${slug}`, + "---", + "", + `# ${slug}`, + "", + ].join("\n"); + return new Response(markdown, { + status: 200, + headers: { "content-type": "text/plain" }, + }); + } + + // Default: 404 + return new Response("Not Found", { status: 404 }); + }); + } + + it("prunes a skill removed from the source, detaches it from agents, and emits a warning", async () => { + // GitHub returns only "keep-skill" — "prune-skill" is no longer in the repo + stubGitHubSource(["keep-skill"]); + + const { companySkillService } = await import("../services/company-skills.js"); + const svc = companySkillService(db); + const result = await svc.scanProjectWorkspaces(companyId, {}); + + // The pruned skill should be deleted from the database + const remaining = await db + .select() + .from(companySkills) + .where(eq(companySkills.companyId, companyId)); + const remainingSlugs = remaining.map((row) => row.slug); + expect(remainingSlugs).toContain("keep-skill"); + expect(remainingSlugs).not.toContain("prune-skill"); + + // The agent's desired skills should no longer include the pruned skill key + const [agentRow] = await db + .select() + .from(agents) + .where(eq(agents.id, agentId)); + const config = agentRow!.adapterConfig as Record; + const syncConfig = config.paperclipSkillSync as Record; + const desiredSkills = syncConfig.desiredSkills as string[]; + expect(desiredSkills).not.toContain("test-org/test-skills/prune-skill"); + expect(desiredSkills).toContain("test-org/test-skills/keep-skill"); + + // A warning should be emitted about the detached skill + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining("prune-skill"), + expect.stringContaining("Builder"), + ]), + ); + }); + + it("deletes a pruned skill even when no agents reference it", async () => { + // Remove the prune skill from the agent's desired skills before scanning + await db.update(agents).set({ + adapterConfig: { + paperclipSkillSync: { + desiredSkills: ["test-org/test-skills/keep-skill"], + }, + }, + }).where(eq(agents.id, agentId)); + + stubGitHubSource(["keep-skill"]); + + const { companySkillService } = await import("../services/company-skills.js"); + const svc = companySkillService(db); + const result = await svc.scanProjectWorkspaces(companyId, {}); + + const remaining = await db + .select() + .from(companySkills) + .where(eq(companySkills.companyId, companyId)); + const remainingSlugs = remaining.map((r) => r.slug); + expect(remainingSlugs).toContain("keep-skill"); + expect(remainingSlugs).not.toContain("prune-skill"); + + // No warnings about agent detachment since no agents used it + const detachWarnings = result.warnings.filter((w) => w.includes("prune-skill") && w.includes("detached")); + expect(detachWarnings).toHaveLength(0); + }); + + it("skips pruning when the source fetch fails and emits a warning", async () => { + // Simulate GitHub being down + mockGhFetch.mockRejectedValue(new Error("network error")); + + const { companySkillService } = await import("../services/company-skills.js"); + const svc = companySkillService(db); + const result = await svc.scanProjectWorkspaces(companyId, {}); + + // Both seeded skills should still exist (bundled skills may also be present) + const remaining = await db + .select() + .from(companySkills) + .where(eq(companySkills.companyId, companyId)); + const remainingSlugs = remaining.map((r) => r.slug); + expect(remainingSlugs).toContain("keep-skill"); + expect(remainingSlugs).toContain("prune-skill"); + + // A warning should mention the failed source + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining("Could not re-scan source"), + ]), + ); + }); +});