import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createBobbinClient } from "#lib/api/client.ts"; import { resolveMiniDoc, type MiniDoc } from "#lib/api/identity.ts"; import { lookups } from "#lib/api/lookupCache.ts"; import { resolveRepoByName } from "#lib/api/repo.ts"; import { subscribeLookupEvents, subscribeUrl } from "#lib/server/lookupEvents.ts"; import type { Fetch } from "#lib/fetch.ts"; const ALICE: MiniDoc = { did: "did:plc:alice", handle: "alice.test", pds: "https://pds.example", signing_key: "zQ3shSiLsnqpyQ4SfDTT1D8qzFEoeYT8rSDXW6o8pVY7VcRBJ" }; const REPO = { uri: "at://did:plc:repo/org.tangled.repo.manifest/self", value: { declaration: { owner: ALICE.did, name: "kernel" } }, git: { defaultBranch: "main", isEmpty: false } }; const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); class FakeSocket { onopen: (() => void) | null = null; onmessage: ((message: { data: unknown }) => void) | null = null; onclose: (() => void) | null = null; constructor(readonly url: string) {} close() { this.onclose?.(); } send(event: unknown) { this.onmessage?.({ data: JSON.stringify(event) }); } } const follow = () => { const sockets: FakeSocket[] = []; const events = subscribeLookupEvents({ url: "wss://hydrant.test/subscribe", connect: (url) => { const socket = new FakeSocket(url); sockets.push(socket); return socket; } }); sockets[0].onopen?.(); return { events, sockets }; }; const counting = (...docs: MiniDoc[]) => { const fetchMock = vi.fn(async (input) => { const identifier = new URL(String(input)).searchParams.get("identifier"); const doc = docs.find((d) => d.did === identifier || d.handle === identifier); return doc ? json(doc) : json({ error: "InvalidRequest", message: "unknown" }, 400); }); return { fetchMock, ctx: createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }) }; }; describe("lookup events", () => { beforeEach(() => { lookups.configure(60_000); vi.spyOn(console, "info").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { lookups.configure(0); vi.useRealTimers(); vi.restoreAllMocks(); }); it("asks hydrant for identity events and the repo records", () => { const url = new URL(subscribeUrl("wss://hydrant.test/subscribe")); expect(url.searchParams.getAll("wantedCollections")).toEqual([ "org.tangled.repo.manifest", "org.tangled.git.refs" ]); }); it("drops the did and both handles when a handle moves", async () => { const { events, sockets } = follow(); const previous: MiniDoc = { ...ALICE, did: "did:plc:previous", handle: "alice.example" }; const { fetchMock, ctx } = counting(ALICE, previous); await resolveMiniDoc(ctx, ALICE.did); await resolveMiniDoc(ctx, "alice.test"); await expect(resolveMiniDoc(ctx, "alice.example")).resolves.toMatchObject(previous); await resolveMiniDoc(ctx, ALICE.did); await resolveMiniDoc(ctx, "alice.example"); expect(fetchMock).toHaveBeenCalledTimes(3); const renamed = { ...ALICE, handle: "alice.example" }; fetchMock.mockImplementation(async () => json(renamed)); sockets[0].send({ did: ALICE.did, kind: "identity", identity: { did: ALICE.did, handle: "alice.example" } }); await expect(resolveMiniDoc(ctx, ALICE.did)).resolves.toMatchObject(renamed); await expect(resolveMiniDoc(ctx, "alice.test")).resolves.toMatchObject(renamed); await expect(resolveMiniDoc(ctx, "alice.example")).resolves.toMatchObject(renamed); expect(fetchMock).toHaveBeenCalledTimes(6); events.close(); }); it("drops an account's lookups when its status changes", async () => { const { events, sockets } = follow(); const { fetchMock, ctx } = counting(ALICE); await resolveMiniDoc(ctx, "alice.test"); sockets[0].send({ did: ALICE.did, kind: "account", account: { active: false } }); await resolveMiniDoc(ctx, "alice.test"); expect(fetchMock).toHaveBeenCalledTimes(2); events.close(); }); it("drops a repo when its manifest or refs change, not on other records", async () => { const { events, sockets } = follow(); const fetchMock = vi.fn(async () => json(REPO)); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); await resolveRepoByName(ctx, ALICE.did, "kernel"); const commit = (collection: string) => sockets[0].send({ did: "did:plc:repo", kind: "commit", commit: { collection } }); commit("org.tangled.track.ticket"); await resolveRepoByName(ctx, ALICE.did, "kernel"); expect(fetchMock).toHaveBeenCalledTimes(1); commit("org.tangled.git.refs"); await resolveRepoByName(ctx, ALICE.did, "kernel"); expect(fetchMock).toHaveBeenCalledTimes(2); events.close(); }); it("drops a lookup again once bobbin has had time to see the same event", async () => { vi.useFakeTimers(); const { events, sockets } = follow(); const { fetchMock, ctx } = counting(ALICE); await resolveMiniDoc(ctx, "alice.test"); sockets[0].send({ did: ALICE.did, kind: "identity", identity: { handle: "alice.test" } }); await resolveMiniDoc(ctx, "alice.test"); expect(fetchMock).toHaveBeenCalledTimes(2); vi.advanceTimersByTime(10_000); await resolveMiniDoc(ctx, "alice.test"); expect(fetchMock).toHaveBeenCalledTimes(3); events.close(); }); it("starts from an empty cache after a reconnect", async () => { vi.useFakeTimers(); const { events, sockets } = follow(); const { fetchMock, ctx } = counting(ALICE); await resolveMiniDoc(ctx, "alice.test"); expect(events.connected).toBe(true); sockets[0].onclose?.(); expect(events.connected).toBe(false); await resolveMiniDoc(ctx, "alice.test"); expect(fetchMock).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(1_000); expect(sockets).toHaveLength(2); sockets[1].onopen?.(); expect(events.connected).toBe(true); await resolveMiniDoc(ctx, "alice.test"); expect(fetchMock).toHaveBeenCalledTimes(2); events.close(); }); });