mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
245 lines
10 KiB
TypeScript
245 lines
10 KiB
TypeScript
/**
|
|
* Booking import e2e (#2477): a Booking.com print read by Gemini through its
|
|
* OpenAI-compatible endpoint, driven through the real upload route.
|
|
*
|
|
* Real: the JwtAuthGuard and TripAccessGuard, multer on the import route, the
|
|
* text extraction of a PDF, LlmParseService, the OpenAI-compatible client with
|
|
* its request body, and the kitinerary mapper. Replaced: the kitinerary binary
|
|
* (not installed, as on the reporter's instance, so the AI path runs) and the
|
|
* one outbound call, `safeFetchLlm`, which answers the way Gemini did.
|
|
*
|
|
* The PDF is built here from synthetic text; the reporter's document is never
|
|
* part of the repository.
|
|
*/
|
|
import { db } from '../../src/db/database';
|
|
import { KitineraryExtractorService } from '../../src/nest/booking-import/kitinerary-extractor.service';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
|
|
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
|
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
|
import { ReservationImportModule } from '../../src/nest/reservation-import/reservation-import.module';
|
|
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { sessionCookie } from './harness';
|
|
import { Test } from '@nestjs/testing';
|
|
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import request from 'supertest';
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
|
|
const { safeFetchLlm } = vi.hoisted(() => ({ safeFetchLlm: vi.fn() }));
|
|
// The temp db is born inside the factory, which runs before anything imports it,
|
|
// and read back below through the mocked module itself. It is a copy of the
|
|
// migrated + seeded snapshot, so the schema is the MikroORM chain's. Trip access
|
|
// is the real TripsRepository lookup against it.
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb } = await import('../helpers/db-mock');
|
|
const tmp = createSnapshotTestDb();
|
|
return {
|
|
db: tmp,
|
|
canAccessTrip: () => undefined,
|
|
isOwner: () => false,
|
|
getPlaceWithTags: vi.fn(),
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
};
|
|
});
|
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
|
vi.mock('../../src/utils/ssrfGuard', async (orig) => ({ ...(await orig<Record<string, unknown>>()), safeFetchLlm }));
|
|
|
|
/** A one-page PDF whose text layer holds `lines`, in the standard Helvetica. */
|
|
function pdfWithText(lines: string[]): Buffer {
|
|
// PDF string literal: escape \ ( ), and write the euro sign as its WinAnsi byte.
|
|
const esc = (s: string) => s.replace(/[\\()]/g, (c) => '\\' + c).replace(/€/g, '\x80');
|
|
const content = ['BT', '/F1 10 Tf', '12 TL', '20 780 Td', ...lines.map((l) => `(${esc(l)}) '`), 'ET'].join('\n');
|
|
const objects = [
|
|
'<< /Type /Catalog /Pages 2 0 R >>',
|
|
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
|
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
|
|
`<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`,
|
|
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>',
|
|
];
|
|
let body = '%PDF-1.4\n';
|
|
const offsets: number[] = [];
|
|
objects.forEach((o, i) => {
|
|
offsets.push(Buffer.byteLength(body, 'latin1'));
|
|
body += `${i + 1} 0 obj\n${o}\nendobj\n`;
|
|
});
|
|
const xref = Buffer.byteLength(body, 'latin1');
|
|
const rows = offsets.map((n) => `${String(n).padStart(10, '0')} 00000 n \n`).join('');
|
|
body += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${rows}`;
|
|
body += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
|
return Buffer.from(body, 'latin1');
|
|
}
|
|
|
|
const PRINT = pdfWithText([
|
|
'Harbour View Inn',
|
|
'Address: Example Road 1, 1000 Sample Town',
|
|
'Preis (für 2 Gäste)',
|
|
'€ 89,35',
|
|
'ANREISE 6 SEPTEMBER 13:00 ABREISE 7 SEPTEMBER 11:00',
|
|
]);
|
|
|
|
/** Gemini's chat-completions answer carrying `reservations` as its content. */
|
|
function geminiAnswers(reservations: unknown[]) {
|
|
safeFetchLlm.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ choices: [{ message: { content: JSON.stringify({ reservations }) } }] }),
|
|
text: async () => '',
|
|
} as unknown as Response);
|
|
}
|
|
|
|
/** The node Gemini returned in the report: every declared root field, no venue. */
|
|
const bareStay = {
|
|
'@type': 'LodgingReservation',
|
|
checkinTime: '2026-09-06T13:00:00',
|
|
checkoutTime: '2026-09-07T11:00:00',
|
|
price: 89.35,
|
|
priceCurrency: 'EURials',
|
|
};
|
|
|
|
describe('Booking import e2e (#2477): a schema-bound provider on the upload route', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
let tripId: number;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({
|
|
imports: [
|
|
await TestUnitOfWorkModule.forRoot(db),
|
|
await createTestMikroOrmModule(db),
|
|
RealtimeModule,
|
|
ReservationImportModule,
|
|
],
|
|
})
|
|
.overrideProvider(KitineraryExtractorService)
|
|
.useValue({
|
|
onModuleInit: () => {},
|
|
isAvailable: () => false,
|
|
extract: vi.fn(),
|
|
describe: () => ({ available: false }),
|
|
})
|
|
.overrideProvider(NotificationsService)
|
|
.useValue({ send: vi.fn().mockResolvedValue(undefined) })
|
|
.compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
const upload = (fileName: string, mode: string) =>
|
|
request(server)
|
|
.post(`/api/trips/${tripId}/reservations/import/booking`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.field('mode', mode)
|
|
.attach('files', PRINT, { filename: fileName, contentType: 'application/pdf' });
|
|
|
|
const sentSchema = () => {
|
|
const body = JSON.parse((safeFetchLlm.mock.calls[0][1] as RequestInit).body as string);
|
|
return body.response_format.json_schema.schema.properties.reservations.items;
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
db.prepare(
|
|
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
|
|
).run();
|
|
tripId = Number(db.prepare("INSERT INTO trips (user_id, title) VALUES (1, 'Albania')").run().lastInsertRowid);
|
|
// The instance config the reporter ran: Gemini behind the "openai" provider.
|
|
db.prepare(
|
|
`INSERT INTO addons (id, name, type, enabled, config) VALUES ('llm_parsing', 'AI Parsing', 'integration', 1, ?)
|
|
ON CONFLICT(id) DO UPDATE SET enabled = 1, config = excluded.config`,
|
|
).run(
|
|
JSON.stringify({
|
|
provider: 'openai',
|
|
model: 'gemini-3.5-flash',
|
|
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
}),
|
|
);
|
|
app = await build();
|
|
vi.spyOn(app.get(PermissionsService), 'checkPermission').mockResolvedValue(true);
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
safeFetchLlm.mockReset();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
it('401 without a cookie', async () => {
|
|
const res = await request(server).post(`/api/trips/${tripId}/reservations/import/booking`);
|
|
expect(res.status).toBe(401);
|
|
expect(safeFetchLlm).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('sends Gemini a reservationFor with declared venue fields, and requires it', async () => {
|
|
geminiAnswers([]);
|
|
await upload('booking.pdf', 'force-ai');
|
|
|
|
const item = sentSchema();
|
|
expect(item.required).toEqual(['@type', 'reservationFor']);
|
|
expect(item.properties.reservationFor.properties).toMatchObject({
|
|
name: { type: 'string' },
|
|
address: { type: 'string' },
|
|
telephone: { type: 'string' },
|
|
});
|
|
// The PDF went as text, and its euro amount survived the extraction.
|
|
const user = JSON.parse((safeFetchLlm.mock.calls[0][1] as RequestInit).body as string).messages[1].content[0].text;
|
|
expect(user).toContain('Harbour View Inn');
|
|
expect(user).toContain('€ 89,35');
|
|
});
|
|
|
|
it("the reported answer: two venue-less nodes give one warning, with the file's own name", async () => {
|
|
geminiAnswers([bareStay, { ...bareStay }]);
|
|
const res = await upload('Bestätigung_1234567890.PDF', 'force-ai');
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.items).toEqual([]);
|
|
// One warning, not two: the copy is gone before the mapper sees it.
|
|
expect(res.body.warnings).toHaveLength(1);
|
|
expect(res.body.warnings[0]).toMatch(
|
|
/^Incomplete LodgingReservation in Bestätigung_1234567890\.PDF\[0\] \(no reservationFor\)/,
|
|
);
|
|
expect(res.body.files).toEqual([{ fileName: 'Bestätigung_1234567890.PDF', aiAvailable: true, aiUsed: true }]);
|
|
});
|
|
|
|
it('once Gemini fills the venue: one hotel in EUR, attached to the uploaded file', async () => {
|
|
const stay = {
|
|
...bareStay,
|
|
reservationFor: { name: 'Harbour View Inn', address: 'Example Road 1, 1000 Sample Town' },
|
|
};
|
|
geminiAnswers([stay, { ...stay }]);
|
|
const res = await upload('Bestätigung_1.pdf', 'force-ai');
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.warnings).toEqual([]);
|
|
expect(res.body.items).toHaveLength(1);
|
|
expect(res.body.items[0]).toMatchObject({
|
|
type: 'hotel',
|
|
title: 'Harbour View Inn',
|
|
needs_review: true,
|
|
_accommodation: { check_in: '2026-09-06T13:00', check_out: '2026-09-07T11:00' },
|
|
metadata: { price: 89.35, priceCurrency: 'EUR' },
|
|
source: { fileName: 'Bestätigung_1.pdf', index: 0 },
|
|
});
|
|
});
|
|
|
|
it("the upload dialog's mode reaches the same path, and a currency the model made up comes from the document", async () => {
|
|
geminiAnswers([{ ...bareStay, priceCurrency: 'ZZZ', reservationFor: { name: 'Harbour View Inn' } }]);
|
|
const res = await upload('Bestätigung_2.pdf', 'fallback-on-empty');
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.items).toHaveLength(1);
|
|
expect(res.body.items[0].metadata.priceCurrency).toBe('EUR');
|
|
expect(res.body.items[0].source.fileName).toBe('Bestätigung_2.pdf');
|
|
});
|
|
});
|