From 29816f81a971802894c8bbf2be7045bf4198d47f Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 13 Nov 2026 20:49:08 +0100 Subject: [PATCH] fix(mail-ingest): send an uploaded .eml as multipart; publish the booking origin as a contract - The upload posted its FormData through the client's JSON default, so the form went as JSON and the server saw no file (400). It goes through postMultipart now, like every other upload. - reservation-origin.ts becomes reservation-origin.contract.ts: the booking import sets an origin, and a contract is what another domain may import. - CI: the client's strict type check holds the whole program with strict checks and outgrew the default ~4 GB heap; it gets 8 GB like the server's. --- .github/workflows/test.yml | 5 ++++- client/src/api/mailIngest.test.ts | 21 +++++++++++++++++++ client/src/api/mailIngest.ts | 5 +++-- .../booking-import/booking-import.service.ts | 2 +- .../nest/reservations/reservation-inputs.ts | 2 +- ...igin.ts => reservation-origin.contract.ts} | 6 ++++++ .../reservations/reservations.controller.ts | 2 +- .../src/nest/reservations/reservations.rpc.ts | 2 +- .../nest/reservations/reservations.service.ts | 2 +- .../unit/nest/reservations.service.test.ts | 2 +- 10 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 client/src/api/mailIngest.test.ts rename server/src/nest/reservations/{reservation-origin.ts => reservation-origin.contract.ts} (86%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd3630fdf..6ed841d9b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -458,7 +458,10 @@ jobs: # tsconfig.strict.json is the same program with strictNullChecks and # noImplicitAny; its errors per file may not rise above # scripts/strict-baseline.json, and a file without an entry, every new - # one included, has to be clean (scripts/strict-lint.mjs). + # one included, has to be clean (scripts/strict-lint.mjs). It holds the + # whole program with strict checks, past Node's default ~4 GB heap. + env: + NODE_OPTIONS: --max-old-space-size=8192 run: cd client && npm run lint:strict - name: Page pattern check diff --git a/client/src/api/mailIngest.test.ts b/client/src/api/mailIngest.test.ts new file mode 100644 index 000000000..425b3997e --- /dev/null +++ b/client/src/api/mailIngest.test.ts @@ -0,0 +1,21 @@ +// FE-MAILINGEST-API-001 +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('./client', () => ({ + default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + postMultipart: vi.fn(async () => ({ duplicate: false })), +})); + +import apiClient, { postMultipart } from './client'; +import { mailIngestApi } from './mailIngest'; + +describe('mailIngestApi.upload', () => { + it('FE-MAILINGEST-API-001: sends the .eml as a multipart form, never through the JSON post', async () => { + const file = new File(['From: a@b.c\r\n\r\nhi'], 'booking.eml', { type: 'message/rfc822' }); + expect(await mailIngestApi.upload(file)).toEqual({ duplicate: false }); + const [url, form] = vi.mocked(postMultipart).mock.calls[0]; + expect(url).toBe('/mail-ingest/upload'); + expect((form as FormData).get('file')).toBe(file); + expect(apiClient.post).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/api/mailIngest.ts b/client/src/api/mailIngest.ts index 321ba22df..92787e3ac 100644 --- a/client/src/api/mailIngest.ts +++ b/client/src/api/mailIngest.ts @@ -1,4 +1,4 @@ -import apiClient from './client'; +import apiClient, { postMultipart } from './client'; /** * The booking mail import (server nest/mail-ingest): the person's settings and @@ -43,6 +43,7 @@ export const mailIngestApi = { upload: (file: File): Promise => { const form = new FormData(); form.append('file', file); - return apiClient.post('/mail-ingest/upload', form).then(data); + // As multipart: under the client's default JSON content type the form went as JSON, without the file. + return postMultipart('/mail-ingest/upload', form); }, }; diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts index f8205563f..089340dfe 100644 --- a/server/src/nest/booking-import/booking-import.service.ts +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -14,7 +14,7 @@ import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { PlaceOriginsService } from '../places/place-origins.service'; import { PlacesService } from '../places/places.service'; import { RealtimeService } from '../realtime/realtime.service'; -import { withoutOrigin } from '../reservations/reservation-origin'; +import { withoutOrigin } from '../reservations/reservation-origin.contract'; import { ReservationsService, type CreateAccommodation } from '../reservations/reservations.service'; import { IMPORT_SOURCE, importExternalId } from './booking-origin'; import { KitineraryExtractorService } from './kitinerary-extractor.service'; diff --git a/server/src/nest/reservations/reservation-inputs.ts b/server/src/nest/reservations/reservation-inputs.ts index 3244ebf23..6b480103e 100644 --- a/server/src/nest/reservations/reservation-inputs.ts +++ b/server/src/nest/reservations/reservation-inputs.ts @@ -1,4 +1,4 @@ -import type { ReservationOrigin } from './reservation-origin'; +import type { ReservationOrigin } from './reservation-origin.contract'; /* * The shapes the reservations service takes in: a booking's endpoints, the diff --git a/server/src/nest/reservations/reservation-origin.ts b/server/src/nest/reservations/reservation-origin.contract.ts similarity index 86% rename from server/src/nest/reservations/reservation-origin.ts rename to server/src/nest/reservations/reservation-origin.contract.ts index 7c198c29c..dcd80da4a 100644 --- a/server/src/nest/reservations/reservation-origin.ts +++ b/server/src/nest/reservations/reservation-origin.contract.ts @@ -1,3 +1,9 @@ +/* + * The origin an imported booking carries, published by the reservations + * domain: the importers (booking import, mail import) set it, and every other + * write path strips it from what a client sends. + */ + /** * Where an imported booking came from (`import`, `mail`) and its key there, * unique per trip. Both or neither; without them the row is a hand-made diff --git a/server/src/nest/reservations/reservations.controller.ts b/server/src/nest/reservations/reservations.controller.ts index 3ff8c240e..63a09784f 100644 --- a/server/src/nest/reservations/reservations.controller.ts +++ b/server/src/nest/reservations/reservations.controller.ts @@ -2,7 +2,7 @@ import type { User } from '../../types'; import { CurrentUser } from '../auth-core/current-user.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; -import { withoutOrigin } from './reservation-origin'; +import { withoutOrigin } from './reservation-origin.contract'; import { ReservationWritesService } from './reservation-writes.service'; import { ReservationCreateDto, diff --git a/server/src/nest/reservations/reservations.rpc.ts b/server/src/nest/reservations/reservations.rpc.ts index fcec9cb3b..a6506a242 100644 --- a/server/src/nest/reservations/reservations.rpc.ts +++ b/server/src/nest/reservations/reservations.rpc.ts @@ -4,7 +4,7 @@ import { PluginController, PluginMethod } from '../../nest-rpc/rpc-kit/decorator import type { PluginRpcContext } from '../../nest-rpc/rpc-kit/types'; import { num, schemaMessage } from '../../nest-rpc/rpc-params'; import { RealtimeService } from '../realtime/realtime.service'; -import { withoutOrigin } from './reservation-origin'; +import { withoutOrigin } from './reservation-origin.contract'; import { ReservationsService } from './reservations.service'; import { reservationCreateRequestSchema, diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index e6ed8f2a7..e3cf0205c 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -1,4 +1,4 @@ -import { originColumns } from './reservation-origin'; +import { originColumns } from './reservation-origin.contract'; import type { CreateAccommodation, CreateReservationData, EndpointInput, ReservationEndpoint, UpdateReservationData } from './reservation-inputs'; export type { CreateAccommodation, CreateReservationData, EndpointInput, ReservationEndpoint, UpdateReservationData } from './reservation-inputs'; import { BadRequestException, Injectable } from '@nestjs/common'; diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index af6c62379..15e5fb370 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -33,7 +33,7 @@ import { HttpException } from '@nestjs/common'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createReservation, createBudgetItem, createPlace, createDay, createDayAccommodation, createDayAssignment, addTripMember } from '../../helpers/factories'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -import { withoutOrigin } from '../../../src/nest/reservations/reservation-origin'; +import { withoutOrigin } from '../../../src/nest/reservations/reservation-origin.contract'; import { ReservationsService, type UpdateReservationData } from '../../../src/nest/reservations/reservations.service'; import { ReservationsReadService } from '../../../src/nest/reservations/reservations-read.service'; import type { BudgetService } from '../../../src/nest/budget/budget.service';