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<typeof import("./client")>()),
createBobbinClient: () => ({ xrpc: { call } }) as unknown as BobbinContext
}));
vi.mock("./_request", async (importOriginal) => ({
...(await importOriginal<typeof import("./_request")>()),
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/);
});
});