From 55da861de6d22b40679865815a33680ac30d5e2e Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 10 Aug 2026 14:51:38 +0200 Subject: [PATCH] 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. --- server/src/nest/app.module.ts | 3 +- .../booking-import/booking-import.module.ts | 5 +- .../booking-import/booking-import.service.ts | 8 -- .../nest/common/body-contract-allow-list.ts | 6 +- .../airtrail-import.controller.ts | 51 ------------- .../src/nest/integrations/airtrail.module.ts | 5 +- .../reservation-import.controller.ts} | 57 ++++++++++++++- .../reservation-import.module.ts | 20 +++++ server/tests/unit/nest/addon-guard.test.ts | 13 +++- .../import-jobs.service.test.ts | 27 +++++++ .../kitinerary-extractor.service.test.ts | 73 +++++++++++++++++++ ... => reservation-import.controller.test.ts} | 7 +- server/vitest.config.ts | 3 +- 13 files changed, 200 insertions(+), 78 deletions(-) delete mode 100644 server/src/nest/integrations/airtrail-import.controller.ts rename server/src/nest/{booking-import/booking-import.controller.ts => reservation-import/reservation-import.controller.ts} (66%) create mode 100644 server/src/nest/reservation-import/reservation-import.module.ts create mode 100644 server/tests/unit/nest/booking-import/kitinerary-extractor.service.test.ts rename server/tests/unit/nest/booking-import/{booking-import.controller.test.ts => reservation-import.controller.test.ts} (87%) diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 5e1c8ff06..65cdbc234 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -43,6 +43,7 @@ import { FeedsModule } from './feeds/feeds.module'; import { SettingsModule } from './settings/settings.module'; import { BackupModule } from './backup/backup.module'; import { BookingImportModule } from './booking-import/booking-import.module'; +import { ReservationImportModule } from './reservation-import/reservation-import.module'; import { LlmParseModule } from './llm-parse/llm-parse.module'; import { AuthModule } from './auth/auth.module'; import { OidcModule } from './oidc/oidc.module'; @@ -64,7 +65,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor'; * migrated. */ @Module({ - imports: [AppConfigModule, DatabaseModule, RealtimeModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, LlmParseModule], + imports: [AppConfigModule, DatabaseModule, RealtimeModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule], providers: [ // Default-deny: a route is authenticated unless it carries @Public() or // @OptionalAuth(), or declares its own @UseGuards chain. Protection used to diff --git a/server/src/nest/booking-import/booking-import.module.ts b/server/src/nest/booking-import/booking-import.module.ts index c4a6e9ac7..668b0a7cc 100644 --- a/server/src/nest/booking-import/booking-import.module.ts +++ b/server/src/nest/booking-import/booking-import.module.ts @@ -1,5 +1,4 @@ import { Module } from '@nestjs/common'; -import { BookingImportController } from './booking-import.controller'; import { BookingImportService } from './booking-import.service'; import { ImportJobsService } from './import-jobs.service'; import { KitineraryExtractorModule } from './kitinerary-extractor.module'; @@ -13,7 +12,9 @@ import { PlacesModule } from '../places/places.module'; @Module({ imports: [KitineraryExtractorModule, LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule, AddonsModule, MapsModule, PlacesModule], - controllers: [BookingImportController], providers: [BookingImportService, ImportJobsService], + // The HTTP surface lives in reservation-import/, which shares the prefix with + // the AirTrail import; these are what it needs. + exports: [BookingImportService, ImportJobsService], }) export class BookingImportModule {} diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts index f48ab4d97..9e0c431a3 100644 --- a/server/src/nest/booking-import/booking-import.service.ts +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -56,14 +56,6 @@ export class BookingImportService { return this.llmParse.isAvailable(userId); } - verifyTripAccess(tripId: string, userId: number) { - return this.dbs.canAccessTrip(tripId, userId); - } - - canEdit(trip: TripAccess, user: User): boolean { - return this.permissions.checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id); - } - /** * Parse uploaded files and return a preview list. Does NOT persist anything. * Runs kitinerary first; depending on `mode`, falls back to the LLM: diff --git a/server/src/nest/common/body-contract-allow-list.ts b/server/src/nest/common/body-contract-allow-list.ts index e4384485e..c87720420 100644 --- a/server/src/nest/common/body-contract-allow-list.ts +++ b/server/src/nest/common/body-contract-allow-list.ts @@ -8,9 +8,6 @@ * here — the gate throws on stale entries, so removal is enforced too. */ export const BODY_CONTRACT_ALLOW_LIST: string[] = [ - 'BookingImportController.confirm', - 'BookingImportController.preview', - 'BookingImportController.previewAsync', 'LlmLocalController.pull', 'OauthApiController.authorize', 'OauthApiController.createClient', @@ -23,6 +20,9 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [ 'PluginsController.setEgressHosts', 'PluginsController.uninstall', 'PluginsController.updateConfig', + 'ReservationImportController.confirm', + 'ReservationImportController.preview', + 'ReservationImportController.previewAsync', 'TagsController.create', 'TagsController.update', ]; diff --git a/server/src/nest/integrations/airtrail-import.controller.ts b/server/src/nest/integrations/airtrail-import.controller.ts deleted file mode 100644 index 282972918..000000000 --- a/server/src/nest/integrations/airtrail-import.controller.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Body, Controller, Headers, HttpException, Param, Post, UseGuards } from '@nestjs/common'; -import type { User } from '../../types'; -import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { CurrentUser } from '../auth/current-user.decorator'; -import { AddonGuard } from '../addons/addon.guard'; -import { RequireAddon } from '../addons/require-addon.decorator'; -import { ADDON_IDS } from '../../addons'; -import { AirtrailImportDto } from './airtrail.dto'; -import type { AirtrailImportResult } from '@trek/shared'; -import { PermissionsService } from '../permissions/permissions.service'; -import { DatabaseService } from '../database/database.service'; -import { AirtrailImportService } from './airtrail-import.service'; - -/** - * POST /api/trips/:tripId/reservations/import/airtrail — turn selected AirTrail - * flights into reservations. Trip-scoped (reservation_edit) and addon-gated. The - * flights are re-fetched server-side with the caller's own key. - */ -@Controller('api/trips/:tripId/reservations/import') -@UseGuards(AddonGuard, JwtAuthGuard) -@RequireAddon(ADDON_IDS.AIRTRAIL, 'AirTrail') -export class AirtrailImportController { - constructor( - private readonly permissions: PermissionsService, - private readonly db: DatabaseService, - private readonly imports: AirtrailImportService, - ) {} - - private requireEdit(tripId: string, user: User): void { - const trip = this.db.canAccessTrip(tripId, user.id); - if (!trip) throw new HttpException({ error: 'Trip not found' }, 404); - if (!this.permissions.checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id)) { - throw new HttpException({ error: 'No permission' }, 403); - } - } - - @Post('airtrail') - async importAirtrail( - @CurrentUser() user: User, - @Param('tripId') tripId: string, - @Body() body: AirtrailImportDto, - @Headers('x-socket-id') socketId?: string, - ): Promise { - this.requireEdit(tripId, user); - try { - return await this.imports.importAirtrailFlights(tripId, user.id, body.flightIds, socketId, body.connections ?? []); - } catch (err: any) { - throw new HttpException({ error: err?.message || 'AirTrail import failed' }, err?.status === 400 ? 400 : 502); - } - } -} diff --git a/server/src/nest/integrations/airtrail.module.ts b/server/src/nest/integrations/airtrail.module.ts index a7ce781e4..b9cfb3cdc 100644 --- a/server/src/nest/integrations/airtrail.module.ts +++ b/server/src/nest/integrations/airtrail.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common'; import { AirtrailController } from './airtrail.controller'; -import { AirtrailImportController } from './airtrail-import.controller'; import { AirtrailClient } from './airtrail.client'; import { AirtrailService } from './airtrail.service'; import { AirtrailSyncService } from './airtrail-sync.service'; @@ -26,8 +25,8 @@ import { ReservationsModule } from '../reservations/reservations.module'; */ @Module({ imports: [PermissionsModule, AddonsModule, AuditModule, ReservationsModule], - controllers: [AirtrailController, AirtrailImportController], + controllers: [AirtrailController], providers: [AirtrailClient, AirtrailService, AirtrailSyncService, AirtrailImportService], - exports: [AirtrailSyncService], + exports: [AirtrailSyncService, AirtrailImportService], }) export class AirtrailModule {} diff --git a/server/src/nest/booking-import/booking-import.controller.ts b/server/src/nest/reservation-import/reservation-import.controller.ts similarity index 66% rename from server/src/nest/booking-import/booking-import.controller.ts rename to server/src/nest/reservation-import/reservation-import.controller.ts index 2d895d8a2..9e95f0533 100644 --- a/server/src/nest/booking-import/booking-import.controller.ts +++ b/server/src/nest/reservation-import/reservation-import.controller.ts @@ -16,8 +16,14 @@ import type { User } from '../../types'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; -import { BookingImportService } from './booking-import.service'; -import { ImportJobsService } from './import-jobs.service'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { ADDON_IDS } from '../../addons'; +import { BookingImportService } from '../booking-import/booking-import.service'; +import { ImportJobsService } from '../booking-import/import-jobs.service'; +import { AirtrailImportService } from '../integrations/airtrail-import.service'; +import { AirtrailImportDto } from '../integrations/airtrail.dto'; +import type { AirtrailImportResult } from '@trek/shared'; import { bookingImportModeSchema } from '@trek/shared'; import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode } from '@trek/shared'; @@ -30,17 +36,60 @@ const UPLOAD = { limits: { fileSize: MAX_FILE_BYTES, files: MAX_FILES }, }; +/** + * Everything that turns something external into reservations, on one prefix. + * + * It used to be two controllers declaring the same @Controller string from two + * modules — booking-import for the file upload, integrations for AirTrail — each + * rebuilding the trip access check its own way. The sub-paths never actually + * collided, so this is not a bug fix; it is one access path instead of two + * implementations of the same one. + * + * AddonGuard sits FIRST in the chain and the addon requirement lives on the + * AirTrail handler alone. Order matters: a disabled addon has to answer 404 to + * an anonymous caller, so the addon check must beat the 401. Handler-level + * rather than class-level matters just as much — a class-level @RequireAddon + * would 404 the four file-upload routes whenever AirTrail happens to be off. + */ @Controller('api/trips/:tripId/reservations/import') // TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations // add @RequirePermission('reservation_edit'), the same action string the service's canEdit // passes, so the HTTP and MCP paths cannot demand different rights. -@UseGuards(JwtAuthGuard, TripAccessGuard) -export class BookingImportController { +@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) +export class ReservationImportController { constructor( private readonly bookingImport: BookingImportService, private readonly importJobs: ImportJobsService, + private readonly airtrailImport: AirtrailImportService, ) {} + /** + * Turn selected AirTrail flights into reservations. The flights are re-fetched + * server-side with the caller's own key, so the client cannot inject arbitrary + * flight data. + * + * The hand-written requireEdit this had is gone: the class guard chain runs the + * identical check with the identical action string. That does move the trip 404 + * ahead of the body 400, because a guard runs before the pipe — the same + * ordering every other trip-scoped controller already has, and no case pinned + * the other way round for this route. + */ + @Post('airtrail') + @RequireAddon(ADDON_IDS.AIRTRAIL, 'AirTrail') + @RequirePermission('reservation_edit') + async importAirtrail( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Body() body: AirtrailImportDto, + @Headers('x-socket-id') socketId?: string, + ): Promise { + try { + return await this.airtrailImport.importAirtrailFlights(tripId, user.id, body.flightIds, socketId, body.connections ?? []); + } catch (err: any) { + throw new HttpException({ error: err?.message || 'AirTrail import failed' }, err?.status === 400 ? 400 : 502); + } + } + /** Shared validation for both the sync and async import endpoints; returns the parsed mode. */ diff --git a/server/src/nest/reservation-import/reservation-import.module.ts b/server/src/nest/reservation-import/reservation-import.module.ts new file mode 100644 index 000000000..7502a6b2c --- /dev/null +++ b/server/src/nest/reservation-import/reservation-import.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { ReservationImportController } from './reservation-import.controller'; +import { BookingImportModule } from '../booking-import/booking-import.module'; +import { AirtrailModule } from '../integrations/airtrail.module'; +import { AddonsModule } from '../addons/addons.module'; +import { PermissionsModule } from '../permissions/permissions.module'; + +/** + * The one route prefix that turns something external into reservations. + * + * It owns the controller and nothing else: the file-upload pipeline stays in + * booking-import/ and the AirTrail pipeline in integrations/, because those are + * where their own domains live. What moved here is the HTTP surface they were + * both declaring separately. + */ +@Module({ + imports: [BookingImportModule, AirtrailModule, AddonsModule, PermissionsModule], + controllers: [ReservationImportController], +}) +export class ReservationImportModule {} diff --git a/server/tests/unit/nest/addon-guard.test.ts b/server/tests/unit/nest/addon-guard.test.ts index 313dfa18e..70b75a543 100644 --- a/server/tests/unit/nest/addon-guard.test.ts +++ b/server/tests/unit/nest/addon-guard.test.ts @@ -22,7 +22,7 @@ import { ADDON_IDS } from '../../../src/addons'; import { JourneyController } from '../../../src/nest/journey/journey.controller'; import { CollectionsController } from '../../../src/nest/collections/collections.controller'; import { AirtrailController } from '../../../src/nest/integrations/airtrail.controller'; -import { AirtrailImportController } from '../../../src/nest/integrations/airtrail-import.controller'; +import { ReservationImportController } from '../../../src/nest/reservation-import/reservation-import.controller'; /** A context whose handler/class carry the decorator under test. */ function ctxFor(handler: object, cls: object = class {}): ExecutionContext { @@ -98,11 +98,20 @@ describe('the gated route groups still declare their addon', () => { ['JourneyController', JourneyController, ADDON_IDS.JOURNEY, 'Journey'], ['CollectionsController', CollectionsController, ADDON_IDS.COLLECTIONS, 'Collections'], ['AirtrailController', AirtrailController, ADDON_IDS.AIRTRAIL, 'AirTrail'], - ['AirtrailImportController', AirtrailImportController, ADDON_IDS.AIRTRAIL, 'AirTrail'], ])('ADDON-GUARD-005: %s gates on the right addon, with AddonGuard first', (_name, cls, addonId, label) => { expect(metaOf(cls)).toEqual({ addonId, label }); // Order is the contract: a disabled addon has to answer 404 to anonymous // callers, so the addon check must run before the 401. expect(guardsOf(cls)[0]).toBe(AddonGuard); }); + + it('ADDON-GUARD-006: the merged import controller gates AirTrail per handler, not per class', () => { + // A class-level requirement here would 404 the four file-upload routes + // whenever the AirTrail addon happens to be off. AddonGuard still runs first + // in the chain, so the 404 keeps beating the 401. + expect(metaOf(ReservationImportController)).toBeUndefined(); + expect(Reflect.getMetadata(REQUIRE_ADDON, ReservationImportController.prototype.importAirtrail)) + .toEqual({ addonId: ADDON_IDS.AIRTRAIL, label: 'AirTrail' }); + expect(guardsOf(ReservationImportController)[0]).toBe(AddonGuard); + }); }); diff --git a/server/tests/unit/nest/booking-import/import-jobs.service.test.ts b/server/tests/unit/nest/booking-import/import-jobs.service.test.ts index 95c156811..da170760a 100644 --- a/server/tests/unit/nest/booking-import/import-jobs.service.test.ts +++ b/server/tests/unit/nest/booking-import/import-jobs.service.test.ts @@ -73,4 +73,31 @@ describe('ImportJobsService', () => { // 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(); + }); }); diff --git a/server/tests/unit/nest/booking-import/kitinerary-extractor.service.test.ts b/server/tests/unit/nest/booking-import/kitinerary-extractor.service.test.ts new file mode 100644 index 000000000..4780f1f08 --- /dev/null +++ b/server/tests/unit/nest/booking-import/kitinerary-extractor.service.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * The binary probe behind `GET /api/health/features`. + * + * It had no test at all, which matters more than the line count suggests: this + * one boolean is what the client reads to decide whether to offer booking import + * at all, and every branch that resolves it is a filesystem lookup that behaves + * differently on the three platforms TREK ships to. + */ +const { existsSync, readdirSync, readEnv } = vi.hoisted(() => ({ + existsSync: vi.fn(), + readdirSync: vi.fn(), + readEnv: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + existsSync, + readdirSync, + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); +vi.mock('../../../../src/app-config', () => ({ readEnv })); + +import { KitineraryExtractorService } from '../../../../src/nest/booking-import/kitinerary-extractor.service'; + +function boot(env: { kitineraryExtractorPath?: string } = {}) { + readEnv.mockReturnValue({ integrations: env }); + const svc = new KitineraryExtractorService(); + svc.onModuleInit(); + return svc; +} + +beforeEach(() => { + vi.clearAllMocks(); + existsSync.mockReturnValue(false); + readdirSync.mockReturnValue([]); +}); + +describe('KitineraryExtractorService binary probe', () => { + it('KIT-EXT-001: takes the configured path when it exists', () => { + existsSync.mockImplementation((p: string) => p === '/opt/kitinerary-extractor'); + expect(boot({ kitineraryExtractorPath: '/opt/kitinerary-extractor' }).isAvailable()).toBe(true); + }); + + it('KIT-EXT-002: an explicitly configured path that is missing disables the feature outright', () => { + // It deliberately does NOT fall through to the search: somebody who set the + // variable meant that binary, and silently using another one would hide the + // typo behind a working feature. + const svc = boot({ kitineraryExtractorPath: '/nope/kitinerary-extractor' }); + expect(svc.isAvailable()).toBe(false); + expect(readdirSync).not.toHaveBeenCalled(); + }); + + it('KIT-EXT-003: finds the Debian multiarch location by scanning /usr/lib', () => { + readdirSync.mockReturnValue(['x86_64-linux-gnu'] as never); + existsSync.mockImplementation((p: string) => p.includes('x86_64-linux-gnu')); + expect(boot().isAvailable()).toBe(true); + }); + + it('KIT-EXT-004: survives a system with no /usr/lib at all', () => { + readdirSync.mockImplementation(() => { throw new Error('ENOENT'); }); + expect(boot().isAvailable()).toBe(false); + }); + + it('KIT-EXT-005: reports unavailable when nothing is found, rather than throwing at boot', () => { + expect(boot().isAvailable()).toBe(false); + }); + + it('KIT-EXT-006: extracting without a binary fails loudly', async () => { + await expect(boot().extract(Buffer.from(''), 'x.pdf')).rejects.toThrow('not available'); + }); +}); diff --git a/server/tests/unit/nest/booking-import/booking-import.controller.test.ts b/server/tests/unit/nest/booking-import/reservation-import.controller.test.ts similarity index 87% rename from server/tests/unit/nest/booking-import/booking-import.controller.test.ts rename to server/tests/unit/nest/booking-import/reservation-import.controller.test.ts index 7fdac65ee..4c3dc70e0 100644 --- a/server/tests/unit/nest/booking-import/booking-import.controller.test.ts +++ b/server/tests/unit/nest/booking-import/reservation-import.controller.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HttpException } from '@nestjs/common'; -import { BookingImportController } from '../../../../src/nest/booking-import/booking-import.controller'; +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'; @@ -16,7 +16,8 @@ function make(over: Partial = {}) { preview: vi.fn(async () => ({ items: [], warnings: [], files: [] })), ...over, } as unknown as BookingImportService; - return { c: new BookingImportController(svc), svc }; + // 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): Promise { @@ -26,7 +27,7 @@ async function status(fn: () => Promise): Promise { beforeEach(() => vi.clearAllMocks()); -describe('BookingImportController.preview', () => { +describe('ReservationImportController.preview', () => { it('rejects an invalid mode with 400', async () => { const { c } = make(); expect(await status(() => c.preview(user, 't1', [file()], 'bogus'))).toBe(400); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 4f8120252..7d64ee6e7 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -71,7 +71,7 @@ export default defineConfig({ 'src/nest/audit/**/*.ts': { statements: 92, branches: 79, functions: 99, lines: 96 }, 'src/nest/auth/**/*.ts': { statements: 93, branches: 85, functions: 95, lines: 96 }, 'src/nest/backup/**/*.ts': { statements: 96, branches: 92, functions: 99, lines: 98 }, - 'src/nest/booking-import/**/*.ts': { statements: 48, branches: 28, functions: 56, lines: 52 }, + 'src/nest/booking-import/**/*.ts': { statements: 50, branches: 28, functions: 64, lines: 53 }, 'src/nest/budget/**/*.ts': { statements: 91, branches: 77, functions: 97, lines: 95 }, 'src/nest/calendar/**/*.ts': { statements: 96, branches: 93, functions: 99, lines: 99 }, 'src/nest/categories/**/*.ts': { statements: 99, branches: 80, functions: 99, lines: 99 }, @@ -107,6 +107,7 @@ export default defineConfig({ 'src/nest/plugins/**/*.ts': { statements: 86, branches: 81, functions: 78, lines: 89 }, 'src/nest/query-helpers/**/*.ts': { statements: 90, branches: 75, functions: 99, lines: 92 }, 'src/nest/realtime/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 }, + 'src/nest/reservation-import/**/*.ts': { statements: 58, branches: 52, functions: 39, lines: 58 }, 'src/nest/reservations/**/*.ts': { statements: 92, branches: 83, functions: 93, lines: 95 }, 'src/nest/settings/**/*.ts': { statements: 85, branches: 68, functions: 99, lines: 87 }, 'src/nest/share/**/*.ts': { statements: 97, branches: 87, functions: 99, lines: 99 },