import { onlineManager, QueryObserver } from "@tanstack/react-query"; import { delay, HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi, } from "vitest"; import { furnitureCursorFixture, furnitureJobFixture, } from "@/test/furniture-jobs-fixtures"; import { createFurnitureJobsClient, furnitureJobsQueryOptions, readFurnitureJobResponse, } from "./furniture-jobs-query"; const endpoint = "http://localhost/api/admin/studio/import-jobs"; const server = setupServer(); const clients: ReturnType[] = []; function client() { const value = createFurnitureJobsClient(); clients.push(value); return value; } beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(async () => { await Promise.all( clients.map(async (value) => { await value.cancelQueries(); value.clear(); }), ); clients.length = 0; server.resetHandlers(); }); afterAll(() => server.close()); describe("furniture history HTTP query", () => { it("deduplicates simultaneous refreshes and caches their validated result", async () => { let calls = 0; let release: (() => void) | undefined; server.use( http.get(endpoint, async () => { calls++; await new Promise((resolve) => { release = resolve; }); return HttpResponse.json({ ok: true, jobs: [furnitureJobFixture], nextCursor: furnitureCursorFixture, }); }), ); const cache = client(); const options = furnitureJobsQueryOptions(true, null); const first = cache.fetchQuery(options); const second = cache.fetchQuery(options); await vi.waitFor(() => expect(calls).toBe(1)); release?.(); const [a, b] = await Promise.all([first, second]); expect(a).toEqual(b); expect(a.jobs[0].id).toBe(furnitureJobFixture.id); expect(cache.getQueryData(options.queryKey)).toEqual(a); }); it("keeps different pages and mounted history clients isolated", async () => { const urls: string[] = []; server.use( http.get(endpoint, ({ request }) => { urls.push(request.url); return HttpResponse.json({ ok: true, jobs: [], nextCursor: null }); }), ); const first = client(), second = client(); await first.fetchQuery(furnitureJobsQueryOptions(true, null)); await first.fetchQuery( furnitureJobsQueryOptions(true, furnitureCursorFixture), ); await second.fetchQuery(furnitureJobsQueryOptions(true, null)); expect(urls).toHaveLength(3); expect(new URL(urls[1]).searchParams.get("before")).toBe( furnitureCursorFixture, ); first.clear(); expect( second.getQueryData(furnitureJobsQueryOptions(true, null).queryKey), ).toEqual({ jobs: [], nextCursor: null }); }); it.each([403, 500])( "surfaces HTTP %i without automatic retries or a false empty result", async (status) => { let calls = 0; server.use( http.get(endpoint, () => { calls++; return HttpResponse.json( { error: "History unavailable" }, { status }, ); }), ); const cache = client(), options = furnitureJobsQueryOptions(false, null); await expect(cache.fetchQuery(options)).rejects.toMatchObject({ status, message: "History unavailable", }); expect(calls).toBe(1); expect(cache.getQueryData(options.queryKey)).toBeUndefined(); }, ); it.each([ { ok: true, jobs: null }, { ok: true, jobs: [{ ...furnitureJobFixture, state: "invented" }] }, { ok: true, jobs: [ { ...furnitureJobFixture, items: [{ ...furnitureJobFixture.items[0], state: "unknown" }], }, ], }, { ok: true, jobs: [{ ...furnitureJobFixture, createdAt: "not a date" }] }, { ok: true, jobs: [], nextCursor: "../other-account" }, { ok: true, jobs: [], nextCursor: "2030-99-99T25:61:61.000Z|aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", }, ])("rejects malformed successful payloads", async (payload) => { server.use(http.get(endpoint, () => HttpResponse.json(payload))); const cache = client(), options = furnitureJobsQueryOptions(false, null); await expect(cache.fetchQuery(options)).rejects.toThrow( "Invalid import history response", ); expect(cache.getQueryData(options.queryKey)).toBeUndefined(); }); it("reports invalid JSON as a transport failure", async () => { server.use( http.get( endpoint, () => new HttpResponse("unexpected", { status: 200 }), ), ); await expect( client().fetchQuery(furnitureJobsQueryOptions(false, null)), ).rejects.toThrow("Invalid import history response"); }); it("times out a stalled HTTP request without retries", async () => { let calls = 0; server.use( http.get(endpoint, async () => { calls++; await delay(100); return HttpResponse.json({ ok: true, jobs: [] }); }), ); await expect( client().fetchQuery(furnitureJobsQueryOptions(false, null, 10)), ).rejects.toThrow("Import history request timed out"); expect(calls).toBe(1); }); it("aborts a cancelled request without replacing cache data with an empty result", async () => { let entered = false; let transportAborted = false; server.use( http.get(endpoint, async ({ request }) => { request.signal.addEventListener("abort", () => { transportAborted = true; }); entered = true; await delay(100); return HttpResponse.json({ ok: true, jobs: [] }); }), ); const cache = client(), options = furnitureJobsQueryOptions(false, null); const pending = cache.fetchQuery(options); const rejection = expect(pending).rejects.toThrow(); await vi.waitFor(() => expect(entered).toBe(true)); await cache.cancelQueries({ queryKey: options.queryKey }); await rejection; await vi.waitFor(() => expect(transportAborted).toBe(true)); expect(cache.getQueryData(options.queryKey)).toBeUndefined(); }); }); describe("history refresh and mutation response integrity", () => { it("retains the last valid page while a refresh fails", async () => { server.use( http.get(endpoint, () => HttpResponse.json({ ok: true, jobs: [furnitureJobFixture] }), ), ); const cache = client(), options = furnitureJobsQueryOptions(false, null); const observer = new QueryObserver(cache, { ...options, enabled: false }); const unsubscribe = observer.subscribe(() => {}); try { await cache.fetchQuery(options); server.use( http.get(endpoint, () => HttpResponse.json({ error: "offline" }, { status: 500 }), ), ); await expect(cache.fetchQuery(options)).rejects.toThrow("offline"); expect(observer.getCurrentResult().data?.jobs[0].id).toBe( furnitureJobFixture.id, ); expect(observer.getCurrentResult().error?.message).toBe("offline"); } finally { unsubscribe(); } }); it("rejects malformed successful mutation responses rather than storing an undefined job", async () => { server.use( http.patch(endpoint, () => HttpResponse.json({ ok: true, job: { id: furnitureJobFixture.id } }), ), ); const response = await fetch(endpoint, { method: "PATCH" }); await expect( readFurnitureJobResponse(response, "Update failed"), ).rejects.toThrow("Invalid import job response"); }); it("reports mutation HTTP failure without issuing a second write", async () => { let calls = 0; server.use( http.post(endpoint, () => { calls++; return HttpResponse.json( { error: "Queue unavailable" }, { status: 500 }, ); }), ); const response = await fetch(endpoint, { method: "POST" }); await expect( readFurnitureJobResponse(response, "Queue failed"), ).rejects.toMatchObject({ status: 500, message: "Queue unavailable" }); expect(calls).toBe(1); }); }); it("does not park manual refresh indefinitely behind a global offline signal", async () => { server.use( http.get(endpoint, () => HttpResponse.json({ ok: true, jobs: [] })), ); onlineManager.setOnline(false); try { await expect( client().fetchQuery(furnitureJobsQueryOptions(false, null)), ).resolves.toEqual({ jobs: [], nextCursor: null }); } finally { onlineManager.setOnline(true); } });