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) <noreply@anthropic.com>
This commit is contained in:
@@ -137,6 +137,25 @@ vi.mock("../routes/org-chart-svg.js", () => ({
|
|||||||
renderOrgChartPng: vi.fn(async () => Buffer.from("png")),
|
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<typeof import("../services/git-source.js")>(
|
||||||
|
"../services/git-source.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
resolveGitRef: gitSourceMock.resolveGitRef,
|
||||||
|
openRepoSnapshot: gitSourceMock.openRepoSnapshot,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const { companyPortabilityService, parseGitHubSourceUrl } = await import("../services/company-portability.js");
|
const { companyPortabilityService, parseGitHubSourceUrl } = await import("../services/company-portability.js");
|
||||||
|
|
||||||
function asTextFile(entry: CompanyPortabilityFileEntry | undefined) {
|
function asTextFile(entry: CompanyPortabilityFileEntry | undefined) {
|
||||||
@@ -3378,3 +3397,173 @@ describe("company portability", () => {
|
|||||||
expect(preview.plan.issuePlans).toHaveLength(0);
|
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<string, string>;
|
||||||
|
binaryContents?: Record<string, Uint8Array>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user