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<Fetch>(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<Fetch>(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();
});
});