From a4bf815d24ad73ae4b440a6ba6b2eb8569cd645f Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 29 Jul 2026 11:43:18 +0200 Subject: [PATCH] test(import): read the upload body instead of parsing it as FormData These are the only two test files that call request.formData() inside an MSW handler, and they hold exactly the three tests that failed on CI while passing locally. The body axios' XHR adapter produces under jsdom is one Node 24's multipart parser rejects, so the handler threw before reading a field and the component reported a failed upload. Reading the raw payload asserts the same thing without depending on the parser. --- .../Planner/BookingImportModal.test.tsx | 7 ++-- .../Planner/FileImportModal.test.tsx | 9 ++-- client/tests/helpers/multipart.ts | 42 +++++++++++++++++++ 3 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 client/tests/helpers/multipart.ts diff --git a/client/src/components/Planner/BookingImportModal.test.tsx b/client/src/components/Planner/BookingImportModal.test.tsx index ea5cdcdb9..54063390e 100644 --- a/client/src/components/Planner/BookingImportModal.test.tsx +++ b/client/src/components/Planner/BookingImportModal.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/rende import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; +import { readMultipart } from '../../../tests/helpers/multipart'; import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'; import { resetAllStores } from '../../../tests/helpers/store'; import { saveImportFiles } from '../../db/offlineDb'; @@ -111,8 +112,7 @@ describe('BookingImportModal', () => { let sentMode: string | null = null; server.use( http.post('/api/trips/4/reservations/import/booking/async', async ({ request }) => { - const fd = await request.formData(); - sentMode = String(fd.get('mode')); + sentMode = (await readMultipart(request)).fields.mode ?? null; return HttpResponse.json({ jobId: 'job-42' }); }), ); @@ -132,8 +132,7 @@ describe('BookingImportModal', () => { server.use( http.get('/api/health/features', () => HttpResponse.json({ bookingImport: true, aiParsing: true })), http.post('/api/trips/4/reservations/import/booking/async', async ({ request }) => { - const fd = await request.formData(); - sentMode = String(fd.get('mode')); + sentMode = (await readMultipart(request)).fields.mode ?? null; return HttpResponse.json({ jobId: 'job-ai' }); }), ); diff --git a/client/src/components/Planner/FileImportModal.test.tsx b/client/src/components/Planner/FileImportModal.test.tsx index fe4ee533f..fcd540760 100644 --- a/client/src/components/Planner/FileImportModal.test.tsx +++ b/client/src/components/Planner/FileImportModal.test.tsx @@ -2,6 +2,7 @@ import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; +import { readMultipart } from '../../../tests/helpers/multipart'; import { useTripStore } from '../../store/tripStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildTrip } from '../../../tests/helpers/factories'; @@ -127,11 +128,11 @@ describe('FileImportModal', () => { let flags: Record = {}; server.use( http.post('/api/trips/3/places/import/gpx', async ({ request }) => { - const fd = await request.formData(); + const { fields } = await readMultipart(request); flags = { - waypoints: String(fd.get('importWaypoints')), - routes: String(fd.get('importRoutes')), - tracks: String(fd.get('importTracks')), + waypoints: fields.importWaypoints, + routes: fields.importRoutes, + tracks: fields.importTracks, }; return HttpResponse.json({ count: 4, skipped: 0, places: [{ id: 11 }, { id: 12 }] }); }), diff --git a/client/tests/helpers/multipart.ts b/client/tests/helpers/multipart.ts new file mode 100644 index 000000000..cb1169573 --- /dev/null +++ b/client/tests/helpers/multipart.ts @@ -0,0 +1,42 @@ +/** + * Reads the fields of a multipart request body without going through + * `Request.formData()`. + * + * Uploads in this app go out through axios' XHR adapter, and the body jsdom + * produces for that is one Node's multipart parser refuses on the CI runner + * (Node 24) while accepting it locally (Node 22) — the handler then throws + * before it ever sees a field. Reading the raw payload keeps the assertion on + * what was actually sent and takes the parser out of the picture. + * + * The body is a stream and can only be read once, so this returns everything in + * one pass. File parts are reported by filename rather than by content. + */ +export interface MultipartBody { + /** Plain text fields, by name. */ + fields: Record; + /** Names of the uploaded files, in the order they were appended. */ + filenames: string[]; +} + +export async function readMultipart(request: Request): Promise { + const body = await request.text(); + const boundary = request.headers.get('content-type')?.match(/boundary=(?:"([^"]+)"|([^;]+))/i); + const marker = boundary ? `--${(boundary[1] ?? boundary[2]).trim()}` : null; + + const fields: Record = {}; + const filenames: string[] = []; + + // Without a boundary there is nothing reliable to split on — hand back an + // empty result so the assertion fails on the value rather than on a throw. + if (!marker) return { fields, filenames }; + + for (const part of body.split(marker).slice(1)) { + const name = part.match(/name="([^"]*)"/)?.[1]; + if (!name) continue; + const filename = part.match(/filename="([^"]*)"/)?.[1]; + if (filename !== undefined) { filenames.push(filename); continue; } + // Headers, blank line, then the value up to the trailing CRLF. + fields[name] = part.match(/\r?\n\r?\n([\s\S]*?)\r?\n?$/)?.[1] ?? ''; + } + return { fields, filenames }; +}