Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282import type { OAuthUserAgent } from "@atcute/oauth-browser-client";import { describe, expect, it, vi } from "vitest";import { createBobbinClient } from "#lib/api/client.ts";import type { Auth } from "#lib/auth.svelte.ts";import { loadFeed, loadFollowingFeed } from "#lib/components/timeline/load.ts";
vi.mock("#lib/auth/agent.ts", () => ({ mintServiceAuth: vi.fn(async () => "the-jwt"), serviceDidForHost: () => "did:web:bobbin.test"}));
const answering = (status: number, body: unknown) => vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => Response.json(body, { status }) );
const repo = { did: "did:plc:repo", slug: "core", owner: { did: "did:plc:owner", handle: "owner.tngl.sh" }};const subject = { did: "did:plc:erin", handle: "erin.tngl.sh" };const feed = [ { eventAt: "2026-10-21T00:00:00Z", event: { $type: "org.tangled.feed.getTimeline#starEvent", uri: "at://s", cid: "c", repo } }, { eventAt: "2026-10-21T00:00:00Z", event: { $type: "org.tangled.feed.getTimeline#followEvent", uri: "at://f", cid: "c", subject } }];const starUri = "at://did:plc:viewer/org.tangled.feed.star/3star";const followUri = "at://did:plc:viewer/org.tangled.graph.follow/3follow";
const json = (body: unknown) => new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } });
describe("loadFeed", () => { it("says so when bobbin wants a viewer, instead of showing nobody you follow", async () => { const fetch = answering(401, { error: "AuthRequired", message: "followingOnly requires a viewer" }); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch });
await expect(loadFeed({ ctx, followingOnly: true })).rejects.toThrow(); });
it("fails when bobbin is down, so the column can offer a retry", async () => { const fetch = answering(500, { error: "InternalServerError" }); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch });
await expect(loadFeed({ ctx, followingOnly: false })).rejects.toThrow(); });
it("hydrates star and follow state for the viewer", async () => { const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(input instanceof Request ? input.url : String(input)); expect(url.pathname).toBe("/xrpc/org.tangled.query.enrichResponse"); const body = JSON.parse(String(init?.body ?? (await (input as Request).text()))); expect(body.viewer).toBe("did:plc:viewer"); return json({ output: { feed }, data: { "did:plc:repo": { "org.tangled.feed.star:subject": { "org.tangled.query.enrichResponse#viewer": starUri } }, "did:plc:erin": { "org.tangled.graph.follow:subject": { "org.tangled.query.enrichResponse#viewer": followUri } } } }); }); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch }); const loaded = await loadFeed({ ctx, followingOnly: false, viewer: "did:plc:viewer" }); expect(loaded[0].event).toMatchObject({ repo: { viewer: { star: starUri } } }); expect(loaded[1].event).toMatchObject({ subject: { viewer: { following: followUri } } }); });
it("asks for the plain timeline without a viewer", async () => { const fetch = vi.fn(async (input: RequestInfo | URL) => { const url = new URL(input instanceof Request ? input.url : String(input)); expect(url.pathname).toBe("/xrpc/org.tangled.feed.getTimeline"); return json({ feed }); }); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch }); await expect(loadFeed({ ctx, followingOnly: false })).resolves.toHaveLength(2); });});
describe("loadFollowingFeed", () => { const auth = (did: string | null) => ({ currentDid: did, agent: { sub: did } as unknown as OAuthUserAgent }) as unknown as Auth;
it("asks for the following feed as the signed viewer", async () => { const fetch = answering(200, { feed: [] }); vi.stubGlobal("fetch", fetch); try { expect(await loadFollowingFeed(auth("did:plc:alice"), "https://bobbin.test")).toEqual( [] ); } finally { vi.unstubAllGlobals(); }
const [url, init] = fetch.mock.calls[0]; expect(String(url)).toContain("org.tangled.feed.getTimeline"); expect(String(url)).toContain("followingOnly=true"); expect(new Headers(init?.headers).get("authorization")).toBe("Bearer the-jwt"); });
it("has nothing to ask for when nobody is signed in", async () => { const fetch = answering(200, { feed: [] }); vi.stubGlobal("fetch", fetch); try { expect(await loadFollowingFeed(auth(null), "https://bobbin.test")).toEqual([]); } finally { vi.unstubAllGlobals(); } expect(fetch).not.toHaveBeenCalled(); });});
describe("loadAnnouncements", () => { const fresh = async () => { vi.resetModules(); return (await import("#lib/components/timeline/load.ts")).loadAnnouncements; };
const item = (slug: string, date: string, tags?: string[]) => ({ id: `https://tangled.org/blog/${slug}`, url: `https://tangled.org/blog/${slug}`, title: slug, summary: `${slug} summary`, date_published: date, tags });
const post = (slug: string, date: string) => ({ url: `https://tangled.org/blog/${slug}`, title: slug, subtitle: `${slug} summary`, date });
it("keeps the three newest posts tagged as announcements", async () => { const loadAnnouncements = await fresh(); const fetch = answering(200, { version: "https://jsonfeed.org/version/1.1", items: [ item("a", "2026-10-01T00:00:00Z", ["announcement"]), item("b", "2026-09-01T00:00:00Z"), item("c", "2026-08-01T00:00:00Z", ["announcement"]), item("d", "2026-07-01T00:00:00Z", ["announcement"]), item("e", "2026-06-01T00:00:00Z", ["announcement"]) ] });
await expect(loadAnnouncements(fetch)).resolves.toEqual([ post("a", "2026-10-01T00:00:00Z"), post("c", "2026-08-01T00:00:00Z"), post("d", "2026-07-01T00:00:00Z") ]); expect(String(fetch.mock.calls[0][0])).toBe("https://tangled.org/blog/feed.json"); });
it("skips an item with a malformed url instead of dropping the rest", async () => { const loadAnnouncements = await fresh(); const fetch = answering(200, { items: [ { ...item("a", "2026-10-01T00:00:00Z", ["announcement"]), url: "not a url" }, item("b", "2026-09-01T00:00:00Z", ["announcement"]) ] });
await expect(loadAnnouncements(fetch)).resolves.toEqual([ post("b", "2026-09-01T00:00:00Z") ]); });
it("drops items that link outside the blog", async () => { const loadAnnouncements = await fresh(); const fetch = answering(200, { items: [ { ...item("a", "2026-10-01T00:00:00Z", ["announcement"]), url: "javascript:alert(1)" }, { ...item("b", "2026-09-01T00:00:00Z", ["announcement"]), url: "https://evil.example/b" }, item("c", "2026-08-01T00:00:00Z", ["announcement"]) ] });
await expect(loadAnnouncements(fetch)).resolves.toEqual([ post("c", "2026-08-01T00:00:00Z") ]); });
it("reuses the feed within five minutes", async () => { vi.useFakeTimers(); try { const loadAnnouncements = await fresh(); const fetch = answering(200, { items: [item("a", "2026-10-01T00:00:00Z", ["announcement"])] });
await loadAnnouncements(fetch); vi.advanceTimersByTime(4 * 60_000); await loadAnnouncements(fetch); expect(fetch).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(2 * 60_000); await loadAnnouncements(fetch); expect(fetch).toHaveBeenCalledTimes(4); } finally { vi.useRealTimers(); } });
it("falls back to the next blog when the main one is down", async () => { const loadAnnouncements = await fresh(); const fetch = vi.fn(async (input: RequestInfo | URL) => String(input).startsWith("https://tangled.org/blog/") ? Response.json({}, { status: 503 }) : Response.json({ items: [item("a", "2026-10-01T00:00:00Z", ["announcement"])] }) );
await expect(loadAnnouncements(fetch)).resolves.toEqual([ post("a", "2026-10-01T00:00:00Z") ]); expect(fetch.mock.calls.map(([input]) => String(input))).toEqual([ "https://tangled.org/blog/feed.json", "https://next-dev.tangled.org/blog/feed.json" ]); });
it("asks every blog at once", async () => { const loadAnnouncements = await fresh(); const asked: string[] = []; const fetch = vi.fn((input: RequestInfo | URL) => { asked.push(String(input)); return new Promise<Response>(() => {}); });
void loadAnnouncements(fetch); await Promise.resolve(); expect(asked).toEqual([ "https://tangled.org/blog/feed.json", "https://next-dev.tangled.org/blog/feed.json" ]); });
it("shows nothing when every blog is down, and asks again next time", async () => { const loadAnnouncements = await fresh(); const fetch = answering(503, {}); await expect(loadAnnouncements(fetch)).resolves.toEqual([]); await loadAnnouncements(fetch); expect(fetch).toHaveBeenCalledTimes(4); });
it("shows nothing when the request fails", async () => { const loadAnnouncements = await fresh(); const fetch = vi.fn(async () => { throw new TypeError("network"); }); await expect(loadAnnouncements(fetch)).resolves.toEqual([]); });});