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 },