forked from farhoodlabs/paperclip
test(skills): add service-level tests for scan prune path
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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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<typeof import("../services/github-fetch.js")>();
|
||||
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<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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<string, unknown>;
|
||||
const syncConfig = config.paperclipSkillSync as Record<string, unknown>;
|
||||
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"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user