import { describe, expect, it, vi } from "vitest"; import { createBobbinClient } from "#lib/api/client.ts"; import { TYPE_MINIDOC, TYPE_REPO_VIEW } from "#lib/api/enrich.ts"; import { fetchPeoplePage, fetchForksPage, fetchPinned, fetchProfileActivity, fetchReposPage, fetchStarredPage } from "#lib/components/profile/pages.ts"; import type { Fetch } from "#lib/fetch.ts"; const enriched = () => Response.json({ output: { items: [], hits: [] }, data: {} }); const bobDoc = { did: "did:plc:bob", handle: "bob.test" }; const star = (id: string, subject: unknown) => ({ uri: `at://did:plc:alice/org.tangled.feed.star/${id}`, value: { subject, createdAt: "2026-08-03T00:00:00Z" } }); const stringSubject = (uri: string) => ({ $type: "org.tangled.feed.star#record", record: { uri } }); const stringRecord = (name: string, description: string, size: number, delay = 0) => ({ delay, value: { snippet: { name, content: { $type: "blob", ref: { $link: "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" }, size, mimeType: "text/plain" } }, description, createdAt: "2026-08-02T00:00:00Z" } }); interface StarredFixture { stars: unknown[]; // a string uri without an entry answers 404 strings?: Record>; repos?: unknown[]; data?: Record; } const starredContext = ({ stars, strings = {}, repos = [], data = {} }: StarredFixture) => { const fetchMock = vi.fn().mockImplementation(async (input, init) => { const url = new URL(String(input instanceof Request ? input.url : input)); if (url.pathname === "/xrpc/org.tangled.string.getString") { const uri = url.searchParams.get("uri") ?? ""; const found = strings[uri]; if (!found) return new Response("gone", { status: 404 }); await new Promise((resolve) => setTimeout(resolve, found.delay)); return Response.json({ uri, value: found.value }); } const { xrpc } = JSON.parse(String(init?.body)) as { xrpc: string }; if (xrpc === "org.tangled.repo.getReposByRepoDids") return Response.json({ output: { items: repos }, data: {} }); return Response.json({ output: { items: stars }, data }); }); return createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); }; const requestBody = (fetchMock: ReturnType, index = 0) => { const init = fetchMock.mock.calls[index][1] as RequestInit; return JSON.parse(String(init.body)) as { xrpc: string; enrich: { source: string; type: string; targets?: string[] }[]; }; }; describe("fetchStarredPage", () => { it("fills a page past stars whose repo is gone", async () => { const ids = Array.from({ length: 70 }, (_, index) => `r${index}`); const gone = new Set(["r5", "r40"]); const fetchMock = vi.fn().mockImplementation(async (_, init) => { const { xrpc, params } = JSON.parse(String(init?.body)) as { xrpc: string; params: { offset?: number; limit: number; dids?: string[] }; }; if (xrpc === "org.tangled.repo.getReposByRepoDids") { const items = (params.dids ?? []) .filter((did) => !gone.has(did.split(":").at(-1) ?? "")) .map((did) => ({ uri: `at://${did}/org.tangled.repo.manifest/self`, value: { declaration: { owner: "did:plc:bob", name: did }, createdAt: "2026-08-01T00:00:00Z" } })); return Response.json({ output: { items }, data: {} }); } const offset = params.offset ?? 0; const items = ids .slice(offset, offset + params.limit) .map((id) => star(id, { $type: "org.tangled.feed.star#repo", identity: `did:plc:${id}` }) ); return Response.json({ output: { items }, data: {} }); }); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const first = await fetchStarredPage(ctx, { did: "did:plc:alice", total: 70 }); const second = await fetchStarredPage(ctx, { did: "did:plc:alice", page: 2, total: 70 }); const third = await fetchStarredPage(ctx, { did: "did:plc:alice", page: 3, total: 70 }); const live = ids.filter((id) => !gone.has(id)); const rkeys = (page: typeof first) => page.items.map((item) => item.uri.split("/").at(-1)); expect(rkeys(first)).toEqual(live.slice(0, 30)); expect(rkeys(second)).toEqual(live.slice(30, 60)); expect(rkeys(third)).toEqual(live.slice(60)); expect(third.dropped).toBe(2); const calls = fetchMock.mock.calls.length; const far = await fetchStarredPage(ctx, { did: "did:plc:alice", page: 50, total: 70 }); expect(far.items).toEqual([]); expect(fetchMock.mock.calls.length).toBe(calls); }); it("only requests minidocs for string URI subjects", async () => { const fetchMock = vi.fn().mockResolvedValue( Response.json({ output: { items: [] }, data: {} }) ); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); await fetchStarredPage(ctx, { did: "did:plc:alice", total: 70 }); const body = requestBody(fetchMock); expect(body.enrich[0].targets).toEqual(["items[].value.subject.record.uri"]); }); it("hydrates starred strings into string cards", async () => { const subject = "at://did:plc:bob/org.tangled.snippet.string/3kstr"; const ctx = starredContext({ stars: [star("one", stringSubject(subject))], strings: { [subject]: stringRecord("hello.rs", "says hi", 2048) }, data: { "did:plc:bob": { "org.tangled.feed.star:subject": { [TYPE_MINIDOC]: bobDoc } } } }); const page = await fetchStarredPage(ctx, { did: "did:plc:alice", total: 70 }); expect(page.items).toMatchObject([ { kind: "string", uri: "at://did:plc:alice/org.tangled.feed.star/one", string: { rkey: "3kstr", ownerHandle: "bob.test", filename: "hello.rs", description: "says hi", createdAt: "2026-08-02T00:00:00Z" } } ]); expect(page.items[0]).toHaveProperty("string.sizeLabel", "2.0 kB"); }); it("drops a starred string that can't be fetched and keeps its neighbours in order", async () => { const before = "at://did:plc:bob/org.tangled.snippet.string/3kbefore"; const gone = "at://did:plc:bob/org.tangled.snippet.string/3kgone"; const after = "at://did:plc:bob/org.tangled.snippet.string/3kafter"; const ctx = starredContext({ stars: [ star("a", stringSubject(before)), star("b", stringSubject(gone)), star("c", { $type: "org.tangled.feed.star#repo", identity: "did:plc:repo" }), star("d", stringSubject(after)) ], strings: { [before]: stringRecord("before.rs", "kept", 10), [after]: stringRecord("after.rs", "kept too", 10) }, repos: [ { uri: "at://did:plc:repo/org.tangled.repo.manifest/self", value: { declaration: { owner: "did:plc:bob", name: "reef" }, createdAt: "2026-08-01T00:00:00Z" } } ] }); const page = await fetchStarredPage(ctx, { did: "did:plc:alice", total: 70 }); expect(page.items.map((item) => item.uri.split("/").at(-1))).toEqual(["a", "c", "d"]); expect(page.items.map((item) => item.kind)).toEqual(["string", "repo", "string"]); }); it("links a starred string to its owner's did when bobbin has no handle for them", async () => { const subject = "at://did:plc:bob/org.tangled.snippet.string/3kstr"; const ctx = starredContext({ stars: [star("one", stringSubject(subject))], strings: { [subject]: stringRecord("hello.rs", "says hi", 2048) }, data: { "did:plc:bob": { "org.tangled.feed.star:subject": { [TYPE_MINIDOC]: { did: "did:plc:bob", handle: "handle.invalid" } } } } }); const page = await fetchStarredPage(ctx, { did: "did:plc:alice", total: 70 }); expect(page.items).toMatchObject([{ string: { ownerHandle: "did:plc:bob" } }]); }); it("keeps repo and string stars in star order, each string with its own record", async () => { const first = "at://did:plc:bob/org.tangled.snippet.string/3kfirst"; const second = "at://did:plc:bob/org.tangled.snippet.string/3ksecond"; const ctx = starredContext({ stars: [ star("a", stringSubject(first)), star("b", { $type: "org.tangled.feed.star#repo", identity: "did:plc:repo" }), star("c", stringSubject(second)) ], // the first string answers last, so a response matched by arrival would swap them strings: { [first]: stringRecord("first.rs", "the first", 10, 30), [second]: stringRecord("second.rs", "the second", 10) }, repos: [ { uri: "at://did:plc:repo/org.tangled.repo.manifest/self", value: { declaration: { owner: "did:plc:bob", name: "reef" }, createdAt: "2026-08-01T00:00:00Z" } } ], data: { "did:plc:bob": { "org.tangled.feed.star:subject": { [TYPE_MINIDOC]: bobDoc } } } }); const page = await fetchStarredPage(ctx, { did: "did:plc:alice", total: 70 }); expect(page.items.map((item) => item.kind)).toEqual(["string", "repo", "string"]); expect(page.items).toMatchObject([ { string: { rkey: "3kfirst", filename: "first.rs", description: "the first" } }, { repo: { name: "reef", repoDid: "did:plc:repo" } }, { string: { rkey: "3ksecond", filename: "second.rs", description: "the second" } } ]); }); it("targets repo views and owner minidocs independently", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( Response.json({ output: { items: [ { uri: "at://did:plc:alice/org.tangled.feed.star/one", value: { subject: { $type: "org.tangled.feed.star#repo", identity: "did:plc:repo" }, createdAt: "2026-08-01T00:00:00Z" } } ] }, data: {} }) ) .mockResolvedValueOnce(Response.json({ output: { items: [] }, data: {} })); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); await fetchStarredPage(ctx, { did: "did:plc:alice", total: 70 }); const body = requestBody(fetchMock, 1); expect(body.enrich).toEqual([ { source: "org.tangled.repo.manifest:.", type: TYPE_REPO_VIEW, targets: ["items[].uri"] }, { source: "org.tangled.repo.manifest:.repo", type: "blue.microcosm.identity.resolveMiniDoc", targets: ["items[].value.declaration.owner"] } ]); }); it.each([ [undefined, "items[].uri"], ["needle", "hits[].uri"] ])("targets repo views for list/search results", async (q, expectedTarget) => { const fetchMock = vi.fn().mockResolvedValue(enriched()); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); await fetchReposPage(ctx, { did: "did:plc:alice", handle: "alice.test", viewerDid: "did:plc:viewer", q }); const body = requestBody(fetchMock); expect(body.enrich).toEqual([ { source: "org.tangled.repo.manifest:.", type: TYPE_REPO_VIEW, targets: [expectedTarget] } ]); }); it.each([ [{ star: "at://did:plc:viewer/org.tangled.feed.star/abc" }, "abc"], [{}, null], [undefined, undefined] ])("fills repo cards from hydrated views", async (viewer, viewerStarRkey) => { const fetchMock = vi.fn().mockResolvedValue( Response.json({ output: { items: [ { uri: "at://did:plc:reef/org.tangled.repo.manifest/self", value: { declaration: { owner: "did:plc:alice", name: "reef" }, createdAt: "2026-08-01T00:00:00Z" } } ] }, data: { "at://did:plc:reef/org.tangled.repo.manifest/self": { "org.tangled.repo.manifest:.": { [TYPE_REPO_VIEW]: { did: "did:plc:reef", slug: "reef", owner: { did: "did:plc:alice", handle: "handle.invalid" }, createdAt: "2026-08-01T00:00:00Z", starCount: 4, forkCount: 2, openIssueCount: 3, openPullCount: 1, primaryLanguage: "Rust", viewer } } } } }) ); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const page = await fetchReposPage(ctx, { did: "did:plc:alice", handle: "alice.test" }); expect(page.items[0]).toMatchObject({ name: "reef", ownerHandle: "alice.test", stars: 4, forks: 2, issues: 3, pulls: 1, language: "Rust" }); expect(page.items[0].viewerStarRkey).toBe(viewerStarRkey); }); it.each([ ["followers" as const, "items[].uri"], ["following" as const, "items[].subject"] ])("targets %s identities and stats", async (direction, expectedTarget) => { const fetchMock = vi.fn().mockResolvedValue(enriched()); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); await fetchPeoplePage(ctx, { did: "did:plc:alice", viewerDid: "did:plc:viewer", direction }); const body = requestBody(fetchMock); expect(body.enrich).toHaveLength(4); expect(body.enrich.every((descriptor) => descriptor.targets?.[0] === expectedTarget)).toBe( true ); }); it("does not retry missing enriched identities", async () => { const fetchMock = vi.fn().mockResolvedValue( Response.json({ output: { items: [ { uri: "at://did:plc:bob/org.tangled.graph.follow/did:plc:alice", subject: "did:plc:alice", value: { createdAt: "2026-08-01T00:00:00Z" } } ] }, data: {} }) ); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const page = await fetchPeoplePage(ctx, { did: "did:plc:alice", direction: "followers" }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(page.items[0].handle).toBe("handle.invalid"); }); it("tells people with an avatar from people without one", async () => { const follows = ["alice", "bob"].map((name) => ({ uri: `at://did:plc:${name}/org.tangled.graph.follow/did:plc:me`, subject: "did:plc:me", value: { createdAt: "2026-08-01T00:00:00Z" } })); const picture = { $type: "blob", ref: { $link: "bafkreigh2akiscaildc7gnvtklbsfhdgwz72eolmpckbqr5ej26byp3uli" }, mimeType: "image/png", size: 10 }; const fetchMock = vi.fn().mockImplementation(async (input) => { const url = new URL(String(input instanceof Request ? input.url : input)); if (url.pathname !== "/xrpc/org.tangled.actor.getProfiles") { return Response.json({ output: { items: follows }, data: {} }); } return Response.json({ items: [ { uri: "at://did:plc:alice/org.tangled.actor.profile/self", value: { avatar: picture } }, { uri: "at://did:plc:bob/org.tangled.actor.profile/self", value: {} } ] }); }); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const page = await fetchPeoplePage(ctx, { did: "did:plc:me", direction: "followers" }); expect(page.items.map((person) => person.avatarCid)).toEqual([picture.ref.$link, null]); }); it("leaves the avatar unknown when the profile records could not be read", async () => { const fetchMock = vi.fn().mockImplementation(async (input) => { const url = new URL(String(input instanceof Request ? input.url : input)); if (url.pathname === "/xrpc/org.tangled.actor.getProfiles") { return new Response("down", { status: 500 }); } return Response.json({ output: { items: [ { uri: "at://did:plc:alice/org.tangled.graph.follow/did:plc:me", value: { createdAt: "2026-08-01T00:00:00Z" } } ] }, data: {} }); }); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const page = await fetchPeoplePage(ctx, { did: "did:plc:me", direction: "followers" }); expect(page.items[0].avatarCid).toBeUndefined(); }); it("targets repo dids for both pinned repo queries", async () => { const fetchMock = vi.fn().mockImplementation(async () => enriched()); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); await fetchPinned(ctx, { keys: ["did:plc:repo", "at://did:plc:alice/sh.tangled.repo/example"], did: "did:plc:alice", handle: "alice.test", viewerDid: "did:plc:viewer" }); const bodies = fetchMock.mock.calls.map((_, index) => requestBody(fetchMock, index)); expect(bodies.map((body) => body.xrpc).sort()).toEqual([ "org.tangled.repo.getRepos", "org.tangled.repo.getReposByRepoDids" ]); expect( bodies.every((body) => body.enrich.every((descriptor) => descriptor.type === TYPE_MINIDOC ? descriptor.targets?.[0] === "items[].value.declaration.owner" : descriptor.targets?.[0] === "items[].uri" ) ) ).toBe(true); }); it("deduplicates repos returned by fetchReposPage by repoKey", async () => { const fetchMock = vi.fn().mockResolvedValue( Response.json({ output: { items: [ { uri: "at://did:plc:repo-one/org.tangled.repo.manifest/self", value: { declaration: { owner: "did:plc:alice", name: "repo-one" }, createdAt: "2026-08-01T00:00:00Z" } }, { uri: "at://did:plc:repo-one/org.tangled.repo.manifest/self", value: { declaration: { owner: "did:plc:alice", name: "repo-one-dup" }, createdAt: "2026-08-02T00:00:00Z" } }, { uri: "at://did:plc:repo-two/org.tangled.repo.manifest/self", value: { declaration: { owner: "did:plc:alice", name: "repo-two" }, createdAt: "2026-08-03T00:00:00Z" } } ] }, data: {} }) ); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const result = await fetchReposPage(ctx, { did: "did:plc:alice", handle: "alice.test", viewerDid: "did:plc:viewer" }); expect(result.items).toHaveLength(2); expect(result.items[0].repoDid).toBe("did:plc:repo-one"); expect(result.items[0].name).toBe("repo-one-dup"); expect(result.items[1].repoDid).toBe("did:plc:repo-two"); }); }); describe("fetchProfileActivity", () => { it("maps one hydrated Bobbin response into the profile view model", async () => { const fetchMock = vi.fn().mockResolvedValue( Response.json({ months: [ { start: "2026-09-01T00:00:00Z", commits: 3, repos: [ { kind: "created", repo: { uri: "at://did:plc:alice/sh.tangled.repo/current", did: "did:plc:current", ownerDid: "did:plc:alice", ownerHandle: "handle.invalid", name: "current" } } ], issues: [ { uri: "at://did:plc:alice/sh.tangled.repo.issue/open", title: "Open issue", state: "open", repo: { uri: "at://did:plc:bob/sh.tangled.repo/target", did: "did:plc:target", ownerDid: "did:plc:bob", ownerHandle: "handle.invalid", name: "target" } } ], pulls: [ { uri: "at://did:plc:alice/sh.tangled.repo.pull/merged", title: "Merged pull", state: "merged", repo: { uri: "at://did:plc:bob/sh.tangled.repo/target", did: "did:plc:target", ownerDid: "did:plc:bob", ownerHandle: "bob.test", name: "target" } } ] }, { start: "2026-08-01T00:00:00Z", commits: 0, repos: [ { kind: "forked", repo: { uri: "at://did:plc:alice/sh.tangled.repo/fork", did: "did:plc:fork", ownerDid: "did:plc:alice", ownerHandle: "alice.test", name: "fork" } } ], issues: [], pulls: [] }, { start: "2026-03-01T00:00:00Z", commits: 0, repos: [], issues: [ { uri: "at://did:plc:alice/sh.tangled.repo.issue/closed", title: "Closed issue", state: "closed", repo: { uri: "at://did:plc:bob/sh.tangled.repo/target", did: "did:plc:target", ownerDid: "did:plc:bob", ownerHandle: "bob.test", name: "target" } } ], pulls: [] } ], truncated: true }) ); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const activity = await fetchProfileActivity(ctx, { did: "did:plc:alice", handle: "alice.test", now: new Date("2026-09-01T12:00:00Z") }); expect(fetchMock).toHaveBeenCalledTimes(1); const request = fetchMock.mock.calls[0][0]; const requestUrl = new URL(request instanceof Request ? request.url : String(request)); expect(requestUrl.pathname).toBe("/xrpc/org.tangled.actor.getProfileActivity"); expect(requestUrl.searchParams.get("actor")).toBe("did:plc:alice"); expect(requestUrl.searchParams.get("months")).toBe("7"); expect(activity.months.map((month) => month.monthsAgo)).toEqual([0, 1, 6]); expect(activity.truncated).toBe(true); expect(activity.months[0].commits).toBe(3); expect(activity.months[0].repos).toMatchObject([ { name: "current", ownerHandle: "alice.test", forked: false } ]); expect(activity.months[1].repos).toMatchObject([{ name: "fork", forked: true }]); expect(activity.months[0].issues).toMatchObject([ { rkey: "open", state: "open", repo: { repoDid: "did:plc:target", ownerHandle: "did:plc:bob", name: "target" } } ]); expect(activity.months[0].pulls).toMatchObject([{ rkey: "merged", state: "merged" }]); expect(activity.months[2].issues).toMatchObject([{ rkey: "closed", state: "closed" }]); }); }); describe("fetchForksPage", () => { const forkUri = "at://did:plc:fork/org.tangled.repo.manifest/self"; it("lists forks of a repo did with the fork owner's handle and counts", async () => { const fetchMock = vi.fn().mockResolvedValue( Response.json({ output: { items: [ { uri: forkUri, value: { declaration: { owner: "did:plc:bob", name: "reef" }, createdAt: "2026-08-01T00:00:00Z" } } ], cursor: "next", total: 41 }, data: { "did:plc:bob": { "org.tangled.repo.manifest:.repo": { [TYPE_MINIDOC]: { did: "did:plc:bob", handle: "bob.test" } } }, [forkUri]: { "org.tangled.repo.manifest:.": { [TYPE_REPO_VIEW]: { did: "did:plc:fork", slug: "reef", owner: { did: "did:plc:bob", handle: "bob.test" }, createdAt: "2026-08-01T00:00:00Z", starCount: 2, forkCount: 0, openIssueCount: 0, openPullCount: 0 } } } } }) ); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const page = await fetchForksPage(ctx, { repoDid: "did:plc:source", offset: 30 }); const body = requestBody(fetchMock) as ReturnType & { params: Record; }; expect(body.xrpc).toBe("org.tangled.repo.listForks"); expect(body.params).toMatchObject({ subject: "did:plc:source", offset: 30 }); expect(body.enrich.map((descriptor) => descriptor.targets?.[0])).toEqual([ "items[].uri", "items[].value.declaration.owner" ]); expect(page.total).toBe(41); expect(page.cursor).toBe("next"); expect(page.items).toMatchObject([ { name: "reef", repoDid: "did:plc:fork", ownerHandle: "bob.test", stars: 2 } ]); }); it("names a fork's owner by did when bobbin has no handle for them", async () => { const fetchMock = vi.fn().mockResolvedValue( Response.json({ output: { items: [ { uri: forkUri, value: { declaration: { owner: "did:plc:bob", name: "reef" }, createdAt: "2026-08-01T00:00:00Z" } } ] }, data: { "did:plc:bob": { "org.tangled.repo.manifest:.repo": { [TYPE_MINIDOC]: { did: "did:plc:bob", handle: "handle.invalid" } } } } }) ); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const page = await fetchForksPage(ctx, { repoDid: "did:plc:source" }); expect(page.items).toMatchObject([{ ownerHandle: "did:plc:bob" }]); }); it("is empty for a repo nobody forked", async () => { const fetchMock = vi .fn() .mockResolvedValue(Response.json({ output: { items: [], total: 0 }, data: {} })); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const page = await fetchForksPage(ctx, { repoDid: "did:plc:source" }); expect(page).toEqual({ items: [], cursor: undefined, total: 0 }); }); });