import { describe, expect, it, vi } from "vitest"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import type { BobbinContext } from "#lib/api/client.ts"; const agent = { sub: "did:plc:alice" } as unknown as OAuthUserAgent; const BLOB = { $type: "blob" as const, ref: { $link: "bafkreifqrrwn3ioqkrquhibb3csu6i3kogd76uk6ktptc7s5exim6rfu6u" }, mimeType: "text/plain", size: 12 }; const load = async () => { vi.resetModules(); const writes: Record[] = []; const waits: Record[] = []; vi.doMock("$app/env", () => ({ browser: true })); vi.doMock("#lib/auth/agent.ts", () => ({ createClient: () => ({ call: async (_schema: unknown, options: { input: Record }) => { writes.push(options.input); return { ok: true, data: { uri: "at://did:plc:alice/org.tangled.snippet.string/3l7abc", cid: "bafyreicu44j74asmgef6cyxrpe5ege6cmovaqx6dzckgtawb7m2odv24gu" } }; } }) })); const strings = await import("./strings"); const graph = await import("./graph"); const records = await import("./records"); const bobbin = { xrpc: { call: async (_schema: unknown, options: { params: Record }) => { waits.push(options.params); return { ok: true, data: options.params.cid ? { status: "indexed", cid: options.params.cid } : { status: "deleted" } }; } } } as unknown as BobbinContext; return { strings, graph, records, writes, waits, bobbin }; }; describe("strings api", () => { it("puts a string record into org.tangled.snippet.string collection with optional swapRecord", async () => { const { strings, writes, waits, bobbin } = await load(); await strings.putString( agent, bobbin, "3l7abc", { $type: "org.tangled.snippet.string", snippet: { $type: "org.tangled.snippet.string#textFile", name: "main.rs", content: BLOB }, description: "test snippet", createdAt: "2026-05-01T00:00:00Z" }, "bafyreigrjynfxx5cdjylqloavwwdjsx3sfdssjqoeohh72jetcrnpgg6cy" ); expect(writes).toHaveLength(1); expect(writes[0]).toMatchObject({ repo: "did:plc:alice", collection: "org.tangled.snippet.string", rkey: "3l7abc", swapRecord: "bafyreigrjynfxx5cdjylqloavwwdjsx3sfdssjqoeohh72jetcrnpgg6cy", record: { $type: "org.tangled.snippet.string", snippet: { name: "main.rs", content: BLOB } } }); expect(waits).toEqual([ { uri: "at://did:plc:alice/org.tangled.snippet.string/3l7abc", cid: "bafyreicu44j74asmgef6cyxrpe5ege6cmovaqx6dzckgtawb7m2odv24gu" } ]); }); it("surfaces a write the appview did not index", async () => { const { strings, bobbin } = await load(); (bobbin as unknown as { xrpc: { call: unknown } }).xrpc.call = async () => ({ ok: true, data: { status: "rejected", detail: "unknown collection" } }); await expect( strings.putString(agent, bobbin, "3l7abc", { $type: "org.tangled.snippet.string", snippet: { $type: "org.tangled.snippet.string#textFile", name: "main.rs", content: BLOB }, createdAt: "2026-05-01T00:00:00Z" }) ).rejects.toThrow("unknown collection"); }); it("deletes a string record from org.tangled.snippet.string collection with optional swapRecord", async () => { const { strings, writes, waits, bobbin } = await load(); await strings.deleteString( agent, bobbin, "at://did:plc:alice/org.tangled.snippet.string/3l7abc", "bafyreigrjynfxx5cdjylqloavwwdjsx3sfdssjqoeohh72jetcrnpgg6cy" ); expect(writes).toHaveLength(1); expect(writes[0]).toMatchObject({ repo: "did:plc:alice", collection: "org.tangled.snippet.string", rkey: "3l7abc", swapRecord: "bafyreigrjynfxx5cdjylqloavwwdjsx3sfdssjqoeohh72jetcrnpgg6cy" }); expect(waits).toEqual([{ uri: "at://did:plc:alice/org.tangled.snippet.string/3l7abc" }]); }); it("reports an unconfirmed delete instead of failing the delete", async () => { vi.useFakeTimers(); try { const { strings, bobbin } = await load(); (bobbin as unknown as { xrpc: { call: unknown } }).xrpc.call = async () => ({ ok: true, data: { status: "indexed", cid: "bafytest" } }); const deleted = strings.deleteString( agent, bobbin, "at://did:plc:alice/org.tangled.snippet.string/3l7abc" ); await vi.advanceTimersByTimeAsync(6000); await expect(deleted).resolves.toBe(false); } finally { vi.useRealTimers(); } }); it("confirms a delete once the remembered write stops answering", async () => { const { strings, bobbin } = await load(); let asks = 0; (bobbin as unknown as { xrpc: { call: unknown } }).xrpc.call = async () => { asks += 1; return asks === 1 ? { ok: true, data: { status: "indexed", cid: "bafytest" } } : { ok: true, data: { status: "deleted" } }; }; await expect( strings.deleteString( agent, bobbin, "at://did:plc:alice/org.tangled.snippet.string/3l7abc" ) ).resolves.toBe(true); expect(asks).toBe(2); }); it("stops asking after the budget when bobbin keeps answering with the write", async () => { const { bobbin } = await load(); let asks = 0; (bobbin as unknown as { xrpc: { call: unknown } }).xrpc.call = async () => { asks += 1; return { ok: true, data: { status: "indexed", cid: "bafytest" } }; }; const { awaitDeletedRecord } = await import("./awaitRecord"); await expect( awaitDeletedRecord(bobbin, "at://did:plc:alice/org.tangled.snippet.string/3l7abc", { budgetMs: 5, pollMs: 1 }) ).resolves.toBe(false); expect(asks).toBeGreaterThan(1); }); it("creates a star on a string AT-URI", async () => { const { graph, writes } = await load(); const { rkey } = await graph.createStar(agent, { kind: "string", uri: "at://did:plc:bob/org.tangled.snippet.string/3l7xyz", cid: "bafyreiasfuctyrni66wjjvcrvckwcjsheir3a56bx2y5j7ietypnsfd7gy" }); expect(writes).toHaveLength(1); expect(writes[0]).toMatchObject({ repo: "did:plc:alice", collection: "org.tangled.feed.star", record: { $type: "org.tangled.feed.star", subject: { $type: "org.tangled.feed.star#record", record: { uri: "at://did:plc:bob/org.tangled.snippet.string/3l7xyz", cid: "bafyreiasfuctyrni66wjjvcrvckwcjsheir3a56bx2y5j7ietypnsfd7gy" } } } }); expect(rkey).toBe("3l7abc"); }); it("fetches a string via getString", async () => { const { records } = await load(); const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ uri: "at://did:plc:alice/org.tangled.snippet.string/3l7abc", cid: "bafyreicu44j74asmgef6cyxrpe5ege6cmovaqx6dzckgtawb7m2odv24gu", value: { $type: "org.tangled.snippet.string", snippet: { $type: "org.tangled.snippet.string#textFile", name: "main.rs", content: BLOB }, description: "test snippet", createdAt: "2026-05-01T00:00:00Z" } }) }); const ctx: BobbinContext = { serviceUrl: "https://bobbin.tangled.local", fetch: mockFetch, xrpc: {} as unknown as BobbinContext["xrpc"] }; const result = await records.getString( ctx, "at://did:plc:alice/org.tangled.snippet.string/3l7abc" ); expect(mockFetch).toHaveBeenCalledTimes(1); const url = new URL(mockFetch.mock.calls[0][0]); expect(url.pathname).toBe("/xrpc/org.tangled.string.getString"); expect(url.searchParams.get("uri")).toBe( "at://did:plc:alice/org.tangled.snippet.string/3l7abc" ); expect(result.value.snippet.name).toBe("main.rs"); }); }); describe("fetchStringContents", () => { const subject = (pds: string) => ({ did: "did:plc:alice", handle: "alice.test", pds, uri: "at://did:plc:alice/org.tangled.snippet.string/3l7abc", record: { value: { snippet: { name: "a.txt", content: BLOB } } } }) as never; const load = async () => { vi.resetModules(); vi.doMock("$app/env", () => ({ browser: false, dev: false })); return import("#lib/api/strings.ts"); }; it("reads the blob from a public pds", async () => { const { fetchStringContents } = await load(); const fetch = vi.fn(async () => new Response("hello")); expect(await fetchStringContents(fetch, subject("https://pds.example.com"))).toBe("hello"); }); it("never fetches from a pds in the private network", async () => { const { fetchStringContents } = await load(); const fetch = vi.fn(async () => new Response("secret")); for (const pds of [ "http://169.254.169.254", "https://localhost:2583", "http://bobbin:8090" ]) await expect(fetchStringContents(fetch, subject(pds))).rejects.toMatchObject({ status: 502 }); expect(fetch).not.toHaveBeenCalled(); }); it("doesn't follow a public pds's redirect into the private network", async () => { const { fetchStringContents } = await load(); const fetch = vi.fn( async () => new Response(null, { status: 302, headers: { location: "http://169.254.169.254/latest/meta-data" } }) ); await expect( fetchStringContents(fetch, subject("https://evil.example.com")) ).rejects.toMatchObject({ status: 502 }); expect(fetch).toHaveBeenCalledTimes(1); }); });