import { describe, expect, it, vi } from "vitest";
import { createBobbinClient } from "#lib/api/client.ts";
import {
completeOnboarding,
dismissOnboardingNudge,
editRecents,
editSavedServices,
getOnboarding,
getRecents,
getPreferences,
hostOf,
showsOnboardingNudge,
startOnboarding
} from "#lib/api/preferences.ts";
import type { Fetch } from "#lib/fetch.ts";
const serving = (body: unknown) =>
vi.fn<Fetch>(
async () =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" }
})
);
const contextFor = (body: unknown, fetch = serving(body)) => ({
ctx: createBobbinClient({ serviceUrl: "https://bobbin.example.com", fetch }),
fetch
});
describe("getPreferences", () => {
it("splits knots from spindles, each in the order the account saved them", async () => {
const { ctx, fetch } = contextFor({
preferences: [
{
$type: "org.tangled.temp.actor.defs#savedSpindlePref",
items: [{ id: "3l", value: "spindle.example" }]
},
{
$type: "org.tangled.temp.actor.defs#savedKnotPref",
items: [
{ id: "3a", value: "zebra.example" },
{ id: "3b", value: "alpha.example" }
]
}
]
});
await expect(getPreferences(ctx)).resolves.toEqual({
knots: ["zebra.example", "alpha.example"],
spindles: ["spindle.example"]
});
expect(fetch.mock.calls.map(([input]) => new URL(String(input)).pathname)).toEqual([
"/xrpc/com.bad-example.pocket.getPreferences"
]);
});
it("a failed read throws instead of looking like an empty account", async () => {
const fetch = vi.fn<Fetch>(
async () =>
new Response(JSON.stringify({ error: "InternalServerError" }), { status: 500 })
);
const { ctx } = contextFor(null, fetch);
await expect(getPreferences(ctx)).rejects.toThrow();
});
it("an account that saved nothing gets two empty lists", async () => {
const { ctx } = contextFor({ preferences: [] });
await expect(getPreferences(ctx)).resolves.toEqual({ knots: [], spindles: [] });
});
});
describe("hostOf", () => {
it.each([
["knot.example.com", "knot.example.com"],
[" KNOT.Example.com ", "knot.example.com"],
["https://knot.example.com/", "knot.example.com"],
["http://knot.example.com:6000/some/path", "knot.example.com:6000"],
["", ""]
])("%s becomes %s", (input, expected) => {
expect(hostOf(input)).toBe(expected);
});
});
describe("editSavedServices", () => {
const roundTrip = () => {
const sent: unknown[] = [];
let stored = {
preferences: [
{
$type: "org.tangled.temp.actor.defs#savedKnotPref",
items: [{ id: "one.example", value: "one.example" }]
},
{
$type: "org.tangled.temp.actor.defs#savedSpindlePref",
items: [{ id: "spindle.example", value: "spindle.example" }]
}
]
};
const fetch = vi.fn<Fetch>(async (input, init) => {
if (String(input).endsWith("putPreferences")) {
const body = JSON.parse(String(init?.body)) as typeof stored;
sent.push(body);
stored = body;
return new Response(null, { status: 200 });
}
return new Response(JSON.stringify(stored), {
status: 200,
headers: { "content-type": "application/json" }
});
});
return {
ctx: createBobbinClient({ serviceUrl: "https://bobbin.example.com", fetch }),
sent,
seed: (pref: Record<string, unknown>) => stored.preferences.push(pref as never),
read: () => stored
};
};
it("appending a knot leaves the spindles alone and reads back", async () => {
const { ctx, sent } = roundTrip();
await expect(
editSavedServices(ctx, "knots", (hosts) => [...hosts, "two.example"])
).resolves.toEqual(["one.example", "two.example"]);
expect(sent).toHaveLength(1);
await expect(getPreferences(ctx)).resolves.toEqual({
knots: ["one.example", "two.example"],
spindles: ["spindle.example"]
});
});
it("leaves preference types it does not know about untouched", async () => {
const { ctx, sent, seed } = roundTrip();
const foreign = { $type: "com.example.other#pref", flag: true };
seed(foreign);
await editSavedServices(ctx, "knots", (hosts) => [...hosts, "two.example"]);
expect((sent[0] as { preferences: unknown[] }).preferences).toContainEqual(foreign);
});
it("never writes when the read fails", async () => {
const fetch = vi.fn<Fetch>(async () => new Response(null, { status: 503 }));
const { ctx } = contextFor(null, fetch);
await expect(editSavedServices(ctx, "knots", (hosts) => hosts)).rejects.toThrow();
expect(fetch).toHaveBeenCalledTimes(1);
});
it("a removed host is gone from the stored preferences", async () => {
const { ctx } = roundTrip();
await editSavedServices(ctx, "spindles", (hosts) =>
hosts.filter((host) => host !== "spindle.example")
);
await expect(getPreferences(ctx)).resolves.toEqual({
knots: ["one.example"],
spindles: []
});
});
});
describe("onboarding state", () => {
const pocket = (preferences: unknown[]) => {
const sent: { preferences: unknown[] }[] = [];
let stored = { preferences };
const fetch = vi.fn<Fetch>(async (input, init) => {
if (String(input).endsWith("putPreferences")) {
stored = JSON.parse(String(init?.body)) as typeof stored;
sent.push(stored);
return new Response(null, { status: 200 });
}
return new Response(JSON.stringify(stored), {
status: 200,
headers: { "content-type": "application/json" }
});
});
return {
ctx: createBobbinClient({ serviceUrl: "https://bobbin.example.com", fetch }),
sent
};
};
const knots = {
$type: "org.tangled.temp.actor.defs#savedKnotPref",
items: [{ id: "one.example", value: "one.example" }]
};
it("treats an account that never started onboarding as done", async () => {
const { ctx, sent } = pocket([knots]);
await expect(getOnboarding(ctx)).resolves.toBeNull();
expect(showsOnboardingNudge(null)).toBe(false);
await completeOnboarding(ctx);
await dismissOnboardingNudge(ctx);
expect(sent).toHaveLength(0);
});
it("nudges a started account until it finishes", async () => {
const { ctx } = pocket([knots]);
await startOnboarding(ctx);
expect(showsOnboardingNudge(await getOnboarding(ctx))).toBe(true);
await completeOnboarding(ctx);
const finished = await getOnboarding(ctx);
expect(finished?.completedAt).toBeTypeOf("string");
expect(showsOnboardingNudge(finished)).toBe(false);
});
it("stops nudging once the account asks not to be asked again", async () => {
const { ctx } = pocket([knots]);
await startOnboarding(ctx);
await dismissOnboardingNudge(ctx);
expect(showsOnboardingNudge(await getOnboarding(ctx))).toBe(false);
});
it("keeps the first start time and the other preferences", async () => {
const { ctx, sent } = pocket([knots]);
await startOnboarding(ctx);
const first = (await getOnboarding(ctx))?.startedAt;
await startOnboarding(ctx);
expect((await getOnboarding(ctx))?.startedAt).toBe(first);
expect(sent).toHaveLength(1);
expect(sent[0].preferences).toContainEqual(knots);
});
});
describe("recents", () => {
const RECENTS = "org.tangled.temp.actor.defs#recentsPref";
const knots = {
$type: "org.tangled.temp.actor.defs#savedKnotPref",
items: [{ id: "one.example", value: "one.example" }]
};
const row = {
$type: "org.tangled.temp.actor.defs#recentRepo",
ownerHandle: "alice.test",
name: "core",
visited: "2026-10-01T00:00:00.000Z"
} as const;
const pocket = (body: unknown, status = 200) => {
const sent: { preferences: unknown[] }[] = [];
const fetch = vi.fn<Fetch>(async (input, init) => {
if (String(input).endsWith("putPreferences")) {
body = JSON.parse(String(init?.body));
sent.push(body as (typeof sent)[number]);
return new Response(null, { status: 200 });
}
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" }
});
});
return {
ctx: createBobbinClient({ serviceUrl: "https://bobbin.example.com", fetch }),
sent
};
};
it("reads the rows saved under the recents preference", async () => {
const { ctx } = pocket({ preferences: [knots, { $type: RECENTS, items: [row] }] });
await expect(getRecents(ctx)).resolves.toEqual([row]);
});
it("an account that never saved anything has no recents", async () => {
const { ctx } = pocket({ error: "NotFound", message: "nothing" }, 400);
await expect(getRecents(ctx)).resolves.toEqual([]);
});
it("replaces the recents and sends every other preference back", async () => {
const foreign = { $type: "com.example.other#pref", flag: true };
const { ctx, sent } = pocket({
preferences: [knots, foreign, { $type: RECENTS, items: [] }]
});
await expect(editRecents(ctx, () => [row])).resolves.toEqual([row]);
expect(sent).toHaveLength(1);
expect(sent[0].preferences).toEqual([knots, foreign, { $type: RECENTS, items: [row] }]);
});
it("does not write when the edit hands back the same list", async () => {
const { ctx, sent } = pocket({ preferences: [{ $type: RECENTS, items: [row] }] });
await editRecents(ctx, (current) => current);
expect(sent).toHaveLength(0);
});
});
describe("compare-and-swap writes", () => {
const knots = (...hosts: string[]) => ({
$type: "org.tangled.temp.actor.defs#savedKnotPref",
items: hosts.map((host) => ({ id: host, value: host }))
});
const ONBOARDING = {
$type: "org.tangled.temp.actor.defs#onboardingPref",
startedAt: "2026-09-01T00:00:00.000Z"
};
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" }
});
const revOf = (preferences: unknown) => JSON.stringify(preferences);
// pocket's contract: an absent swapRev writes blind, null only when nothing is stored, a rev only while it is current
const swapPocket = (preferences: unknown[] | null, { swaps = true } = {}) => {
let stored = preferences;
const requests: string[] = [];
const swapRevs: unknown[] = [];
const hooks = new Map<number, () => void>();
const fetch = vi.fn<Fetch>(async (input, init) => {
hooks.get(requests.length)?.();
if (String(input).endsWith("putPreferences")) {
requests.push("put");
const body = JSON.parse(String(init?.body)) as {
preferences: unknown[];
swapRev?: unknown;
};
swapRevs.push(body.swapRev);
const current = stored && revOf(stored);
if (swaps && "swapRev" in body && body.swapRev !== current)
return json(
{ error: "InvalidSwap", message: "stored prefs do not match swapRev" },
400
);
stored = body.preferences;
return new Response("saved.", { status: 200 });
}
requests.push("get");
if (!stored)
return json({ error: "NotFound", message: "could not find prefs for u" }, 400);
return json(
swaps ? { preferences: stored, rev: revOf(stored) } : { preferences: stored }
);
});
const ctx = createBobbinClient({ serviceUrl: "https://bobbin.example.com", fetch });
return {
ctx,
requests,
swapRevs,
/** runs write right before pocket serves request number n */
before: (n: number, write: () => void) => hooks.set(n, write),
read: () => structuredClone(stored),
write: (next: unknown[]) => (stored = next),
knots: async () => (await getPreferences(ctx)).knots
};
};
const adding =
(host: string) =>
(hosts: string[]): string[] =>
hosts.includes(host) ? hosts : [...hosts, host];
const addMine = adding("mine.example");
it("an edit sends the rev it read and costs one read and one write", async () => {
const start = [knots("one.example")];
const pocket = swapPocket(start);
await expect(editSavedServices(pocket.ctx, "knots", addMine)).resolves.toEqual([
"one.example",
"mine.example"
]);
expect(pocket.requests).toEqual(["get", "put"]);
expect(pocket.swapRevs).toEqual([revOf(start)]);
});
it("a first write asks for nothing to be stored yet", async () => {
const pocket = swapPocket(null);
await editSavedServices(pocket.ctx, "knots", addMine);
expect(pocket.swapRevs).toEqual([null]);
expect(await pocket.knots()).toEqual(["mine.example"]);
});
it("a write landing between our read and our put gets ours redone on top, keeping both", async () => {
const pocket = swapPocket([knots("one.example")]);
pocket.before(1, () => pocket.write([knots("one.example", "other.example")]));
await expect(editSavedServices(pocket.ctx, "knots", addMine)).resolves.toEqual([
"one.example",
"other.example",
"mine.example"
]);
expect(pocket.requests).toEqual(["get", "put", "get", "put"]);
expect(await pocket.knots()).toEqual(["one.example", "other.example", "mine.example"]);
});
it("two first writers both keep their change", async () => {
const pocket = swapPocket(null);
await Promise.all([
editSavedServices(pocket.ctx, "knots", addMine),
editSavedServices(pocket.ctx, "knots", adding("other.example"))
]);
expect(new Set(await pocket.knots())).toEqual(new Set(["mine.example", "other.example"]));
});
it("a redo keeps the other preferences, including one written meanwhile", async () => {
const foreign = { $type: "com.example.other#pref", flag: true };
const pocket = swapPocket([knots("one.example"), foreign]);
pocket.before(1, () => pocket.write([...(pocket.read() ?? []), ONBOARDING]));
await editSavedServices(pocket.ctx, "knots", addMine);
expect(pocket.read()).toEqual(
expect.arrayContaining([foreign, ONBOARDING, knots("one.example", "mine.example")])
);
});
it("gives up and throws after three swaps in a row are lost", async () => {
const pocket = swapPocket([knots("one.example")]);
for (const n of [1, 3, 5])
pocket.before(n, () => pocket.write([knots("one.example", `other${n}.example`)]));
await expect(editSavedServices(pocket.ctx, "knots", addMine)).rejects.toThrow(
/kept changing/
);
expect(pocket.requests.slice(0, 6)).toEqual(["get", "put", "get", "put", "get", "put"]);
});
it("an edit that changes nothing does not write", async () => {
const pocket = swapPocket([knots("one.example")]);
await completeOnboarding(pocket.ctx);
expect(pocket.requests).toEqual(["get"]);
});
it("a pocket without swaps gets a blind write", async () => {
const pocket = swapPocket([knots("one.example")], { swaps: false });
await editSavedServices(pocket.ctx, "knots", addMine);
expect(pocket.requests).toEqual(["get", "put"]);
expect(pocket.swapRevs).toEqual([undefined]);
expect(await pocket.knots()).toEqual(["one.example", "mine.example"]);
});
});