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<Fetch>(
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<Fetch>(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<Fetch>(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<Fetch>(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<Uint8Array>);
expect(records.length).toBeGreaterThan(200);
expect(new Set(records.map((record) => record.collection))).toContain(
"org.tangled.actor.profile"
);
});
});