import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ClientResponseError } from "$lib/api/client"; import { createRequestCache, httpStatusFor, parallel, toHttpError } from "$lib/api/load"; vi.mock("$app/environment", () => ({ 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 => (await import("$lib/api/load")).settle; const freshEnd = async (): Promise => (await import("$lib/api/load")).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((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((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((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((resolve) => setTimeout(() => resolve("late"), 400)); const page = settle({ late }); await vi.advanceTimersByTimeAsync(60); expect((await page).late).toBe(late); }); });