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"; import type { RecordView, RepoRecord } from "$lib/api/records"; const putRecord = vi.fn(async () => ({ uri: "at://did:plc:owner/sh.tangled.repo/core", cid: "bafynew" })); const deleteRecord = vi.fn(async () => undefined); const createRecord = vi.fn(async () => ({ uri: "at://did:plc:owner/sh.tangled.repo/core", cid: "bafynew" })); const awaitIndexedRecord = vi.fn(async () => undefined); const awaitDeletedRecord = vi.fn(async () => true); const pingIndexNow = vi.fn(); vi.mock("./write", () => ({ createRecord, putRecord, deleteRecord })); vi.mock("./awaitRecord", () => ({ awaitIndexedRecord, awaitDeletedRecord })); vi.mock("$lib/api/indexnow", () => ({ pingIndexNow })); const call = vi.fn(async (_schema: unknown, _options: unknown) => ({ ok: true, data: undefined })); const post = vi.fn( async (_name: unknown, _options: { as: null; input: Record }) => ({ 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, post } }) 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 view: RecordView = { uri: "at://did:plc:owner/sh.tangled.repo/core", cid: "bafyold", value: { $type: "sh.tangled.repo", knot: "knot.test", createdAt: "2026-01-01T00:00:00Z" as never, description: "a forge", website: "https://tangled.org" } }; describe("editedRepoRecord", () => { it("keeps untouched fields and drops the ones an edit clears", () => { expect( api.editedRepoRecord(view.value, { description: undefined, topics: ["svelte"] }) ).toEqual({ $type: "sh.tangled.repo", knot: "knot.test", createdAt: "2026-01-01T00:00:00Z", website: "https://tangled.org", topics: ["svelte"] }); }); it("drops an empty list rather than writing it", () => { expect(api.editedRepoRecord(view.value, { topics: [] })).not.toHaveProperty("topics"); }); }); describe("saveRepoRecord", () => { it("swaps on the cid it read and waits for the appview", async () => { await api.saveRepoRecord(agent, bobbin, view, { description: "a forge for git" }); expect(putRecord).toHaveBeenCalledWith( agent, "sh.tangled.repo", "core", expect.objectContaining({ description: "a forge for git" }), "bafyold" ); expect(awaitIndexedRecord).toHaveBeenCalled(); }); }); describe("knot procedures", () => { it("names the repo by DID", async () => { await api.setDefaultBranch(agent, "knot.test", "did:plc:repo", "trunk"); expect(call.mock.calls.at(-1)?.[1]).toEqual({ input: { repo: "did:plc:repo", defaultBranch: "trunk" } }); await api.addCollaborator(agent, "knot.test", "did:plc:repo", "did:plc:friend"); expect(call.mock.calls.at(-1)?.[1]).toEqual({ input: { repo: "did:plc:repo", subject: "did:plc:friend" } }); }); }); describe("deleteRepo", () => { beforeEach(() => vi.clearAllMocks()); const location = { uri: view.uri, knot: "knot.test", name: "core", ownerHandle: "dawn", ownerDid: "did:plc:owner", repoDid: "did:plc:repo", cid: view.cid }; it("drops the record before the knot repository", async () => { const order: string[] = []; deleteRecord.mockImplementationOnce(async () => { order.push("record"); return undefined; }); post.mockImplementationOnce(async () => { order.push("knot"); return { ok: true, data: undefined }; }); await api.deleteRepo(agent, bobbin, location); expect(order).toEqual(["record", "knot"]); expect(deleteRecord).toHaveBeenCalledWith(agent, "sh.tangled.repo", "core", "bafyold"); expect(post.mock.calls.at(-1)?.[0]).toBe("sh.tangled.repo.delete"); expect(post.mock.calls.at(-1)?.[1]).toEqual({ as: null, input: { repo: "did:plc:repo", did: "did:plc:owner", name: "core", rkey: "core" } }); expect(awaitDeletedRecord).toHaveBeenCalled(); }); it("names a repository whose record predates repo DIDs", async () => { await api.deleteRepo(agent, bobbin, { ...location, repoDid: undefined }); expect(post.mock.calls.at(-1)?.[1]?.input).toEqual({ did: "did:plc:owner", name: "core", rkey: "core" }); }); it("carries on when the record 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(post).toHaveBeenCalled(); }); it("stops when the record moved under a concurrent edit", async () => { deleteRecord.mockRejectedValueOnce( new ClientResponseError({ status: 400, data: { error: "InvalidSwap", message: "record moved" } }) ); await expect(api.deleteRepo(agent, bobbin, location)).rejects.toThrow(/InvalidSwap/); expect(post).not.toHaveBeenCalled(); }); it("reports a knot that refuses after the record is gone", async () => { post.mockRejectedValueOnce(new Error("repo already exists: core")); 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/dawn/core"); }); it("reports a deletion the appview has not observed", async () => { awaitDeletedRecord.mockResolvedValueOnce(false); await expect(api.deleteRepo(agent, bobbin, location)).rejects.toThrow(/appview/); }); });