Files
TREK/server/tests/unit/nest/booking-import/import-jobs.service.test.ts
T
Maurice 55da861de6 Put the reservation imports behind one door
Two controllers declared the identical @Controller string from two modules and
each rebuilt the trip access check its own way: booking-import ran it as a
guard chain, integrations ran it by hand inside the handler, and
BookingImportService carried a third copy that nothing called at all.

They never actually collided - the four file-upload sub-paths and the one
AirTrail sub-path are disjoint - so this is not a bug fix. It is one access
path instead of three implementations of the same one, and the dead copy is
gone.

The addon requirement sits on the AirTrail handler rather than the class.
Class-level would have 404d the four upload routes whenever AirTrail happened
to be off. AddonGuard still runs first in the chain, because a disabled addon
has to answer 404 to an anonymous caller before anything answers 401, and
ADDON-GUARD-006 pins both halves of that.

One deliberate behaviour change: the AirTrail route now gets its trip 404 from
the guard, ahead of the body 400, because a guard runs before the pipe. That
is the ordering every other trip-scoped controller already has and nothing
pinned the other way for this route.

The class rename would have stopped the server booting: the body-contract gate
keys on ControllerClass.methodName and throws on stale entries. Three entries
renamed, and the list re-sorted - GATE-006 caught that the rename had broken
the ordering it asserts.

booking-import lost the controller from its coverage denominator, so the
branch floor was about to fall. It goes up instead: the off-request parse had
no case for its catch arm, so a throw would have left the job on 'running' and
the widget spinning, and the binary probe behind /api/health/features had no
test at all - that one boolean decides whether the client offers booking
import.
2026-08-10 17:08:54 +02:00

104 lines
4.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
const { broadcastToUser } = vi.hoisted(() => ({ broadcastToUser: vi.fn() }));
vi.mock('../../../../src/websocket', () => ({ broadcastToUser }));
import { ImportJobsService } from '../../../../src/nest/booking-import/import-jobs.service';
import { RealtimeService } from '../../../../src/nest/realtime/realtime.service';
type Preview = ReturnType<typeof vi.fn>;
function makeService(preview: Preview) {
return new ImportJobsService({ preview } as never, new RealtimeService());
}
const files = (n: number) => Array.from({ length: n }, (_, i) => ({ originalname: `f${i}.pdf` })) as never;
const eventsFor = (jobId: string) => broadcastToUser.mock.calls.map((c) => c[1]).filter((p) => p.jobId === jobId);
beforeEach(() => vi.clearAllMocks());
describe('ImportJobsService', () => {
it('runs the parse off-request, reports progress and pushes the result on done', async () => {
const preview = vi.fn(async (_f, _m, _u, onProgress: (d: number, t: number, name?: string) => void) => {
onProgress(1, 2, 'f0.pdf');
return { items: [{ id: 'x' }] };
});
const svc = makeService(preview);
const id = svc.start('7', files(2), 'fallback-on-empty', 42);
expect(typeof id).toBe('string');
await vi.waitFor(() => expect(svc.get(id, 42)?.status).toBe('done'));
const job = svc.get(id, 42)!;
expect(job.result).toEqual({ items: [{ id: 'x' }] });
expect(job.done).toBe(1);
expect(preview).toHaveBeenCalledWith(expect.anything(), 'fallback-on-empty', 42, expect.any(Function));
const types = eventsFor(id).map((p) => p.type);
expect(types).toContain('import:progress');
expect(types).toContain('import:done');
expect(eventsFor(id).every((p) => p.tripId === '7')).toBe(true);
});
it('records an error and pushes import:error when the parse throws', async () => {
const preview = vi.fn(async () => { throw new Error('parse boom'); });
const svc = makeService(preview);
const id = svc.start('1', files(1), 'no-ai', 9);
await vi.waitFor(() => expect(svc.get(id, 9)?.status).toBe('error'));
expect(svc.get(id, 9)!.error).toBe('parse boom');
expect(eventsFor(id).map((p) => p.type)).toContain('import:error');
});
it('only returns a job to its owner', async () => {
const svc = makeService(vi.fn(async () => ({ items: [] })));
const id = svc.start('1', files(1), 'no-ai', 9);
expect(svc.get(id, 9)).toBeDefined();
expect(svc.get(id, 999)).toBeUndefined();
expect(svc.get('does-not-exist', 9)).toBeUndefined();
});
it('chains a user\'s parses so they run one at a time', async () => {
const order: string[] = [];
const preview = vi.fn(async (f: { originalname: string }[]) => {
order.push(`start:${f[0].originalname}`);
await new Promise((r) => setTimeout(r, 5));
order.push(`end:${f[0].originalname}`);
return { items: [] };
});
const svc = makeService(preview);
const a = svc.start('1', [{ originalname: 'A.pdf' }] as never, 'no-ai', 5);
const b = svc.start('1', [{ originalname: 'B.pdf' }] as never, 'no-ai', 5);
await vi.waitFor(() => expect(svc.get(b, 5)?.status).toBe('done'));
expect(svc.get(a, 5)?.status).toBe('done');
// B must not start before A finished — the per-user chain serializes them.
expect(order).toEqual(['start:A.pdf', 'end:A.pdf', 'start:B.pdf', 'end:B.pdf']);
});
it('reports a parse failure as an error job rather than losing it', async () => {
// The catch arm had no case: a throw inside the off-request parse would have
// left the job stuck on 'running' and the widget spinning forever.
const preview = vi.fn(async () => { throw new Error('kitinerary exploded'); });
const svc = makeService(preview);
const id = svc.start('7', files(1), 'no-ai', 42);
await vi.waitFor(() => expect(svc.get(id, 42)?.status).toBe('error'));
expect(svc.get(id, 42)?.error).toBe('kitinerary exploded');
expect(eventsFor(id).some((p) => p.message === 'kitinerary exploded')).toBe(true);
});
it('turns a non-Error throw into a readable message', async () => {
const preview = vi.fn(async () => { throw 'just a string'; });
const svc = makeService(preview);
const id = svc.start('7', files(1), 'no-ai', 42);
await vi.waitFor(() => expect(svc.get(id, 42)?.status).toBe('error'));
expect(svc.get(id, 42)?.error).toBe('just a string');
});
it('hides a job from a different user', () => {
const svc = makeService(vi.fn(async () => ({ items: [] })));
const id = svc.start('7', files(1), 'no-ai', 42);
expect(svc.get(id, 99)).toBeUndefined();
});
});