import { beforeEach, describe, expect, it, vi } from "vitest"; import { ClientResponseError } from "@atcute/client"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import type { BobbinContext } from "#lib/api/client.ts"; import type { RepoRecord } from "#lib/api/records.ts"; const manifest: RepoRecord = { $type: "org.tangled.repo.manifest", declaration: { owner: "did:plc:owner", name: "core" }, createdAt: "2026-01-01T00:00:00Z" as never, description: "a forge", website: "https://tangled.org" }; const manifestUri = "at://did:plc:repo/org.tangled.repo.manifest/self"; const written = { uri: manifestUri, cid: "bafyreihkszkjxz4fuml3wol5lujacnrlfhwutc2o7yj2hdsc3kr6usccyu" }; const updateCob = vi.fn( async ( _agent: unknown, _knot: unknown, _uri: unknown, mutate: (m: RepoRecord) => RepoRecord ) => ({ ...written, value: mutate(manifest) }) ); const createGrant = vi.fn(async () => undefined); const eraseGrant = vi.fn(async () => undefined); const refsWritten = { uri: "at://did:plc:repo/org.tangled.git.refs/self", cid: "bafyreib2rxk3rh6kzwq3u5ft6aqvq2tqkr4nwm7ud3ebp6m4uttaxyi6nm" }; const knotProcedure = vi.fn(async () => refsWritten); vi.mock("./cob", () => ({ updateCob, createGrant, eraseGrant, knotProcedure })); const declaration = { value: { $type: "org.tangled.repo.declaration", repo: "did:plc:repo", createdAt: "2026-01-01T00:00:00Z" }, written: { uri: "at://did:plc:owner/org.tangled.repo.declaration/core", cid: "bafyold" } }; const readRecord = vi.fn(async (_agent: unknown, _collection: unknown, rkey: string) => rkey === "core" ? declaration : null ); const createRecord = vi.fn(async () => ({ uri: "at://did:plc:owner/org.tangled.repo.declaration/forge", cid: "bafynew" })); const deleteRecord = vi.fn(async () => undefined); vi.mock("./write", () => ({ createRecord, deleteRecord, readRecord })); const awaitIndexedRecord = vi.fn(async () => undefined); const awaitDeletedRecord = vi.fn(async () => true); vi.mock("./awaitRecord", () => ({ awaitIndexedRecord, awaitDeletedRecord })); const pingIndexNow = vi.fn(); vi.mock("#lib/api/indexnow.ts", () => ({ pingIndexNow })); const call = vi.fn(async (_schema: unknown, _options: unknown) => ({ ok: true, data: undefined })); vi.mock("./client", async (importOriginal) => ({ ...(await importOriginal()), createBobbinClient: () => ({ xrpc: { call } }) as unknown as BobbinContext })); vi.mock("./_request", async (importOriginal) => ({ ...(await importOriginal()), serviceClient: () => ({ xrpc: { call } }) as unknown as BobbinContext })); const api = await import("./repoSettings"); const agent = { sub: "did:plc:owner" } as unknown as OAuthUserAgent; const bobbin = "https://bobbin.test"; const repo = { uri: manifestUri, knot: "knot.test", rkey: "core" }; beforeEach(() => vi.clearAllMocks()); describe("editedManifest", () => { it("keeps untouched fields and drops the ones an edit clears", () => { expect( api.editedManifest(manifest, { description: undefined, topics: ["svelte"] }) ).toEqual({ $type: "org.tangled.repo.manifest", declaration: { owner: "did:plc:owner", name: "core" }, createdAt: "2026-01-01T00:00:00Z", website: "https://tangled.org", topics: ["svelte"] }); }); it("drops an empty list rather than writing it", () => { expect(api.editedManifest(manifest, { topics: [] })).not.toHaveProperty("topics"); }); it("drops a defaultBranch left over from older manifests", () => { const onMain = { ...manifest, defaultBranch: "main" } as RepoRecord; expect(api.editedManifest(onMain, {})).not.toHaveProperty("defaultBranch"); }); }); describe("editManifest", () => { it("edits the manifest on the knot and waits for the appview", async () => { await api.editManifest(agent, bobbin, repo, { description: "a new forge" }); expect(updateCob).toHaveBeenCalledWith( agent, "knot.test", manifestUri, expect.any(Function) ); expect((await updateCob.mock.results[0].value).value).toMatchObject({ description: "a new forge" }); expect(awaitIndexedRecord).toHaveBeenCalledWith( expect.anything(), expect.objectContaining(written) ); }); }); describe("setDefaultBranch", () => { it("asks the knot to move the default branch and waits for the refs record", async () => { await api.setDefaultBranch(agent, bobbin, "knot.test", "did:plc:repo", "trunk"); expect(knotProcedure).toHaveBeenCalledWith( agent, expect.anything(), "did:plc:repo", "org.tangled.git.setDefaultBranch", { branch: "trunk" } ); expect(awaitIndexedRecord).toHaveBeenCalledWith(expect.anything(), refsWritten); }); }); describe("renameRepo", () => { it("only edits the manifest when the record key stays the same", async () => { await api.renameRepo(agent, bobbin, repo, "Core"); expect((await updateCob.mock.results[0].value).value.declaration.name).toBe("Core"); expect(createRecord).not.toHaveBeenCalled(); expect(deleteRecord).not.toHaveBeenCalled(); }); it("moves the declaration around the manifest edit", async () => { const order: string[] = []; createRecord.mockImplementationOnce(async () => { order.push("new declaration"); return { uri: "at://did:plc:owner/org.tangled.repo.declaration/forge", cid: "bafynew" }; }); updateCob.mockImplementationOnce(async (_a, _k, _u, mutate) => { order.push("manifest"); return { ...written, value: mutate(manifest) }; }); deleteRecord.mockImplementationOnce(async () => { order.push("old declaration"); }); await api.renameRepo(agent, bobbin, repo, "Forge"); expect(order).toEqual(["new declaration", "manifest", "old declaration"]); expect(awaitIndexedRecord).toHaveBeenCalledWith(expect.anything(), { uri: "at://did:plc:owner/org.tangled.repo.declaration/forge", cid: "bafynew" }); expect(createRecord).toHaveBeenCalledWith( agent, "org.tangled.repo.declaration", declaration.value, "forge" ); expect(deleteRecord).toHaveBeenCalledWith( agent, "org.tangled.repo.declaration", "core", "bafyold" ); }); it("takes the new declaration back down when the knot refuses the rename", async () => { updateCob.mockRejectedValueOnce( new Error("the record key forge already belongs to another repository") ); await expect(api.renameRepo(agent, bobbin, repo, "Forge")).rejects.toThrow( /another repository/ ); expect(deleteRecord).toHaveBeenCalledTimes(1); expect(deleteRecord).toHaveBeenCalledWith( agent, "org.tangled.repo.declaration", "forge", "bafynew" ); }); it("refuses a name the account already has without writing anything", async () => { readRecord.mockImplementation(async () => declaration); await expect(api.renameRepo(agent, bobbin, repo, "Taken")).rejects.toThrow( /already exists/ ); expect(createRecord).not.toHaveBeenCalled(); expect(updateCob).not.toHaveBeenCalled(); readRecord.mockImplementation(async (_a, _c, rkey: string) => rkey === "core" ? declaration : null ); }); }); describe("collaborators", () => { it("are grant records on the repo, keyed by the collaborator", async () => { await api.addCollaborator(agent, "knot.test", "did:plc:repo", "did:plc:friend"); expect(createGrant).toHaveBeenCalledWith( agent, "knot.test", "did:plc:repo", "org.tangled.graph.collaborator", "did:plc:friend" ); await api.removeCollaborator(agent, "knot.test", "did:plc:repo", "did:plc:friend"); expect(eraseGrant).toHaveBeenCalledWith( agent, "knot.test", "did:plc:repo", "org.tangled.graph.collaborator", "did:plc:friend" ); }); }); describe("deleteRepo", () => { const location = { rkey: "core", ownerDid: "did:plc:owner", knot: "knot.test", name: "core", ownerHandle: "alice", repoDid: "did:plc:repo" }; it("drops the declaration before asking the knot", async () => { const order: string[] = []; deleteRecord.mockImplementationOnce(async () => { order.push("declaration"); }); call.mockImplementationOnce(async () => { order.push("knot"); return { ok: true, data: undefined }; }); await api.deleteRepo(agent, bobbin, location); expect(order).toEqual(["declaration", "knot"]); expect(deleteRecord).toHaveBeenCalledWith(agent, "org.tangled.repo.declaration", "core"); expect(call.mock.calls[0][1]).toEqual({ input: { repo: "did:plc:repo" } }); expect(awaitDeletedRecord).toHaveBeenCalledWith( expect.anything(), "at://did:plc:owner/org.tangled.repo.declaration/core" ); }); it("carries on when the declaration is already gone", async () => { deleteRecord.mockRejectedValueOnce( new ClientResponseError({ status: 400, data: { error: "RecordNotFound", message: "could not locate record" } }) ); await api.deleteRepo(agent, bobbin, location); expect(call).toHaveBeenCalled(); }); it("stops when the pds refuses for another reason", async () => { deleteRecord.mockRejectedValueOnce( new ClientResponseError({ status: 500, data: { error: "InternalServerError" } }) ); await expect(api.deleteRepo(agent, bobbin, location)).rejects.toThrow( /InternalServerError/ ); expect(call).not.toHaveBeenCalled(); }); it("reports a knot that refuses after the declaration is gone", async () => { call.mockRejectedValueOnce( new Error("only repository owner or a knot admin may delete it") ); await expect(api.deleteRepo(agent, bobbin, location)).rejects.toThrow( /did not delete core/ ); expect(awaitDeletedRecord).not.toHaveBeenCalled(); }); it("tells indexnow the repo url went away", async () => { await api.deleteRepo(agent, bobbin, location); expect(pingIndexNow).toHaveBeenCalledWith("https://tangled.org/alice/core"); }); it("reports a deletion the appview has not observed", async () => { awaitDeletedRecord.mockResolvedValueOnce(false); await expect(api.deleteRepo(agent, bobbin, location)).rejects.toThrow(/appview/); }); });