import { describe, expect, it, vi } from "vitest"; import type { Did } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; const agent = { sub: "did:plc:alice" } as unknown as OAuthUserAgent; interface HarnessOptions { createError?: Error; putError?: Error; readError?: Error; existing?: { value: Record; cid?: string | null } | null; } // mocks have to be installed before the module is loaded const load = async ({ createError, putError, readError, existing = null }: HarnessOptions = {}) => { vi.resetModules(); const events: string[] = []; const records: unknown[] = []; const knotInputs: Record[] = []; const knotAgents: OAuthUserAgent[] = []; const createRecord = vi.fn(async (_agent, _collection, record) => { events.push("record:create"); records.push(record); return { uri: "at://did:plc:alice/sh.tangled.repo/demo", cid: "initial-cid" }; }); const runCalls: Record[] = []; const putRecord = vi.fn(async (_agent, _collection, _rkey, record, swapRecord) => { events.push("record:put"); if (putError) throw putError; records.push(record); return { uri: "at://did:plc:alice/sh.tangled.repo/demo", cid: "committed-cid", swapRecord }; }); const deleteRecord = vi.fn(async () => { events.push("record:delete"); }); const readRecord = vi.fn(async () => { if (readError) throw readError; return existing === null ? null : { value: existing.value, written: existing.cid === null ? null : { uri: "at://did:plc:alice/sh.tangled.repo/demo", cid: existing.cid ?? "existing-cid" } }; }); vi.doMock("./write", () => ({ createRecord, putRecord, deleteRecord, readRecord })); vi.doMock("./client", () => ({ createBobbinClient: ({ serviceUrl, agent: caller }: { serviceUrl: string; agent?: OAuthUserAgent; }) => ({ xrpc: { call: async (_schema: unknown, options: { input?: Record }) => { if (serviceUrl === "https://bobbin.test") { events.push("bobbin:await"); return { ok: true, data: { status: "indexed" } }; } if (caller) knotAgents.push(caller); if (options.input) { knotInputs.push(options.input); runCalls.push(options.input); } if (options.input && "repo" in options.input) { events.push("knot:delete"); return { ok: true, data: null }; } events.push("knot:create"); if (createError) throw createError; return { ok: true, data: { repoDid: "did:plc:repo" } }; } } }) })); return { api: await import("./repoCreate"), events, records, runCalls, knotInputs, knotAgents, createRecord, putRecord, deleteRecord, readRecord }; }; const input = { ownerDid: "did:plc:alice" as Did, ownerHandle: "alice.test", name: "Demo.git", description: "a repo", defaultBranch: "main", knot: "knot.test", spindle: "spindle.test" }; describe("repository creation", () => { it("uses the organization agent for the PDS record and knot creation", async () => { const { api, createRecord, putRecord, knotAgents } = await load(); const org = { sub: "did:plc:org" } as unknown as OAuthUserAgent; const result = await api.createRepo(org, "https://bobbin.test", { ...input, ownerDid: "did:plc:org", ownerHandle: "org.test" }); expect(createRecord.mock.calls[0][0]).toBe(org); expect(putRecord.mock.calls[0][0]).toBe(org); expect(knotAgents).toEqual([org]); expect(result.ownerHandle).toBe("org.test"); }); it("rejects an organization owner paired with the controller's session", async () => { const { api, createRecord } = await load(); await expect( api.createRepo(agent, "https://bobbin.test", { ...input, ownerDid: "did:plc:org", ownerHandle: "org.test" }) ).rejects.toThrow("active account changed"); expect(createRecord).not.toHaveBeenCalled(); }); it("announces the record before creating the knot repo, then commits the repo DID", async () => { const { api, events, records, putRecord } = await load(); const creation = await api.createRepo(agent, "https://bobbin.test", input); expect(creation.name).toBe("Demo"); expect(events).toEqual([ "record:create", "bobbin:await", "knot:create", "record:put", "bobbin:await" ]); expect(records[0]).toMatchObject({ $type: "sh.tangled.repo", knot: "knot.test", name: "Demo", spindle: "spindle.test" }); expect(records[1]).toMatchObject({ repoDid: "did:plc:repo" }); expect(putRecord).toHaveBeenCalledWith( agent, "sh.tangled.repo", "demo", expect.anything(), "initial-cid" ); }); it("removes the pds record before cleaning up the knot when commit fails", async () => { const { api, events, deleteRecord } = await load({ putError: new Error("pds refused") }); await expect(api.createRepo(agent, "https://bobbin.test", input)).rejects.toThrow( "pds refused" ); expect(events.slice(-2)).toEqual(["record:delete", "knot:delete"]); expect(deleteRecord).toHaveBeenCalledWith(agent, "sh.tangled.repo", "demo", "initial-cid"); }); it("removes the announced record when the knot rejects creation", async () => { const { api, events } = await load({ createError: new Error("no capacity") }); await expect(api.createRepo(agent, "https://bobbin.test", input)).rejects.toThrow( "no capacity" ); expect(events).toContain("record:delete"); expect(events).not.toContain("knot:delete"); }); it("refuses a name the account already has instead of failing inside the pds", async () => { const { api, events, readRecord } = await load({ existing: { value: { $type: "sh.tangled.repo", knot: "knot.test", repoDid: "did:plc:theirs" } } }); await expect(api.createRepo(agent, "https://bobbin.test", input)).rejects.toThrow( 'A repository named "Demo" already exists in your account. Choose another name.' ); expect(readRecord).toHaveBeenCalledWith(agent, "sh.tangled.repo", "demo"); expect(events).toEqual([]); }); it("resumes a creation that never reached the knot instead of stranding the name", async () => { const { api, events, runCalls, createRecord, putRecord } = await load({ existing: { value: { $type: "sh.tangled.repo", knot: "knot.test" } } }); const creation = await api.createRepo(agent, "https://bobbin.test", input); expect(creation.repoDid).toBe("did:plc:repo"); expect(createRecord).not.toHaveBeenCalled(); expect(events).toEqual([ "record:put", "bobbin:await", "knot:create", "record:put", "bobbin:await" ]); expect(putRecord.mock.calls.map((call) => call[4])).toEqual([ "existing-cid", "committed-cid" ]); expect(runCalls.at(-1)).toMatchObject({ rkey: "demo", defaultBranch: "main" }); }); it("refuses an interrupted record left on a different knot", async () => { const { api, events } = await load({ existing: { value: { $type: "sh.tangled.repo", knot: "other.knot.test" } } }); await expect(api.createRepo(agent, "https://bobbin.test", input)).rejects.toThrow( 'A repository named "Demo" already exists in your account. Choose another name.' ); expect(events).toEqual([]); }); it("fails immediately when readRecord throws rather than proceeding with blind create", async () => { const { api } = await load({ readError: new Error("PDS connection dropped") }); await expect(api.createRepo(agent, "https://bobbin.test", input)).rejects.toThrow( "PDS connection dropped" ); }); it("passes fork identity and clone source to both halves", async () => { const { api, records, knotInputs } = await load(); await api.createRepo(agent, "https://bobbin.test", { ...input, name: "fork", source: { url: "https://knot.source/did:plc:upstream", kind: "fork", record: "did:plc:upstream" } }); expect(records[0]).toMatchObject({ source: "did:plc:upstream" }); expect(knotInputs[0]).toMatchObject({ source: { url: "https://knot.source/did:plc:upstream", kind: "fork" } }); }); it("creates optimistic record without repoDid and awaits bobbin indexing", async () => { const { api, records, events } = await load(); const result = await api.createOptimisticRepoRecord(agent, "https://bobbin.test", input); expect(result.rkey).toBe("demo"); expect(result.name).toBe("Demo"); expect(records[0]).toMatchObject({ $type: "sh.tangled.repo", knot: "knot.test" }); expect((records[0] as Record).repoDid).toBeUndefined(); expect(events).toEqual(["record:create", "bobbin:await"]); }); it("normalizes knot did:web to hostname in the created record", async () => { const { api, records } = await load(); await api.createOptimisticRepoRecord(agent, "https://bobbin.test", { ...input, knot: "did:web:knot2.tngl.boltless.dev" }); expect(records[0]).toMatchObject({ $type: "sh.tangled.repo", knot: "knot2.tngl.boltless.dev" }); }); it("normalizes knot did:web with encoded port to hostname in the created repo", async () => { const { api, records } = await load(); await api.createRepo(agent, "https://bobbin.test", { ...input, knot: "did:web:knot.oyster.cafe%3A8443" }); expect(records[0]).toMatchObject({ $type: "sh.tangled.repo", knot: "knot.oyster.cafe:8443" }); }); }); describe("repository names", () => { it.each(["", ".hidden", "trail.", "a/b", "a..b", "self", "bad name"])( "rejects %j", async (name) => { const { api } = await load(); expect(() => api.validateRepoName(name)).toThrow(); } ); });