import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ClientResponseError } from "#lib/api/client.ts";
import { createRequestCache, httpStatusFor, parallel, toHttpError } from "#lib/api/load.ts";
vi.mock("$app/env", () => ({ browser: true }));
const cre = (status: number, error: string, message?: string): ClientResponseError =>
new ClientResponseError({ status, data: { error, message } });
describe("httpStatusFor", () => {
it("passes through an in-band XRPC status", () => {
expect(httpStatusFor(cre(404, "RecordNotFound"))).toBe(404);
});
it("prefers an in-band status over the error-name mapping", () => {
// status 503 is in [400,599] so it wins even though the name maps to 404.
expect(httpStatusFor(cre(503, "RecordNotFound"))).toBe(503);
});
it("maps the error name when the status is out of the 400-599 band", () => {
expect(httpStatusFor(cre(200, "RecordNotFound"))).toBe(404);
expect(httpStatusFor(cre(200, "InvalidRequest"))).toBe(400);
expect(httpStatusFor(cre(200, "UpstreamFailed"))).toBe(502);
expect(httpStatusFor(cre(200, "UpstreamGone"))).toBe(502);
expect(httpStatusFor(cre(200, "InvalidRecord"))).toBe(502);
expect(httpStatusFor(cre(200, "Overloaded"))).toBe(503);
expect(httpStatusFor(cre(200, "SomethingElse"))).toBe(500);
});
it("maps any non-XRPC cause to 500", () => {
expect(httpStatusFor(new Error("boom"))).toBe(500);
expect(httpStatusFor("a string")).toBe(500);
expect(httpStatusFor(undefined)).toBe(500);
expect(httpStatusFor({ status: 404 })).toBe(500);
});
});
describe("toHttpError", () => {
it("throws a SvelteKit error with the mapped status and the XRPC description", () => {
let thrown: unknown;
try {
toHttpError(cre(404, "RecordNotFound", "no such repo"));
} catch (e) {
thrown = e;
}
expect(thrown).toMatchObject({ status: 404, body: { message: "no such repo" } });
});
it("falls back to the error name when the description is absent", () => {
let thrown: unknown;
try {
toHttpError(cre(200, "Overloaded"));
} catch (e) {
thrown = e;
}
expect(thrown).toMatchObject({ status: 503, body: { message: "Overloaded" } });
});
it("uses the fallback message + 500 for a non-XRPC cause", () => {
let thrown: unknown;
try {
toHttpError(new Error("boom"), "could not load");
} catch (e) {
thrown = e;
}
expect(thrown).toMatchObject({ status: 500, body: { message: "could not load" } });
});
});
describe("parallel", () => {
it("preserves key/value pairing regardless of settle order", async () => {
// deferred across a few microtasks (no real timer) so it settles after `fast`.
const slow = Promise.resolve()
.then(() => Promise.resolve())
.then(() => "slow");
const result = await parallel({ fast: Promise.resolve("fast"), slow });
expect(result).toEqual({ fast: "fast", slow: "slow" });
});
it("resolves to an empty object for no tasks", async () => {
await expect(parallel({})).resolves.toEqual({});
});
it("rejects if any input promise rejects", async () => {
await expect(
parallel({ a: Promise.resolve(1), b: Promise.reject(new Error("boom")) })
).rejects.toThrow("boom");
});
});
describe("createRequestCache", () => {
it("invokes the loader once per key and shares the value", async () => {
const cache = createRequestCache();
const load = vi.fn(async () => 42);
const first = await cache.run("k", load);
const second = await cache.run("k", load);
expect(first).toBe(42);
expect(second).toBe(42);
expect(load).toHaveBeenCalledTimes(1);
});
it("shares one in-flight promise for concurrent same-key calls", () => {
const cache = createRequestCache();
const load = vi.fn(async () => "v");
const a = cache.run("k", load);
const b = cache.run("k", load);
expect(a).toBe(b);
expect(load).toHaveBeenCalledTimes(1);
});
it("runs distinct keys independently", async () => {
const cache = createRequestCache();
const load = vi.fn(async (key: string) => key.toUpperCase());
await cache.run("a", () => load("a"));
await cache.run("b", () => load("b"));
expect(load).toHaveBeenCalledTimes(2);
});
});
describe("settle", () => {
// the instant window lives in module state, so every case starts from a fresh module
const fresh = async (): Promise<typeof import("#lib/api/load.ts").settle> =>
(await import("#lib/api/load.ts")).settle;
const freshEnd = async (): Promise<typeof import("#lib/api/load.ts").endNavigation> =>
(await import("#lib/api/load.ts")).endNavigation;
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.useRealTimers();
vi.resetModules();
});
it("hands a promise that settled over as its value, so no boundary suspends on it", async () => {
const settle = await fresh();
const settled = await settle({ fast: Promise.resolve("v"), plain: 2 });
expect(settled).toEqual({ fast: "v", plain: 2 });
});
it("keeps a promise that outlasts the window, its boundary paints the skeleton", async () => {
const settle = await fresh();
const slow = new Promise<string>((resolve) => setTimeout(() => resolve("late"), 400));
const page = settle({ slow, fast: Promise.resolve("v") });
await vi.advanceTimersByTimeAsync(200);
const data = await page;
expect(data.fast).toBe("v");
expect(data.slow).toBe(slow);
await vi.advanceTimersByTimeAsync(400);
expect(await data.slow).toBe("late");
});
it("leaves the rejection on the promise, whoever awaits it owns the failure", async () => {
const settle = await fresh();
const broken = Promise.reject(new Error("boom"));
const page = settle({ broken, fast: Promise.resolve("v") });
await vi.advanceTimersByTimeAsync(200);
const data = await page;
expect(data.fast).toBe("v");
await expect(data.broken).rejects.toThrow("boom");
});
it("opens a fresh window for the navigation after the last one committed", async () => {
const settle = await fresh();
const endNavigation = await freshEnd();
await settle({ fast: Promise.resolve("v") });
await vi.advanceTimersByTimeAsync(100);
endNavigation();
const late = new Promise<string>((resolve) => setTimeout(() => resolve("late"), 400));
const page = settle({ late });
await vi.advanceTimersByTimeAsync(200);
expect((await page).late).toBe(late);
});
it("opens a fresh window for the navigation after an abandoned one", async () => {
const settle = await fresh();
await settle({ fast: Promise.resolve("v") });
await vi.advanceTimersByTimeAsync(500);
const late = new Promise<string>((resolve) => setTimeout(() => resolve("late"), 400));
const page = settle({ late });
await vi.advanceTimersByTimeAsync(200);
expect((await page).late).toBe(late);
});
it("spends one window across the loads of a navigation", async () => {
const settle = await fresh();
await settle({ fast: Promise.resolve("v") });
await vi.advanceTimersByTimeAsync(150);
// 50ms of the first load's window is left
const late = new Promise<string>((resolve) => setTimeout(() => resolve("late"), 400));
const page = settle({ late });
await vi.advanceTimersByTimeAsync(60);
expect((await page).late).toBe(late);
});
});