Files
TREK/server/tests/unit/nest/booking-import/reservation-import.controller.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

128 lines
5.5 KiB
TypeScript

import type { BookingImportService } from '../../../../src/nest/booking-import/booking-import.service';
import { ReservationImportController } from '../../../../src/nest/reservation-import/reservation-import.controller';
import type { User } from '../../../../src/types';
import { HttpException, type Type } from '@nestjs/common';
import { INTERCEPTORS_METADATA } from '@nestjs/common/constants';
import { describe, it, expect, vi, beforeEach } from 'vitest';
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),
readsImages: vi.fn(async () => 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('takes a photo when the model reads images, without asking otherwise', async () => {
const { c, svc } = make();
await c.preview(user, 't1', [file('ticket.JPG')], { mode: 'fallback-on-empty' });
expect(svc.preview).toHaveBeenCalledWith([expect.anything()], 'fallback-on-empty', 1);
(svc.readsImages as ReturnType<typeof vi.fn>).mockClear();
await c.preview(user, 't1', [file('a.pdf')], { mode: 'fallback-on-empty' });
expect(svc.readsImages).not.toHaveBeenCalled();
});
it('refuses a photo with 400 when the model does not read images, or AI is not asked', async () => {
const { c, svc } = make({ readsImages: vi.fn(async () => false) });
expect(
await status(() => c.preview(user, 't1', [file('a.pdf'), file('ticket.png')], { mode: 'fallback-on-empty' })),
).toBe(400);
const { c: c2 } = make();
expect(await status(() => c2.preview(user, 't1', [file('ticket.png')], { mode: 'no-ai' }))).toBe(400);
expect(svc.preview).not.toHaveBeenCalled();
});
it('still refuses a file type it does not know, HEIC included', async () => {
const { c } = make();
expect(await status(() => c.preview(user, 't1', [file('IMG_1.heic')], { mode: 'fallback-on-empty' }))).toBe(400);
});
it('names the photo formats in that refusal too', async () => {
const { c } = make();
const err = await c
.preview(user, 't1', [file('IMG_1.heic')], { mode: 'fallback-on-empty' })
.catch((e: unknown) => e);
expect((err as HttpException).getResponse()).toEqual({
error:
'Unsupported file type: IMG_1.heic. Accepted: EML, PDF, PKPass, HTML, TXT, JPG, JPEG, PNG, WEBP (photos when the AI model reads images)',
});
});
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);
});
});
/**
* Multer decodes a multipart filename as latin1 unless told otherwise, and the
* booking import did not tell it: "Bestätigung.pdf" reached the warnings as
* "Bestätigung.pdf", and the review could not attach the file it no longer
* recognised by name (#2477). Read off the interceptor both upload routes carry.
*/
describe('ReservationImportController upload options', () => {
const multerOf = (handler: keyof ReservationImportController) => {
const [Interceptor] = Reflect.getMetadata(
INTERCEPTORS_METADATA,
ReservationImportController.prototype[handler],
) as Type<{ multer: { defParamCharset: string; limits: unknown } }>[];
return new Interceptor().multer;
};
it.each(['preview', 'previewAsync'] as const)('%s decodes non-ASCII filenames as UTF-8', (handler) => {
const multer = multerOf(handler);
expect(multer.defParamCharset).toBe('utf8');
expect(multer.limits).toEqual({ fileSize: 10 * 1024 * 1024, files: 5 });
});
});