import { is } from "@atcute/lexicons/validations";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import { describe, expect, it, vi } from "vitest";
import { createBobbinClient } from "#lib/api/client.ts";
import {
createEntitySubscription,
ENTITY_SUBSCRIBED,
ensureEntitySubscription,
getEntitySubscription,
listNetworkVouches,
setEntitySubscribed
} from "#lib/api/graph.ts";
import { mainSchema as subscriptionSchema } from "#lib/api/lexicons/types/org/tangled/feed/subscription.ts";
import { createRecord, putRecord } from "#lib/api/write.ts";
import type { Fetch } from "#lib/fetch.ts";
vi.mock("#lib/api/write.ts", () => ({
createRecord: vi.fn(async () => ({
uri: "at://did:plc:alice/org.tangled.feed.subscription/3l7abc",
cid: "bafytest"
})),
deleteRecord: vi.fn(),
putRecord: vi.fn(async () => ({
uri: "at://did:plc:alice/org.tangled.feed.subscription/3l7abc",
cid: "bafytest"
}))
}));
const response = (items: unknown[] = []) =>
new Response(JSON.stringify({ items }), {
status: 200,
headers: { "content-type": "application/json" }
});
describe("network vouches", () => {
it("deduplicates actors and chunks requests at fifty", async () => {
const fetch = vi.fn<Fetch>(async () => response());
const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch });
const actors = Array.from({ length: 55 }, (_, index) => `did:plc:actor${index}`);
await listNetworkVouches(ctx, [...actors, actors[0]]);
expect(fetch).toHaveBeenCalledTimes(2);
expect(new URL(String(fetch.mock.calls[0][0])).searchParams.getAll("actors")).toEqual(
actors.slice(0, 50)
);
expect(new URL(String(fetch.mock.calls[1][0])).searchParams.getAll("actors")).toEqual(
actors.slice(50)
);
});
it("groups tagged items by requested actor", async () => {
const alice = "did:plc:alice";
const bob = "did:plc:bob";
const item = (actor: string) => ({
actor,
uri: `at://did:plc:viewer/org.tangled.graph.vouch/${actor}`,
value: {
$type: "org.tangled.graph.vouch",
kind: "vouch",
createdAt: "2026-01-01T00:00:00.000Z"
}
});
const ctx = createBobbinClient({
serviceUrl: "https://bobbin.test",
fetch: async () => response([item(bob), item(alice), item(bob)])
});
const result = await listNetworkVouches(ctx, [alice, bob]);
expect(result.items[alice]).toHaveLength(1);
expect(result.items[bob]).toHaveLength(2);
});
});
describe("entity subscriptions", () => {
it("writes a record the subscription lexicon accepts", async () => {
const agent = { sub: "did:plc:alice" } as unknown as OAuthUserAgent;
await createEntitySubscription(agent, "at://did:plc:bob/org.tangled.track.ticket/3l7xyz");
const [, , record] = vi.mocked(createRecord).mock.calls[0];
expect(is(subscriptionSchema, record)).toBe(true);
});
const ticket = "at://did:plc:bob/org.tangled.track.ticket/3l7xyz";
const agent = { sub: "did:plc:alice" } as unknown as OAuthUserAgent;
const bobbinWith = (found: { ignore?: boolean } | null) =>
createBobbinClient({
serviceUrl: "https://bobbin.test",
fetch: async () =>
found
? Response.json({
uri: "at://did:plc:alice/org.tangled.feed.subscription/3lsub",
...found
})
: new Response(null, { status: 404 })
});
it("reads the rkey and mute flag, and null when there is no record", async () => {
expect(await getEntitySubscription(bobbinWith(null), "did:plc:alice", ticket)).toBeNull();
expect(await getEntitySubscription(bobbinWith({}), "did:plc:alice", ticket)).toEqual({
rkey: "3lsub",
ignore: false
});
expect(
await getEntitySubscription(bobbinWith({ ignore: true }), "did:plc:alice", ticket)
).toEqual({ rkey: "3lsub", ignore: true });
});
it("mutes by putting ignore on the existing record, never deleting", async () => {
await setEntitySubscribed(
agent,
bobbinWith({ ignore: false }),
"did:plc:alice",
ticket,
false
);
const [, , rkey, record] = vi.mocked(putRecord).mock.calls.at(-1)!;
expect(rkey).toBe("3lsub");
expect(record).toMatchObject({ ignore: true });
expect(is(subscriptionSchema, record)).toBe(true);
});
it("creates a mute when there is no record yet", async () => {
vi.mocked(createRecord).mockClear();
await setEntitySubscribed(agent, bobbinWith(null), "did:plc:alice", ticket, false);
const [, , record] = vi.mocked(createRecord).mock.calls[0];
expect(record).toMatchObject({ ignore: true });
});
it("unmutes by dropping ignore from the record", async () => {
await setEntitySubscribed(
agent,
bobbinWith({ ignore: true }),
"did:plc:alice",
ticket,
true
);
const [, , rkey, record] = vi.mocked(putRecord).mock.calls.at(-1)!;
expect(rkey).toBe("3lsub");
expect(record).not.toHaveProperty("ignore");
});
it("commenting unmutes a muted thread but leaves a live subscription alone", async () => {
vi.mocked(putRecord).mockClear();
await ensureEntitySubscription(
agent,
bobbinWith({ ignore: false }),
"did:plc:alice",
ticket
);
expect(putRecord).not.toHaveBeenCalled();
await ensureEntitySubscription(
agent,
bobbinWith({ ignore: true }),
"did:plc:alice",
ticket
);
expect(vi.mocked(putRecord).mock.calls[0][3]).not.toHaveProperty("ignore");
});
it("tells the page when a comment unmutes the thread, and not when nothing changed", async () => {
const target = new EventTarget();
const seen: unknown[] = [];
target.addEventListener(ENTITY_SUBSCRIBED, (event) =>
seen.push((event as CustomEvent).detail)
);
vi.stubGlobal("window", target);
try {
await ensureEntitySubscription(
agent,
bobbinWith({ ignore: true }),
"did:plc:alice",
ticket
);
expect(seen).toEqual([{ entityUri: ticket, rkey: "3lsub" }]);
seen.length = 0;
await ensureEntitySubscription(
agent,
bobbinWith({ ignore: false }),
"did:plc:alice",
ticket
);
expect(seen).toEqual([]);
} finally {
vi.unstubAllGlobals();
}
});
});