import { randomUUID } from "node:crypto"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; import { companies, createDb, activityLog, externalObjectMentions, externalObjects, issueComments, issues, plugins, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { createExternalObjectDetectorRegistry, createExternalObjectResolverRegistry, externalObjectService, type ExternalObjectResolver, } from "../services/external-objects.js"; import { canonicalizeExternalObjectUrl } from "@paperclipai/shared/external-objects-server"; import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import { createGitHubExternalObjectProvider } from "../services/github-external-object-provider.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; if (!embeddedPostgresSupport.supported) { console.warn( `Skipping embedded Postgres external object tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, ); } describe("external object registries", () => { it("lets provider detectors claim urls before the generic fallback", async () => { const canonical = canonicalizeExternalObjectUrl("https://github.com/acme/app/pull/42"); if (!canonical) throw new Error("expected canonical url"); const registry = createExternalObjectDetectorRegistry([ { key: "github", detect: ({ urls }) => urls.map((url) => ({ canonical: url, detectorKey: "github", providerKey: "github", objectType: "pull_request", externalId: "acme/app#42", confidence: "exact", })), }, ]); const detections = await registry.detect({ companyId: "company-1", urls: [canonical], sourceContext: { companyId: "company-1", sourceIssueId: "issue-1", sourceKind: "description", sourceRecordId: null, documentKey: null, propertyKey: null, }, }); expect(detections).toHaveLength(1); expect(detections[0]).toMatchObject({ providerKey: "github", objectType: "pull_request", externalId: "acme/app#42", }); }); it("falls back to generic url objects when no provider detector claims a url", async () => { const canonical = canonicalizeExternalObjectUrl("https://example.com/path?token=secret#frag"); if (!canonical) throw new Error("expected canonical url"); const registry = createExternalObjectDetectorRegistry([]); const detections = await registry.detect({ companyId: "company-1", urls: [canonical], sourceContext: { companyId: "company-1", sourceIssueId: "issue-1", sourceKind: "description", sourceRecordId: null, documentKey: null, propertyKey: null, }, }); expect(detections[0]).toMatchObject({ providerKey: "url", objectType: "link", externalId: canonical.canonicalIdentityHash, displayTitle: "https://example.com/path", }); }); it("matches resolvers by provider and optional object type", () => { const fallbackResolver: ExternalObjectResolver = { providerKey: "github", resolve: async () => ({ ok: true, snapshot: { statusCategory: "unknown", statusTone: "neutral" }, }), }; const pullRequestResolver: ExternalObjectResolver = { providerKey: "github", objectType: "pull_request", resolve: async () => ({ ok: true, snapshot: { statusCategory: "open", statusTone: "info" }, }), }; const registry = createExternalObjectResolverRegistry([pullRequestResolver, fallbackResolver]); expect(registry.find({ providerKey: "github", objectType: "pull_request" })).toBe(pullRequestResolver); expect(registry.find({ providerKey: "github", objectType: "issue" })).toBe(fallbackResolver); expect(registry.find({ providerKey: "linear", objectType: "issue" })).toBeNull(); }); }); describe("GitHub external object provider", () => { function githubObject(path: string, objectType: "pull_request" | "issue") { const canonical = canonicalizeExternalObjectUrl(`https://github.com/acme/app/${path}`); if (!canonical) throw new Error("expected canonical url"); return { id: randomUUID(), companyId: "company-1", providerKey: "github", objectType, externalId: `acme/app#${path}`, sanitizedCanonicalUrl: canonical.sanitizedCanonicalUrl, canonicalIdentityHash: canonical.canonicalIdentityHash, displayTitle: "acme/app#42", statusKey: null, statusLabel: null, statusCategory: "unknown", statusTone: "neutral", liveness: "unknown", isTerminal: false, data: {}, remoteVersion: null, etag: null, } as any; } function response(body: Record, init: ResponseInit = {}) { return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json", etag: '"etag-1"', ...(init.headers ?? {}) }, ...init, }); } it("detects GitHub pull request and issue URLs before the generic fallback", async () => { const provider = createGitHubExternalObjectProvider({} as any, { tokenProvider: null }); const pr = canonicalizeExternalObjectUrl("https://github.com/Acme/App/pull/42?token=secret#discussion"); const issue = canonicalizeExternalObjectUrl("https://github.com/Acme/App/issues/7"); const other = canonicalizeExternalObjectUrl("https://example.com/Acme/App/pull/42"); if (!pr || !issue || !other) throw new Error("expected canonical urls"); const detections = await provider.detector.detect({ companyId: "company-1", urls: [pr, issue, other], sourceContext: { companyId: "company-1", sourceIssueId: "issue-1", sourceKind: "description", sourceRecordId: null, documentKey: null, propertyKey: null, }, }); expect(detections).toEqual([ expect.objectContaining({ providerKey: "github", objectType: "pull_request", externalId: "acme/app#pull/42", displayKey: "GitHub Pull Request", iconKey: "github", displayTitle: "Acme/App#42", }), expect.objectContaining({ providerKey: "github", objectType: "issue", externalId: "acme/app#issues/7", displayKey: "GitHub Issue", iconKey: "github", displayTitle: "Acme/App#7", }), ]); expect(JSON.stringify(detections)).not.toContain("secret"); }); it.each([ [ "open", { state: "open", draft: false, merged: false, title: "Ship it", updated_at: "2026-04-24T01:02:03Z" }, { statusKey: "open", statusLabel: "Open", statusIconKey: "git-pull-request", statusCategory: "open", statusTone: "info", isTerminal: false }, ], [ "draft", { state: "open", draft: true, merged: false, title: "WIP", updated_at: "2026-04-24T01:02:03Z" }, { statusKey: "draft", statusLabel: "Draft", statusIconKey: "clock", statusCategory: "waiting", statusTone: "warning", isTerminal: false }, ], [ "closed", { state: "closed", draft: false, merged: false, title: "Closed PR", updated_at: "2026-04-24T01:02:03Z" }, { statusKey: "closed", statusLabel: "Closed", statusIconKey: "x-circle", statusCategory: "closed", statusTone: "muted", isTerminal: true }, ], [ "merged", { state: "closed", draft: false, merged: true, title: "Merged PR", updated_at: "2026-04-24T01:02:03Z" }, { statusKey: "merged", statusLabel: "Merged", statusIconKey: "git-merge", statusCategory: "succeeded", statusTone: "success", isTerminal: true }, ], ])("resolves a %s pull request snapshot", async (_name, body, expected) => { const fetch = vi.fn(async () => response(body)); const provider = createGitHubExternalObjectProvider({} as any, { fetch, tokenProvider: null }); const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!; const result = await resolver.resolve({ companyId: "company-1", object: githubObject("pull/42", "pull_request"), }); expect(fetch).toHaveBeenCalledWith( "https://api.github.com/repos/acme/app/pulls/42", expect.objectContaining({ headers: expect.not.objectContaining({ authorization: expect.any(String) }), }), ); expect(result).toEqual({ ok: true, snapshot: expect.objectContaining({ ...expected, displayKey: "GitHub Pull Request", iconKey: "github", displayTitle: expect.stringContaining(String(body.title)), remoteVersion: "2026-04-24T01:02:03Z", etag: '"etag-1"', data: expect.objectContaining({ provider: "github", owner: "acme", repo: "app", number: 42, }), }), }); expect(JSON.stringify(result)).not.toContain("authorization"); }); it.each([ [ "open", { state: "open", title: "Issue", state_reason: null, updated_at: "2026-04-24T01:02:03Z" }, { statusKey: "open", statusLabel: "Open", statusIconKey: "circle-dot", statusCategory: "open", statusTone: "info", isTerminal: false }, ], [ "closed", { state: "closed", title: "Issue", state_reason: "completed", updated_at: "2026-04-24T01:02:03Z" }, { statusKey: "closed_completed", statusLabel: "Closed: completed", statusIconKey: "circle", statusCategory: "closed", statusTone: "muted", isTerminal: true }, ], ])("resolves a %s issue snapshot", async (_name, body, expected) => { const fetch = vi.fn(async () => response(body)); const provider = createGitHubExternalObjectProvider({} as any, { fetch, tokenProvider: null }); const resolver = provider.resolvers.find((entry) => entry.objectType === "issue")!; const result = await resolver.resolve({ companyId: "company-1", object: githubObject("issues/42", "issue"), }); expect(fetch).toHaveBeenCalledWith("https://api.github.com/repos/acme/app/issues/42", expect.any(Object)); expect(result).toEqual({ ok: true, snapshot: expect.objectContaining({ ...expected, displayKey: "GitHub Issue", iconKey: "github", data: expect.objectContaining({ provider: "github", owner: "acme", repo: "app", number: 42, }), }), }); }); it("uses a configured token without storing it in the resolved snapshot", async () => { const fetch = vi.fn(async (_url: string, init?: RequestInit) => { expect(init?.headers).toEqual(expect.objectContaining({ authorization: "Bearer ghp_secret" })); return response({ state: "open", draft: false, merged: false, title: "Private PR" }); }); const provider = createGitHubExternalObjectProvider({} as any, { fetch, tokenProvider: async () => "ghp_secret", }); const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!; const result = await resolver.resolve({ companyId: "company-1", object: githubObject("pull/42", "pull_request"), }); expect(result.ok).toBe(true); expect(JSON.stringify(result)).not.toContain("ghp_secret"); }); it.each([ [ "auth-required", new Response("", { status: 401 }), { ok: false, liveness: "auth_required", errorCode: "github_auth_required" }, ], [ "rate-limit", new Response("", { status: 403, headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(Math.floor(Date.now() / 1000) + 120) }, }), { ok: false, liveness: "unreachable", errorCode: "github_rate_limited" }, ], [ "not-found", new Response("", { status: 404, headers: { etag: '"missing"' } }), { ok: true, snapshot: expect.objectContaining({ displayKey: "GitHub Pull Request", iconKey: "github", statusKey: "not_found", statusIconKey: "archive", statusCategory: "archived", statusTone: "muted" }) }, ], ])("maps %s responses to provider-safe results", async (_name, githubResponse, expected) => { const provider = createGitHubExternalObjectProvider({} as any, { fetch: async () => githubResponse, tokenProvider: null, }); const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!; const result = await resolver.resolve({ companyId: "company-1", object: githubObject("pull/42", "pull_request"), }); expect(result).toEqual(expect.objectContaining(expected)); expect(JSON.stringify(result)).not.toContain("http"); }); }); describeEmbeddedPostgres("externalObjectService", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-external-objects-"); db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { await db.delete(activityLog); await db.delete(externalObjectMentions); await db.delete(externalObjects); await db.delete(issueComments); await db.delete(issues); await db.delete(plugins); await db.delete(companies); vi.restoreAllMocks(); }); afterAll(async () => { await tempDb?.cleanup(); }); async function createIssue(companyId = randomUUID()) { const issueId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: `E${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await db.insert(issues).values({ id: issueId, companyId, identifier: `PAP-${companyId.replace(/-/g, "").slice(0, 12).toUpperCase()}`, title: "External refs", description: "Track https://github.com/acme/app/pull/42?token=secret#discussion twice https://github.com/acme/app/pull/42.", status: "todo", priority: "medium", }); return { companyId, issueId }; } it("syncs sanitized, deduped mentions without storing secret-bearing urls", async () => { const { companyId, issueId } = await createIssue(); const svc = externalObjectService(db); await svc.syncIssue(issueId); const [objectRows, mentionRows] = await Promise.all([ db.select().from(externalObjects), db.select().from(externalObjectMentions), ]); expect(objectRows).toHaveLength(1); expect(objectRows[0]).toMatchObject({ companyId, providerKey: "github", objectType: "pull_request", externalId: "acme/app#pull/42", sanitizedCanonicalUrl: "https://github.com/acme/app/pull/42", liveness: "unknown", statusCategory: "unknown", }); expect(JSON.stringify(objectRows[0])).not.toContain("secret"); expect(mentionRows).toHaveLength(1); expect(mentionRows[0]).toMatchObject({ companyId, sourceIssueId: issueId, sourceKind: "description", sanitizedDisplayUrl: "https://github.com/acme/app/pull/42", matchedTextRedacted: "https://github.com/acme/app/pull/42", }); }); it("no-ops detection and summaries when external objects are disabled", async () => { const { issueId } = await createIssue(); const svc = externalObjectService(db, { enabled: false }); await svc.syncIssue(issueId); const [objectRows, mentionRows, summary] = await Promise.all([ db.select().from(externalObjects), db.select().from(externalObjectMentions), svc.getIssueSummary(issueId), ]); expect(objectRows).toHaveLength(0); expect(mentionRows).toHaveLength(0); expect(summary).toMatchObject({ total: 0, byStatusCategory: {}, byLiveness: {}, highestSeverity: "neutral", objects: [], }); }); it("preserves last-known status when resolver reports auth and unreachable failures", async () => { const { companyId, issueId } = await createIssue(); const resolver: ExternalObjectResolver = { providerKey: "url", objectType: "link", resolve: vi .fn() .mockResolvedValueOnce({ ok: true, snapshot: { statusCategory: "open", statusTone: "info", statusKey: "open", statusLabel: "Open", ttlSeconds: 1, }, }) .mockResolvedValueOnce({ ok: false, liveness: "auth_required", errorCode: "auth_required", errorMessage: "token=secret failed for https://github.com/acme/app/pull/42?token=secret", retryAfterSeconds: 60, }) .mockResolvedValueOnce({ ok: false, liveness: "unreachable", errorCode: "network", errorMessage: "GET https://github.com/acme/app/pull/42 failed", retryAfterSeconds: 60, }), }; const svc = externalObjectService(db, { resolvers: [resolver], github: false }); await svc.syncIssue(issueId); const object = await db.select().from(externalObjects).then((rows) => rows[0]!); await svc.refreshObject(object.id, { companyId, force: true }); await svc.refreshObject(object.id, { companyId, force: true }); const authFailure = await db.select().from(externalObjects).then((rows) => rows[0]!); expect(authFailure.lastErrorMessage).toContain("token=[redacted]"); expect(authFailure.lastErrorMessage).not.toContain("secret"); await svc.refreshObject(object.id, { companyId, force: true }); const updated = await db.select().from(externalObjects).then((rows) => rows[0]!); expect(updated.statusCategory).toBe("open"); expect(updated.statusLabel).toBe("Open"); expect(updated.liveness).toBe("unreachable"); expect(updated.lastErrorMessage).toContain("[redacted-url]"); expect(updated.lastErrorMessage).not.toContain("secret"); }); it("schedules newly detected objects for automatic refresh", async () => { const { companyId, issueId } = await createIssue(); const resolve = vi.fn(async () => ({ ok: true as const, snapshot: { statusCategory: "open" as const, statusTone: "info" as const, statusKey: "open", statusLabel: "Open", ttlSeconds: 300, }, })); const resolver: ExternalObjectResolver = { providerKey: "url", objectType: "link", resolve, }; const svc = externalObjectService(db, { resolvers: [resolver], github: false }); await svc.syncIssue(issueId); const object = await db.select().from(externalObjects).then((rows) => rows[0]!); expect(object.nextRefreshAt).toBeInstanceOf(Date); const refreshed = await svc.refreshDueObjects(companyId, 50, new Date(Date.now() + 1_000)); expect(refreshed).toHaveLength(1); expect(resolve).toHaveBeenCalledTimes(1); }); it("coalesces automatic and manual refreshes for the same object", async () => { const { companyId, issueId } = await createIssue(); let markResolverStarted!: () => void; const resolverStarted = new Promise((resolve) => { markResolverStarted = resolve; }); let finishResolver!: () => void; const resolverCanFinish = new Promise((resolve) => { finishResolver = resolve; }); const resolve = vi.fn(async () => { markResolverStarted(); await resolverCanFinish; return { ok: true as const, snapshot: { statusCategory: "open" as const, statusTone: "info" as const, statusKey: "open", statusLabel: "Open", ttlSeconds: 300, }, }; }); const resolver: ExternalObjectResolver = { providerKey: "url", objectType: "link", resolve, }; const svc = externalObjectService(db, { resolvers: [resolver], github: false }); await svc.syncIssue(issueId); const object = await db.select().from(externalObjects).then((rows) => rows[0]!); const dueRefresh = svc.refreshDueObjects(companyId, 50, new Date(Date.now() + 1_000)); await resolverStarted; const manualRefresh = svc.refreshObject(object.id, { companyId, force: true }); finishResolver(); const [dueResults, manualResult] = await Promise.all([dueRefresh, manualRefresh]); expect(dueResults).toHaveLength(1); expect(dueResults[0]?.refreshed).toBe(true); expect(manualResult.refreshed).toBe(true); expect(manualResult.object.statusLabel).toBe("Open"); expect(resolve).toHaveBeenCalledTimes(1); }); it("prevents duplicate refreshes across service instances", async () => { const { companyId, issueId } = await createIssue(); let markResolverStarted!: () => void; const resolverStarted = new Promise((resolve) => { markResolverStarted = resolve; }); let finishResolver!: () => void; const resolverCanFinish = new Promise((resolve) => { finishResolver = resolve; }); const resolve = vi.fn(async () => { markResolverStarted(); await resolverCanFinish; return { ok: true as const, snapshot: { statusCategory: "open" as const, statusTone: "info" as const, statusKey: "open", statusLabel: "Open", ttlSeconds: 300, }, }; }); const resolver: ExternalObjectResolver = { providerKey: "url", objectType: "link", resolve, }; const scheduledService = externalObjectService(db, { resolvers: [resolver], github: false }); const manualService = externalObjectService(db, { resolvers: [resolver], github: false }); await scheduledService.syncIssue(issueId); const object = await db.select().from(externalObjects).then((rows) => rows[0]!); const dueRefresh = scheduledService.refreshDueObjects(companyId, 50, new Date(Date.now() + 1_000)); await resolverStarted; const manualRefresh = await manualService.refreshObject(object.id, { companyId, force: true }); finishResolver(); const dueResults = await dueRefresh; expect(dueResults).toHaveLength(1); expect(dueResults[0]?.refreshed).toBe(true); expect(manualRefresh.refreshed).toBe(false); expect(manualRefresh.reason).toBe("refresh_in_progress"); expect(manualRefresh.object).not.toHaveProperty("refreshToken"); expect(resolve).toHaveBeenCalledTimes(1); await expect( db.select().from(externalObjects).then((rows) => rows[0]?.refreshStartedAt ?? null), ).resolves.toBeNull(); }); it("does not let a stale refresh overwrite a replacement claimant", async () => { const { companyId, issueId } = await createIssue(); let markResolverStarted!: () => void; const resolverStarted = new Promise((resolve) => { markResolverStarted = resolve; }); let finishResolver!: () => void; const resolverCanFinish = new Promise((resolve) => { finishResolver = resolve; }); const slowResolve = vi.fn(async () => { markResolverStarted(); await resolverCanFinish; return { ok: true as const, snapshot: { statusCategory: "open" as const, statusTone: "info" as const, statusKey: "open", statusLabel: "Open", ttlSeconds: 300, }, }; }); const replacementResolve = vi.fn(async () => ({ ok: true as const, snapshot: { statusCategory: "closed" as const, statusTone: "muted" as const, statusKey: "closed", statusLabel: "Closed", ttlSeconds: 300, }, })); const scheduledService = externalObjectService(db, { resolvers: [{ providerKey: "url", objectType: "link", resolve: slowResolve }], github: false, }); const replacementService = externalObjectService(db, { resolvers: [{ providerKey: "url", objectType: "link", resolve: replacementResolve }], github: false, }); await scheduledService.syncIssue(issueId); const object = await db.select().from(externalObjects).then((rows) => rows[0]!); const claimedAt = new Date(Date.now() + 1_000); const dueRefresh = scheduledService.refreshDueObjects(companyId, 50, claimedAt); await resolverStarted; const replacementResult = await replacementService.refreshObject(object.id, { companyId, force: true, now: new Date(claimedAt.getTime() + 301_000), }); finishResolver(); const dueResults = await dueRefresh; const finalObject = await db.select().from(externalObjects).then((rows) => rows[0]!); expect(replacementResult.refreshed).toBe(true); expect(dueResults[0]).toMatchObject({ refreshed: false, reason: "refresh_superseded" }); expect(finalObject.statusLabel).toBe("Closed"); expect(finalObject.refreshStartedAt).toBeNull(); expect(finalObject.refreshToken).toBeNull(); expect(slowResolve).toHaveBeenCalledTimes(1); expect(replacementResolve).toHaveBeenCalledTimes(1); }); it("refreshes due objects for active companies only", async () => { const active = await createIssue(); const paused = await createIssue(); await db .update(companies) .set({ status: "paused" }) .where(eq(companies.id, paused.companyId)); const resolve = vi.fn(async () => ({ ok: true as const, snapshot: { statusCategory: "open" as const, statusTone: "info" as const, statusKey: "open", statusLabel: "Open", ttlSeconds: 300, }, })); const resolver: ExternalObjectResolver = { providerKey: "url", objectType: "link", resolve, }; const svc = externalObjectService(db, { resolvers: [resolver], github: false }); await svc.syncIssue(active.issueId); await svc.syncIssue(paused.issueId); const result = await svc.refreshDueObjectsForActiveCompanies(50, new Date(Date.now() + 1_000)); expect(result).toEqual({ companies: 1, checked: 1, refreshed: 1 }); expect(resolve).toHaveBeenCalledTimes(1); const rows = await db.select().from(externalObjects); const activeObject = rows.find((row) => row.companyId === active.companyId); const pausedObject = rows.find((row) => row.companyId === paused.companyId); expect(activeObject?.statusLabel).toBe("Open"); expect(pausedObject?.statusLabel).toBeNull(); }); it("removes comment mentions when a synced comment is hard-deleted", async () => { const { companyId, issueId } = await createIssue(); const commentId = randomUUID(); await db.insert(issueComments).values({ id: commentId, companyId, issueId, authorType: "user", authorUserId: "local-board", body: "See https://github.com/acme/app/issues/88", }); const svc = externalObjectService(db, { github: false }); await svc.syncComment(commentId); expect(await db.select().from(externalObjectMentions)).toHaveLength(1); await db.delete(issueComments).where(eq(issueComments.id, commentId)); await svc.syncComment(commentId); expect(await db.select().from(externalObjectMentions)).toHaveLength(0); }); it("skips terminal objects when refreshing due objects", async () => { const { companyId, issueId } = await createIssue(); const resolve = vi.fn(async () => ({ ok: true as const, snapshot: { statusCategory: "closed" as const, statusTone: "muted" as const, statusKey: "closed", statusLabel: "Closed", isTerminal: true, ttlSeconds: 1, }, })); const resolver: ExternalObjectResolver = { providerKey: "url", objectType: "link", resolve, }; const svc = externalObjectService(db, { resolvers: [resolver], github: false }); await svc.syncIssue(issueId); const object = await db.select().from(externalObjects).then((rows) => rows[0]!); await svc.refreshObject(object.id, { companyId, force: true }); await db .update(externalObjects) .set({ nextRefreshAt: new Date(0) }) .where(eq(externalObjects.id, object.id)); const refreshed = await svc.refreshDueObjects(companyId); expect(refreshed).toEqual([]); expect(resolve).toHaveBeenCalledTimes(1); }); it("keeps external object identities company-scoped for duplicate urls", async () => { const first = await createIssue(); const second = await createIssue(); const svc = externalObjectService(db); await svc.syncIssue(first.issueId); await svc.syncIssue(second.issueId); const objectRows = await db.select().from(externalObjects); expect(objectRows).toHaveLength(2); expect(new Set(objectRows.map((row) => row.companyId))).toEqual(new Set([first.companyId, second.companyId])); expect(new Set(objectRows.map((row) => row.canonicalIdentityHash)).size).toBe(1); }); it("uses a mock plugin provider to detect and resolve non-GitHub objects", async () => { const { companyId, issueId } = await createIssue(); await db .update(issues) .set({ description: "Track https://mock.example/tickets/123?secret=drop", }) .where(eq(issues.id, issueId)); const manifest: PaperclipPluginManifestV1 = { id: "paperclip.mock-object-provider", apiVersion: 1, version: "1.0.0", displayName: "Mock Object Provider", description: "Detects mock tracker tickets", author: "Paperclip", categories: ["connector"], capabilities: ["external.objects.detect", "external.objects.read"], entrypoints: { worker: "dist/worker.js" }, objectReferences: [ { providerKey: "mocktracker", displayName: "Mock Tracker", objectTypes: ["ticket"], urlPatterns: ["https://mock.example/tickets/:id"], }, ], }; const [plugin] = await db.insert(plugins).values({ pluginKey: manifest.id, packageName: "@paperclip/mock-object-provider", version: manifest.version, apiVersion: 1, categories: manifest.categories, manifestJson: manifest, status: "ready", installOrder: 1, }).returning(); const workerManager = { call: vi.fn(async (pluginId: string, method: string, params: any) => { expect(pluginId).toBe(plugin!.id); if (method === "detectExternalObjects") { return { detections: params.urls.map((url: any) => ({ urlIdentityHash: url.canonicalIdentityHash, providerKey: "mocktracker", objectType: "ticket", externalId: "MOCK-123", displayKey: "Mock Ticket", iconKey: "circle-dot", displayTitle: "Mock ticket 123", confidence: "exact", })), }; } if (method === "resolveExternalObject") { return { ok: true, snapshot: { displayKey: "Mock Ticket", iconKey: "circle-dot", displayTitle: `Resolved ${params.externalId}`, statusKey: "ready", statusLabel: "Ready", statusIconKey: "check-circle", statusCategory: "succeeded", statusTone: "success", ttlSeconds: 300, }, }; } throw new Error(`unexpected method ${method}`); }), } as unknown as PluginWorkerManager; const svc = externalObjectService(db, { pluginWorkerManager: workerManager }); await svc.syncIssue(issueId); const object = await db.select().from(externalObjects).then((rows) => rows[0]!); expect(object).toMatchObject({ companyId, providerKey: "mocktracker", objectType: "ticket", externalId: "MOCK-123", displayKey: "Mock Ticket", iconKey: "circle-dot", pluginId: plugin!.id, sanitizedCanonicalUrl: "https://mock.example/tickets/123", }); expect(JSON.stringify(object)).not.toContain("secret"); const refreshed = await svc.refreshObject(object.id, { companyId, force: true }); expect(refreshed.object).toMatchObject({ displayKey: "Mock Ticket", iconKey: "circle-dot", displayTitle: "Resolved MOCK-123", statusIconKey: "check-circle", statusCategory: "succeeded", statusTone: "success", liveness: "fresh", }); }); });