Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437import { 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";
const serving = (body: unknown) => vi.fn<typeof globalThis.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<typeof globalThis.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<typeof globalThis.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<typeof globalThis.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<typeof globalThis.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<typeof globalThis.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<typeof globalThis.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"]); });});