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"; const serving = (body: unknown) => vi.fn( 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( 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(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) => 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( 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(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(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 void>(); const fetch = vi.fn(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"]); }); });