mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
41b14ebc2a
Gemini behind the OpenAI-compatible provider decodes against the JSON schema TREK sends and fills declared properties only. reservationFor was an open object without any, so the model left it out: a Booking.com stay came back with its dates and price but no hotel name, and the mapper had to drop it (#2477). It is the same cause as #1638, whose fix only made the JSON readable. The schema now declares every field the prompt names inside reservationFor, for venues, flights, trains, buses, boats, events and rental cars, at most two levels deep, and requires it; the car desks at the root get the same venue fields. Every object stays open to keys the prompt does not name. On the cloud AI path an answer is cleaned up before it is mapped. Exact duplicate nodes are dropped, so one stay is one booking. priceCurrency is read as an ISO 4217 code ("EURials" is EUR, a lone symbol maps to its code); when the model names none, a priced node takes the currency of the document's total, and otherwise the field goes instead of saving a code nobody can convert. The kitinerary path is untouched. The import upload decodes file names as UTF-8 like every other upload route. "Bestätigung.pdf" reached the warnings as mojibake, and the review could not attach the file to the booking it created, because it matches on the name. The OpenAI-compatible client logs which response_format the answering attempt carried, metadata only, so the next report shows whether the schema reached the model at all. The background import card showed a green tick beside "no reservations could be extracted". A parse that found nothing now wears the warning mark instead; it is the same card on the desktop and on the phone.
83 lines
3.7 KiB
TypeScript
83 lines
3.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { HttpException, type Type } from '@nestjs/common';
|
|
import { INTERCEPTORS_METADATA } from '@nestjs/common/constants';
|
|
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);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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 });
|
|
});
|
|
});
|