import 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([]);
});
});