From b819a8c745db635796ad33da52e211dba2ae04f3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 5 Mar 2025 17:28:15 +0100 Subject: [PATCH] Add and use spyOnHandler to expect MSW handler calls (#1127) --- .../check_and_save/CheckAndSaveForm.test.tsx | 28 ++++----- .../form/user/login/LoginForm.test.tsx | 25 ++------ .../AbortDataEntryControl.test.tsx | 54 ++++++---------- .../page/PollingStationUpdatePage.test.tsx | 62 +++++-------------- frontend/lib/api-mocks/RequestHandlers.ts | 33 ++++++---- frontend/lib/api-mocks/UserMockData.ts | 10 ++- frontend/lib/test/server.ts | 9 +-- frontend/lib/test/test-utils.ts | 23 ++++++- 8 files changed, 106 insertions(+), 138 deletions(-) diff --git a/frontend/app/component/form/data_entry/check_and_save/CheckAndSaveForm.test.tsx b/frontend/app/component/form/data_entry/check_and_save/CheckAndSaveForm.test.tsx index 54942a97a..e915b5e06 100644 --- a/frontend/app/component/form/data_entry/check_and_save/CheckAndSaveForm.test.tsx +++ b/frontend/app/component/form/data_entry/check_and_save/CheckAndSaveForm.test.tsx @@ -1,14 +1,15 @@ import { userEvent } from "@testing-library/user-event"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import { ElectionProvider, PollingStationResults } from "@kiesraad/api"; import { electionMockData, ElectionRequestHandler, + PollingStationDataEntryFinaliseHandler, PollingStationDataEntryGetHandler, PollingStationDataEntrySaveHandler, } from "@kiesraad/api-mocks"; -import { overrideOnce, renderReturningRouter, screen, server, within } from "@kiesraad/test"; +import { renderReturningRouter, screen, server, spyOnHandler, within } from "@kiesraad/test"; import { defaultFormState, emptyDataEntryRequest, errorWarningMocks } from "../../testHelperFunctions"; import { FormState, PollingStationFormController } from "../PollingStationFormController"; @@ -34,7 +35,12 @@ function renderForm(defaultFormState: Partial = {}, defaultValues?: P describe("Test CheckAndSaveForm", () => { beforeEach(() => { - server.use(ElectionRequestHandler, PollingStationDataEntryGetHandler, PollingStationDataEntrySaveHandler); + server.use( + ElectionRequestHandler, + PollingStationDataEntryGetHandler, + PollingStationDataEntrySaveHandler, + PollingStationDataEntryFinaliseHandler, + ); }); test("Data entry can be finalised", async () => { @@ -42,19 +48,13 @@ describe("Test CheckAndSaveForm", () => { const user = userEvent.setup(); // set up a listener to check if the finalisation request is made - let request_method, request_url; - overrideOnce("post", "/api/polling_stations/1/data_entries/1/finalise", 200, null); - server.events.on("request:start", ({ request }) => { - request_method = request.method; - request_url = request.url; - }); + const finalise = spyOnHandler(PollingStationDataEntryFinaliseHandler); // click the save button await user.click(await screen.findByRole("button", { name: "Opslaan" })); // check that the finalisation request was made - expect(request_method).toBe("POST"); - expect(request_url).toBe("http://localhost:3000/api/polling_stations/1/data_entries/1/finalise"); + expect(finalise).toHaveBeenCalledOnce(); // check that the user is navigated back to the data entry page expect(router.state.location.pathname).toEqual("/elections/1/data-entry"); @@ -63,17 +63,15 @@ describe("Test CheckAndSaveForm", () => { test("Shift+Enter submits form", async () => { renderForm(); + const finalise = spyOnHandler(PollingStationDataEntryFinaliseHandler); expect(await screen.findByRole("group", { name: "Controleren en opslaan" })); - overrideOnce("post", "/api/polling_stations/1/data_entries/1/finalise", 200, null); - const user = userEvent.setup(); - const spy = vi.spyOn(global, "fetch"); await user.keyboard("{shift>}{enter}{/shift}"); - expect(spy).toHaveBeenCalled(); + expect(finalise).toHaveBeenCalled(); }); test("Data entry does not show finalise button with errors", async () => { diff --git a/frontend/app/component/form/user/login/LoginForm.test.tsx b/frontend/app/component/form/user/login/LoginForm.test.tsx index ab38b613a..9a8f57522 100644 --- a/frontend/app/component/form/user/login/LoginForm.test.tsx +++ b/frontend/app/component/form/user/login/LoginForm.test.tsx @@ -1,28 +1,17 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, test } from "vitest"; -import { overrideOnce, render, screen, waitFor } from "@kiesraad/test"; +import { LoginHandler } from "@kiesraad/api-mocks"; +import { overrideOnce, render, screen, server, spyOnHandler, waitFor } from "@kiesraad/test"; import { LoginForm } from "./LoginForm"; describe("LoginForm", () => { test("Successful login", async () => { - render(); - let requestBody: object | null = null; + server.use(LoginHandler); + const login = spyOnHandler(LoginHandler); - overrideOnce( - "post", - "/api/user/login", - 200, - { - user_id: 1, - username: "admin", - }, - undefined, - async (request) => { - requestBody = (await request.json()) as object; - }, - ); + render(); const user = userEvent.setup(); @@ -34,9 +23,7 @@ describe("LoginForm", () => { const submitButton = screen.getByRole("button", { name: "Inloggen" }); await user.click(submitButton); - await waitFor(() => { - expect(requestBody).toStrictEqual({ username: "user", password: "password" }); - }); + expect(login).toHaveBeenCalledWith({ username: "user", password: "password" }); }); test("Unsuccessful login", async () => { diff --git a/frontend/app/module/data_entry/polling_station/AbortDataEntryControl.test.tsx b/frontend/app/module/data_entry/polling_station/AbortDataEntryControl.test.tsx index ec5edf111..7c55ea045 100644 --- a/frontend/app/module/data_entry/polling_station/AbortDataEntryControl.test.tsx +++ b/frontend/app/module/data_entry/polling_station/AbortDataEntryControl.test.tsx @@ -1,13 +1,7 @@ import { userEvent } from "@testing-library/user-event"; -import { http, HttpResponse } from "msw"; import { beforeEach, describe, expect, test } from "vitest"; -import { - ElectionProvider, - ErrorResponse, - POLLING_STATION_DATA_ENTRY_SAVE_REQUEST_BODY, - SaveDataEntryResponse, -} from "@kiesraad/api"; +import { ElectionProvider } from "@kiesraad/api"; import { electionMockData, ElectionRequestHandler, @@ -15,7 +9,7 @@ import { PollingStationDataEntryGetHandler, PollingStationDataEntrySaveHandler, } from "@kiesraad/api-mocks"; -import { renderReturningRouter, screen, server } from "@kiesraad/test"; +import { renderReturningRouter, screen, server, spyOnHandler } from "@kiesraad/test"; import { PollingStationFormController } from "../../../component/form/data_entry/PollingStationFormController"; import { VotersAndVotesForm } from "../../../component/form/data_entry/voters_and_votes/VotersAndVotesForm"; @@ -63,37 +57,31 @@ describe("Test AbortDataEntryControl", () => { // fill in the form await user.type(await screen.findByTestId("poll_card_count"), "42"); - // set up a custom request handler that saves the request body - // this cannot be done with a listener because it would consume the request body - let request_body: POLLING_STATION_DATA_ENTRY_SAVE_REQUEST_BODY | undefined; - server.use( - http.post( - "/api/polling_stations/1/data_entries/1", - async ({ request }) => { - request_body = await request.json(); - return HttpResponse.json({ validation_results: { errors: [], warnings: [] } }, { status: 200 }); - }, - { once: true }, - ), - ); + const dataEntrySave = spyOnHandler(PollingStationDataEntrySaveHandler); // click the save button in the modal await user.click(screen.getByRole("button", { name: "Invoer bewaren" })); // check that the save request was made with the correct data - expect(request_body?.data).toEqual({ - ...emptyDataEntryRequest.data, - voters_counts: { - ...emptyDataEntryRequest.data.voters_counts, - poll_card_count: 42, - }, - }); + expect(dataEntrySave).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + ...emptyDataEntryRequest.data, + voters_counts: { + ...emptyDataEntryRequest.data.voters_counts, + poll_card_count: 42, + }, + }, + }), + ); // check that the user is navigated back to the data entry page expect(router.state.location.pathname).toBe("/elections/1/data-entry"); }); test("deletes the data entry and navigates on delete", async () => { + server.use(PollingStationDataEntryDeleteHandler); + // render the abort button, then click it to open the modal const router = renderAbortDataEntryControl(); const user = userEvent.setup(); @@ -103,19 +91,13 @@ describe("Test AbortDataEntryControl", () => { await user.click(abortButton); // set up a listener to check if the delete request is made - let request_method, request_url; - server.use(PollingStationDataEntryDeleteHandler); - server.events.on("request:start", ({ request }) => { - request_method = request.method; - request_url = request.url; - }); + const dataEntryDelete = spyOnHandler(PollingStationDataEntryDeleteHandler); // click the delete button in the modal await user.click(screen.getByRole("button", { name: "Niet bewaren" })); // check that the delete request was made and the user is navigated back to the data entry page - expect(request_method).toBe("DELETE"); - expect(request_url).toBe("http://localhost:3000/api/polling_stations/1/data_entries/1"); + expect(dataEntryDelete).toHaveBeenCalled(); // check that the user is navigated back to the data entry page expect(router.state.location.pathname).toBe("/elections/1/data-entry"); diff --git a/frontend/app/module/polling_stations/page/PollingStationUpdatePage.test.tsx b/frontend/app/module/polling_stations/page/PollingStationUpdatePage.test.tsx index b864ab853..35ab82c3d 100644 --- a/frontend/app/module/polling_stations/page/PollingStationUpdatePage.test.tsx +++ b/frontend/app/module/polling_stations/page/PollingStationUpdatePage.test.tsx @@ -4,8 +4,13 @@ import { userEvent } from "@testing-library/user-event"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { ElectionProvider, PollingStation } from "@kiesraad/api"; -import { ElectionRequestHandler, PollingStationUpdateHandler } from "@kiesraad/api-mocks"; -import { overrideOnce, render, renderReturningRouter, server } from "@kiesraad/test"; +import { + ElectionRequestHandler, + PollingStationDeleteHandler, + PollingStationGetHandler, + PollingStationUpdateHandler, +} from "@kiesraad/api-mocks"; +import { overrideOnce, render, renderReturningRouter, server, spyOnHandler } from "@kiesraad/test"; import { PollingStationUpdatePage } from "./PollingStationUpdatePage"; @@ -28,17 +33,10 @@ describe("PollingStationUpdatePage", () => { }; beforeEach(() => { - server.use(ElectionRequestHandler, PollingStationUpdateHandler); + server.use(ElectionRequestHandler, PollingStationGetHandler, PollingStationUpdateHandler); }); test("Shows form", async () => { - overrideOnce( - "get", - `/api/elections/${testPollingStation.election_id}/polling_stations/${testPollingStation.id}`, - 200, - testPollingStation, - ); - render( @@ -48,18 +46,11 @@ describe("PollingStationUpdatePage", () => { const form = await screen.findByTestId("polling-station-form"); expect(form).toBeVisible(); - expect(screen.getByRole("textbox", { name: "Nummer" })).toHaveValue("1"); - expect(screen.getByRole("textbox", { name: "Naam" })).toHaveValue("test"); + expect(screen.getByRole("textbox", { name: "Nummer" })).toHaveValue("33"); + expect(screen.getByRole("textbox", { name: "Naam" })).toHaveValue("Op Rolletjes"); }); test("Navigates back on save", async () => { - overrideOnce( - "get", - `/api/elections/${testPollingStation.election_id}/polling_stations/${testPollingStation.id}`, - 200, - testPollingStation, - ); - const router = renderReturningRouter( @@ -77,15 +68,9 @@ describe("PollingStationUpdatePage", () => { describe("Delete polling station", () => { test("Returns to list page with a message", async () => { + server.use(PollingStationDeleteHandler); const user = userEvent.setup(); - overrideOnce( - "get", - `/api/elections/${testPollingStation.election_id}/polling_stations/${testPollingStation.id}`, - 200, - testPollingStation, - ); - const router = renderReturningRouter( @@ -98,40 +83,21 @@ describe("PollingStationUpdatePage", () => { const modal = await screen.findByTestId("modal-dialog"); expect(modal).toHaveTextContent("Stembureau verwijderen"); - let request_method: string; - let request_url: string; - - overrideOnce( - "delete", - `/api/elections/${testPollingStation.election_id}/polling_stations/${testPollingStation.id}`, - 200, - "", - ); - - server.events.on("request:start", ({ request }) => { - request_method = request.method; - request_url = request.url; - }); + const deletePollingStation = spyOnHandler(PollingStationDeleteHandler); const confirmButton = await within(modal).findByRole("button", { name: "Verwijderen" }); await user.click(confirmButton); - await waitFor(() => { - expect(request_method).toEqual("DELETE"); - expect(request_url).toContain( - `/api/elections/${testPollingStation.election_id}/polling_stations/${testPollingStation.id}`, - ); - }); + expect(deletePollingStation).toHaveBeenCalled(); expect(router.state.location.pathname).toEqual("/elections/1/polling-stations"); - expect(router.state.location.search).toEqual("?deleted=1%20(test)"); + expect(router.state.location.search).toEqual("?deleted=33%20(Op%20Rolletjes)"); }); test("Shows an error message when delete was not possible", async () => { const user = userEvent.setup(); const url = `/api/elections/${testPollingStation.election_id}/polling_stations/${testPollingStation.id}`; - overrideOnce("get", url, 200, testPollingStation); overrideOnce("delete", url, 422, { error: "Invalid data", fatal: false, diff --git a/frontend/lib/api-mocks/RequestHandlers.ts b/frontend/lib/api-mocks/RequestHandlers.ts index b42ab4cf5..d8a771dc0 100644 --- a/frontend/lib/api-mocks/RequestHandlers.ts +++ b/frontend/lib/api-mocks/RequestHandlers.ts @@ -2,10 +2,14 @@ import { http, type HttpHandler, HttpResponse } from "msw"; import { ErrorResponse, + LOGIN_REQUEST_BODY, + LOGIN_REQUEST_PARAMS, + LOGIN_REQUEST_PATH, LoginResponse, POLLING_STATION_CREATE_REQUEST_PARAMS, POLLING_STATION_DATA_ENTRY_SAVE_REQUEST_BODY, POLLING_STATION_DATA_ENTRY_SAVE_REQUEST_PARAMS, + POLLING_STATION_DELETE_REQUEST_PARAMS, POLLING_STATION_GET_REQUEST_PARAMS, POLLING_STATION_LIST_REQUEST_PARAMS, POLLING_STATION_UPDATE_REQUEST_PARAMS, @@ -26,11 +30,13 @@ import { USER_UPDATE_REQUEST_PARAMS, USER_UPDATE_REQUEST_PATH, UserListResponse, + WHOAMI_REQUEST_PARAMS, + WHOAMI_REQUEST_PATH, } from "@kiesraad/api"; import { electionDetailsMockResponse, electionListMockResponse, electionStatusMockResponse } from "./ElectionMockData"; import { pollingStationMockData } from "./PollingStationMockData"; -import { userMockData } from "./UserMockData"; +import { loginResponseMockData, userMockData } from "./UserMockData"; type ParamsToString = { [P in keyof T]: string; @@ -55,17 +61,16 @@ export const pingHandler = http.post( + "/api/user/login", + () => HttpResponse.json(loginResponseMockData, { status: 200 }), +); + // get user handler -export const WhoAmIRequestHandler = http.get("/api/user/whoami", () => { - const loginResponse: LoginResponse = { - user_id: 1, - fullname: "Example Name", - username: "admin", - role: "administrator", - needs_password_change: false, - }; - return HttpResponse.json(loginResponse, { status: 200 }); -}); +export const WhoAmIRequestHandler = http.get( + "/api/user/whoami", + () => HttpResponse.json(loginResponseMockData, { status: 200 }), +); // get election list handler export const ElectionListRequestHandler = http.get("/api/elections", () => @@ -129,6 +134,11 @@ export const PollingStationCreateHandler = http.post HttpResponse.json(pollingStationMockData[1]! satisfies PollingStation, { status: 201 }), ); +export const PollingStationDeleteHandler = http.delete>( + "/api/elections/:election_id/polling_stations/:polling_station_id", + () => HttpResponse.text("", { status: 200 }), +); + export const PollingStationUpdateHandler = http.put>( "/api/elections/:election_id/polling_stations/:polling_station_id", () => HttpResponse.text("", { status: 200 }), @@ -186,6 +196,7 @@ export const handlers: HttpHandler[] = [ PollingStationDataEntryDeleteHandler, PollingStationDataEntryFinaliseHandler, PollingStationCreateHandler, + PollingStationDeleteHandler, PollingStationGetHandler, PollingStationUpdateHandler, UserCreateRequestHandler, diff --git a/frontend/lib/api-mocks/UserMockData.ts b/frontend/lib/api-mocks/UserMockData.ts index f2dcacb10..d0d4bb3d8 100644 --- a/frontend/lib/api-mocks/UserMockData.ts +++ b/frontend/lib/api-mocks/UserMockData.ts @@ -1,4 +1,4 @@ -import { User } from "@kiesraad/api"; +import { LoginResponse, User } from "@kiesraad/api"; const today = new Date(); today.setHours(10, 20); @@ -50,3 +50,11 @@ export const userMockData: User[] = [ updated_at, }, ]; + +export const loginResponseMockData: LoginResponse = { + user_id: 1, + username: "Sanne", + role: "administrator", + fullname: "Sanne Molenaar", + needs_password_change: true, +}; diff --git a/frontend/lib/test/server.ts b/frontend/lib/test/server.ts index c84a3936c..a8b6dd60c 100644 --- a/frontend/lib/test/server.ts +++ b/frontend/lib/test/server.ts @@ -7,7 +7,7 @@ * * https://github.com/oxidecomputer/console/blob/8dcddcef62b8d10dfcd3adb470439212b23b3d5e/test/unit/server.ts */ -import { DefaultBodyType, delay, http, HttpResponse, JsonBodyType, StrictRequest } from "msw"; +import { delay, http, HttpResponse, JsonBodyType } from "msw"; import { setupServer } from "msw/node"; export const server = setupServer(); @@ -19,16 +19,11 @@ export function overrideOnce( status: number, body: string | null | JsonBodyType, delayResponse?: "infinite" | number, - onRequest?: (request: StrictRequest) => Promise, ) { server.use( http[method]( path, - async ({ request }) => { - if (onRequest) { - await onRequest(request); - } - + async () => { if (delayResponse) { await delay(delayResponse); } diff --git a/frontend/lib/test/test-utils.ts b/frontend/lib/test/test-utils.ts index 049e65e56..f8fba1dd5 100644 --- a/frontend/lib/test/test-utils.ts +++ b/frontend/lib/test/test-utils.ts @@ -3,10 +3,12 @@ import { createMemoryRouter, RouteObject } from "react-router"; import { render, RenderOptions, screen } from "@testing-library/react"; import { UserEvent } from "@testing-library/user-event"; -import { expect } from "vitest"; +import { HttpHandler, matchRequestUrl } from "msw"; +import { expect, vi } from "vitest"; import { Providers } from "./Providers"; import { getRouter } from "./router"; +import { server } from "./server"; const customRender = (ui: ReactElement, options?: Omit) => render(ui, { wrapper: Providers, ...options }); @@ -68,3 +70,22 @@ export async function userTypeInputs(user: UserEvent, inputs: { [key: string]: s expect(input).toHaveValue(value.toString()); } } + +export function spyOnHandler(handler: HttpHandler) { + const spy = vi.fn(); + const { method, path } = handler.info; + + server.events.on("request:start", ({ request }) => { + const url = new URL(request.url); + if (request.method === method && matchRequestUrl(url, path).matches) { + void request + .clone() + .text() + .then((body) => { + spy(body.length ? JSON.parse(body) : null); + }); + } + }); + + return spy; +}