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 }; +}