Files
TREK/server/tests/unit/nest/booking-import/reservation-import.controller.test.ts
T
2026-10-06 21:50:59 +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 });
});
});