Files
TREK/server/tests/unit/nest/booking-import/reservation-import.controller.test.ts
T
Maurice ba7d55f999 Finish the contract ratchet, the upload bridge and the client leftovers
BODY_CONTRACT_ALLOW_LIST is empty. The last seventeen handlers type their
body with a createZodDto class, so the fail-closed boot gate has nothing left
to forgive; it still refuses a boot for a new handler that reads @Body()
without one.

Several of those contracts describe rather than constrain, and that is the
whole care in this change. The pipe runs before the handler, so a schema
stricter than the handler moves a user-visible answer:

- TagsController.create answers { error: 'Tag name is required' }, pinned
  byte-for-byte in two tests, and the pipe formats as `field: message`. The
  DTO leaves name optional and the handler keeps the check that owns the
  message. Same shape for the booking-import confirm body, which additionally
  gains per-item validation the handler never did.
- PluginsController.activate and install answer 503 when plugins are disabled,
  and check that BEFORE looking at the body. A schema that rejected first
  would turn that into a 400 for an operator whose server has plugins off.
- setEgressHosts reads a non-array `hosts` as "clear the list", which is the
  documented reset. Typing it as string[] would have removed a working admin
  action rather than tightened anything.
- PluginRoutesController.route answers { route: null } at 200 for everything
  it dislikes, so the client falls back to straight lines. A 400 there would
  turn a graceful degradation into a visible error.

files.bridge is gone. The multer options are built by
MulterModule.registerAsync from an AllowedFileTypesService leaf, so the
fileFilter reads the operator's extension list from the container instead of
from a second FilesService built outside it. Nest injects
MULTER_MODULE_OPTIONS with @Optional(), so a token that fails to resolve is
not a boot error: it silently falls back to defaults, with no filter and no
size cap. FILE-020 already proved that for trip files by uploading an .svg
over HTTP and expecting 400; journey uploads had no such case, and now do.
VIDEO_UPLOAD is untouched, including the filename callback that forces .jpg
regardless of what the client sends, which guards the public share proxy.

PluginFrame gets an onError. A frame whose document fails to load left a
blank element at full height, which reads as a hung app rather than as a
plugin that is gone.

typecheck:tests runs in CI now, with the test tree at zero errors. tz-lookup
is left as it is: the build puts it in a lazy helpers chunk, not the entry
chunk, and the remaining split would trade a synchronous render for an async
one to move 172 kB that is already off the critical path.
2026-08-10 19:54:46 +02:00

63 lines
2.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HttpException } from '@nestjs/common';
import { ReservationImportController } from '../../../../src/nest/reservation-import/reservation-import.controller';
import type { BookingImportService } from '../../../../src/nest/booking-import/booking-import.service';
import type { User } from '../../../../src/types';
const user = { id: 1, role: 'user' } as User;
const file = (name = 'a.pdf') => ({ originalname: name, buffer: Buffer.from('x') } as Express.Multer.File);
function make(over: Partial<BookingImportService> = {}) {
const svc = {
verifyTripAccess: vi.fn(() => ({ user_id: 1 })),
canEdit: vi.fn(() => true),
isAvailable: vi.fn(() => true),
aiAvailable: vi.fn(() => true),
preview: vi.fn(async () => ({ items: [], warnings: [], files: [] })),
...over,
} as unknown as BookingImportService;
// airtrailImport is the third slot; these cases never reach it.
return { c: new ReservationImportController(svc, undefined as never, undefined as never), svc };
}
async function status(fn: () => Promise<unknown>): Promise<number> {
try { await fn(); } catch (e) { expect(e).toBeInstanceOf(HttpException); return (e as HttpException).getStatus(); }
throw new Error('expected throw');
}
beforeEach(() => vi.clearAllMocks());
describe('ReservationImportController.preview', () => {
it('rejects an invalid mode with 400', async () => {
const { c } = make();
expect(await status(() => c.preview(user, 't1', [file()], { mode: 'bogus' }))).toBe(400);
});
it('returns 409 for force-ai when AI is not configured', async () => {
const { c } = make({ aiAvailable: vi.fn(() => false) as any });
expect(await status(() => c.preview(user, 't1', [file()], { mode: 'force-ai' }))).toBe(409);
});
it('returns 503 for no-ai when the extractor is unavailable', async () => {
const { c } = make({ isAvailable: vi.fn(() => false) as any });
expect(await status(() => c.preview(user, 't1', [file()], { mode: 'no-ai' }))).toBe(503);
});
it('returns 400 when no files are uploaded', async () => {
const { c } = make();
expect(await status(() => c.preview(user, 't1', [], { mode: 'no-ai' }))).toBe(400);
});
it('passes the parsed mode and user id through to the service', async () => {
const { c, svc } = make();
await c.preview(user, 't1', [file()], { mode: 'fallback-on-empty' });
expect(svc.preview).toHaveBeenCalledWith([expect.anything()], 'fallback-on-empty', 1);
});
it('defaults the mode to no-ai when omitted', async () => {
const { c, svc } = make();
await c.preview(user, 't1', [file()], {});
expect(svc.preview).toHaveBeenCalledWith([expect.anything()], 'no-ai', 1);
});
});