From bf251188dffa8989dc933279fd3c43f5cd40b231 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Sat, 16 May 2026 10:35:56 -0400 Subject: [PATCH] test(portability): cover resolveSource orchestration via previewImport Closes the coverage gap on the actual migrated function. Mocks the two network-touching git-source exports (resolveGitRef, openRepoSnapshot) while keeping parseGitSourceUrl real so the parseGitHubSourceUrl shim contract stays honest. Adds 5 cases: - happy path: opens one snapshot, calls listFiles, readFileOptional on COMPANY.md, readFile on candidate paths - ref fallback: when openRepoSnapshot('main') rejects, falls back to 'master' and emits the expected warning - COMPANY.md absent everywhere: throws "missing COMPANY.md" - referenced logo: readBinary is called for the logoPath from .paperclip.yaml - logo read failure: warning emitted, no throw 57/57 portability tests passing; existing 52 unchanged via shim. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/__tests__/company-portability.test.ts | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 3a258263..bcdadea5 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -137,6 +137,25 @@ vi.mock("../routes/org-chart-svg.js", () => ({ renderOrgChartPng: vi.fn(async () => Buffer.from("png")), })); +const gitSourceMock = vi.hoisted(() => ({ + resolveGitRef: vi.fn(), + openRepoSnapshot: vi.fn(), +})); + +// parseGitSourceUrl stays real (the shim parseGitHubSourceUrl delegates to it +// and is asserted by existing tests). Only the network-touching functions are +// overridable per-test. +vi.mock("../services/git-source.js", async () => { + const actual = await vi.importActual( + "../services/git-source.js", + ); + return { + ...actual, + resolveGitRef: gitSourceMock.resolveGitRef, + openRepoSnapshot: gitSourceMock.openRepoSnapshot, + }; +}); + const { companyPortabilityService, parseGitHubSourceUrl } = await import("../services/company-portability.js"); function asTextFile(entry: CompanyPortabilityFileEntry | undefined) { @@ -3378,3 +3397,173 @@ describe("company portability", () => { expect(preview.plan.issuePlans).toHaveLength(0); }); }); + +describe("git source orchestration via resolveSource", () => { + const minimalCompanyMarkdown = "---\ncompany:\n name: Demo\n---\n# Demo\n"; + const githubUrl = "https://git.example.com/acme/co?ref=main&path="; + + function makeSnapshot(overrides: { + files?: string[]; + fileContents?: Record; + binaryContents?: Record; + readBinaryReject?: Error; + } = {}) { + const files = overrides.files ?? ["COMPANY.md"]; + const fileContents = overrides.fileContents ?? { "COMPANY.md": minimalCompanyMarkdown }; + const binaryContents = overrides.binaryContents ?? {}; + return { + sha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + listFiles: vi.fn(async () => files), + readFile: vi.fn(async (p: string) => { + if (p in fileContents) return fileContents[p]; + throw Object.assign(new Error(`not found: ${p}`), { code: "NotFoundError" }); + }), + readFileOptional: vi.fn(async (p: string) => fileContents[p] ?? null), + readBinary: vi.fn(async (p: string) => { + if (overrides.readBinaryReject) throw overrides.readBinaryReject; + if (p in binaryContents) return binaryContents[p]!; + throw Object.assign(new Error(`not found: ${p}`), { code: "NotFoundError" }); + }), + }; + } + + function setupResolveStub() { + gitSourceMock.resolveGitRef.mockResolvedValue({ + pinnedSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + trackingRef: "main", + }); + } + + beforeEach(() => { + gitSourceMock.resolveGitRef.mockReset(); + gitSourceMock.openRepoSnapshot.mockReset(); + companySvc.getById.mockResolvedValue(null); + agentSvc.list.mockResolvedValue([]); + projectSvc.list.mockResolvedValue([]); + issueSvc.list.mockResolvedValue([]); + issueSvc.listComments.mockResolvedValue([]); + companySkillSvc.list.mockResolvedValue([]); + }); + + it("opens a snapshot and walks the tree for a github source", async () => { + setupResolveStub(); + const snapshot = makeSnapshot({ + files: ["COMPANY.md", "README.md", "skills/x/SKILL.md"], + fileContents: { + "COMPANY.md": minimalCompanyMarkdown, + "README.md": "# readme", + "skills/x/SKILL.md": "---\nname: x\n---\n", + }, + }); + gitSourceMock.openRepoSnapshot.mockResolvedValue(snapshot); + + const portability = companyPortabilityService({} as any); + const preview = await portability.previewImport({ + source: { type: "github", url: githubUrl }, + include: { company: true, agents: false, projects: false, issues: false, skills: false }, + target: { mode: "new_company", newCompanyName: "Demo" }, + agents: "all", + collisionStrategy: "rename", + }); + + expect(gitSourceMock.resolveGitRef).toHaveBeenCalledTimes(1); + expect(gitSourceMock.openRepoSnapshot).toHaveBeenCalledTimes(1); + expect(snapshot.listFiles).toHaveBeenCalled(); + expect(snapshot.readFileOptional).toHaveBeenCalledWith("COMPANY.md"); + expect(snapshot.readFile).toHaveBeenCalledWith("README.md"); + expect(snapshot.readFile).toHaveBeenCalledWith("skills/x/SKILL.md"); + expect(preview.errors).toEqual([]); + }); + + it("falls back from main to master when the main ref does not exist", async () => { + setupResolveStub(); + const masterSnap = makeSnapshot(); + // First call (ref=main) rejects; second (ref=master) succeeds. + gitSourceMock.openRepoSnapshot + .mockRejectedValueOnce(new Error("ref not found")) + .mockResolvedValueOnce(masterSnap); + + const portability = companyPortabilityService({} as any); + const preview = await portability.previewImport({ + source: { type: "github", url: githubUrl }, + include: { company: true, agents: false, projects: false, issues: false, skills: false }, + target: { mode: "new_company", newCompanyName: "Demo" }, + agents: "all", + collisionStrategy: "rename", + }); + + expect(gitSourceMock.openRepoSnapshot).toHaveBeenCalledTimes(2); + expect(masterSnap.readFileOptional).toHaveBeenCalledWith("COMPANY.md"); + expect(preview.warnings).toContain("Git ref main not found; falling back to master."); + }); + + it("throws when COMPANY.md is missing on both main and master", async () => { + setupResolveStub(); + const emptySnap = makeSnapshot({ fileContents: {} }); + gitSourceMock.openRepoSnapshot.mockResolvedValue(emptySnap); + + const portability = companyPortabilityService({} as any); + await expect( + portability.previewImport({ + source: { type: "github", url: githubUrl }, + include: { company: true, agents: false, projects: false, issues: false, skills: false }, + target: { mode: "new_company", newCompanyName: "Demo" }, + agents: "all", + collisionStrategy: "rename", + }), + ).rejects.toThrow(/missing COMPANY.md/i); + }); + + it("fetches a referenced company logo as binary", async () => { + setupResolveStub(); + // logoPath lives in .paperclip.yaml (paperclip extension), not COMPANY.md. + const paperclipYaml = "company:\n logoPath: images/logo.png\n"; + const logoBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const snapshot = makeSnapshot({ + files: ["COMPANY.md", ".paperclip.yaml", "images/logo.png"], + fileContents: { + "COMPANY.md": minimalCompanyMarkdown, + ".paperclip.yaml": paperclipYaml, + }, + binaryContents: { "images/logo.png": logoBytes }, + }); + gitSourceMock.openRepoSnapshot.mockResolvedValue(snapshot); + + const portability = companyPortabilityService({} as any); + await portability.previewImport({ + source: { type: "github", url: githubUrl }, + include: { company: true, agents: false, projects: false, issues: false, skills: false }, + target: { mode: "new_company", newCompanyName: "Demo" }, + agents: "all", + collisionStrategy: "rename", + }); + + expect(snapshot.readBinary).toHaveBeenCalledWith("images/logo.png"); + }); + + it("warns instead of throwing when the logo blob can't be read", async () => { + setupResolveStub(); + const paperclipYaml = "company:\n logoPath: images/logo.png\n"; + const snapshot = makeSnapshot({ + files: ["COMPANY.md", ".paperclip.yaml"], + fileContents: { + "COMPANY.md": minimalCompanyMarkdown, + ".paperclip.yaml": paperclipYaml, + }, + readBinaryReject: new Error("blob missing"), + }); + gitSourceMock.openRepoSnapshot.mockResolvedValue(snapshot); + + const portability = companyPortabilityService({} as any); + const preview = await portability.previewImport({ + source: { type: "github", url: githubUrl }, + include: { company: true, agents: false, projects: false, issues: false, skills: false }, + target: { mode: "new_company", newCompanyName: "Demo" }, + agents: "all", + collisionStrategy: "rename", + }); + + expect(snapshot.readBinary).toHaveBeenCalled(); + expect(preview.warnings.some((w: string) => /Failed to fetch company logo/i.test(w))).toBe(true); + }); +});