From 66d06242791d7a811a0bc61289538d42ba184023 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Wed, 3 Jun 2026 22:35:55 +0200 Subject: [PATCH] test(web): MSW harness with typed handlers, fixtures, and client tests Co-Authored-By: Claude Sonnet 4.6 --- web/package.json | 1 + web/pnpm-lock.yaml | 3 +++ web/src/api/client.test.ts | 33 +++++++++++++++++++++++++++++++++ web/src/api/client.ts | 5 ++++- web/src/test/fixtures.ts | 34 ++++++++++++++++++++++++++++++++++ web/src/test/handlers.test.ts | 10 ++++++++++ web/src/test/handlers.ts | 35 +++++++++++++++++++++++++++++++++++ web/src/test/server.ts | 5 +++++ web/src/test/setup.ts | 10 ++++++++++ web/vite.config.ts | 5 +++++ 10 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 web/src/api/client.test.ts create mode 100644 web/src/test/fixtures.ts create mode 100644 web/src/test/handlers.test.ts create mode 100644 web/src/test/handlers.ts create mode 100644 web/src/test/server.ts diff --git a/web/package.json b/web/package.json index d6ee085..7c025e9 100644 --- a/web/package.json +++ b/web/package.json @@ -40,6 +40,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "jsdom": "^26.1.0", + "msw": "^2.14.6", "openapi-typescript": "^7.13.0", "shadcn": "^4.10.0", "tailwindcss": "^4.3.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 16730ad..1b0ad01 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 + msw: + specifier: ^2.14.6 + version: 2.14.6(@types/node@25.9.1)(typescript@5.8.3) openapi-typescript: specifier: ^7.13.0 version: 7.13.0(typescript@5.8.3) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 0000000..d8bef11 --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test, vi } from "vitest"; +import { http, HttpResponse } from "msw"; + +import { server } from "../test/server"; +import * as authRedirect from "./auth-redirect"; +import { api } from "./client"; + +describe("api client", () => { + test("returns typed data on success", async () => { + server.use( + http.get("/api/admin/me", () => + HttpResponse.json({ id: "u1", email: "a@b.se", role: "admin" }), + ), + ); + + const { data, error } = await api.GET("/api/admin/me"); + + expect(error).toBeUndefined(); + expect(data?.email).toBe("a@b.se"); + }); + + test("a 401 triggers the auth redirect", async () => { + const spy = vi.spyOn(authRedirect, "redirectToLogin").mockImplementation(() => {}); + + server.use(http.get("/api/admin/me", () => new HttpResponse(null, { status: 401 }))); + + await api.GET("/api/admin/me"); + + expect(spy).toHaveBeenCalledOnce(); + + spy.mockRestore(); + }); +}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ec510e3..f29aae5 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -13,6 +13,9 @@ const onUnauthorized: Middleware = { }, }; -export const api = createClient({ credentials: "include" }); +export const api = createClient({ + baseUrl: window.location.origin, + credentials: "include", +}); api.use(onUnauthorized); diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts new file mode 100644 index 0000000..b028285 --- /dev/null +++ b/web/src/test/fixtures.ts @@ -0,0 +1,34 @@ +import type { components } from "../api/schema"; + +export type AdminObjectView = components["schemas"]["AdminObjectView"]; +export type AdminObjectPage = components["schemas"]["AdminObjectPage"]; + +export const amphora: AdminObjectView = { + id: "11111111-1111-1111-1111-111111111111", + object_number: "LM-0042", + object_name: "Amphora", + number_of_objects: 1, + brief_description: "Storage jar", + current_location: "Vault 3", + current_owner: null, + recorder: null, + recording_date: null, + visibility: "public", + fields: {}, +}; + +export const fibula: AdminObjectView = { + ...amphora, + id: "22222222-2222-2222-2222-222222222222", + object_number: "LM-0043", + object_name: "Bronze fibula", + visibility: "internal", + fields: {}, +}; + +export const objectsPage: AdminObjectPage = { + items: [amphora, fibula], + total: 2, + limit: 50, + offset: 0, +}; diff --git a/web/src/test/handlers.test.ts b/web/src/test/handlers.test.ts new file mode 100644 index 0000000..3128f1f --- /dev/null +++ b/web/src/test/handlers.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "vitest"; + +import { api } from "../api/client"; + +test("default handler serves the objects page", async () => { + const { data } = await api.GET("/api/admin/objects", { params: { query: {} } }); + + expect(data?.total).toBe(2); + expect(data?.items[0].object_number).toBe("LM-0042"); +}); diff --git a/web/src/test/handlers.ts b/web/src/test/handlers.ts new file mode 100644 index 0000000..1a01673 --- /dev/null +++ b/web/src/test/handlers.ts @@ -0,0 +1,35 @@ +import { http, HttpResponse } from "msw"; + +import { amphora, fibula, objectsPage } from "./fixtures"; + +export const handlers = [ + http.get("/api/admin/me", () => + HttpResponse.json({ id: "u1", email: "editor@example.com", role: "editor" }), + ), + + http.get("/api/admin/objects", () => HttpResponse.json(objectsPage)), + + http.get("/api/admin/objects/:id", ({ params }) => { + const found = [amphora, fibula].find((o) => o.id === params.id); + + return found ? HttpResponse.json(found) : new HttpResponse(null, { status: 404 }); + }), + + http.get("/api/admin/field-definitions", () => + HttpResponse.json([ + { + key: "material", + data_type: "term", + vocabulary_id: "v1", + authority_kind: null, + required: false, + group: null, + labels: [{ lang: "en", label: "Material" }], + }, + ]), + ), + + http.post("/api/admin/login", () => new HttpResponse(null, { status: 204 })), + + http.post("/api/admin/logout", () => new HttpResponse(null, { status: 204 })), +]; diff --git a/web/src/test/server.ts b/web/src/test/server.ts new file mode 100644 index 0000000..fa95245 --- /dev/null +++ b/web/src/test/server.ts @@ -0,0 +1,5 @@ +import { setupServer } from "msw/node"; + +import { handlers } from "./handlers"; + +export const server = setupServer(...handlers); diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index f149f27..1fdb76d 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -1 +1,11 @@ import "@testing-library/jest-dom/vitest"; +import { afterAll, afterEach } from "vitest"; + +import { server } from "./server"; + +// Start MSW at module level so its fetch patch is in place before any test +// module captures globalThis.fetch via openapi-fetch's createClient(). +server.listen({ onUnhandledRequest: "error" }); + +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); diff --git a/web/vite.config.ts b/web/vite.config.ts index 456d181..1af8dcf 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -22,5 +22,10 @@ export default defineConfig({ environment: "jsdom", globals: true, setupFiles: ["./src/test/setup.ts"], + environmentOptions: { + jsdom: { + url: "http://localhost", + }, + }, }, });