Files
EpicNext-Cms/src/components/admin/studio/furniture-jobs-query.test.ts
T
Simo 88790d1a0d
CI / check (push) Successful in 2m3s
CI / deploy (push) Successful in 1m31s
CI / publish-container (push) Successful in 1m41s
feat(cms): recover drafts and failed imports with verified UI workflows
2026-09-11 08:58:10 +02:00

267 lines
7.9 KiB
TypeScript

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<typeof createFurnitureJobsClient>[] = [];
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<void>((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("<html>unexpected</html>", { 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);
}
});