import { describe, expect, it, vi } from "vitest"; import type { BobbinContext } from "#lib/api/client.ts"; import type { MiniDoc } from "#lib/api/identity.ts"; import { mockHydrantDoc, mockLiarFetch } from "#lib/migration/mock.ts"; import { readLiarRecords } from "#lib/migration/liar.ts"; import { migrationPending, sameOrigin, waitForMigration } from "#lib/migration/status.ts"; import type { Fetch } from "#lib/fetch.ts"; const liarPdsUrl = "https://liar.test"; const did = "did:plc:alice"; const bobbinWith = (pds: () => string | undefined) => { const fetch = vi.fn( async () => new Response(JSON.stringify({ did, handle: "alice.test", pds: pds() }), { headers: { "content-type": "application/json" } }) ); return { fetch, ctx: { serviceUrl: "https://bobbin.test", fetch } as unknown as BobbinContext }; }; describe("sameOrigin", () => { it("compares origins, so a trailing slash or a path doesn't matter", () => { expect(sameOrigin("https://liar.test/", liarPdsUrl)).toBe(true); expect(sameOrigin("https://liar.test/xrpc", liarPdsUrl)).toBe(true); expect(sameOrigin("https://pds.test", liarPdsUrl)).toBe(false); expect(sameOrigin(undefined, liarPdsUrl)).toBe(false); }); it("never matches when no liar pds is configured", () => { expect(sameOrigin("https://pds.test", "")).toBe(false); }); }); describe("migrationPending", () => { it("is pending while the did doc names the liar pds", async () => { const bobbin = bobbinWith(() => liarPdsUrl); await expect( migrationPending(bobbin.ctx, did, { liarPdsUrl, liarMock: false }) ).resolves.toBe(true); }); it("is done once the did doc names the user's own pds", async () => { const bobbin = bobbinWith(() => "https://pds.test"); await expect( migrationPending(bobbin.ctx, did, { liarPdsUrl, liarMock: false }) ).resolves.toBe(false); }); it("skips the lookup entirely when the migration is off", async () => { const bobbin = bobbinWith(() => liarPdsUrl); await expect( migrationPending(bobbin.ctx, did, { liarPdsUrl: "", liarMock: false }) ).resolves.toBe(false); expect(bobbin.fetch).not.toHaveBeenCalled(); }); }); describe("waitForMigration", () => { it("resolves once hydrant's doc flips", async () => { let lookups = 0; const bobbin = bobbinWith(() => (++lookups < 3 ? liarPdsUrl : "https://pds.test")); await expect( waitForMigration(bobbin.ctx, did, { liarPdsUrl, liarMock: false }, { intervalMs: 1 }) ).resolves.toBe(true); expect(lookups).toBe(3); }); it("keeps polling through a failed lookup", async () => { let lookups = 0; const fetch = vi.fn(async () => { if (++lookups === 1) throw new Error("bobbin down"); return new Response( JSON.stringify({ did, handle: "alice.test", pds: "https://pds.test" }), { headers: { "content-type": "application/json" } } ); }); const ctx = { serviceUrl: "https://bobbin.test", fetch } as unknown as BobbinContext; await expect( waitForMigration(ctx, did, { liarPdsUrl, liarMock: false }, { intervalMs: 1 }) ).resolves.toBe(true); }); it("stops sleeping when aborted, even before it starts", async () => { const bobbin = bobbinWith(() => liarPdsUrl); const controller = new AbortController(); controller.abort(new Error("stop")); await expect( waitForMigration( bobbin.ctx, did, { liarPdsUrl, liarMock: false }, { intervalMs: 60_000, signal: controller.signal } ) ).rejects.toThrow("stop"); }); it("gives up at the deadline", async () => { const bobbin = bobbinWith(() => liarPdsUrl); await expect( waitForMigration( bobbin.ctx, did, { liarPdsUrl, liarMock: false }, { intervalMs: 1, timeoutMs: 20 } ) ).resolves.toBe(false); }); }); describe("migrationPending with the mock hydrant", () => { it("stays pending until the user's pds has the mock records", async () => { let imported = false; const fetch = vi.fn(async (input) => { const url = new URL(String(input)); if (url.pathname.endsWith("resolveMiniDoc")) { return new Response( JSON.stringify({ did, handle: "alice.test", pds: "https://pds.test" }), { headers: { "content-type": "application/json" } } ); } return new Response("{}", { status: imported ? 200 : 400 }); }); const ctx = { serviceUrl: "https://bobbin.test", fetch } as unknown as BobbinContext; const config = { liarPdsUrl, liarMock: true }; await expect(migrationPending(ctx, did, config)).resolves.toBe(true); imported = true; await expect(migrationPending(ctx, did, config)).resolves.toBe(false); }); }); describe("mock hydrant", () => { const realDoc = { did, handle: "alice.test", pds: "https://pds.test" } as unknown as MiniDoc; const pdsHolding = (imported: boolean) => vi.fn(async () => new Response("{}", { status: imported ? 200 : 400 })); it("shows the liar pds until the user's pds holds the mock records", async () => { const before = pdsHolding(false); expect((await mockHydrantDoc(realDoc, liarPdsUrl, before)).pds).toBe(liarPdsUrl); const after = pdsHolding(true); expect((await mockHydrantDoc(realDoc, liarPdsUrl, after)).pds).toBe("https://pds.test"); const asked = new URL(String(after.mock.calls[0]?.[0])); expect(asked.origin).toBe("https://pds.test"); expect(asked.searchParams.get("collection")).toBe("org.tangled.feed.star"); }); it("serves a repo the real reader can walk", async () => { const response = await mockLiarFetch( `${liarPdsUrl}/xrpc/com.atproto.sync.getRepo?did=${did}` ); const records = await readLiarRecords(response.body as ReadableStream); expect(records.length).toBeGreaterThan(200); expect(new Set(records.map((record) => record.collection))).toContain( "org.tangled.actor.profile" ); }); });