mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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<AirtrailImportResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
+53
-4
@@ -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<AirtrailImportResult> {
|
||||
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. */
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+4
-3
@@ -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<BookingImportService> = {}) {
|
||||
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<unknown>): Promise<number> {
|
||||
@@ -26,7 +27,7 @@ async function status(fn: () => Promise<unknown>): Promise<number> {
|
||||
|
||||
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);
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user