diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 73b008848..86d1d803c 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -18,6 +18,6 @@ import { getPluginMcpToolSource } from '../plugin-mcp-tools'; * exist and an app built without the plugins runtime resolves it to undefined. * Tests pass it explicitly instead of reaching for the global. */ -export function registerTools(registry: McpRegistry | null, server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null, onInvoke?: McpAttachOptions['onInvoke'], dynamicTools: McpAttachOptions['dynamicTools'] = getPluginMcpToolSource() ?? undefined): void { - if (registry) registry.attach(server, { userId, scopes, isStaticToken, getDeprecationNotice }, { onInvoke, dynamicTools }); +export async function registerTools(registry: McpRegistry | null, server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null, onInvoke?: McpAttachOptions['onInvoke'], dynamicTools: McpAttachOptions['dynamicTools'] = getPluginMcpToolSource() ?? undefined): Promise { + if (registry) await registry.attach(server, { userId, scopes, isStaticToken, getDeprecationNotice }, { onInvoke, dynamicTools }); } diff --git a/server/src/nest-mcp/registry.ts b/server/src/nest-mcp/registry.ts index ab020875e..61fff8d0e 100644 --- a/server/src/nest-mcp/registry.ts +++ b/server/src/nest-mcp/registry.ts @@ -115,10 +115,10 @@ export class McpRegistry { * `ctx` as the handler's last argument (in the SDK `extra` slot). `opts` * carries per-session hooks — see `McpAttachOptions`. */ - attach(server: McpServer, ctx: McpContext, opts?: McpAttachOptions): void { + async attach(server: McpServer, ctx: McpContext, opts?: McpAttachOptions): Promise { const registrar = server as unknown as LooseRegistrar; for (const { entry, instance } of this.bound) { - if (!this.allowed(entry, ctx, instance)) continue; + if (!(await this.allowed(entry, ctx, instance))) continue; const handler = (instance as unknown as Record)[entry.methodName]; switch (entry.kind) { case 'tool': @@ -142,7 +142,7 @@ export class McpRegistry { // tools/list is insertion-ordered, so host-contributed tools sort last, // which is the right priority signal in a long list; and it reads the way // it works — the registry, then whatever this session added on top. - if (opts?.dynamicTools) this.attachDynamicTools(registrar, ctx, opts, opts.dynamicTools); + if (opts?.dynamicTools) await this.attachDynamicTools(registrar, ctx, opts, opts.dynamicTools); } /** @@ -161,12 +161,12 @@ export class McpRegistry { return this.reserved; } - private attachDynamicTools( + private async attachDynamicTools( registrar: LooseRegistrar, ctx: McpContext, opts: McpAttachOptions, source: McpDynamicToolSource, - ): void { + ): Promise { let tools: readonly McpDynamicTool[]; try { tools = source(ctx) ?? []; @@ -197,7 +197,7 @@ export class McpRegistry { // entries only avoid it because validate() pre-checks them at boot, and // a per-session source has no boot to be checked at. const entry: McpEntry = { kind: 'tool', methodName: DYNAMIC_METHOD_NAME, options: tool.options }; - if (!this.allowed(entry, ctx, owner)) continue; + if (!(await this.allowed(entry, ctx, owner))) continue; this.attachTool(registrar, tool.options, owner, tool.handler as AnyHandler, ctx, opts); } catch (err) { console.warn(`[nest-mcp] skipped dynamic tool "${String(name)}": ${describeError(err)}`); @@ -263,12 +263,12 @@ export class McpRegistry { }; } - private allowed(entry: McpEntry, ctx: McpContext, instance: object): boolean { + private async allowed(entry: McpEntry, ctx: McpContext, instance: object): Promise { // The declaring instance goes in so the gate can read an injected // collaborator. It is resolved here, at attach, rather than captured when // the options object was built — the class body runs long before the // container exists. - if (entry.options.when && !entry.options.when(ctx, instance)) return false; + if (entry.options.when && !(await entry.options.when(ctx, instance))) return false; const access = entry.options.access; if (access === undefined) return true; if (typeof access === 'function') return access(ctx); diff --git a/server/src/nest-mcp/types.ts b/server/src/nest-mcp/types.ts index 82b895fa1..c4ef9f7eb 100644 --- a/server/src/nest-mcp/types.ts +++ b/server/src/nest-mcp/types.ts @@ -103,7 +103,7 @@ interface McpEntryOptionsBase { * so a predicate written against its own controller class stays assignable * here. The registry always passes the instance that declared the entry. */ - when?(ctx: McpContext, self: object): boolean; + when?(ctx: McpContext, self: object): boolean | Promise; /** Omitted ⇒ the entry is always registered (subject to `when`). */ access?: McpAccess; } diff --git a/server/src/nest/accommodations/accommodations.controller.ts b/server/src/nest/accommodations/accommodations.controller.ts index 91cdc90c5..60f68c888 100644 --- a/server/src/nest/accommodations/accommodations.controller.ts +++ b/server/src/nest/accommodations/accommodations.controller.ts @@ -74,7 +74,7 @@ export class AccommodationsController { @RequirePermission('day_edit') @Post() - create( + async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() rawBody: AccommodationCreateDto, @@ -89,10 +89,10 @@ export class AccommodationsController { if (errors.length > 0) { throw new HttpException({ error: errors[0].message }, 404); } - const { accommodation, mirror } = this.accommodations.create(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never); + const { accommodation, mirror } = await this.accommodations.create(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never); this.accommodations.broadcast(tripId, 'accommodation:created', { accommodation }, socketId); this.accommodations.broadcast(tripId, 'reservation:created', {}, socketId); - this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId); + await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId); // The stop rides in the answer as well, for the session that booked the night // with its socket down: over the socket it would already have it. return { accommodation, assignment: mirror.created }; @@ -100,7 +100,7 @@ export class AccommodationsController { @RequirePermission('day_edit') @Put(':id') - update( + async update( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -117,15 +117,15 @@ export class AccommodationsController { if (errors.length > 0) { throw new HttpException({ error: errors[0].message }, 404); } - const { accommodation, mirror } = this.accommodations.update(id, existing as never, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never); + const { accommodation, mirror } = await this.accommodations.update(id, existing as never, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never); this.accommodations.broadcast(tripId, 'accommodation:updated', { accommodation }, socketId); - this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId); + await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId); return { accommodation, assignment: mirror.created, movedAssignment: mirror.moved, removedAssignments: mirror.removed }; } @RequirePermission('day_edit') @Delete(':id') - remove( + async remove( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -138,8 +138,8 @@ export class AccommodationsController { // Turning a night back into a pause in road trip mode: the booking goes, the // stop stays and becomes the traveller's. Everywhere else a cancelled booking // takes the stop it brought with it. - const { linkedReservationIds, deletedBudgetItemIds, mirror } = this.accommodations.remove(id, { keepStop: keepStop === 'true' }); - this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId); + const { linkedReservationIds, deletedBudgetItemIds, mirror } = await this.accommodations.remove(id, { keepStop: keepStop === 'true' }); + await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId); for (const reservationId of linkedReservationIds) { this.accommodations.broadcast(tripId, 'reservation:deleted', { reservationId }, socketId); } diff --git a/server/src/nest/accommodations/accommodations.mcp.ts b/server/src/nest/accommodations/accommodations.mcp.ts index 2dd6e0e54..e0de5bca7 100644 --- a/server/src/nest/accommodations/accommodations.mcp.ts +++ b/server/src/nest/accommodations/accommodations.mcp.ts @@ -9,6 +9,7 @@ import { AuthService } from '../auth/auth.service'; import { PlacesService } from '../places/places.service'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { AccommodationsService, type MirrorSender } from './accommodations.service'; function parseId(value: string | string[]): number | null { @@ -34,6 +35,7 @@ export class AccommodationsMcp { private readonly places: PlacesService, private readonly auth: AuthService, private readonly guards: McpToolGuardsService, + private readonly uow: UnitOfWork, ) {} /** The tools' own broadcast, handed to announceMirror so the day stop a booking @@ -68,12 +70,12 @@ export class AccommodationsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); const errors = this.accommodations.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id); if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true }; - const { accommodation, mirror } = this.accommodations.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }); + const { accommodation, mirror } = await this.accommodations.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }); this.guards.safeBroadcast(tripId, 'accommodation:created', { accommodation }); - this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId)); + await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId)); return ok({ accommodation, assignment: mirror.created }); } @@ -119,22 +121,22 @@ export class AccommodationsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); const dayErrors = this.accommodations.validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id); if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true }; try { - const result = this.db.transaction(() => { + const result = await this.uow.transactional(async () => { const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency }); - const { accommodation, mirror } = this.accommodations.createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes }); + const { accommodation, mirror } = await this.accommodations.createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes }); return { place, accommodation, mirror }; }); // The stamped copy, not the one create() returned: booking the night types the // place as lodging, and place:created is the only announcement it gets here. - const place = result.mirror.stamped ?? result.place; + const place = (await result.mirror).stamped ?? result.place; this.guards.safeBroadcast(tripId, 'place:created', { place }); this.guards.safeBroadcast(tripId, 'accommodation:created', { accommodation: result.accommodation }); - this.accommodations.announceMirror(tripId, { ...result.mirror, stamped: null }, this.mirrorSender(tripId)); - return ok({ place, accommodation: result.accommodation, assignment: result.mirror.created }); + await this.accommodations.announceMirror(tripId, { ...result.mirror, stamped: null }, this.mirrorSender(tripId)); + return ok({ place, accommodation: result.accommodation, assignment: (await result.mirror).created }); } catch { return { content: [{ type: 'text' as const, text: 'Failed to create place and accommodation.' }], isError: true }; } @@ -167,12 +169,12 @@ export class AccommodationsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); const existing = this.accommodations.getAccommodation(accommodationId, tripId); if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true }; - const { accommodation, mirror } = this.accommodations.updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }); + const { accommodation, mirror } = await this.accommodations.updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }); this.guards.safeBroadcast(tripId, 'accommodation:updated', { accommodation }); - this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId)); + await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId)); // movedAssignment rather than a delete/create pair: an edit carries the booking's // own stop across instead of rebuilding it, so the caller sees the same row. return ok({ accommodation, assignment: mirror.created, movedAssignment: mirror.moved, removedAssignments: mirror.removed }); @@ -191,12 +193,12 @@ export class AccommodationsMcp { async deleteAccommodation({ tripId, accommodationId }: { tripId: number; accommodationId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.accommodations.getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true }; // linkedReservationId stays the first one so the tool's answer keeps its shape; // linkedReservationIds carries the rest for a block that had more than one booking. - const { linkedReservationId, linkedReservationIds, mirror } = this.accommodations.deleteAccommodation(accommodationId); - this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId)); + const { linkedReservationId, linkedReservationIds, mirror } = await this.accommodations.deleteAccommodation(accommodationId); + await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId)); this.guards.safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId, linkedReservationIds }); return ok({ success: true, linkedReservationId, linkedReservationIds, removedAssignments: mirror.removed }); } diff --git a/server/src/nest/accommodations/accommodations.rpc.ts b/server/src/nest/accommodations/accommodations.rpc.ts index e5375038e..1e3a2df95 100644 --- a/server/src/nest/accommodations/accommodations.rpc.ts +++ b/server/src/nest/accommodations/accommodations.rpc.ts @@ -42,12 +42,12 @@ export class AccommodationsRpc { ) {} @PluginMethod('accommodations.create', { permission: 'db:write:accommodations' }) - create(params: Record, ctx: PluginRpcContext): unknown { + async create(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.guards.requireActor(ctx, 'accommodation'); const parsed = accommodationCreateRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid accommodation: ${schemaMessage(parsed.error)}`); - this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION); const input = parsed.data as AccommodationInput; const placeId = Math.trunc(Number(input.place_id)); const startDayId = Math.trunc(Number(input.start_day_id)); @@ -56,7 +56,7 @@ export class AccommodationsRpc { // Verifies the place and both days belong to this trip. const errors = this.days.validateAccommodationRefs(tripId, placeId, startDayId, endDayId); if (errors.length > 0) throw new ForbiddenResource(errors[0].message); - const { accommodation, mirror } = this.days.createAccommodation(tripId, { + const { accommodation, mirror } = await this.days.createAccommodation(tripId, { place_id: placeId, start_day_id: startDayId, end_day_id: endDayId, @@ -69,41 +69,41 @@ export class AccommodationsRpc { this.realtime.broadcast(tripId, 'accommodation:created', { accommodation }); // The block creates a partner hotel reservation, so the bookings view refreshes too. this.realtime.broadcast(tripId, 'reservation:created', {}); - this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload)); + await this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload)); return accommodation; } @PluginMethod('accommodations.update', { permission: 'db:write:accommodations' }) - update(params: Record, ctx: PluginRpcContext): unknown { + async update(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const accommodationId = num(params.accommodationId, 'accommodationId'); const actor = this.guards.requireActor(ctx, 'accommodation'); const parsed = accommodationUpdateRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid accommodation: ${schemaMessage(parsed.error)}`); - this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION); const existing = this.days.getAccommodation(accommodationId, tripId); if (!existing) throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`); const input = parsed.data as { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string }; const errors = this.days.validateAccommodationRefs(tripId, input.place_id, input.start_day_id, input.end_day_id); if (errors.length > 0) throw new ForbiddenResource(errors[0].message); - const { accommodation, mirror } = this.days.updateAccommodation(accommodationId, existing, input); + const { accommodation, mirror } = await this.days.updateAccommodation(accommodationId, existing, input); this.realtime.broadcast(tripId, 'accommodation:updated', { accommodation }); - this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload)); + await this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload)); return accommodation; } @PluginMethod('accommodations.delete', { permission: 'db:write:accommodations' }) - delete(params: Record, ctx: PluginRpcContext): unknown { + async delete(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const accommodationId = num(params.accommodationId, 'accommodationId'); const actor = this.guards.requireActor(ctx, 'accommodation'); - this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION); if (!this.days.getAccommodation(accommodationId, tripId)) { throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`); } // Deleting a block can take its partner reservation and budget item with it. - const { linkedReservationIds, deletedBudgetItemIds, mirror } = this.days.deleteAccommodation(accommodationId); - this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload)); + const { linkedReservationIds, deletedBudgetItemIds, mirror } = await this.days.deleteAccommodation(accommodationId); + await this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload)); for (const reservationId of linkedReservationIds) this.realtime.broadcast(tripId, 'reservation:deleted', { reservationId }); for (const itemId of deletedBudgetItemIds) this.realtime.broadcast(tripId, 'budget:deleted', { itemId }); this.realtime.broadcast(tripId, 'accommodation:deleted', { accommodationId }); diff --git a/server/src/nest/accommodations/accommodations.service.ts b/server/src/nest/accommodations/accommodations.service.ts index a7403fe7d..04de23c29 100644 --- a/server/src/nest/accommodations/accommodations.service.ts +++ b/server/src/nest/accommodations/accommodations.service.ts @@ -2,13 +2,14 @@ import { Injectable } from '@nestjs/common'; import type { RoadtripVia, TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService, type PlaceWithTags, type TripAccess } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { PermissionsService } from '../permissions/permissions.service'; import { AssignmentsService } from '../assignments/assignments.service'; import type { User } from '../../types'; type Trip = TripAccess; -type MirroredAssignment = ReturnType; +type MirroredAssignment = Awaited>; /** One day's drawn roads after a write re-pinned them, as the road trip broadcasts them. */ type DayVias = { dayId: number; vias: RoadtripVia[] }; @@ -95,6 +96,7 @@ export class AccommodationsService { private readonly permissions: PermissionsService, private readonly realtime: RealtimeService, private readonly assignments: AssignmentsService, + private readonly uow: UnitOfWork, ) {} private get db() { @@ -107,7 +109,7 @@ export class AccommodationsService { return this.dbs.canAccessTrip(Number(tripId), userId); } - canEdit(trip: Trip, user: User): boolean { + async canEdit(trip: Trip, user: User): Promise { return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id); } @@ -160,24 +162,24 @@ export class AccommodationsService { // ------------------------------------------------------------------------- /** Put a freshly written stay on the map. */ - attachStayStop(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror { + async attachStayStop(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): Promise { return this.mirrorStay(accommodationId, placeId, dayId, checkIn); } /** Carry a stay's own stop over to where the stay now is. `checkInChanged` says the * booking was given a new hour, which seats the night afresh (see remirrorStay). */ - moveStayStop( + async moveStayStop( accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null, opts: { checkInChanged?: boolean } = {}, - ): AccommodationMirror { + ): Promise { return this.remirrorStay(accommodationId, placeId, dayId, checkIn, opts); } /** Take back the stops of a stay that is being deleted elsewhere. */ - dropStayStops(accommodationId: number): AccommodationMirror { + async dropStayStops(accommodationId: number): Promise { return this.releaseStops(accommodationId, {}); } @@ -191,7 +193,7 @@ export class AccommodationsService { * sent the request. The day order and the vias sent here are news to that * session too, and they only make sense arriving behind the stop they concern. */ - announceMirror(tripId: string | number, mirror: AccommodationMirror, send: MirrorSender, socketId?: string): void { + async announceMirror(tripId: string | number, mirror: AccommodationMirror, send: MirrorSender, socketId?: string): Promise { for (const stop of mirror.removed) send('assignment:deleted', { assignmentId: stop.id, dayId: stop.dayId }); if (mirror.created) send('assignment:created', { assignment: mirror.created }); if (mirror.moved) { @@ -207,7 +209,7 @@ export class AccommodationsService { // A night is seated by its check-in, which renumbers the stops around it. The // created/moved event alone puts the row at the end of the day on every other // screen, so the day that changed sends its order along. - for (const dayId of this.touchedDays(mirror)) { + for (const dayId of await this.touchedDays(mirror)) { const orderedIds = this.db.all<{ id: number }>( 'SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index', dayId).map(row => row.id); send('assignment:reordered', { dayId, orderedIds }); @@ -220,7 +222,7 @@ export class AccommodationsService { } /** Days whose stop order this write can have changed, each named once. */ - private touchedDays(mirror: AccommodationMirror): number[] { + private async touchedDays(mirror: AccommodationMirror): Promise { const days = new Set(); if (mirror.created) days.add(mirror.created.day_id); if (mirror.moved) { days.add(mirror.moved.assignment.day_id); days.add(mirror.moved.oldDayId); } @@ -479,12 +481,12 @@ export class AccommodationsService { * * Runs inside the caller's transaction. */ - private relocateOwnStop( + private async relocateOwnStop( stop: { id: number; day_id: number; order_index: number }, placeId: number, dayId: number, checkIn?: string | null, - ): MirroredAssignment | null { + ): Promise { if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ? AND id != ?', dayId, placeId, stop.id)) { return null; } @@ -516,7 +518,7 @@ export class AccommodationsService { return this.assignments.getAssignmentWithPlace(stop.id); } - private mirrorStay(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror { + private async mirrorStay(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): Promise { const mirror = noMirror(); // A stay can outlive its place (place_id is ON DELETE SET NULL) and the booking // form writes stays that never had one. Nothing to put on the map then. @@ -539,7 +541,7 @@ export class AccommodationsService { // would leave that copy without it. The day list has nothing else to tell the // stop from a place the traveller added, so it would show the hotel a second // time until the next reload. - mirror.created = this.assignments.createAssignment(dayId, placeId, null, { + mirror.created = await this.assignments.createAssignment(dayId, placeId, null, { accommodationId, orderIndex: this.positionForCheckIn(dayId, checkIn), }); @@ -568,7 +570,7 @@ export class AccommodationsService { * * Runs inside the caller's transaction. */ - private releaseStops(accommodationId: number, opts: { keepStop?: boolean }): AccommodationMirror { + private async releaseStops(accommodationId: number, opts: { keepStop?: boolean }): Promise { const mirror = noMirror(); const own = this.ownStops(accommodationId); const before = this.stopOrders(own.map(stop => stop.day_id)); @@ -578,7 +580,7 @@ export class AccommodationsService { // The stop stays, but it is the traveller's now. Days hides a stop whose // accommodation_id is set, so a client left holding the old row keeps the // place invisible on a day it is standing on. - const released = this.assignments.getAssignmentWithPlace(stop.id); + const released = await this.assignments.getAssignmentWithPlace(stop.id); if (released) mirror.updated.push(released); continue; } @@ -601,13 +603,13 @@ export class AccommodationsService { * * Runs inside the caller's transaction. */ - private remirrorStay( + private async remirrorStay( accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null, opts: { checkInChanged?: boolean } = {}, - ): AccommodationMirror { + ): Promise { const own = this.ownStops(accommodationId); if (own.length === 0) return noMirror(); if (own.length === 1 && own[0].day_id === dayId && own[0].place_id === placeId) { @@ -629,7 +631,7 @@ export class AccommodationsService { // on its id, all of which a DELETE takes with it. if (own.length === 1 && placeId) { const before = this.stopOrders([own[0].day_id, dayId]); - const moved = this.relocateOwnStop(own[0], placeId, dayId, checkIn); + const moved = await this.relocateOwnStop(own[0], placeId, dayId, checkIn); if (moved) { mirror.moved = { assignment: moved, oldDayId: own[0].day_id }; mirror.stamped = this.stampLodging(placeId); @@ -644,19 +646,19 @@ export class AccommodationsService { mirror.removed.push({ id: stop.id, dayId: stop.day_id }); } this.reanchorVias(mirror, beforeRebuild); - const fresh = this.mirrorStay(accommodationId, placeId, dayId, checkIn); + const fresh = await this.mirrorStay(accommodationId, placeId, dayId, checkIn); mirror.created = fresh.created; mirror.stamped = fresh.stamped; for (const day of fresh.vias ?? []) this.noteVias(mirror, day); return mirror; } - createAccommodation(tripId: string | number, data: CreateAccommodationData) { + async createAccommodation(tripId: string | number, data: CreateAccommodationData) { const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = data; // The stay, its partner hotel reservation and the day stop it implies are one // logical write, and an atomic one, so a failed insert halfway can't leave an orphan. - const written = this.db.transaction(() => { + const written = await this.uow.transactional(async () => { const result = this.db.run( 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', tripId, place_id, start_day_id, end_day_id, check_in || null, check_in_end || null, check_out || null, confirmation || null, notes || null @@ -680,7 +682,7 @@ export class AccommodationsService { Object.keys(meta).length > 0 ? JSON.stringify(meta) : null ); - return { accommodationId: newId, mirror: this.mirrorStay(Number(newId), place_id ?? null, start_day_id, check_in) }; + return { accommodationId: newId, mirror: await this.mirrorStay(Number(newId), place_id ?? null, start_day_id, check_in) }; }); return { accommodation: this.getAccommodationWithPlace(written.accommodationId), mirror: written.mirror }; @@ -690,7 +692,7 @@ export class AccommodationsService { return this.db.get('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?', id, tripId); } - updateAccommodation(id: string | number, existing: DayAccommodation, fields: { + async updateAccommodation(id: string | number, existing: DayAccommodation, fields: { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string; }) { @@ -705,12 +707,12 @@ export class AccommodationsService { // The stay row and the day stop that mirrors it describe the same booking, so a // move that wrote only one of the two must not survive. - const mirror = this.db.transaction(() => { + const mirror = await this.uow.transactional(async () => { this.db.run( 'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?', newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id ); - return this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn, { + return await this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn, { checkInChanged: fields.check_in !== undefined && (fields.check_in || null) !== (existing.check_in || null), }); }); @@ -742,14 +744,14 @@ export class AccommodationsService { * that no longer exists. `linkedReservationId` / `deletedBudgetItemId` stay on the * result as the first of each, because the RPC, MCP and REST callers read them. */ - deleteAccommodation(id: string | number, opts: { keepStop?: boolean } = {}): { + async deleteAccommodation(id: string | number, opts: { keepStop?: boolean } = {}): Promise<{ linkedReservationId: number | null; deletedBudgetItemId: number | null; linkedReservationIds: number[]; deletedBudgetItemIds: number[]; mirror: AccommodationMirror; - } { - return this.db.transaction(() => { + }> { + return this.uow.transactional(async () => { const linkedRes = this.db.all<{ id: number }>('SELECT id FROM reservations WHERE accommodation_id = ?', Number(id)); const deletedBudgetItemIds: number[] = []; for (const res of linkedRes) { @@ -761,7 +763,7 @@ export class AccommodationsService { this.db.run('DELETE FROM reservations WHERE id = ?', res.id); } - const mirror = this.releaseStops(Number(id), opts); + const mirror = await this.releaseStops(Number(id), opts); this.db.run('DELETE FROM day_accommodations WHERE id = ?', id); const linkedReservationIds = linkedRes.map(r => r.id); diff --git a/server/src/nest/addons/addon-gate.ts b/server/src/nest/addons/addon-gate.ts index 6e1aebf35..4407144b7 100644 --- a/server/src/nest/addons/addon-gate.ts +++ b/server/src/nest/addons/addon-gate.ts @@ -27,7 +27,7 @@ export interface HasAddons { /** Gate an MCP entry on an addon being enabled. */ export function addonGate(addonId: string) { - return (_ctx: McpContext, self: HasAddons): boolean => self.addons.isAddonEnabled(addonId); + return (_ctx: McpContext, self: HasAddons): Promise => self.addons.isAddonEnabled(addonId); } /** @@ -35,6 +35,6 @@ export function addonGate(addonId: string) { * addon toggle and its own flag — notes, polls or chat. */ export function collabFeatureGate(feature: 'chat' | 'notes' | 'polls') { - return (_ctx: McpContext, self: HasAddons): boolean => - self.addons.isAddonEnabled(ADDON_IDS.COLLAB) && self.addons.getCollabFeatures()[feature]; + return async (_ctx: McpContext, self: HasAddons): Promise => + (await self.addons.isAddonEnabled(ADDON_IDS.COLLAB)) && (await self.addons.getCollabFeatures())[feature]; } diff --git a/server/src/nest/addons/addon.guard.ts b/server/src/nest/addons/addon.guard.ts index 2dadbb787..a05e1477b 100644 --- a/server/src/nest/addons/addon.guard.ts +++ b/server/src/nest/addons/addon.guard.ts @@ -19,13 +19,13 @@ export class AddonGuard implements CanActivate { private readonly reflector: Reflector, ) {} - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const meta = this.reflector.getAllAndOverride(REQUIRE_ADDON, [ context.getHandler(), context.getClass(), ]); if (!meta) return true; - if (!this.addons.isAddonEnabled(meta.addonId)) { + if (!(await this.addons.isAddonEnabled(meta.addonId))) { throw new HttpException({ error: `${meta.label} addon is not enabled` }, 404); } return true; diff --git a/server/src/nest/addons/addons.controller.ts b/server/src/nest/addons/addons.controller.ts index b9cf54ffd..62830c6bd 100644 --- a/server/src/nest/addons/addons.controller.ts +++ b/server/src/nest/addons/addons.controller.ts @@ -16,7 +16,7 @@ export class AddonsController { constructor(private readonly addons: AddonsService) {} @Get() - list() { + async list() { return this.addons.list(); } } diff --git a/server/src/nest/addons/addons.mcp.ts b/server/src/nest/addons/addons.mcp.ts index 6e0d9e478..98214b24c 100644 --- a/server/src/nest/addons/addons.mcp.ts +++ b/server/src/nest/addons/addons.mcp.ts @@ -30,8 +30,8 @@ export class AddonsMcp { inputSchema: {}, annotations: TOOL_ANNOTATIONS_READONLY, }) - listAddons(_args: Record, _ctx: McpContext) { - const { addons, collabFeatures, bagTracking } = this.addons.list(); + async listAddons(_args: Record, _ctx: McpContext) { + const { addons, collabFeatures, bagTracking } = await this.addons.list(); // icon, config and fields are admin-panel chrome: an icon name and the // photo-provider settings form say nothing about what a tool can do. return ok({ diff --git a/server/src/nest/addons/addons.service.ts b/server/src/nest/addons/addons.service.ts index a1bcdf55f..3a818a5a9 100644 --- a/server/src/nest/addons/addons.service.ts +++ b/server/src/nest/addons/addons.service.ts @@ -29,28 +29,28 @@ export class AddonsService { return this.dbs.connection; } - isAddonEnabled(addonId: string): boolean { + async isAddonEnabled(addonId: string): Promise { const addon = this.db.prepare('SELECT enabled FROM addons WHERE id = ?').get(addonId) as | { enabled: number } | undefined; return !!addon?.enabled; } - getBagTracking() { + async getBagTracking() { const row = this.db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as | { value: string } | undefined; return { enabled: row?.value === 'true' }; } - updateBagTracking(enabled: boolean) { + async updateBagTracking(enabled: boolean) { this.db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run( enabled ? 'true' : 'false', ); return { enabled: !!enabled }; } - getCollabFeatures() { + async getCollabFeatures() { const rows = this.db .prepare( "SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_links_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')", @@ -67,7 +67,7 @@ export class AddonsService { }; } - updateCollabFeatures(features: { chat?: boolean; notes?: boolean; links?: boolean; polls?: boolean; whatsnext?: boolean }) { + async updateCollabFeatures(features: { chat?: boolean; notes?: boolean; links?: boolean; polls?: boolean; whatsnext?: boolean }) { const mapping: Record = { chat: 'collab_chat_enabled', notes: 'collab_notes_enabled', @@ -75,12 +75,12 @@ export class AddonsService { polls: 'collab_polls_enabled', whatsnext: 'collab_whatsnext_enabled', }; - const before = this.getCollabFeatures(); + const before = await this.getCollabFeatures(); const stmt = this.db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)'); for (const [feat, key] of Object.entries(mapping)) { if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false'); } - const after = this.getCollabFeatures(); + const after = await this.getCollabFeatures(); // Collab flags gate MCP tool/resource registration, so callers must know // whether anything actually flipped — a no-op save must not tear down every // live MCP session (#1414). @@ -88,7 +88,7 @@ export class AddonsService { return { features: after, changed }; } - list() { + async list() { const addons = this.db .prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order') .all() as Pick[]; @@ -97,7 +97,7 @@ export class AddonsService { // here (instead of a migration) also covers installs that still hold an // enabled provider under a disabled journey from before updateAddon // cascaded the disable. - const providers = !this.isAddonEnabled(ADDON_IDS.JOURNEY) + const providers = !(await this.isAddonEnabled(ADDON_IDS.JOURNEY)) ? [] : (this.db .prepare( @@ -135,8 +135,8 @@ export class AddonsService { } return { - collabFeatures: this.getCollabFeatures(), - bagTracking: this.getBagTracking().enabled, + collabFeatures: await this.getCollabFeatures(), + bagTracking: (await this.getBagTracking()).enabled, addons: [ ...addons.map((a) => ({ ...a, enabled: !!a.enabled })), ...providers.map((p) => ({ @@ -171,22 +171,22 @@ export class AddonsService { // backfills 'true' for installs that never touched the switches, so nobody // loses a feature on upgrade. - private readFlag(key: string) { + private async readFlag(key: string) { const row = this.db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined; return { enabled: row?.value === 'true' }; } - private writeFlag(key: string, enabled: boolean) { + private async writeFlag(key: string, enabled: boolean) { this.db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, enabled ? 'true' : 'false'); return { enabled: !!enabled }; } - getPlacesPhotos() { return this.readFlag('places_photos_enabled'); } - updatePlacesPhotos(enabled: boolean) { return this.writeFlag('places_photos_enabled', enabled); } - getPlacesAutocomplete() { return this.readFlag('places_autocomplete_enabled'); } - updatePlacesAutocomplete(enabled: boolean) { return this.writeFlag('places_autocomplete_enabled', enabled); } - getPlacesDetails() { return this.readFlag('places_details_enabled'); } - updatePlacesDetails(enabled: boolean) { return this.writeFlag('places_details_enabled', enabled); } + async getPlacesPhotos() { return this.readFlag('places_photos_enabled'); } + async updatePlacesPhotos(enabled: boolean) { return this.writeFlag('places_photos_enabled', enabled); } + async getPlacesAutocomplete() { return this.readFlag('places_autocomplete_enabled'); } + async updatePlacesAutocomplete(enabled: boolean) { return this.writeFlag('places_autocomplete_enabled', enabled); } + async getPlacesDetails() { return this.readFlag('places_details_enabled'); } + async updatePlacesDetails(enabled: boolean) { return this.writeFlag('places_details_enabled', enabled); } /** * The shadow log, fail-CLOSED like the three above but for the opposite @@ -195,8 +195,8 @@ export class AddonsService { * so there is nothing to backfill and an absent row correctly means off. * PlaceShadowService.enabled() reads the same key the same way. */ - getPlaceShadow() { return this.readFlag('place_shadow_enabled'); } - updatePlaceShadow(enabled: boolean) { return this.writeFlag('place_shadow_enabled', enabled); } + async getPlaceShadow() { return this.readFlag('place_shadow_enabled'); } + async updatePlaceShadow(enabled: boolean) { return this.writeFlag('place_shadow_enabled', enabled); } /** * Enrichment reads fail-OPEN, unlike the three switches above. @@ -211,14 +211,14 @@ export class AddonsService { * the same key the same way. If these two ever disagree the admin panel shows * "off" while the feature runs, which is worse than either default. */ - getPlacesEnrich() { + async getPlacesEnrich() { const row = this.db.prepare("SELECT value FROM app_settings WHERE key = 'places_enrich_enabled'").get() as | { value: string } | undefined; return { enabled: row?.value !== 'false' }; } - updatePlacesEnrich(enabled: boolean) { return this.writeFlag('places_enrich_enabled', enabled); } + async updatePlacesEnrich(enabled: boolean) { return this.writeFlag('places_enrich_enabled', enabled); } // ── Transit backend (#1699) ──────────────────────────────────────────────── // Not a flag: two named backends, so it stores the name rather than a @@ -243,11 +243,11 @@ export class AddonsService { return resolveApiKey(this.dbs, 'maps_api_key', userId, readEnv().maps.placesApiKey).source; } - getTransitProvider(userId = 0) { - return { provider: readTransitProvider(this.dbs), googleKeySource: this.googleKeySource(userId) }; + async getTransitProvider(userId = 0) { + return { provider: await readTransitProvider(this.dbs), googleKeySource: this.googleKeySource(userId) }; } - updateTransitProvider(provider: TransitProvider, userId = 0) { - return { provider: writeTransitProvider(this.dbs, provider), googleKeySource: this.googleKeySource(userId) }; + async updateTransitProvider(provider: TransitProvider, userId = 0) { + return { provider: await writeTransitProvider(this.dbs, provider), googleKeySource: this.googleKeySource(userId) }; } } diff --git a/server/src/nest/addons/mcp-addon-gate.ts b/server/src/nest/addons/mcp-addon-gate.ts index 4cb30d37b..ac479a984 100644 --- a/server/src/nest/addons/mcp-addon-gate.ts +++ b/server/src/nest/addons/mcp-addon-gate.ts @@ -9,11 +9,16 @@ import { ADDON_IDS } from '../../addons'; * hand the bare RequestHandler to consumer.apply() ahead of the SDK routers. */ export function createMcpAddonGate(addons: AddonsService): RequestHandler { + // Express cannot await a middleware, so the now-async gate runs in a helper and + // its rejection is handed to next() — the same error path a synchronous throw + // took before (recipe R1.5). return (_req, res, next) => { - if (!addons.isAddonEnabled(ADDON_IDS.MCP)) { - res.status(404).end(); - return; - } - next(); + void (async () => { + if (!(await addons.isAddonEnabled(ADDON_IDS.MCP))) { + res.status(404).end(); + return; + } + next(); + })().catch(next); }; } diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index 0706992c3..f86557001 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -79,39 +79,39 @@ export class AdminController { @Post('users') @HttpCode(201) - createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) { + async createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) { const result = ok(this.admin.createUser(body as Parameters[0])); - this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails }); + await this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails }); return { user: result.user }; } @Put('users/:id') - updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request) { + async updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request) { const result = ok(this.admin.updateUser(id, body)); - this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } }); + await this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } }); logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`); return { user: result.user }; } @Delete('users/:id') - deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { + async deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { const result = ok(this.admin.deleteUser(id, user.id)); - this.audit.writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } }); + await this.audit.writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } }); logInfo(`Admin ${user.email} deleted user ${result.email}`); return { success: true }; } @Delete('users/:id/passkeys') - resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { + async resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { const result = ok(this.admin.resetUserPasskeys(id)); - this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } }); + await this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } }); return { success: true, deleted: result.deleted }; } @Delete('users/:id/mfa') - resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { + async resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { const result = ok(this.admin.resetUserMfa(id, user.id)); - this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } }); + await this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } }); return { success: true }; } @@ -123,9 +123,9 @@ export class AdminController { permissions() { return this.admin.getPermissions(); } @Put('permissions') - savePermissions(@CurrentUser() user: User, @Body() body: AdminPermissionsDto, @Req() req: Request) { - const result = this.admin.savePermissions(body.permissions as unknown as Parameters[0]); - this.audit.writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record }); + async savePermissions(@CurrentUser() user: User, @Body() body: AdminPermissionsDto, @Req() req: Request) { + const result = await this.admin.savePermissions(body.permissions as unknown as Parameters[0]); + await this.audit.writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record }); return { success: true, permissions: result.permissions, ...(result.skipped.length ? { skipped: result.skipped } : {}) }; } @@ -134,12 +134,12 @@ export class AdminController { @Post('save-demo-baseline') @HttpCode(200) - saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) { + async saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) { const result = this.admin.saveDemoBaseline(); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) }); return { success: true, message: result.message }; } @@ -171,16 +171,16 @@ export class AdminController { @Post('invites') @HttpCode(201) - createInvite(@CurrentUser() user: User, @Body() body: AdminInviteCreateDto, @Req() req: Request) { + async createInvite(@CurrentUser() user: User, @Body() body: AdminInviteCreateDto, @Req() req: Request) { const result = this.invites.createInvite(user.id, body); - this.audit.writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId } }); + await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId } }); return { invite: result.invite }; } @Delete('invites/:id') - deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { + async deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { ok(this.invites.deleteInvite(id)); - this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) }); return { success: true }; } @@ -197,9 +197,9 @@ export class AdminController { getBagTracking() { return this.addons.getBagTracking(); } @Put('bag-tracking') - updateBagTracking(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { - const result = this.addons.updateBagTracking(body.enabled); - this.audit.writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled } }); + async updateBagTracking(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { + const result = await this.addons.updateBagTracking(body.enabled); + await this.audit.writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled } }); return result; } @@ -207,9 +207,9 @@ export class AdminController { getPlacesPhotos() { return this.addons.getPlacesPhotos(); } @Put('places-photos') - updatePlacesPhotos(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { - const result = this.addons.updatePlacesPhotos(body.enabled); - this.audit.writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled } }); + async updatePlacesPhotos(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { + const result = await this.addons.updatePlacesPhotos(body.enabled); + await this.audit.writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled } }); return result; } @@ -217,9 +217,9 @@ export class AdminController { getPlacesAutocomplete() { return this.addons.getPlacesAutocomplete(); } @Put('places-autocomplete') - updatePlacesAutocomplete(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { - const result = this.addons.updatePlacesAutocomplete(body.enabled); - this.audit.writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled } }); + async updatePlacesAutocomplete(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { + const result = await this.addons.updatePlacesAutocomplete(body.enabled); + await this.audit.writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled } }); return result; } @@ -227,9 +227,9 @@ export class AdminController { getPlacesDetails() { return this.addons.getPlacesDetails(); } @Put('places-details') - updatePlacesDetails(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { - const result = this.addons.updatePlacesDetails(body.enabled); - this.audit.writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled } }); + async updatePlacesDetails(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { + const result = await this.addons.updatePlacesDetails(body.enabled); + await this.audit.writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled } }); return result; } @@ -237,9 +237,9 @@ export class AdminController { getPlaceShadow() { return this.addons.getPlaceShadow(); } @Put('place-shadow') - updatePlaceShadow(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { - const result = this.addons.updatePlaceShadow(body.enabled); - this.audit.writeAudit({ userId: user.id, action: 'admin.place_shadow', ip: getClientIp(req), details: { enabled: result.enabled } }); + async updatePlaceShadow(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { + const result = await this.addons.updatePlaceShadow(body.enabled); + await this.audit.writeAudit({ userId: user.id, action: 'admin.place_shadow', ip: getClientIp(req), details: { enabled: result.enabled } }); return result; } @@ -247,9 +247,9 @@ export class AdminController { getPlacesEnrich() { return this.addons.getPlacesEnrich(); } @Put('places-enrich') - updatePlacesEnrich(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { - const result = this.addons.updatePlacesEnrich(body.enabled); - this.audit.writeAudit({ userId: user.id, action: 'admin.places_enrich', ip: getClientIp(req), details: { enabled: result.enabled } }); + async updatePlacesEnrich(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { + const result = await this.addons.updatePlacesEnrich(body.enabled); + await this.audit.writeAudit({ userId: user.id, action: 'admin.places_enrich', ip: getClientIp(req), details: { enabled: result.enabled } }); return result; } @@ -257,12 +257,12 @@ export class AdminController { getCollabFeatures() { return this.addons.getCollabFeatures(); } @Put('collab-features') - updateCollabFeatures(@CurrentUser() user: User, @Body() body: AdminCollabFeaturesDto, @Req() req: Request) { - const { features, changed } = this.addons.updateCollabFeatures(body); + async updateCollabFeatures(@CurrentUser() user: User, @Body() body: AdminCollabFeaturesDto, @Req() req: Request) { + const { features, changed } = await this.addons.updateCollabFeatures(body); // Collab flags gate MCP registration, but a no-op save must not tear down // every live MCP session (#1414). if (changed) this.admin.invalidateMcpSessions(); - this.audit.writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: features }); + await this.audit.writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: features }); return features; } @@ -272,9 +272,9 @@ export class AdminController { getTransitProvider(@CurrentUser() user: User) { return this.addons.getTransitProvider(user.id); } @Put('transit-provider') - updateTransitProvider(@CurrentUser() user: User, @Body() body: AdminTransitProviderDto, @Req() req: Request) { - const result = this.addons.updateTransitProvider(body.provider, user.id); - this.audit.writeAudit({ userId: user.id, action: 'admin.transit_provider', ip: getClientIp(req), details: { provider: result.provider } }); + async updateTransitProvider(@CurrentUser() user: User, @Body() body: AdminTransitProviderDto, @Req() req: Request) { + const result = await this.addons.updateTransitProvider(body.provider, user.id); + await this.audit.writeAudit({ userId: user.id, action: 'admin.transit_provider', ip: getClientIp(req), details: { provider: result.provider } }); return result; } @@ -284,8 +284,8 @@ export class AdminController { @Put('addons/:id') async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminAddonUpdateDto, @Req() req: Request) { - const result = ok(this.admin.updateAddon(id, body)); - this.audit.writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails }); + const result = await ok(await this.admin.updateAddon(id, body)); + await this.audit.writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails }); // Sessions only need re-creating when the registered MCP surface can // actually change — an enabled-flip of an MCP-relevant addon. Config-only // saves and photo-provider toggles used to kill every session (#1414). @@ -309,9 +309,9 @@ export class AdminController { listMcpTokens() { return { tokens: this.tokens.listAllMcpTokens() }; } @Delete('mcp-tokens/:id') - deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { + async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { ok(this.tokens.adminDeleteMcpToken(id)); - this.audit.writeAudit({ userId: user.id, action: 'admin.mcp_token_delete', resource: String(id), ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'admin.mcp_token_delete', resource: String(id), ip: getClientIp(req) }); return { success: true }; } @@ -319,9 +319,9 @@ export class AdminController { listOAuthSessions() { return { sessions: this.oauth.listAllOAuthSessions() }; } @Delete('oauth-sessions/:id') - revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { + async revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { ok(this.oauth.adminRevokeOAuthSession(id)); - this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session_revoke', resource: String(id), ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session_revoke', resource: String(id), ip: getClientIp(req) }); return { success: true }; } @@ -329,12 +329,12 @@ export class AdminController { @ManagedForbidden('rotating the secret signs every user out and fixes nothing the admin can reach') @Post('rotate-jwt-secret') @HttpCode(200) - rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) { + async rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) { const result = this.admin.rotateJwtSecret(); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) }); return { success: true }; } diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 5cc8e3af1..065443f99 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -309,8 +309,8 @@ export class AdminService { // ── Permissions ──────────────────────────────────────────────────────────── - getPermissions() { - const current = this.permissions.getAllPermissions(); + async getPermissions() { + const current = await this.permissions.getAllPermissions(); const actions = PERMISSION_ACTIONS.map((a) => ({ key: a.key, level: current[a.key], @@ -320,9 +320,9 @@ export class AdminService { return { permissions: actions }; } - savePermissions(permissions: Record) { - const { skipped } = this.permissions.savePermissions(permissions); - return { permissions: this.permissions.getAllPermissions(), skipped }; + async savePermissions(permissions: Record) { + const { skipped } = await this.permissions.savePermissions(permissions); + return { permissions: await this.permissions.getAllPermissions(), skipped }; } // ── Audit Log ────────────────────────────────────────────────────────────── @@ -664,7 +664,7 @@ export class AdminService { ]; } - updateAddon(id: string, data: { enabled?: boolean; config?: Record }) { + async updateAddon(id: string, data: { enabled?: boolean; config?: Record }) { type ProviderRow = { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }; const addon = this.db.get('SELECT * FROM addons WHERE id = ?', id); const provider = this.db.get('SELECT * FROM photo_providers WHERE id = ?', id); @@ -682,14 +682,14 @@ export class AdminService { // Photo providers are Journey's shelf rows — their whole UI lives inside // journeys, so enabling one under a disabled journey addon would only // advertise an integration nothing can reach. - if (provider && data.enabled === true && !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) { + if (provider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) { return { error: 'Enable the Journey addon first', status: 409 }; } // Same rule one shelf down: a document provider only exists to serve the // file manager, so switching one on under a disabled Documents addon would // advertise a sync nothing can reach. - if (docProvider && data.enabled === true && !this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) { + if (docProvider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) { return { error: 'Enable the Documents addon first', status: 409 }; } diff --git a/server/src/nest/airports/airports.service.ts b/server/src/nest/airports/airports.service.ts index 2d6a7d7b1..e580fc583 100644 --- a/server/src/nest/airports/airports.service.ts +++ b/server/src/nest/airports/airports.service.ts @@ -16,9 +16,9 @@ import { DatabaseService } from '../database/database.service'; export class AirportsService implements OnApplicationBootstrap { constructor(private readonly db: DatabaseService) {} - onApplicationBootstrap(): void { + async onApplicationBootstrap(): Promise { try { - this.backfillFlightEndpoints(); + await this.backfillFlightEndpoints(); } catch (err) { console.error('[DB] Flight endpoint backfill failed:', err); } @@ -32,7 +32,7 @@ export class AirportsService implements OnApplicationBootstrap { return findByIata(code) as Airport | null; } - backfillFlightEndpoints(): void { + async backfillFlightEndpoints(): Promise { const pending = this.db.prepare(` SELECT r.id, r.metadata, r.reservation_time, r.reservation_end_time FROM reservations r diff --git a/server/src/nest/assignments/assignments.controller.ts b/server/src/nest/assignments/assignments.controller.ts index 7d32e03c0..5f317f5e5 100644 --- a/server/src/nest/assignments/assignments.controller.ts +++ b/server/src/nest/assignments/assignments.controller.ts @@ -48,16 +48,16 @@ export class DayAssignmentsController { constructor(private readonly assignments: AssignmentsService) {} @Get() - list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) { + async list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) { if (!this.assignments.dayExists(dayId, tripId)) { throw new HttpException({ error: 'Day not found' }, 404); } - return { assignments: this.assignments.listDayAssignments(dayId) }; + return { assignments: await this.assignments.listDayAssignments(dayId) }; } @RequirePermission('day_edit') @Post() - create( + async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string, @@ -70,7 +70,7 @@ export class DayAssignmentsController { if (!this.assignments.placeExists(body.place_id, tripId)) { throw new HttpException({ error: 'Place not found' }, 404); } - const assignment = this.assignments.createAssignment(dayId, body.place_id, body.notes); + const assignment = await this.assignments.createAssignment(dayId, body.place_id, body.notes); this.assignments.broadcast(tripId, 'assignment:created', { assignment }, socketId); this.assignments.reconcile(tripId, socketId); return { assignment }; @@ -128,7 +128,7 @@ export class AssignmentOpsController { @RequirePermission('day_edit') @Put(':id/move') - move( + async move( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -141,7 +141,7 @@ export class AssignmentOpsController { if (!this.assignments.dayExists(String(body.new_day_id), tripId)) { throw new HttpException({ error: 'Target day not found' }, 404); } - const { assignment, oldDayId } = this.assignments.moveAssignment(id, body.new_day_id, body.order_index); + const { assignment, oldDayId } = await this.assignments.moveAssignment(id, body.new_day_id, body.order_index); this.assignments.broadcast(tripId, 'assignment:moved', { assignment, oldDayId: Number(oldDayId), newDayId: Number(body.new_day_id) }, socketId); this.assignments.reconcile(tripId, socketId); return { assignment }; @@ -157,7 +157,7 @@ export class AssignmentOpsController { @RequirePermission('day_edit') @Put(':id/time') - time( + async time( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -167,7 +167,7 @@ export class AssignmentOpsController { if (!this.assignments.getAssignmentForTrip(id, tripId)) { throw new HttpException({ error: 'Assignment not found' }, 404); } - const { assignment, reordered, vias } = this.assignments.updateTime(id, body.place_time, body.end_time); + const { assignment, reordered, vias } = await this.assignments.updateTime(id, body.place_time, body.end_time); this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId); // The whole day when a start moved stops, or collaborators apply the one row // they were sent to their old order and end up with a third one. @@ -184,7 +184,7 @@ export class AssignmentOpsController { @RequirePermission('day_edit') @Put(':id/end-day') - endDay( + async endDay( @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: AssignmentEndDayDto, @@ -193,7 +193,7 @@ export class AssignmentOpsController { if (!this.assignments.getAssignmentForTrip(id, tripId)) { throw new HttpException({ error: 'Assignment not found' }, 404); } - const assignment = this.assignments.setEndDay(id, body.end_day); + const assignment = await this.assignments.setEndDay(id, body.end_day); this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId); return { assignment }; } @@ -203,7 +203,7 @@ export class AssignmentOpsController { // neighbours; no reconcile — the note doesn't touch the journey skeleton. @RequirePermission('day_edit') @Put(':id/notes') - notes( + async notes( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -213,14 +213,14 @@ export class AssignmentOpsController { if (!this.assignments.getAssignmentForTrip(id, tripId)) { throw new HttpException({ error: 'Assignment not found' }, 404); } - const assignment = this.assignments.updateNotes(id, body.notes); + const assignment = await this.assignments.updateNotes(id, body.notes); this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId); return { assignment }; } @RequirePermission('day_edit') @Put(':id/transport') - transport( + async transport( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -231,8 +231,8 @@ export class AssignmentOpsController { throw new HttpException({ error: 'Assignment not found' }, 404); } const assignment = body.direction === 'incoming' - ? this.assignments.setIncomingLegTransportMode(id, body.transport_mode ?? null) - : this.assignments.setLegTransportMode(id, body.transport_mode ?? null); + ? await this.assignments.setIncomingLegTransportMode(id, body.transport_mode ?? null) + : await this.assignments.setLegTransportMode(id, body.transport_mode ?? null); this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId); return { assignment }; } diff --git a/server/src/nest/assignments/assignments.mcp.ts b/server/src/nest/assignments/assignments.mcp.ts index 1fa212be1..6240db808 100644 --- a/server/src/nest/assignments/assignments.mcp.ts +++ b/server/src/nest/assignments/assignments.mcp.ts @@ -48,10 +48,10 @@ export class AssignmentsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.dayExists(dayId, tripId)) return errorResult('Day not found.'); if (!this.assignments.placeExists(placeId, tripId)) return errorResult('Place not found.'); - const assignment = this.assignments.createAssignment(dayId, placeId, notes || null); + const assignment = await this.assignments.createAssignment(dayId, placeId, notes || null); this.guards.safeBroadcast(tripId, 'assignment:created', { assignment }); this.assignments.reconcile(tripId); return ok({ assignment }); @@ -74,9 +74,9 @@ export class AssignmentsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.'); - const assignment = this.assignments.setEndDay(assignmentId, end_day); + const assignment = await this.assignments.setEndDay(assignmentId, end_day); this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment }); return ok({ assignment }); } @@ -98,7 +98,7 @@ export class AssignmentsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.assignmentExistsInDay(assignmentId, dayId, tripId)) return errorResult('Assignment not found.'); this.assignments.deleteAssignment(assignmentId); @@ -127,10 +127,10 @@ export class AssignmentsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId); if (!existing) return errorResult('Assignment not found.'); - const { assignment, reordered, vias } = this.assignments.updateTime( + const { assignment, reordered, vias } = await this.assignments.updateTime( assignmentId, place_time !== undefined ? place_time : existing.assignment_time, end_time !== undefined ? end_time : existing.assignment_end_time @@ -163,9 +163,9 @@ export class AssignmentsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.'); - const assignment = this.assignments.updateNotes(assignmentId, notes); + const assignment = await this.assignments.updateNotes(assignmentId, notes); this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment }); return ok({ assignment }); } @@ -190,11 +190,11 @@ export class AssignmentsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.'); const assignment = direction === 'incoming' - ? this.assignments.setIncomingLegTransportMode(assignmentId, transport_mode ?? null) - : this.assignments.setLegTransportMode(assignmentId, transport_mode ?? null); + ? await this.assignments.setIncomingLegTransportMode(assignmentId, transport_mode ?? null) + : await this.assignments.setLegTransportMode(assignmentId, transport_mode ?? null); this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment }); return ok({ assignment }); } @@ -220,10 +220,10 @@ export class AssignmentsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.'); if (!this.days.getDay(newDayId, tripId)) return errorResult('Day not found.'); - const result = this.assignments.moveAssignment(assignmentId, newDayId, orderIndex ?? 0); + const result = await this.assignments.moveAssignment(assignmentId, newDayId, orderIndex ?? 0); // REST parity shape ({ assignment, oldDayId, newDayId }) — the client keys its // per-day assignment map on newDayId, so omitting it filed the moved assignment // under "undefined" on collaborator screens. @@ -269,7 +269,7 @@ export class AssignmentsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.'); const participants = this.assignments.setParticipants(assignmentId, userIds, tripId); this.guards.safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants }); @@ -293,7 +293,7 @@ export class AssignmentsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.'); this.assignments.reorderAssignments(dayId, assignmentIds); // REST parity shape ({ dayId, orderedIds }) — the client only reads orderedIds, diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index 632d5f59a..b8f72842b 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -25,7 +25,7 @@ interface DayStopRow { * vias; both stay null when the save left every stop where it was. */ export interface AssignmentTimeUpdate { - assignment: ReturnType; + assignment: Awaited>; reordered: { dayId: number; orderedIds: number[] } | null; vias: { dayId: number; vias: RoadtripVia[] } | null; } @@ -70,7 +70,7 @@ export class AssignmentsService { return this.dbs.canAccessTrip(Number(tripId), userId); } - canEdit(trip: Trip, user: User): boolean { + async canEdit(trip: Trip, user: User): Promise { return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id); } @@ -92,7 +92,7 @@ export class AssignmentsService { * Public because the accommodation mirror moves a stop in place and has to hand * the moved row back in exactly this shape. */ - getAssignmentWithPlace(assignmentId: number | bigint) { + async getAssignmentWithPlace(assignmentId: number | bigint) { const a = this.dbs.get(` SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, @@ -111,7 +111,7 @@ export class AssignmentsService { // Same compact tag projection as listDayAssignments, so an assignment has // one wire shape regardless of which read path produced it. - const tags = this.queryHelpers.loadTagsByPlaceIds([a.place_id], { compact: true })[a.place_id] || []; + const tags = (await this.queryHelpers.loadTagsByPlaceIds([a.place_id], { compact: true }))[a.place_id] || []; const participants = this.dbs.all(` SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar @@ -128,7 +128,7 @@ export class AssignmentsService { return formatAssignmentWithPlace(a, tags, participants); } - listDayAssignments(dayId: string | number) { + async listDayAssignments(dayId: string | number) { const assignments = this.dbs.all(` SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, @@ -145,10 +145,10 @@ export class AssignmentsService { `, dayId); const placeIds = [...new Set(assignments.map(a => a.place_id))]; - const tagsByPlaceId = this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true }); + const tagsByPlaceId = await this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true }); const assignmentIds = assignments.map(a => a.id); - const participantsByAssignment = this.queryHelpers.loadParticipantsByAssignmentIds(assignmentIds); + const participantsByAssignment = await this.queryHelpers.loadParticipantsByAssignmentIds(assignmentIds); return assignments.map(a => { return formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []); @@ -219,7 +219,7 @@ export class AssignmentsService { `, id, tripId); } - moveAssignment(id: string | number, newDayId: unknown, orderIndex: number | null | undefined) { + async moveAssignment(id: string | number, newDayId: unknown, orderIndex: number | null | undefined) { // The source day comes from the row, not the caller — callers can't lie // about (or race on) where the assignment was. const oldDayId = this.dbs.transaction(() => { @@ -227,7 +227,7 @@ export class AssignmentsService { this.dbs.run('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?', newDayId, orderIndex ?? 0, id); return row?.day_id; }); - const updated = this.getAssignmentWithPlace(Number(id)); + const updated = await this.getAssignmentWithPlace(Number(id)); return { assignment: updated, oldDayId }; } @@ -253,7 +253,7 @@ export class AssignmentsService { * such a day the order stored and the order drawn can differ. The old sort did the * same. */ - updateTime(id: string | number, placeTime: unknown, endTime: unknown): AssignmentTimeUpdate { + async updateTime(id: string | number, placeTime: unknown, endTime: unknown): Promise { const sorted = this.dbs.transaction(() => { const stored = this.dbs.get<{ day_id: number; start: string | null }>(` SELECT da.day_id, COALESCE(da.assignment_time, p.place_time, acc.check_in) AS start @@ -280,7 +280,7 @@ export class AssignmentsService { }); return { - assignment: this.getAssignmentWithPlace(Number(id)), + assignment: await this.getAssignmentWithPlace(Number(id)), reordered: sorted ? { dayId: sorted.dayId, orderedIds: sorted.orderedIds } : null, vias: sorted?.viasMoved ? { dayId: sorted.dayId, vias: this.listDayVias(sorted.dayId) } : null, }; diff --git a/server/src/nest/assignments/itinerary.rpc.ts b/server/src/nest/assignments/itinerary.rpc.ts index 49982fbc9..32f51f486 100644 --- a/server/src/nest/assignments/itinerary.rpc.ts +++ b/server/src/nest/assignments/itinerary.rpc.ts @@ -25,27 +25,27 @@ export class ItineraryRpc { ) {} @PluginMethod('itinerary.assign', { permission: 'db:write:itinerary' }) - assign(params: Record, ctx: PluginRpcContext): unknown { + async assign(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const dayId = num(params.dayId, 'dayId'); const placeId = num(params.placeId, 'placeId'); const actor = this.guards.requireActor(ctx, 'itinerary'); const notes = params.notes === undefined || params.notes === null ? null : str(params.notes, 'notes'); - this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION); if (!this.assignments.dayExists(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`); if (!this.assignments.placeExists(placeId, tripId)) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`); - const assignment = this.assignments.createAssignment(dayId, placeId, notes); + const assignment = await this.assignments.createAssignment(dayId, placeId, notes); this.realtime.broadcast(tripId, 'assignment:created', { assignment }); this.assignments.reconcile(tripId); return assignment; } @PluginMethod('itinerary.unassign', { permission: 'db:write:itinerary' }) - unassign(params: Record, ctx: PluginRpcContext): unknown { + async unassign(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const assignmentId = num(params.assignmentId, 'assignmentId'); const actor = this.guards.requireActor(ctx, 'itinerary'); - this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION); const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId); if (!existing) throw new ForbiddenResource(`no assignment ${assignmentId} on trip ${tripId}`); this.assignments.deleteAssignment(assignmentId); diff --git a/server/src/nest/atlas/atlas.rpc.ts b/server/src/nest/atlas/atlas.rpc.ts index 46c7480fd..0644a7107 100644 --- a/server/src/nest/atlas/atlas.rpc.ts +++ b/server/src/nest/atlas/atlas.rpc.ts @@ -23,9 +23,9 @@ export class AtlasRpc { ) {} @PluginMethod('atlas.visited', { permission: 'db:read:atlas' }) - visited(_params: Record, ctx: PluginRpcContext): unknown { + async visited(_params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireAtlasUser(ctx, 'reads'); - this.requireAtlasAddon(); + await this.requireAtlasAddon(); return { countries: this.atlas.listVisitedCountries(userId), regions: this.atlas.listManuallyVisitedRegions(userId), @@ -33,32 +33,32 @@ export class AtlasRpc { } @PluginMethod('atlas.bucketList', { permission: 'db:read:atlas' }) - bucketList(_params: Record, ctx: PluginRpcContext): unknown { + async bucketList(_params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireAtlasUser(ctx, 'reads'); - this.requireAtlasAddon(); + await this.requireAtlasAddon(); return this.atlas.bucketList(userId) as unknown[]; } @PluginMethod('atlas.markCountry', { permission: 'db:write:atlas' }) - markCountry(params: Record, ctx: PluginRpcContext): unknown { + async markCountry(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireAtlasUser(ctx, 'writes'); const code = this.code(params.code, 'code'); - this.requireAtlasAddon(); + await this.requireAtlasAddon(); this.atlas.markCountry(userId, code); return { visited: true }; } @PluginMethod('atlas.unmarkCountry', { permission: 'db:write:atlas' }) - unmarkCountry(params: Record, ctx: PluginRpcContext): unknown { + async unmarkCountry(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireAtlasUser(ctx, 'writes'); const code = this.code(params.code, 'code'); - this.requireAtlasAddon(); + await this.requireAtlasAddon(); this.atlas.unmarkCountry(userId, code); return { visited: false }; } @PluginMethod('atlas.markRegion', { permission: 'db:write:atlas' }) - markRegion(params: Record, ctx: PluginRpcContext): unknown { + async markRegion(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireAtlasUser(ctx, 'writes'); // A missing name falls back to the code, so the row is never nameless. const regionName = typeof params.regionName === 'string' && params.regionName @@ -66,26 +66,26 @@ export class AtlasRpc { : String(params.regionCode ?? ''); const regionCode = this.code(params.regionCode, 'regionCode'); const countryCode = this.code(params.countryCode, 'countryCode'); - this.requireAtlasAddon(); + await this.requireAtlasAddon(); this.atlas.markRegion(userId, regionCode, regionName, countryCode); return { visited: true }; } @PluginMethod('atlas.unmarkRegion', { permission: 'db:write:atlas' }) - unmarkRegion(params: Record, ctx: PluginRpcContext): unknown { + async unmarkRegion(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireAtlasUser(ctx, 'writes'); const regionCode = this.code(params.regionCode, 'regionCode'); - this.requireAtlasAddon(); + await this.requireAtlasAddon(); this.atlas.unmarkRegion(userId, regionCode); return { visited: false }; } @PluginMethod('atlas.createBucketItem', { permission: 'db:write:atlas' }) - createBucketItem(params: Record, ctx: PluginRpcContext): unknown { + async createBucketItem(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireAtlasUser(ctx, 'writes'); const input = asPayload(params.input); if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bucket item name is required'); - this.requireAtlasAddon(); + await this.requireAtlasAddon(); try { return this.atlas.createBucketItem(userId, input as never); } catch (err) { @@ -97,10 +97,10 @@ export class AtlasRpc { } @PluginMethod('atlas.deleteBucketItem', { permission: 'db:write:atlas' }) - deleteBucketItem(params: Record, ctx: PluginRpcContext): unknown { + async deleteBucketItem(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireAtlasUser(ctx, 'writes'); const itemId = num(params.itemId, 'itemId'); - this.requireAtlasAddon(); + await this.requireAtlasAddon(); if (!this.atlas.deleteBucketItem(userId, itemId)) { throw new ForbiddenResource(`no bucket item ${itemId} for this user`); } @@ -121,8 +121,8 @@ export class AtlasRpc { return ctx.actingUserId; } - private requireAtlasAddon(): void { - this.guards.requireAddon(ADDON_IDS.ATLAS, 'atlas'); + private async requireAtlasAddon(): Promise { + await this.guards.requireAddon(ADDON_IDS.ATLAS, 'atlas'); } /** Country and region codes are short and stored upper-cased. */ diff --git a/server/src/nest/audit/audit.service.ts b/server/src/nest/audit/audit.service.ts index c85d158c4..b0b464b2d 100644 --- a/server/src/nest/audit/audit.service.ts +++ b/server/src/nest/audit/audit.service.ts @@ -68,7 +68,7 @@ function buildInfoSummary(action: string, details?: Record): st export class AuditService { constructor(private readonly dbs: DatabaseService) {} - private resolveUserEmail(userId: number | null): string { + private async resolveUserEmail(userId: number | null): Promise { if (userId == null) return 'anonymous'; try { const row = this.dbs.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId); @@ -77,14 +77,14 @@ export class AuditService { } /** Best-effort; never throws — failures are logged only. */ - writeAudit(entry: { + async writeAudit(entry: { userId: number | null; action: string; resource?: string | null; details?: Record; debugDetails?: Record; ip?: string | null; - }): void { + }): Promise { try { const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null; this.dbs.run( @@ -92,7 +92,7 @@ export class AuditService { entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null ); - const email = this.resolveUserEmail(entry.userId); + const email = await this.resolveUserEmail(entry.userId); const label = ACTION_LABELS[entry.action] || entry.action; const brief = buildInfoSummary(entry.action, entry.details); logInfo(oneLine(`${email} ${label}${brief} ip=${entry.ip || '-'}`)); diff --git a/server/src/nest/auth/auth-public.controller.ts b/server/src/nest/auth/auth-public.controller.ts index a107711c4..b29b25c57 100644 --- a/server/src/nest/auth/auth-public.controller.ts +++ b/server/src/nest/auth/auth-public.controller.ts @@ -68,13 +68,13 @@ export class AuthPublicController { @Post('register') @Public('creating the account that would carry the session') @HttpCode(201) - register(@Body() body: RegisterDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) { + async register(@Body() body: RegisterDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) { this.limit('login', req, 10); - const result = this.auth.registerUser(body); + const result = await this.auth.registerUser(body); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails }); + await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails }); this.auth.setAuthCookie(res, result.token!, req); return { token: result.token, user: result.user }; } @@ -87,7 +87,7 @@ export class AuthPublicController { const started = Date.now(); const result = this.auth.loginUser(body); if (result.auditAction) { - this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails }); + await this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails }); } const elapsed = Date.now() - started; if (elapsed < LOGIN_MIN_LATENCY_MS) await delay(LOGIN_MIN_LATENCY_MS - elapsed); @@ -120,15 +120,15 @@ export class AuthPublicController { if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) { const origin = this.auth.getAppUrl(); const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`; - this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } }); + await this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } }); try { const delivery = await this.auth.sendPasswordResetEmail(outcome.userEmail, url, outcome.userId); - this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } }); + await this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } }); } catch { - this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } }); + await this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } }); } } else { - this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } }); + await this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } }); } const elapsed = Date.now() - started; if (elapsed < FORGOT_MIN_LATENCY_MS) await delay(FORGOT_MIN_LATENCY_MS - elapsed); @@ -138,33 +138,33 @@ export class AuthPublicController { @Post('reset-password') @Public('the reset token IS the credential') @HttpCode(200) - resetPassword(@Body() body: ResetPasswordDto, @Req() req: Request) { + async resetPassword(@Body() body: ResetPasswordDto, @Req() req: Request) { // Per-IP brute-force guard, parity with the legacy resetLimiter (5 / 15 min on // a dedicated bucket) — without it reset tokens could be guessed unthrottled. this.limit('reset', req, 5); const ip = getClientIp(req); const result = this.auth.resetPassword(body); if (result.error) { - this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } }); + await this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } }); throw new HttpException({ error: result.error }, result.status!); } if (result.mfa_required) { return { mfa_required: true }; } - this.audit.writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip }); + await this.audit.writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip }); return { success: true }; } @Post('mfa/verify-login') @Public('second factor of a login that has no session yet') @HttpCode(200) - verifyMfaLogin(@Body() body: MfaVerifyLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) { + async verifyMfaLogin(@Body() body: MfaVerifyLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) { this.limit('mfa', req, 5); const result = this.auth.verifyMfaLogin(body); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } }); + await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } }); this.auth.setAuthCookie(res, result.token!, req, result.remember); return { token: result.token, user: result.user }; } diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts index f52b214ee..8861c7c6b 100644 --- a/server/src/nest/auth/auth.controller.ts +++ b/server/src/nest/auth/auth.controller.ts @@ -107,7 +107,7 @@ export class AuthController { } @Put('me/password') - changePassword(@CurrentUser() user: User, @Body() body: ChangePasswordDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) { + async changePassword(@CurrentUser() user: User, @Body() body: ChangePasswordDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) { this.limit('login', req, 5); // Carry the session's remember choice into the re-issued token/cookie so a // "remember me" login survives a password change (#1927). Bearer callers @@ -120,17 +120,17 @@ export class AuthController { // Refresh this device's cookie with the new password_version so the user // stays logged in here while all other sessions are invalidated. if (result.token) this.auth.setAuthCookie(res, result.token, req, remember); - this.audit.writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) }); return { success: true }; } @Delete('me') - deleteAccount(@CurrentUser() user: User, @Req() req: Request) { + async deleteAccount(@CurrentUser() user: User, @Req() req: Request) { const result = this.auth.deleteAccount(user.id, user.email, user.role); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - this.audit.writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) }); return { success: true }; } @@ -145,9 +145,9 @@ export class AuthController { * Nothing is written when nothing changed — the panel saves before every test * click, and each click would otherwise cost a log line. */ - private auditApiKeys(userId: number, changed: string[], req: Request): void { + private async auditApiKeys(userId: number, changed: string[], req: Request): Promise { if (!changed.length) return; - this.audit.writeAudit({ + await this.audit.writeAudit({ userId, action: 'settings.api_keys_update', resource: 'api_keys', @@ -157,28 +157,28 @@ export class AuthController { } @Put('me/maps-key') - mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto, @Req() req: Request) { + async mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto, @Req() req: Request) { // changedKeys is for the audit line, not for the client: destructured off so // the response body stays what it always was. const { changedKeys = [], ...result } = this.profile.updateMapsKey(user.id, body.maps_api_key); - this.auditApiKeys(user.id, changedKeys, req); + await this.auditApiKeys(user.id, changedKeys, req); return result; } @Put('me/api-keys') - apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto, @Req() req: Request) { + async apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto, @Req() req: Request) { const { changedKeys = [], ...result } = this.profile.updateApiKeys(user.id, body); - this.auditApiKeys(user.id, changedKeys, req); + await this.auditApiKeys(user.id, changedKeys, req); return result; } @Put('me/settings') - updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) { + async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) { const result = this.profile.updateSettings(user.id, body); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - this.auditApiKeys(user.id, result.changedKeys ?? [], req); + await this.auditApiKeys(user.id, result.changedKeys ?? [], req); return { success: result.success, user: result.user }; } @@ -239,12 +239,12 @@ export class AuthController { @Put('app-settings') @MfaExempt('an admin locked out by their own policy must still be able to lift it') - updateAppSettings(@CurrentUser() user: User, @Body() body: AppSettingsUpdateDto, @Req() req: Request) { + async updateAppSettings(@CurrentUser() user: User, @Body() body: AppSettingsUpdateDto, @Req() req: Request) { const result = this.auth.updateAppSettings(user.id, body); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - this.audit.writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails }); + await this.audit.writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails }); // Named so the settings tab can say which fields the operator holds rather // than showing a saved value that silently did not save. return { success: true, ...(result.managedKeys?.length ? { managed_keys: result.managedKeys } : {}) }; @@ -274,25 +274,25 @@ export class AuthController { @Post('mfa/enable') @MfaExempt('completing setup is the way out of the policy') @HttpCode(200) - mfaEnable(@CurrentUser() user: User, @Body() body: MfaEnableDto, @Req() req: Request) { + async mfaEnable(@CurrentUser() user: User, @Body() body: MfaEnableDto, @Req() req: Request) { this.limit('mfa', req, 5); const result = this.auth.enableMfa(user.id, body.code); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - this.audit.writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) }); return { success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes }; } @Post('mfa/disable') @HttpCode(200) - mfaDisable(@CurrentUser() user: User, @Body() body: MfaDisableDto, @Req() req: Request) { + async mfaDisable(@CurrentUser() user: User, @Body() body: MfaDisableDto, @Req() req: Request) { this.limit('login', req, 5); const result = this.auth.disableMfa(user.id, user.email, body); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } - this.audit.writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) }); return { success: true, mfa_enabled: result.mfa_enabled }; } diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index 844c17cd6..b3543edac 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -10,6 +10,7 @@ import type { Request, Response } from 'express'; import { readEnv } from '../../app-config'; import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { PermissionsService } from '../permissions/permissions.service'; import { validatePassword } from '../common/passwordPolicy'; import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto'; @@ -132,6 +133,7 @@ export class AuthService { private readonly mailer: MailerService, private readonly tokens: EphemeralTokenService, private readonly allowedFileTypes: AllowedFileTypesService, + private readonly uow: UnitOfWork, ) {} // Cookie @@ -253,7 +255,7 @@ export class AuthService { return !!(declaredRpId || env.app.appUrl || env.http.allowedOriginsRaw); } - getAppConfig(authenticatedUser: User | undefined | null) { + async getAppConfig(authenticatedUser: User | undefined | null) { const userCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0')!.count; const isDemo = readEnv().demo.enabled; const toggles = this.resolveAuthToggles(); @@ -350,7 +352,7 @@ export class AuthService { places_details_enabled: placesDetailsEnabled, places_enrich_enabled: placesEnrichEnabled, place_shadow_enabled: placeShadowEnabled, - permissions: authenticatedUser ? this.permissions.getAllPermissions() : undefined, + permissions: authenticatedUser ? (await this.permissions.getAllPermissions()) : undefined, // Case-sensitive on purpose (legacy parity). dev_mode: readEnv().app.nodeEnv === 'development', }; @@ -379,7 +381,7 @@ export class AuthService { return { valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at }; } - registerUser(rawBody: unknown): { error?: string; status?: number; token?: string; user?: Record; auditUserId?: number; auditDetails?: Record } { + async registerUser(rawBody: unknown): Promise<{ error?: string; status?: number; token?: string; user?: Record; auditUserId?: number; auditDetails?: Record }> { const body = rawBody as { username?: string; email?: string; password?: string; invite_token?: string }; const username = typeof body.username === 'string' ? body.username.trim() : ''; const email = typeof body.email === 'string' ? body.email.trim() : ''; @@ -426,7 +428,7 @@ export class AuthService { try { // One transaction for the whole signup: a mid-sequence throw (invite // bookkeeping, trip auto-join) must not leave a half-registered user. - return this.db.transaction(() => { + return await this.uow.transactional(async () => { const result = this.db.run( 'INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, 0)', username, email, password_hash, role, readEnv().app.appVersion || '0.0.0' @@ -446,7 +448,7 @@ export class AuthService { // Trip-bound invite (#1402): auto-add the freshly registered user to the // trip. Idempotent + owner-safe; no-ops if the bound trip was since deleted. if (validInvite.trip_id) { - this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(result.lastInsertRowid), validInvite.created_by ?? null); + await this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(result.lastInsertRowid), validInvite.created_by ?? null); } } diff --git a/server/src/nest/auth/passkey.controller.ts b/server/src/nest/auth/passkey.controller.ts index 9aba3f5e0..d1141771f 100644 --- a/server/src/nest/auth/passkey.controller.ts +++ b/server/src/nest/auth/passkey.controller.ts @@ -63,7 +63,7 @@ export class PasskeyController { async registerVerify(@CurrentUser() user: User, @Body() body: PasskeyRegisterVerifyDto, @Req() req: Request) { const result = await this.passkeys.passkeyRegisterVerify(user.id, body); if (result.error) throw new HttpException({ error: result.error }, result.status!); - this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) }); return { success: true, credential: result.credential }; } @@ -88,14 +88,14 @@ export class PasskeyController { const started = Date.now(); const result = await this.passkeys.passkeyLoginVerify(body); if (result.auditAction) { - this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) }); } // Pad to the same floor as password login so timing can't distinguish a // known credential from an unknown one. const elapsed = Date.now() - started; if (elapsed < LOGIN_MIN_LATENCY_MS) await delay(LOGIN_MIN_LATENCY_MS - elapsed); if (result.error) throw new HttpException({ error: result.error }, result.status!); - this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { method: 'passkey' } }); + await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { method: 'passkey' } }); setAuthCookie(res, result.token!, req); return { token: result.token, user: result.user }; } @@ -118,11 +118,11 @@ export class PasskeyController { @Delete('credentials/:id') @UseGuards(JwtAuthGuard) - remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyDeleteDto, @Req() req: Request) { + async remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyDeleteDto, @Req() req: Request) { this.limit('login', req, 5); const result = this.passkeys.deletePasskey(user.id, id, body?.password); if (result.error) throw new HttpException({ error: result.error }, result.status!); - this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) }); return { success: true }; } } diff --git a/server/src/nest/backup/backup.controller.ts b/server/src/nest/backup/backup.controller.ts index b270d0080..1b0327c0b 100644 --- a/server/src/nest/backup/backup.controller.ts +++ b/server/src/nest/backup/backup.controller.ts @@ -67,7 +67,7 @@ export class BackupController { } try { const backup = await this.backup.createBackup(); - this.audit.writeAudit({ userId: user.id, action: 'backup.create', resource: backup.filename, ip: getClientIp(req), details: { size: backup.size } }); + await this.audit.writeAudit({ userId: user.id, action: 'backup.create', resource: backup.filename, ip: getClientIp(req), details: { size: backup.size } }); return { success: true, backup }; } catch { throw new HttpException({ error: 'Error creating backup' }, 500); @@ -109,7 +109,7 @@ export class BackupController { if (!result.success) { throw new HttpException({ error: result.error }, result.status || 400); } - this.audit.writeAudit({ userId: user.id, action: 'backup.restore', resource: filename, ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'backup.restore', resource: filename, ip: getClientIp(req) }); return { success: true }; } catch (err) { if (err instanceof HttpException) throw err; @@ -142,7 +142,7 @@ export class BackupController { if (!result.success) { throw new HttpException({ error: result.error }, result.status || 400); } - this.audit.writeAudit({ userId: user.id, action: 'backup.upload_restore', resource: origName, ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'backup.upload_restore', resource: origName, ip: getClientIp(req) }); return { success: true }; } catch (err) { if (err instanceof HttpException) throw err; @@ -164,10 +164,10 @@ export class BackupController { @ManagedForbidden('the operator schedules backups off-volume; a second schedule inside it is not one') @Put('auto-settings') - updateAutoSettings(@CurrentUser() user: User, @Body() body: AutoBackupSettingsDto, @Req() req: Request) { + async updateAutoSettings(@CurrentUser() user: User, @Body() body: AutoBackupSettingsDto, @Req() req: Request) { try { const settings = this.autoBackup.updateAutoSettings(body || {}); - this.audit.writeAudit({ userId: user.id, action: 'backup.auto_settings', ip: getClientIp(req), details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days } }); + await this.audit.writeAudit({ userId: user.id, action: 'backup.auto_settings', ip: getClientIp(req), details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days } }); return { settings }; } catch (err) { console.error('[backup] PUT auto-settings:', err); @@ -185,7 +185,7 @@ export class BackupController { throw new HttpException({ error: 'Backup not found' }, 404); } await this.backup.deleteBackup(filename); - this.audit.writeAudit({ userId: user.id, action: 'backup.delete', resource: filename, ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'backup.delete', resource: filename, ip: getClientIp(req) }); return { success: true }; } } diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts index e436b17cc..fcd9ec93f 100644 --- a/server/src/nest/booking-import/booking-import.service.ts +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -52,7 +52,7 @@ export class BookingImportService { } /** True when the LLM fallback is enabled and configured for this user. */ - aiAvailable(userId: number): boolean { + async aiAvailable(userId: number): Promise { return this.llmParse.isAvailable(userId); } @@ -137,7 +137,7 @@ export class BookingImportService { onProgress?: (done: number, total: number, fileName: string) => void, ): Promise { const kitineraryAvailable = this.extractor.isAvailable(); - const aiAvailable = this.llmParse.isAvailable(userId); + const aiAvailable = await this.llmParse.isAvailable(userId); if (!kitineraryAvailable && !aiAvailable) { throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503); } @@ -296,7 +296,7 @@ export class BookingImportService { }; } - const { reservation, accommodationCreated } = this.reservations.create(tripId, { + const { reservation, accommodationCreated } = await this.reservations.create(tripId, { ...reservationData, place_id: placeId, create_accommodation: createAccommodation, @@ -309,7 +309,7 @@ export class BookingImportService { // Turn an extracted price into a real linked cost (Costs addon), so the // booking shows up as an expense — not just a price in metadata. - if (this.addons.isAddonEnabled(ADDON_IDS.BUDGET)) { + if ((await this.addons.isAddonEnabled(ADDON_IDS.BUDGET))) { const meta = reservationData.metadata && typeof reservationData.metadata === 'object' ? (reservationData.metadata as Record) diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 5664e0c92..2969544de 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -99,11 +99,11 @@ export class BudgetMcp { * and is passed through. Reads ride the leaf TripMembershipService — the * hydrated member services all live in modules that import this one. */ - private resolveMemberIds(tripId: number, member_ids?: number[]): number[] | undefined { + private async resolveMemberIds(tripId: number, member_ids?: number[]): Promise { if (member_ids !== undefined) return member_ids; - const ownerId = this.membership.getOwnerId(tripId); + const ownerId = await this.membership.getOwnerId(tripId); if (ownerId === null) return undefined; - return Array.from(new Set([ownerId, ...this.membership.listMemberUserIds(tripId)])); + return Array.from(new Set([ownerId, ...(await this.membership.listMemberUserIds(tripId))])); } /** @@ -205,7 +205,7 @@ export class BudgetMcp { ) { if (this.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied(); if (members !== undefined && member_ids !== undefined) return errorResult('Pass either members (uneven split) or member_ids (equal split), not both.'); if (place_id != null && !this.placeOnTrip(tripId, place_id)) return errorResult('place_id does not belong to this trip.'); if (members !== undefined) { @@ -214,7 +214,7 @@ export class BudgetMcp { } // The split participants are the members of an uneven split; the equal-split // list still carries them so the row's `persons` count comes out the same. - const splitIds = members ? members.map(m => m.user_id) : this.resolveMemberIds(tripId, member_ids); + const splitIds = members ? members.map(m => m.user_id) : await this.resolveMemberIds(tripId, member_ids); const itemData = { category, name, total_price, currency, member_ids: splitIds, members, payers, expense_date, place_id, note }; // Freeze the live FX rate at entry time so a settled position isn't re-opened // when live rates drift (#1445) — same as the REST create path. @@ -238,7 +238,7 @@ export class BudgetMcp { async deleteBudgetItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) { if (this.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied(); const deleted = this.budget.deleteBudgetItem(itemId, tripId); if (!deleted) return errorResult('Budget item not found.'); this.guards.safeBroadcast(tripId, 'budget:deleted', { itemId }); @@ -280,7 +280,7 @@ export class BudgetMcp { ) { if (this.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied(); if (members !== undefined && member_ids !== undefined) return errorResult('Pass either members (uneven split) or member_ids (equal split), not both.'); if (members !== undefined) { // An edit that leaves the total alone still has to reconcile against it, so @@ -324,10 +324,10 @@ export class BudgetMcp { ) { if (this.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied(); if (place_id != null && !this.placeOnTrip(tripId, place_id)) return errorResult('place_id does not belong to this trip.'); // Omitted userIds → default to the whole trip, matching create_budget_item. - const members = (userIds && userIds.length > 0) ? userIds : this.resolveMemberIds(tripId, undefined); + const members = (userIds && userIds.length > 0) ? userIds : await this.resolveMemberIds(tripId, undefined); try { const item = this.db.transaction(() => { const created = this.budget.createBudgetItem(tripId, { category, name, total_price, note, member_ids: members, place_id }); @@ -356,7 +356,7 @@ export class BudgetMcp { async setBudgetItemMembers({ tripId, itemId, userIds }: { tripId: number; itemId: number; userIds: number[] }, ctx: McpContext) { if (this.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied(); const result = this.budget.updateMembers(itemId, tripId, userIds); if (!result) return errorResult('Budget item not found.'); const item = this.budget.getBudgetItem(itemId, tripId); @@ -380,7 +380,7 @@ export class BudgetMcp { async toggleBudgetMemberPaid({ tripId, itemId, memberId, paid }: { tripId: number; itemId: number; memberId: number; paid: boolean }, ctx: McpContext) { if (this.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied(); const member = this.budget.toggleMemberPaid(itemId, tripId, memberId, paid); this.guards.safeBroadcast(tripId, 'budget:member-paid-updated', { itemId, userId: memberId, paid: paid ? 1 : 0 }); return ok({ member }); @@ -445,7 +445,7 @@ export class BudgetMcp { ) { if (this.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied(); // Freeze-then-write composite, same as the REST path: the rate for the display // currency is frozen at entry time (#1445). const settlement = await this.budget.createSettlement(tripId, { from_user_id, to_user_id, amount, currency, settled_at }, ctx.userId); @@ -476,7 +476,7 @@ export class BudgetMcp { ) { if (this.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied(); // Freeze-then-write composite, same as the REST path: an edit that leaves the // currency alone keeps the rate frozen at settle time. const settlement = await this.budget.updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount, currency, settled_at }); @@ -499,7 +499,7 @@ export class BudgetMcp { async deleteSettlement({ tripId, settlementId }: { tripId: number; settlementId: number }, ctx: McpContext) { if (this.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied(); const deleted = this.budget.deleteSettlement(settlementId, tripId); if (!deleted) return errorResult('Settlement not found.'); this.guards.safeBroadcast(tripId, 'budget:settlement-deleted', { settlementId }); diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index b2a6272f1..d3eb21804 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -118,7 +118,7 @@ export class BudgetService { return this.db.canAccessTrip(tripId, userId); } - canEdit(trip: Trip, user: User): boolean { + async canEdit(trip: Trip, user: User): Promise { return this.permissions.checkPermission('budget_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id); } diff --git a/server/src/nest/budget/costs.rpc.ts b/server/src/nest/budget/costs.rpc.ts index 0aebef480..a853233aa 100644 --- a/server/src/nest/budget/costs.rpc.ts +++ b/server/src/nest/budget/costs.rpc.ts @@ -38,22 +38,22 @@ export class CostsRpc { @PluginMethod('costs.getByTrip', { permission: 'db:read:costs' }) getByTrip(params: Record, ctx: PluginRpcContext): unknown { - return this.guards.tripRead(params, ctx, () => { - this.requireBudgetAddon(); + return this.guards.tripRead(params, ctx, async () => { + await this.requireBudgetAddon(); return this.budget.listBudgetItems(num(params.tripId, 'tripId')); }); } @PluginMethod('costs.listMine', { permission: 'db:read:costs' }) - listMine(_params: Record, ctx: PluginRpcContext): unknown { + async listMine(_params: Record, ctx: PluginRpcContext): Promise { // Cross-trip aggregate. The acting user is host-bound; a job or onLoad is refused // the same way tripRead refuses one. if (ctx.actingUserId === undefined) throw new ForbiddenResource('cost reads require an authenticated user context'); - this.requireBudgetAddon(); + await this.requireBudgetAddon(); // The leaf membership read, not TripsService.list: TripsModule imports this // one, so injecting TripsService here would close a cycle. Same id set, // same newest-first order. - const tripIds = this.membership.listAccessibleTripIds(ctx.actingUserId); + const tripIds = await this.membership.listAccessibleTripIds(ctx.actingUserId); return tripIds.flatMap((id) => this.budget.listBudgetItems(id)); } @@ -61,10 +61,10 @@ export class CostsRpc { async create(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.requireCostActor(ctx); - this.requireBudgetAddon(); + await this.requireBudgetAddon(); const parsed = budgetCreateItemRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid cost: ${schemaMessage(parsed.error)}`); - this.requireCostEdit(tripId, actor); + await this.requireCostEdit(tripId, actor); // BudgetService.create freezes the FX rate and resolves members/payers, so the // plugin path produces the same row the web app would. const item = await this.budget.create(String(tripId), parsed.data); @@ -77,10 +77,10 @@ export class CostsRpc { const tripId = num(params.tripId, 'tripId'); const itemId = num(params.itemId, 'itemId'); const actor = this.requireCostActor(ctx); - this.requireBudgetAddon(); + await this.requireBudgetAddon(); const parsed = budgetUpdateItemRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid cost: ${schemaMessage(parsed.error)}`); - this.requireCostEdit(tripId, actor); + await this.requireCostEdit(tripId, actor); // update re-freezes the FX rate on a currency change, exactly like create. const item = await this.budget.update(String(itemId), String(tripId), parsed.data); if (item == null) throw new ForbiddenResource(`no cost ${itemId} on trip ${tripId}`); @@ -89,12 +89,12 @@ export class CostsRpc { } @PluginMethod('costs.delete', { permission: 'db:write:costs' }) - delete(params: Record, ctx: PluginRpcContext): unknown { + async delete(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const itemId = num(params.itemId, 'itemId'); const actor = this.requireCostActor(ctx); - this.requireBudgetAddon(); - this.requireCostEdit(tripId, actor); + await this.requireBudgetAddon(); + await this.requireCostEdit(tripId, actor); if (!this.budget.remove(String(itemId), String(tripId))) { throw new ForbiddenResource(`no cost ${itemId} on trip ${tripId}`); } @@ -110,14 +110,14 @@ export class CostsRpc { return ctx.actingUserId; } - private requireBudgetAddon(): void { - this.guards.requireAddon(ADDON_IDS.BUDGET, 'costs'); + private async requireBudgetAddon(): Promise { + await this.guards.requireAddon(ADDON_IDS.BUDGET, 'costs'); } /** Trip access plus budget_edit, with the cost-specific refusal message. */ - private requireCostEdit(tripId: number, userId: number): void { + private async requireCostEdit(tripId: number, userId: number): Promise { if (!this.db.canAccessTrip(tripId, userId)) throw new ForbiddenResource(`no access to trip ${tripId}`); - if (!this.guards.canEditAs(BUDGET_EDIT_ACTION, tripId, userId)) { + if (!(await this.guards.canEditAs(BUDGET_EDIT_ACTION, tripId, userId))) { throw new ForbiddenResource(`no permission to edit costs on trip ${tripId}`); } } diff --git a/server/src/nest/categories/categories.controller.ts b/server/src/nest/categories/categories.controller.ts index a0f9da370..8bb31cb58 100644 --- a/server/src/nest/categories/categories.controller.ts +++ b/server/src/nest/categories/categories.controller.ts @@ -28,32 +28,32 @@ export class CategoriesController { @Get() @UseGuards(JwtAuthGuard) - list(): CategoryListResponse { - return { categories: this.categories.list() }; + async list(): Promise { + return { categories: await this.categories.list() }; } @Post() @UseGuards(JwtAuthGuard, AdminGuard) - create(@CurrentUser() user: User, @Body() body: CategoryCreateDto): { category: Category } { - return { category: this.categories.create(user.id, body.name, body.color, body.icon) }; + async create(@CurrentUser() user: User, @Body() body: CategoryCreateDto): Promise<{ category: Category }> { + return { category: await this.categories.create(user.id, body.name, body.color, body.icon) }; } @Put(':id') @UseGuards(JwtAuthGuard, AdminGuard) - update(@Param('id') id: string, @Body() body: CategoryUpdateDto): { category: Category } { - if (!this.categories.getById(id)) { + async update(@Param('id') id: string, @Body() body: CategoryUpdateDto): Promise<{ category: Category }> { + if (!(await this.categories.getById(id))) { throw new HttpException({ error: 'Category not found' }, 404); } - return { category: this.categories.update(id, body.name, body.color, body.icon) }; + return { category: await this.categories.update(id, body.name, body.color, body.icon) }; } @Delete(':id') @UseGuards(JwtAuthGuard, AdminGuard) - remove(@Param('id') id: string): { success: boolean } { - if (!this.categories.getById(id)) { + async remove(@Param('id') id: string): Promise<{ success: boolean }> { + if (!(await this.categories.getById(id))) { throw new HttpException({ error: 'Category not found' }, 404); } - this.categories.remove(id); + await this.categories.remove(id); return { success: true }; } } diff --git a/server/src/nest/categories/categories.mcp.ts b/server/src/nest/categories/categories.mcp.ts index ba47bf1f6..65e803f84 100644 --- a/server/src/nest/categories/categories.mcp.ts +++ b/server/src/nest/categories/categories.mcp.ts @@ -51,7 +51,7 @@ export class CategoriesMcp { access: { group: 'places', mode: 'read' }, }) async listCategories(_args: Record, _ctx: McpContext) { - const categories = this.categories.list(); + const categories = await this.categories.list(); return ok({ categories }); } @@ -70,7 +70,7 @@ export class CategoriesMcp { if (this.isDemoUser(ctx.userId)) return demoDenied(); // The palette is instance-wide; the REST route restricts management to admins. Match it. if (!this.guards.isAdminUser(ctx.userId)) return adminRequired(); - const category = this.categories.create(ctx.userId, name, color, icon); + const category = await this.categories.create(ctx.userId, name, color, icon); return ok({ category }); } @@ -89,8 +89,8 @@ export class CategoriesMcp { async updateCategory({ categoryId, name, color, icon }: { categoryId: number; name?: string; color?: string; icon?: string }, ctx: McpContext) { if (this.isDemoUser(ctx.userId)) return demoDenied(); if (!this.guards.isAdminUser(ctx.userId)) return adminRequired(); - if (!this.categories.getById(categoryId)) return errorResult('Category not found'); - const category = this.categories.update(categoryId, name, color, icon); + if (!(await this.categories.getById(categoryId))) return errorResult('Category not found'); + const category = await this.categories.update(categoryId, name, color, icon); return ok({ category }); } @@ -106,8 +106,8 @@ export class CategoriesMcp { async deleteCategory({ categoryId }: { categoryId: number }, ctx: McpContext) { if (this.isDemoUser(ctx.userId)) return demoDenied(); if (!this.guards.isAdminUser(ctx.userId)) return adminRequired(); - if (!this.categories.getById(categoryId)) return errorResult('Category not found'); - this.categories.remove(categoryId); + if (!(await this.categories.getById(categoryId))) return errorResult('Category not found'); + await this.categories.remove(categoryId); return ok({ success: true }); } @@ -118,7 +118,7 @@ export class CategoriesMcp { mimeType: 'application/json', }) async categoriesResource(uri: URL, _ctx: McpContext) { - const categories = this.categories.list(); + const categories = await this.categories.list(); return { contents: [{ uri: uri.href, diff --git a/server/src/nest/categories/categories.rpc.ts b/server/src/nest/categories/categories.rpc.ts index 36b2a2642..67a5c7a81 100644 --- a/server/src/nest/categories/categories.rpc.ts +++ b/server/src/nest/categories/categories.rpc.ts @@ -11,7 +11,7 @@ export class CategoriesRpc { constructor(private readonly categories: CategoriesService) {} @PluginMethod('categories.list', { permission: 'db:read:categories' }) - list(): unknown[] { - return this.categories.list() as unknown[]; + async list(): Promise { + return (await this.categories.list()) as unknown[]; } } diff --git a/server/src/nest/categories/categories.service.ts b/server/src/nest/categories/categories.service.ts index ac280d529..5921c6a3b 100644 --- a/server/src/nest/categories/categories.service.ts +++ b/server/src/nest/categories/categories.service.ts @@ -12,15 +12,15 @@ import { DatabaseService } from '../database/database.service'; export class CategoriesService { constructor(private readonly db: DatabaseService) {} - list(): Category[] { + async list(): Promise { return this.db.all('SELECT * FROM categories ORDER BY name ASC'); } - getById(id: string | number): Category | undefined { + async getById(id: string | number): Promise { return this.db.get('SELECT * FROM categories WHERE id = ?', id); } - create(userId: number, name: string, color?: string, icon?: string): Category { + async create(userId: number, name: string, color?: string, icon?: string): Promise { const result = this.db.run( 'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)', name, @@ -34,7 +34,7 @@ export class CategoriesService { ) as Category; } - update(id: string | number, name?: string, color?: string, icon?: string): Category { + async update(id: string | number, name?: string, color?: string, icon?: string): Promise { this.db.run( ` UPDATE categories SET @@ -51,7 +51,7 @@ export class CategoriesService { return this.db.get('SELECT * FROM categories WHERE id = ?', id) as Category; } - remove(id: string | number): void { + async remove(id: string | number): Promise { this.db.run('DELETE FROM categories WHERE id = ?', id); } } diff --git a/server/src/nest/collab/collab.controller.ts b/server/src/nest/collab/collab.controller.ts index 0c62eb9fc..88ea36699 100644 --- a/server/src/nest/collab/collab.controller.ts +++ b/server/src/nest/collab/collab.controller.ts @@ -118,8 +118,8 @@ export class CollabController { return trip; } - private requireEdit(trip: NonNullable>, user: User): void { - if (!this.collab.canEdit(trip, user)) { + private async requireEdit(trip: NonNullable>, user: User): Promise { + if (!(await this.collab.canEdit(trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } } @@ -188,7 +188,7 @@ export class CollabController { }; try { const trip = this.requireTrip(tripId, user); - if (!this.collab.canUploadFiles(trip, user)) { + if (!(await this.collab.canUploadFiles(trip, user))) { throw new HttpException({ error: 'No permission to upload files' }, 403); } } catch (err) { @@ -345,12 +345,12 @@ export class CollabController { let trip; try { trip = this.requireTrip(tripId, user); - this.requireEdit(trip, user); + await this.requireEdit(trip, user); } catch (err) { cleanupSpool(); throw err; } - if (uploaded.length && !this.collab.canUploadFiles(trip, user)) { + if (uploaded.length && !(await this.collab.canUploadFiles(trip, user))) { cleanupSpool(); throw new HttpException({ error: 'No permission to upload files' }, 403); } diff --git a/server/src/nest/collab/collab.mcp.ts b/server/src/nest/collab/collab.mcp.ts index c344f1b9a..4bd065a7e 100644 --- a/server/src/nest/collab/collab.mcp.ts +++ b/server/src/nest/collab/collab.mcp.ts @@ -96,7 +96,7 @@ export class CollabMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const note = this.collab.createNote(tripId, ctx.userId, { title, content, category, color, website, pinned }); this.guards.safeBroadcast(tripId, 'collab:note:created', { note }); return ok({ note }); @@ -127,7 +127,7 @@ export class CollabMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const note = this.collab.updateNote(tripId, noteId, { title, content, category, color, website, pinned }); if (!note) return errorResult('Note not found.'); this.guards.safeBroadcast(tripId, 'collab:note:updated', { note }); @@ -148,7 +148,7 @@ export class CollabMcp { async deleteCollabNote({ tripId, noteId }: { tripId: number; noteId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const deleted = await this.collab.deleteNote(tripId, noteId); if (!deleted) return errorResult('Note not found.'); this.guards.safeBroadcast(tripId, 'collab:note:deleted', { noteId }); @@ -195,7 +195,7 @@ export class CollabMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const poll = this.collab.createPoll(tripId, ctx.userId, { question, options, multiple, deadline }); this.guards.safeBroadcast(tripId, 'collab:poll:created', { poll }); return ok({ poll }); @@ -216,7 +216,7 @@ export class CollabMcp { async voteCollabPoll({ tripId, pollId, optionIndex }: { tripId: number; pollId: number; optionIndex: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const result = this.collab.votePoll(tripId, pollId, ctx.userId, optionIndex); if (result.error) return errorResult(result.error); this.guards.safeBroadcast(tripId, 'collab:poll:voted', { poll: result.poll }); @@ -237,7 +237,7 @@ export class CollabMcp { async closeCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const poll = this.collab.closePoll(tripId, pollId); if (!poll) return errorResult('Poll not found.'); this.guards.safeBroadcast(tripId, 'collab:poll:closed', { poll }); @@ -258,7 +258,7 @@ export class CollabMcp { async deleteCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const deleted = this.collab.deletePoll(tripId, pollId); if (!deleted) return errorResult('Poll not found.'); this.guards.safeBroadcast(tripId, 'collab:poll:deleted', { pollId }); @@ -297,7 +297,7 @@ export class CollabMcp { async sendCollabMessage({ tripId, text, replyTo }: { tripId: number; text: string; replyTo?: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const result = this.collab.createMessage(tripId, ctx.userId, text, replyTo ?? null); if (result.error) return errorResult(result.error); this.guards.safeBroadcast(tripId, 'collab:message:created', { message: result.message }); @@ -318,7 +318,7 @@ export class CollabMcp { async deleteCollabMessage({ tripId, messageId }: { tripId: number; messageId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const result = this.collab.deleteMessage(tripId, messageId, ctx.userId); if (result.error) return errorResult(result.error); this.guards.safeBroadcast(tripId, 'collab:message:deleted', { messageId, username: result.username }); @@ -340,7 +340,7 @@ export class CollabMcp { async reactCollabMessage({ tripId, messageId, emoji }: { tripId: number; messageId: number; emoji: string }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const result = this.collab.reactMessage(messageId, tripId, ctx.userId, emoji); if (!result.found) return errorResult('Message not found.'); this.guards.safeBroadcast(tripId, 'collab:message:reacted', { messageId, reactions: result.reactions }); diff --git a/server/src/nest/collab/collab.rpc.ts b/server/src/nest/collab/collab.rpc.ts index dc13a6370..0e7b6cfd3 100644 --- a/server/src/nest/collab/collab.rpc.ts +++ b/server/src/nest/collab/collab.rpc.ts @@ -31,62 +31,62 @@ export class CollabRpc { @PluginMethod('collab.listNotes', { permission: 'db:read:collab' }) listNotes(params: Record, ctx: PluginRpcContext): unknown { - return this.guards.tripRead(params, ctx, () => { - this.requireCollabAddon(); + return this.guards.tripRead(params, ctx, async () => { + await this.requireCollabAddon(); return this.collab.listNotes(num(params.tripId, 'tripId')) as unknown[]; }); } @PluginMethod('collab.listPolls', { permission: 'db:read:collab' }) listPolls(params: Record, ctx: PluginRpcContext): unknown { - return this.guards.tripRead(params, ctx, () => { - this.requireCollabAddon(); + return this.guards.tripRead(params, ctx, async () => { + await this.requireCollabAddon(); return this.collab.listPolls(num(params.tripId, 'tripId')) as unknown[]; }); } @PluginMethod('collab.listMessages', { permission: 'db:read:collab' }) listMessages(params: Record, ctx: PluginRpcContext): unknown { - return this.guards.tripRead(params, ctx, () => { - this.requireCollabAddon(); + return this.guards.tripRead(params, ctx, async () => { + await this.requireCollabAddon(); const before = params.before != null ? num(params.before, 'before') : undefined; return this.collab.listMessages(num(params.tripId, 'tripId'), before) as unknown[]; }); } @PluginMethod('collab.createNote', { permission: 'db:write:collab' }) - createNote(params: Record, ctx: PluginRpcContext): unknown { + async createNote(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.guards.requireActor(ctx, 'collab note'); const input = asPayload(params.input); if (typeof input.title !== 'string' || input.title.trim() === '') throw new BadParams('note title is required'); - this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); - this.requireCollabAddon(); + await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); + await this.requireCollabAddon(); const note = this.collab.createNote(String(tripId), actor, input as never); this.realtime.broadcast(tripId, 'collab:note:created', { note }, undefined); return note; } @PluginMethod('collab.createPoll', { permission: 'db:write:collab' }) - createPoll(params: Record, ctx: PluginRpcContext): unknown { + async createPoll(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.guards.requireActor(ctx, 'collab poll'); const input = asPayload(params.input); if (typeof input.question !== 'string' || input.question.trim() === '') throw new BadParams('poll question is required'); if (!Array.isArray(input.options) || input.options.length < 2) throw new BadParams('a poll needs at least two options'); - this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); - this.requireCollabAddon(); + await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); + await this.requireCollabAddon(); const poll = this.collab.createPoll(String(tripId), actor, input as never); this.realtime.broadcast(tripId, 'collab:poll:created', { poll }, undefined); return poll; } @PluginMethod('collab.votePoll', { permission: 'db:write:collab' }) - votePoll(params: Record, ctx: PluginRpcContext): unknown { + async votePoll(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.guards.requireActor(ctx, 'collab poll'); - this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); - this.requireCollabAddon(); + await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); + await this.requireCollabAddon(); const result = this.collab.votePoll(String(tripId), String(num(params.pollId, 'pollId')), actor, num(params.optionIndex, 'optionIndex')); // The service reports its own validation failures rather than throwing. if (result.error) throw new BadParams(result.error); @@ -95,14 +95,14 @@ export class CollabRpc { } @PluginMethod('collab.createMessage', { permission: 'db:write:collab' }) - createMessage(params: Record, ctx: PluginRpcContext): unknown { + async createMessage(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.guards.requireActor(ctx, 'collab message'); if (typeof params.text !== 'string' || params.text.trim() === '' || params.text.length > 4000) { throw new BadParams('message text is required (max 4000 chars)'); } - this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); - this.requireCollabAddon(); + await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); + await this.requireCollabAddon(); const replyTo = typeof params.replyTo === 'number' ? params.replyTo : null; const result = this.collab.createMessage(String(tripId), actor, params.text, replyTo); if (result.error) throw new BadParams(result.error); @@ -110,7 +110,7 @@ export class CollabRpc { return result.message; } - private requireCollabAddon(): void { - this.guards.requireAddon(ADDON_IDS.COLLAB, 'collab'); + private async requireCollabAddon(): Promise { + await this.guards.requireAddon(ADDON_IDS.COLLAB, 'collab'); } } diff --git a/server/src/nest/collab/collab.service.ts b/server/src/nest/collab/collab.service.ts index 67aca9369..65ad1ff53 100644 --- a/server/src/nest/collab/collab.service.ts +++ b/server/src/nest/collab/collab.service.ts @@ -147,11 +147,11 @@ export class CollabService { return this.db.canAccessTrip(tripId, userId); } - canEdit(trip: Trip, user: User): boolean { + async canEdit(trip: Trip, user: User): Promise { return this.permissions.checkPermission('collab_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id); } - canUploadFiles(trip: Trip, user: User): boolean { + async canUploadFiles(trip: Trip, user: User): Promise { return this.permissions.checkPermission('file_upload', user.role, trip.user_id, user.id, trip.user_id !== user.id); } diff --git a/server/src/nest/collections/collections.mcp.ts b/server/src/nest/collections/collections.mcp.ts index c90bcdcc8..efbef2807 100644 --- a/server/src/nest/collections/collections.mcp.ts +++ b/server/src/nest/collections/collections.mcp.ts @@ -304,7 +304,7 @@ export class CollectionsMcp { }) async copyCollectionPlacesToTrip(body: CollectionCopyToTripRequest, ctx: McpContext) { const demo = this.denyDemo(ctx.userId); if (demo) return demo; - try { return ok(this.collections.copyToTrip(ctx.userId, body)); } catch (err) { return fail(err); } + try { return ok(await this.collections.copyToTrip(ctx.userId, body)); } catch (err) { return fail(err); } } // ── Labels ──────────────────────────────────────────────────────────── diff --git a/server/src/nest/collections/collections.rpc.ts b/server/src/nest/collections/collections.rpc.ts index 8504bfa4a..292e89a1a 100644 --- a/server/src/nest/collections/collections.rpc.ts +++ b/server/src/nest/collections/collections.rpc.ts @@ -29,56 +29,56 @@ export class CollectionsRpc { ) {} @PluginMethod('collections.listMine', { permission: 'db:read:collections' }) - listMine(_params: Record, ctx: PluginRpcContext): unknown { + async listMine(_params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireCollectionsUser(ctx, 'reads'); - this.requireCollectionsAddon(); + await this.requireCollectionsAddon(); return this.collections.listCollections(userId); } @PluginMethod('collections.get', { permission: 'db:read:collections' }) - get(params: Record, ctx: PluginRpcContext): unknown { + async get(params: Record, ctx: PluginRpcContext): Promise { // getCollection is user-scoped by the service, so a plugin only ever fetches one // the acting user may see. const userId = this.requireCollectionsUser(ctx, 'reads'); const id = num(params.id, 'id'); - this.requireCollectionsAddon(); + await this.requireCollectionsAddon(); return this.collections.getCollection(userId, id); } @PluginMethod('collections.create', { permission: 'db:write:collections' }) - create(params: Record, ctx: PluginRpcContext): unknown { + async create(params: Record, ctx: PluginRpcContext): Promise { const parsed = collectionCreateRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid collection: ${schemaMessage(parsed.error)}`); const userId = this.requireCollectionsUser(ctx, 'writes'); - this.requireCollectionsAddon(); + await this.requireCollectionsAddon(); return this.mapCollectionError(() => this.collections.createCollection(userId, parsed.data as never)); } @PluginMethod('collections.update', { permission: 'db:write:collections' }) - update(params: Record, ctx: PluginRpcContext): unknown { + async update(params: Record, ctx: PluginRpcContext): Promise { const parsed = collectionUpdateRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid collection: ${schemaMessage(parsed.error)}`); const userId = this.requireCollectionsUser(ctx, 'writes'); const id = num(params.id, 'id'); - this.requireCollectionsAddon(); + await this.requireCollectionsAddon(); return this.mapCollectionError(() => this.collections.updateCollection(userId, id, parsed.data as never, undefined)); } @PluginMethod('collections.savePlace', { permission: 'db:write:collections' }) - savePlace(params: Record, ctx: PluginRpcContext): unknown { + async savePlace(params: Record, ctx: PluginRpcContext): Promise { const parsed = collectionSavePlaceRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid place: ${schemaMessage(parsed.error)}`); const userId = this.requireCollectionsUser(ctx, 'writes'); - this.requireCollectionsAddon(); + await this.requireCollectionsAddon(); return this.mapCollectionError(() => this.collections.savePlace(userId, parsed.data as never, undefined)); } @PluginMethod('collections.copyToTrip', { permission: 'db:write:collections' }) - copyToTrip(params: Record, ctx: PluginRpcContext): unknown { + async copyToTrip(params: Record, ctx: PluginRpcContext): Promise { const parsed = collectionCopyToTripRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid copy request: ${schemaMessage(parsed.error)}`); const userId = this.requireCollectionsUser(ctx, 'writes'); - this.requireCollectionsAddon(); + await this.requireCollectionsAddon(); return this.mapCollectionError(() => this.collections.copyToTrip(userId, parsed.data as never)); } @@ -86,7 +86,7 @@ export class CollectionsRpc { async deletePlace(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireCollectionsUser(ctx, 'writes'); const placeId = num(params.placeId, 'placeId'); - this.requireCollectionsAddon(); + await this.requireCollectionsAddon(); // deletePlace is async (it deletes the underlying storage object): await it so a // refusal actually reaches the plugin as RESOURCE_FORBIDDEN/BAD_PARAMS instead of // being dropped as an unhandled rejection while this returns {deleted: true} anyway. @@ -101,8 +101,8 @@ export class CollectionsRpc { return ctx.actingUserId; } - private requireCollectionsAddon(): void { - this.guards.requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); + private async requireCollectionsAddon(): Promise { + await this.guards.requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); } /** diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index 8006c03e4..ef2c98c48 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -1232,11 +1232,11 @@ export class CollectionsService { // Copy to trip // ------------------------------------------------------------------------- - copyToTrip(userId: number, body: CollectionCopyToTripRequest): { copied: number; skipped: { id: number; name: string }[] } { + async copyToTrip(userId: number, body: CollectionCopyToTripRequest): Promise<{ copied: number; skipped: { id: number; name: string }[] }> { const trip = this.db.canAccessTrip(body.trip_id, userId); if (!trip) httpError(404, 'Trip not found'); const role = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId)?.role ?? 'user'; - if (!this.permissions.checkPermission('place_edit', role, trip.user_id, userId, trip.user_id !== userId)) { + if (!(await this.permissions.checkPermission('place_edit', role, trip.user_id, userId, trip.user_id !== userId))) { httpError(403, 'Not allowed to edit this trip'); } diff --git a/server/src/nest/day-notes/day-notes.mcp.ts b/server/src/nest/day-notes/day-notes.mcp.ts index 69a8d3b88..bba8bc9c9 100644 --- a/server/src/nest/day-notes/day-notes.mcp.ts +++ b/server/src/nest/day-notes/day-notes.mcp.ts @@ -69,7 +69,7 @@ export class DayNotesMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.notes.dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; const note = this.notes.create(dayId, tripId, text, time, icon, sort_order, color); this.guards.safeBroadcast(tripId, 'dayNote:created', { dayId, note }); @@ -100,7 +100,7 @@ export class DayNotesMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); const existing = this.notes.getNote(noteId, dayId, tripId); if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; const note = this.notes.update(noteId, existing, { text, time: time !== undefined ? time : undefined, icon, color, sort_order }); @@ -122,7 +122,7 @@ export class DayNotesMcp { async deleteDayNote({ tripId, dayId, noteId }: { tripId: number; dayId: number; noteId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); const note = this.notes.getNote(noteId, dayId, tripId); if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; this.notes.remove(noteId); diff --git a/server/src/nest/day-notes/day-notes.rpc.ts b/server/src/nest/day-notes/day-notes.rpc.ts index 0f222676b..fa522aa86 100644 --- a/server/src/nest/day-notes/day-notes.rpc.ts +++ b/server/src/nest/day-notes/day-notes.rpc.ts @@ -35,13 +35,13 @@ export class DayNotesRpc { } @PluginMethod('daynotes.create', { permission: 'db:write:daynotes' }) - create(params: Record, ctx: PluginRpcContext): unknown { + async create(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const dayId = num(params.dayId, 'dayId'); const actor = this.guards.requireActor(ctx, 'day note'); const input = asPayload(params.input); if (typeof input.text !== 'string' || input.text.trim() === '') throw new BadParams('note text is required'); - this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION); if (!this.dayNotes.dayExists(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`); const i = input as DayNoteInput; const note = this.dayNotes.create(dayId, tripId, i.text ?? '', i.time, i.icon, i.sort_order); @@ -50,12 +50,12 @@ export class DayNotesRpc { } @PluginMethod('daynotes.update', { permission: 'db:write:daynotes' }) - update(params: Record, ctx: PluginRpcContext): unknown { + async update(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const dayId = num(params.dayId, 'dayId'); const noteId = num(params.noteId, 'noteId'); const actor = this.guards.requireActor(ctx, 'day note'); - this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION); const current = this.dayNotes.getNote(noteId, dayId, tripId); if (!current) throw new ForbiddenResource(`no note ${noteId} on day ${dayId}`); const note = this.dayNotes.update(noteId, current as never, asPayload(params.input) as DayNoteInput); @@ -64,12 +64,12 @@ export class DayNotesRpc { } @PluginMethod('daynotes.delete', { permission: 'db:write:daynotes' }) - delete(params: Record, ctx: PluginRpcContext): unknown { + async delete(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const dayId = num(params.dayId, 'dayId'); const noteId = num(params.noteId, 'noteId'); const actor = this.guards.requireActor(ctx, 'day note'); - this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION); const current = this.dayNotes.getNote(noteId, dayId, tripId); if (!current) throw new ForbiddenResource(`no note ${noteId} on day ${dayId}`); this.dayNotes.remove(noteId); diff --git a/server/src/nest/day-notes/day-notes.service.ts b/server/src/nest/day-notes/day-notes.service.ts index 4a1ffacf6..177c3038e 100644 --- a/server/src/nest/day-notes/day-notes.service.ts +++ b/server/src/nest/day-notes/day-notes.service.ts @@ -37,7 +37,7 @@ export class DayNotesService { return this.dbs.canAccessTrip(tripId, userId); } - canEdit(trip: TripAccess, user: User): boolean { + async canEdit(trip: TripAccess, user: User): Promise { return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id); } diff --git a/server/src/nest/days/days.controller.ts b/server/src/nest/days/days.controller.ts index 37380d2ef..94aa81651 100644 --- a/server/src/nest/days/days.controller.ts +++ b/server/src/nest/days/days.controller.ts @@ -61,7 +61,7 @@ export class DaysController { @RequirePermission('day_edit') @Put('reorder') - reorder( + async reorder( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: DayReorderDto, @@ -71,7 +71,7 @@ export class DaysController { throw new HttpException({ error: 'orderedIds must be an array' }, 400); } try { - this.days.reorder(tripId, body.orderedIds); + await this.days.reorder(tripId, body.orderedIds); } catch (err) { if (err instanceof DayReorderError) { throw new HttpException({ error: err.message }, 400); @@ -84,7 +84,7 @@ export class DaysController { @RequirePermission('day_edit') @Put(':id') - update( + async update( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -98,14 +98,14 @@ export class DaysController { // The zod-parsed body carries only the keys the client actually sent, so // the service's presence sentinels preserve the omitted column (the client // updates notes and title in separate requests). - const day = this.days.update(id, current as never, body); + const day = await this.days.update(id, current as never, body); this.days.broadcast(tripId, 'day:updated', { day }, socketId); return { day }; } @RequirePermission('day_edit') @Put(':id/transport') - transport( + async transport( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -115,7 +115,7 @@ export class DaysController { if (!this.days.getDay(id, tripId)) { throw new HttpException({ error: 'Day not found' }, 404); } - const day = this.days.setDefaultTransportMode(id, body.transport_mode ?? null); + const day = await this.days.setDefaultTransportMode(id, body.transport_mode ?? null); this.days.broadcast(tripId, 'day:updated', { day }, socketId); return { day }; } diff --git a/server/src/nest/days/days.mcp.ts b/server/src/nest/days/days.mcp.ts index 25bc75baf..bcf7301ce 100644 --- a/server/src/nest/days/days.mcp.ts +++ b/server/src/nest/days/days.mcp.ts @@ -64,13 +64,13 @@ export class DaysMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); const current = this.days.getDay(dayId, tripId); if (!current) return errorResult('Day not found.'); // The rest spread carries only the keys the caller actually sent, which is // what update()'s presence sentinels need: naming the two fields here would // hand it an undefined notes on a title-only call and wipe the day's notes. - const updated = this.days.update(dayId, current, fields); + const updated = await this.days.update(dayId, current, fields); this.guards.safeBroadcast(tripId, 'day:updated', { day: updated }); return ok({ day: updated }); } @@ -93,7 +93,7 @@ export class DaysMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (position === undefined) { const day = this.days.create(tripId, date, notes); this.guards.safeBroadcast(tripId, 'day:created', { day }); @@ -128,9 +128,9 @@ export class DaysMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); try { - this.days.reorder(tripId, orderedIds); + await this.days.reorder(tripId, orderedIds); } catch (err) { // A non-permutation and an inverted stay are both the caller's input, so // they come back as tool errors rather than a throw the SDK has to dress up. @@ -155,7 +155,7 @@ export class DaysMcp { async deleteDay({ tripId, dayId }: { tripId: number; dayId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.'); this.days.remove(dayId); // REST parity shape ({ dayId }) — the client reads payload.dayId, so the { id } @@ -181,9 +181,9 @@ export class DaysMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.'); - const day = this.days.setDefaultTransportMode(dayId, transport_mode ?? null); + const day = await this.days.setDefaultTransportMode(dayId, transport_mode ?? null); this.guards.safeBroadcast(tripId, 'day:updated', { day }); return ok({ day }); } @@ -206,7 +206,7 @@ export class DaysMcp { }], }; } - const { days } = this.days.list(id); + const { days } = await this.days.list(id); return { contents: [{ uri: uri.href, diff --git a/server/src/nest/days/days.rpc.ts b/server/src/nest/days/days.rpc.ts index bd9f820be..7e2feece6 100644 --- a/server/src/nest/days/days.rpc.ts +++ b/server/src/nest/days/days.rpc.ts @@ -24,12 +24,12 @@ export class DaysRpc { ) {} @PluginMethod('days.create', { permission: 'db:write:days' }) - create(params: Record, ctx: PluginRpcContext): unknown { + async create(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.guards.requireActor(ctx, 'day'); const parsed = dayCreateRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid day: ${schemaMessage(parsed.error)}`); - this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION); const input = parsed.data as { date?: string; notes?: string }; const day = this.days.create(tripId, input.date, input.notes); this.realtime.broadcast(tripId, 'day:created', { day }); @@ -37,27 +37,27 @@ export class DaysRpc { } @PluginMethod('days.update', { permission: 'db:write:days' }) - update(params: Record, ctx: PluginRpcContext): unknown { + async update(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const dayId = num(params.dayId, 'dayId'); const actor = this.guards.requireActor(ctx, 'day'); const parsed = dayUpdateRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid day: ${schemaMessage(parsed.error)}`); - this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION); // getDay scopes the row to the trip before the write touches it. const current = this.days.getDay(dayId, tripId); if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`); - const day = this.days.update(dayId, current, parsed.data as { notes?: string; title?: string | null }); + const day = await this.days.update(dayId, current, parsed.data as { notes?: string; title?: string | null }); this.realtime.broadcast(tripId, 'day:updated', { day }); return day; } @PluginMethod('days.delete', { permission: 'db:write:days' }) - delete(params: Record, ctx: PluginRpcContext): unknown { + async delete(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const dayId = num(params.dayId, 'dayId'); const actor = this.guards.requireActor(ctx, 'day'); - this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION); if (!this.days.getDay(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`); this.days.remove(dayId); this.realtime.broadcast(tripId, 'day:deleted', { dayId }); diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index e26398746..5deb1a733 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -72,7 +72,7 @@ export class DaysService { return this.db.canAccessTrip(Number(tripId), userId); } - canEdit(trip: Trip, user: User): boolean { + async canEdit(trip: Trip, user: User): Promise { return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id); } @@ -84,7 +84,7 @@ export class DaysService { // Day assignment helpers // ------------------------------------------------------------------------- - getAssignmentsForDay(dayId: number | string) { + async getAssignmentsForDay(dayId: number | string) { const assignments = this.db.all(` SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, @@ -103,7 +103,7 @@ export class DaysService { // One batched tag load instead of the legacy per-assignment query; the // non-compact loader returns the same full tag rows (t.* minus the join // key), so the output shape is unchanged. - const tagsByPlaceId = this.queryHelpers.loadTagsByPlaceIds([...new Set(assignments.map(a => a.place_id))]); + const tagsByPlaceId = await this.queryHelpers.loadTagsByPlaceIds([...new Set(assignments.map(a => a.place_id))]); return assignments.map(a => { const tags = tagsByPlaceId[a.place_id] || []; @@ -160,7 +160,7 @@ export class DaysService { // Day CRUD // ------------------------------------------------------------------------- - list(tripId: string | number) { + async list(tripId: string | number) { const days = this.db.all('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC', tripId); if (days.length === 0) { @@ -186,10 +186,10 @@ export class DaysService { `, ...dayIds); const placeIds = [...new Set(allAssignments.map(a => a.place_id))]; - const tagsByPlaceId = this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true }); + const tagsByPlaceId = await this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true }); const allAssignmentIds = allAssignments.map(a => a.id); - const participantsByAssignment = this.queryHelpers.loadParticipantsByAssignmentIds(allAssignmentIds); + const participantsByAssignment = await this.queryHelpers.loadParticipantsByAssignmentIds(allAssignmentIds); const assignmentsByDayId: Record[]> = {}; for (const a of allAssignments) { @@ -233,7 +233,7 @@ export class DaysService { return this.db.get('SELECT * FROM days WHERE id = ? AND trip_id = ?', id, tripId); } - update(id: string | number, current: Day, fields: { notes?: string; title?: string | null }) { + async update(id: string | number, current: Day, fields: { notes?: string; title?: string | null }) { // Both columns use the presence sentinel: an absent key preserves the // current value (the legacy version always wrote notes, so setting a title // silently wiped the day's notes — the client sends the two fields in @@ -244,7 +244,7 @@ export class DaysService { id ); const updatedDay = this.db.get('SELECT * FROM days WHERE id = ?', id)!; - return { ...updatedDay, assignments: this.getAssignmentsForDay(id) }; + return { ...updatedDay, assignments: await this.getAssignmentsForDay(id) }; } /** @@ -252,10 +252,10 @@ export class DaysService { * notes/title the way the general day update would, and symmetric with the * per-leg assignment transport setter. Per-segment leg modes still override it. */ - setDefaultTransportMode(id: string | number, mode: string | null) { + async setDefaultTransportMode(id: string | number, mode: string | null) { this.db.run('UPDATE days SET default_transport_mode = ? WHERE id = ?', mode ?? null, id); const updatedDay = this.db.get('SELECT * FROM days WHERE id = ?', id)!; - return { ...updatedDay, assignments: this.getAssignmentsForDay(id) }; + return { ...updatedDay, assignments: await this.getAssignmentsForDay(id) }; } remove(id: string | number): void { diff --git a/server/src/nest/doc-sync/doc-sync-webhook.controller.ts b/server/src/nest/doc-sync/doc-sync-webhook.controller.ts index 5a2c5f660..47ad14cc6 100644 --- a/server/src/nest/doc-sync/doc-sync-webhook.controller.ts +++ b/server/src/nest/doc-sync/doc-sync-webhook.controller.ts @@ -54,8 +54,8 @@ export class DocSyncWebhookController implements OnModuleDestroy { * well as on arrival, so a switch thrown during the debounce window still * takes effect. */ - private syncIsOn(link: LinkRow): boolean { - if (this.sync.isSwitchedOff(link)) return false; + private async syncIsOn(link: LinkRow): Promise { + if ((await this.sync.isSwitchedOff(link))) return false; const killSwitch = this.db.get<{ value: string }>( 'SELECT value FROM app_settings WHERE key = ?', SETTING_SYNC_ENABLED, )?.value; @@ -72,12 +72,12 @@ export class DocSyncWebhookController implements OnModuleDestroy { @Post(':token') @Public('A provider cannot hold a TREK session; the per-link token in the URL is the authentication, and the call can only ever trigger a sync run.') @HttpCode(200) - nudge(@Param('token') token: string, @Req() req: Request) { + async nudge(@Param('token') token: string, @Req() req: Request) { const link = this.config.getLinkByToken(token); // Always 200, even for an unknown token: a 404 here would let anyone probe // which tokens exist, and a provider that gets an error will retry anyway. if (!link || link.sync_enabled !== 1) return { received: true }; - if (!this.syncIsOn(link)) return { received: true }; + if (!(await this.syncIsOn(link))) return { received: true }; // The secret is only known to a provider TREK subscribed at itself, so it // is only demanded there. A URL pasted into a store by hand (Papra, or a @@ -108,11 +108,11 @@ export class DocSyncWebhookController implements OnModuleDestroy { */ private schedule(linkId: number, reload: () => ReturnType, isRetry = false): void { if (this.pending.has(linkId)) return; - const timer = setTimeout(() => { + const timer = setTimeout(async () => { this.pending.delete(linkId); const fresh = reload(); if (!fresh || fresh.sync_enabled !== 1) return; - if (!this.syncIsOn(fresh)) return; + if (!(await this.syncIsOn(fresh))) return; void this.sync.syncLink(fresh).then((res) => { // A run that was already in flight answers `busy`, and the changes this // nudge was about may have landed after that run read the folder. Ask diff --git a/server/src/nest/doc-sync/doc-sync.controller.ts b/server/src/nest/doc-sync/doc-sync.controller.ts index 3ad7c4f42..9b8d06c01 100644 --- a/server/src/nest/doc-sync/doc-sync.controller.ts +++ b/server/src/nest/doc-sync/doc-sync.controller.ts @@ -382,7 +382,7 @@ export class DocSyncController { // Refused before the shelved rows are touched, so a binding an admin // switched off stays exactly as it was and resumes where it stopped. A code // rather than a sentence: the client says it in the reader's language. - if (this.sync.isSwitchedOff(link)) { + if ((await this.sync.isSwitchedOff(link))) { throw new HttpException({ error: PROVIDER_DISABLED }, 409); } // A person asking for a run is also asking for the rows that gave up to be diff --git a/server/src/nest/doc-sync/doc-sync.job.ts b/server/src/nest/doc-sync/doc-sync.job.ts index 025fff129..68c9087ab 100644 --- a/server/src/nest/doc-sync/doc-sync.job.ts +++ b/server/src/nest/doc-sync/doc-sync.job.ts @@ -82,7 +82,7 @@ export class DocSyncJob implements OnApplicationBootstrap { async tick(): Promise { try { - if (!this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) return; + if (!(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) return; const killSwitch = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', SETTING_SYNC_ENABLED)?.value; // Unrecognised values mean ON here because the setting is absent by // default; only an explicit 'false' stops the sync. diff --git a/server/src/nest/doc-sync/doc-sync.mcp.ts b/server/src/nest/doc-sync/doc-sync.mcp.ts index 7e0f5f0cf..f84240901 100644 --- a/server/src/nest/doc-sync/doc-sync.mcp.ts +++ b/server/src/nest/doc-sync/doc-sync.mcp.ts @@ -51,9 +51,9 @@ export class DocSyncMcp { access: { group: 'files', mode: 'read' }, when: documentsAddonOn, }) - getTripDocumentSync({ tripId }: { tripId: number }, ctx: McpContext) { + async getTripDocumentSync({ tripId }: { tripId: number }, ctx: McpContext) { if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - return ok(this.sync.status(tripId)); + return ok(await this.sync.status(tripId)); } @Tool({ @@ -108,7 +108,7 @@ export class DocSyncMcp { // Refused as the REST route refuses it, before the shelved rows below are // touched: a binding an admin switched off stays exactly as it was, so it // resumes where it stopped once the provider is back on. - if (this.sync.isSwitchedOff(link)) { + if ((await this.sync.isSwitchedOff(link))) { results.push({ linkId: link.id, provider: link.provider_id, state: 'disabled', errorCode: PROVIDER_DISABLED }); continue; } diff --git a/server/src/nest/doc-sync/doc-sync.service.ts b/server/src/nest/doc-sync/doc-sync.service.ts index 35d7f139a..812224160 100644 --- a/server/src/nest/doc-sync/doc-sync.service.ts +++ b/server/src/nest/doc-sync/doc-sync.service.ts @@ -133,8 +133,8 @@ export class DocSyncService { * resumes exactly where it stopped once the switch is back on. Public because * a manual run has to refuse before it un-shelves anything. */ - isSwitchedOff(link: Pick): boolean { - if (!this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) return true; + async isSwitchedOff(link: Pick): Promise { + if (!(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) return true; return !this.config.enabledProviderIds().includes(link.provider_id); } @@ -174,7 +174,7 @@ export class DocSyncService { // First, before anything that records a failure: a binding whose provider // is switched off must come back as it was left. The webhook and a first // run after binding reach this without asking beforehand. - if (this.isSwitchedOff(link)) { + if ((await this.isSwitchedOff(link))) { return { state: 'disabled', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }; } // An orphaned binding runs for nobody. Its credential belongs to somebody @@ -1100,7 +1100,7 @@ export class DocSyncService { } /** Per-trip view for the UI: what is synced, what needs attention. */ - status(tripId: number): Record { + async status(tripId: number): Promise> { const links = this.config.listLinks(tripId); const counts = this.db.connection .prepare('SELECT state, COUNT(*) AS n FROM document_sync_items WHERE trip_id = ? GROUP BY state') @@ -1138,22 +1138,26 @@ export class DocSyncService { .all(tripId) as Array<{ link_id: number; paired: number; atProvider: number; missing: number }>; const byLink = new Map(perLink.map((r) => [r.link_id, r])); + // `map` cannot await the provider-off read, so the projection runs as an + // explicit loop — same order, same rows. + const linkCards = []; + for (const l of links) { + const row = byLink.get(l.id); + linkCards.push({ + ...this.config.publicLink(l, null), + // Paused rather than failed, so the card can say why nothing moves + // without the binding's own state being touched. + providerOff: await this.isSwitchedOff(l), + holdings: { + inTrek: totalHere.n, + atProvider: Number(row?.atProvider ?? 0), + paired: Number(row?.paired ?? 0), + missing: Number(row?.missing ?? 0), + }, + }); + } return { - links: links.map((l) => { - const row = byLink.get(l.id); - return { - ...this.config.publicLink(l, null), - // Paused rather than failed, so the card can say why nothing moves - // without the binding's own state being touched. - providerOff: this.isSwitchedOff(l), - holdings: { - inTrek: totalHere.n, - atProvider: Number(row?.atProvider ?? 0), - paired: Number(row?.paired ?? 0), - missing: Number(row?.missing ?? 0), - }, - }; - }), + links: linkCards, items: Object.fromEntries(counts.map((c) => [c.state, c.n])), }; } diff --git a/server/src/nest/feeds/feeds.mcp.ts b/server/src/nest/feeds/feeds.mcp.ts index fedc59f24..b8852530c 100644 --- a/server/src/nest/feeds/feeds.mcp.ts +++ b/server/src/nest/feeds/feeds.mcp.ts @@ -57,9 +57,9 @@ export class FeedsMcp { } /** Trip access first (404-equivalent), then share_manage, exactly as TripFeedTokenController is gated. */ - private denyTripFeed(tripId: number, userId: number) { + private async denyTripFeed(tripId: number, userId: number) { if (!this.db.canAccessTrip(tripId, userId)) return noAccess(); - if (!this.guards.hasTripPermission('share_manage', tripId, userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('share_manage', tripId, userId))) return permissionDenied(); return null; } @@ -75,8 +75,8 @@ export class FeedsMcp { access: { group: 'trips', mode: 'share' }, }) async getTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) { - const denied = this.denyTripFeed(tripId, ctx.userId); - if (denied) return denied; + const denied = await this.denyTripFeed(tripId, ctx.userId); + if ((await denied)) return denied; return ok(this.feeds.getTripToken(String(tripId), ctx.userId, this.base())); } @@ -91,8 +91,8 @@ export class FeedsMcp { }) async enableTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) { if (this.isDemoUser(ctx.userId)) return demoDenied(); - const denied = this.denyTripFeed(tripId, ctx.userId); - if (denied) return denied; + const denied = await this.denyTripFeed(tripId, ctx.userId); + if ((await denied)) return denied; return ok(this.feeds.generateTripToken(String(tripId), ctx.userId, this.base())); } @@ -107,8 +107,8 @@ export class FeedsMcp { }) async rotateTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) { if (this.isDemoUser(ctx.userId)) return demoDenied(); - const denied = this.denyTripFeed(tripId, ctx.userId); - if (denied) return denied; + const denied = await this.denyTripFeed(tripId, ctx.userId); + if ((await denied)) return denied; return ok(this.feeds.rotateTripToken(String(tripId), ctx.userId, this.base())); } @@ -123,8 +123,8 @@ export class FeedsMcp { }) async disableTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) { if (this.isDemoUser(ctx.userId)) return demoDenied(); - const denied = this.denyTripFeed(tripId, ctx.userId); - if (denied) return denied; + const denied = await this.denyTripFeed(tripId, ctx.userId); + if ((await denied)) return denied; this.feeds.disableTripToken(String(tripId), ctx.userId); // Matches the route, which answers the cleared token as a null URL. return ok({ feed_url: null }); diff --git a/server/src/nest/files/files.controller.ts b/server/src/nest/files/files.controller.ts index c5077c0a8..e03f566af 100644 --- a/server/src/nest/files/files.controller.ts +++ b/server/src/nest/files/files.controller.ts @@ -129,7 +129,7 @@ export class FilesController { if (isDemoWriteBlocked(this.env, user.email)) { throw new HttpException(DEMO_WRITE_ERROR, 403); } - if (!this.files.can('file_upload', trip, user)) { + if (!(await this.files.can('file_upload', trip, user))) { throw new HttpException({ error: 'No permission to upload files' }, 403); } } catch (err) { @@ -173,8 +173,8 @@ export class FilesController { @UseGuards(TripAccessGuard) @Put(':id') - update(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileUpdateDto, @Headers('x-socket-id') socketId?: string) { - if (!this.files.can('file_edit', trip, user)) { + async update(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileUpdateDto, @Headers('x-socket-id') socketId?: string) { + if (!(await this.files.can('file_edit', trip, user))) { throw new HttpException({ error: 'No permission to edit files' }, 403); } const file = this.files.getFileById(id, tripId); @@ -194,8 +194,8 @@ export class FilesController { @UseGuards(TripAccessGuard) @Patch(':id/star') - star(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { - if (!this.files.can('file_edit', trip, user)) { + async star(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { + if (!(await this.files.can('file_edit', trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } const file = this.files.getFileById(id, tripId); @@ -210,7 +210,7 @@ export class FilesController { @UseGuards(TripAccessGuard) @Delete('trash/empty') async emptyTrash(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string) { - if (!this.files.can('file_delete', trip, user)) { + if (!(await this.files.can('file_delete', trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } const deleted = await this.files.emptyTrash(tripId); @@ -220,7 +220,7 @@ export class FilesController { @UseGuards(TripAccessGuard) @Delete(':id/permanent') async permanent(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { - if (!this.files.can('file_delete', trip, user)) { + if (!(await this.files.can('file_delete', trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } const file = this.files.getDeletedFile(id, tripId); @@ -234,8 +234,8 @@ export class FilesController { @UseGuards(TripAccessGuard) @Delete(':id') - remove(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { - if (!this.files.can('file_delete', trip, user)) { + async remove(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { + if (!(await this.files.can('file_delete', trip, user))) { throw new HttpException({ error: 'No permission to delete files' }, 403); } const file = this.files.getFileById(id, tripId); @@ -250,8 +250,8 @@ export class FilesController { @UseGuards(TripAccessGuard) @Post(':id/restore') @HttpCode(200) // Express answers restore with res.json (200), not the POST-default 201. - restore(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { - if (!this.files.can('file_delete', trip, user)) { + async restore(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { + if (!(await this.files.can('file_delete', trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } const file = this.files.getDeletedFile(id, tripId); @@ -266,8 +266,8 @@ export class FilesController { @UseGuards(TripAccessGuard) @Post(':id/link') @HttpCode(200) // Express answers link with res.json (200). - link(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileLinkDto) { - if (!this.files.can('file_edit', trip, user)) { + async link(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileLinkDto) { + if (!(await this.files.can('file_edit', trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } const file = this.files.getFileById(id, tripId); @@ -281,8 +281,8 @@ export class FilesController { @UseGuards(TripAccessGuard) @Delete(':id/link/:linkId') - unlink(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Param('linkId') linkId: string) { - if (!this.files.can('file_edit', trip, user)) { + async unlink(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Param('linkId') linkId: string) { + if (!(await this.files.can('file_edit', trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } // deleteFileLink scopes by (linkId, fileId) only, so the file itself has to diff --git a/server/src/nest/files/files.mcp.ts b/server/src/nest/files/files.mcp.ts index 3858c2485..76119722c 100644 --- a/server/src/nest/files/files.mcp.ts +++ b/server/src/nest/files/files.mcp.ts @@ -123,7 +123,7 @@ export class FilesMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied(); const current = this.files.getFileById(fileId, tripId); if (!current) return errorResult('File not found.'); // Same enforcement as the REST route's assertLinkTargets, through the same @@ -163,7 +163,7 @@ export class FilesMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.files.getFileById(fileId, tripId)) return errorResult('File not found.'); // The REST body allows all three to be absent and stores a link row pointing at // nothing. A tool caller that gets here with no target made a mistake, and saying @@ -194,7 +194,7 @@ export class FilesMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied(); // deleteFileLink scopes by (linkId, fileId) only, so the file has to be resolved // against :tripId first, exactly as the REST route does it. Otherwise a member of // any trip could drop a link row belonging to a foreign trip's file. diff --git a/server/src/nest/files/files.rpc.ts b/server/src/nest/files/files.rpc.ts index 90b034595..439f3a272 100644 --- a/server/src/nest/files/files.rpc.ts +++ b/server/src/nest/files/files.rpc.ts @@ -101,7 +101,7 @@ export class FilesRpc { if (input.content_base64.length > 14 * 1024 * 1024) { throw new BadParams('file exceeds the 10MB plugin upload cap'); } - this.guards.requireTripEdit(tripId, actor, UPLOAD_ACTION); + await this.guards.requireTripEdit(tripId, actor, UPLOAD_ACTION); return this.writeFile(tripId, input as unknown as CreateInput, actor); } @@ -148,11 +148,11 @@ export class FilesRpc { } @PluginMethod('files.createLink', { permission: 'db:write:files' }) - createLink(params: Record, ctx: PluginRpcContext): unknown { + async createLink(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const fileId = num(params.fileId, 'fileId'); const actor = this.guards.requireActor(ctx, 'file link'); - this.guards.requireTripEdit(tripId, actor, EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, EDIT_ACTION); if (!this.files.getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`); const opts = asPayload(params.opts) as { reservation_id?: number; assignment_id?: number; place_id?: number }; // A link target on another trip would otherwise attach this trip's file to it. @@ -166,11 +166,11 @@ export class FilesRpc { } @PluginMethod('files.update', { permission: 'db:write:files' }) - update(params: Record, ctx: PluginRpcContext): unknown { + async update(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const fileId = num(params.fileId, 'fileId'); const actor = this.guards.requireActor(ctx, 'file'); - this.guards.requireTripEdit(tripId, actor, EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, EDIT_ACTION); const current = this.files.getFileById(fileId, tripId); if (!current) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`); const input = asPayload(params.input) as { description?: string; place_id?: number | null; reservation_id?: number | null }; @@ -190,11 +190,11 @@ export class FilesRpc { } @PluginMethod('files.softDelete', { permission: 'db:write:files' }) - softDelete(params: Record, ctx: PluginRpcContext): unknown { + async softDelete(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const fileId = num(params.fileId, 'fileId'); const actor = this.guards.requireActor(ctx, 'file'); - this.guards.requireTripEdit(tripId, actor, DELETE_ACTION); + await this.guards.requireTripEdit(tripId, actor, DELETE_ACTION); if (!this.files.getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`); this.files.softDeleteFile(fileId); this.realtime.broadcast(tripId, 'file:deleted', { fileId }, undefined); diff --git a/server/src/nest/files/files.service.ts b/server/src/nest/files/files.service.ts index 43aa44029..0033b9eb4 100644 --- a/server/src/nest/files/files.service.ts +++ b/server/src/nest/files/files.service.ts @@ -85,7 +85,7 @@ export class FilesService { return this.db.canAccessTrip(tripId, userId); } - can(action: FilePermission, trip: Trip, user: User): boolean { + async can(action: FilePermission, trip: Trip, user: User): Promise { return this.permissions.checkPermission(action, user.role, trip.user_id, user.id, trip.user_id !== user.id); } diff --git a/server/src/nest/health/features.controller.ts b/server/src/nest/health/features.controller.ts index 9df6b7041..a13a2d6a1 100644 --- a/server/src/nest/health/features.controller.ts +++ b/server/src/nest/health/features.controller.ts @@ -25,12 +25,12 @@ export class FeaturesController { } @Get('features') - features() { + async features() { return { bookingImport: this.extractor.isAvailable(), // Addon-level flag (per-user config availability is reported per-file in // the preview response). Drives whether the client shows AI affordances. - aiParsing: this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING), + aiParsing: await this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING), }; } } diff --git a/server/src/nest/integrations/airtrail-import.service.ts b/server/src/nest/integrations/airtrail-import.service.ts index 1265e9788..86ce06eae 100644 --- a/server/src/nest/integrations/airtrail-import.service.ts +++ b/server/src/nest/integrations/airtrail-import.service.ts @@ -234,7 +234,7 @@ export class AirtrailImportService { const ids = chain.map(f => String(f.id)); try { const mapped = mapFlightsToMultiLegReservation(chain, resolveDayId); - const { reservation } = this.reservations.create(tripId, mapped as any); + const { reservation } = await this.reservations.create(tripId, mapped as any); const now = new Date().toISOString(); this.db.prepare( `UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?, @@ -278,7 +278,7 @@ export class AirtrailImportService { } try { - const { reservation } = this.reservations.create(tripId, mapped as any); + const { reservation } = await this.reservations.create(tripId, mapped as any); const now = new Date().toISOString(); this.db.prepare( `UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?, diff --git a/server/src/nest/integrations/airtrail-link.service.ts b/server/src/nest/integrations/airtrail-link.service.ts index 1312f7201..e5164edfc 100644 --- a/server/src/nest/integrations/airtrail-link.service.ts +++ b/server/src/nest/integrations/airtrail-link.service.ts @@ -34,8 +34,8 @@ export class AirtrailLinkService { ) {} /** Global on/off: the addon must be enabled and sync not explicitly turned off. */ - syncGloballyEnabled(): boolean { - if (!this.addons.isAddonEnabled(ADDON_IDS.AIRTRAIL)) return false; + async syncGloballyEnabled(): Promise { + if (!(await this.addons.isAddonEnabled(ADDON_IDS.AIRTRAIL))) return false; const row = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'airtrail_sync_enabled'"); return row?.value !== 'false'; } @@ -82,7 +82,7 @@ export class AirtrailLinkService { * next pull's AirTrail-wins policy can't silently revert the local edit. */ async pushReservationToAirtrail(reservationId: number, tripId: number): Promise { - if (!this.syncGloballyEnabled()) return; + if (!(await this.syncGloballyEnabled())) return; const row = this.db.get<{ id: number; trip_id: number; external_id: string; external_owner_user_id: number | null; sync_enabled: number; diff --git a/server/src/nest/integrations/airtrail-sync.service.ts b/server/src/nest/integrations/airtrail-sync.service.ts index afdf5156f..6b897aaf2 100644 --- a/server/src/nest/integrations/airtrail-sync.service.ts +++ b/server/src/nest/integrations/airtrail-sync.service.ts @@ -38,7 +38,7 @@ export class AirtrailSyncService { * than a module-level flag, because the service is a container singleton. */ private running = false; - syncGloballyEnabled(): boolean { + async syncGloballyEnabled(): Promise { return this.link.syncGloballyEnabled(); } @@ -91,7 +91,7 @@ export class AirtrailSyncService { continue; } try { - this.reservations.update(row.id, row.trip_id, mapFlightToReservation(flight) as any, current as any); + await this.reservations.update(row.id, row.trip_id, mapFlightToReservation(flight) as any, current as any); this.db.run( 'UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?', hash, @@ -110,7 +110,7 @@ export class AirtrailSyncService { /** Background poll across every connected owner (scheduler). */ async runAirtrailSync(): Promise { if (this.running) return; - if (!this.link.syncGloballyEnabled()) return; + if (!(await this.link.syncGloballyEnabled())) return; this.running = true; let changed = 0; try { @@ -132,7 +132,7 @@ export class AirtrailSyncService { * background poll. */ async runAirtrailSyncForUser(userId: number): Promise<{ changed: number }> { - if (!this.link.syncGloballyEnabled()) return { changed: 0 }; + if (!(await this.link.syncGloballyEnabled())) return { changed: 0 }; try { return { changed: await this.syncOwner(userId) }; } catch (err) { diff --git a/server/src/nest/integrations/airtrail.service.ts b/server/src/nest/integrations/airtrail.service.ts index 579c8ac40..5c2a43824 100644 --- a/server/src/nest/integrations/airtrail.service.ts +++ b/server/src/nest/integrations/airtrail.service.ts @@ -100,7 +100,7 @@ export class AirtrailService { return { success: false, error: ssrf.error ?? 'Invalid AirTrail URL' }; } if (ssrf.isPrivate) { - this.audit.writeAudit({ + await this.audit.writeAudit({ userId, action: 'airtrail.private_ip_configured', ip: clientIp, diff --git a/server/src/nest/integrations/dawarich-suggestions.service.ts b/server/src/nest/integrations/dawarich-suggestions.service.ts index 28b281455..88247ca02 100644 --- a/server/src/nest/integrations/dawarich-suggestions.service.ts +++ b/server/src/nest/integrations/dawarich-suggestions.service.ts @@ -13,6 +13,7 @@ import { type DawarichSuggestionList, } from '@trek/shared'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { AtlasService } from '../atlas/atlas.service'; import { PlacesService } from '../places/places.service'; import { AssignmentsService } from '../assignments/assignments.service'; @@ -53,6 +54,7 @@ export class DawarichSuggestionsService { private readonly assignments: AssignmentsService, private readonly permissions: PermissionsService, private readonly journey: JourneyDomainService, + private readonly uow: UnitOfWork, ) {} /** @@ -175,7 +177,7 @@ export class DawarichSuggestionsService { * MCP tool and any future caller each shape the failure their own way — the * atlas domain's `BucketItemExistsError` is the precedent. */ - accept(userId: number, id: number, body: DawarichAccept, sid?: string): DawarichAcceptResult { + async accept(userId: number, id: number, body: DawarichAccept, sid?: string): Promise { const row = this.db.get( 'SELECT * FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?', id, @@ -201,12 +203,12 @@ export class DawarichSuggestionsService { * written: permission to touch the trip is not permission to attach a place * to somebody else's day. */ - private acceptAsPlace( + private async acceptAsPlace( userId: number, row: SuggestionRow, body: DawarichAccept, sid?: string, - ): DawarichAcceptResult { + ): Promise { const tripId = body.tripId ?? row.trip_id; if (!tripId) throw new AcceptError('trip_required', 'A trip is required to create a place', 400); @@ -219,10 +221,10 @@ export class DawarichSuggestionsService { // owner, a member accepting a stay must be refused exactly as they would be // in the planner. Asked here rather than in the controller so the MCP tool // cannot take a different route to the same write. - this.requirePermission('place_edit', access.user_id, userId); + await this.requirePermission('place_edit', access.user_id, userId); if (body.dayId !== undefined) { - this.requirePermission('day_edit', access.user_id, userId); + await this.requirePermission('day_edit', access.user_id, userId); if (!this.assignments.dayExists(body.dayId, tripId)) { throw new AcceptError('day_not_on_trip', 'Day does not belong to this trip', 400); } @@ -235,7 +237,7 @@ export class DawarichSuggestionsService { // a place with no assignment, or a place nobody recorded as accepted — is // worse than none: the stay would come back as unhandled while the place it // already produced sat on the trip. - const { created, placeId, assignment } = this.db.transaction(() => { + const { created, placeId, assignment } = await this.uow.transactional(async () => { const place = this.places.create(String(tripId), { name: body.name?.trim() || row.name, lat, @@ -253,7 +255,7 @@ export class DawarichSuggestionsService { // because this path made it. this.db.run("UPDATE places SET source = 'dawarich' WHERE id = ?", id); - const day = body.dayId !== undefined ? this.assignments.createAssignment(body.dayId, id, null) : undefined; + const day = body.dayId !== undefined ? await this.assignments.createAssignment(body.dayId, id, null) : undefined; this.markAccepted(row.id, 'place', { placeId: id }); // Re-read, so what goes out on the wire carries the source: everyone else // on the trip should see the Dawarich mark on it too, not only the person @@ -379,16 +381,16 @@ export class DawarichSuggestionsService { * tool holds `ctx.userId`, and a permission that depended on which door * somebody came through would not be a permission. */ - private requirePermission(action: 'place_edit' | 'day_edit', tripOwnerId: number, userId: number): void { + private async requirePermission(action: 'place_edit' | 'day_edit', tripOwnerId: number, userId: number): Promise { const actor = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId); - const allowed = this.permissions.checkPermission( + const allowed = await this.permissions.checkPermission( action, actor?.role ?? 'user', tripOwnerId, userId, tripOwnerId !== userId, ); - if (!allowed) throw new AcceptError('forbidden', 'No permission', 403); + if (!(await allowed)) throw new AcceptError('forbidden', 'No permission', 403); } /** diff --git a/server/src/nest/integrations/dawarich-sync.service.ts b/server/src/nest/integrations/dawarich-sync.service.ts index d22541f5c..f8b1d0cdc 100644 --- a/server/src/nest/integrations/dawarich-sync.service.ts +++ b/server/src/nest/integrations/dawarich-sync.service.ts @@ -73,13 +73,13 @@ export class DawarichSyncService { ) {} /** The addon gate, evaluated per tick so an admin toggle lands without a restart. */ - syncGloballyEnabled(): boolean { + async syncGloballyEnabled(): Promise { return this.addons.isAddonEnabled(ADDON_IDS.DAWARICH); } /** Every connected user, one after another. The cron's entry point. */ async runSync(): Promise { - if (!this.syncGloballyEnabled()) return; + if (!(await this.syncGloballyEnabled())) return; if (this.running) return; this.running = true; try { @@ -132,7 +132,7 @@ export class DawarichSyncService { /** The body of a sync, with the guard above already held. */ private async syncUserOnce(userId: number): Promise { - if (!this.syncGloballyEnabled()) { + if (!(await this.syncGloballyEnabled())) { this.dawarich.recordSyncResult(userId, 'failed', 'addon_disabled'); return { state: 'failed', created: 0, updated: 0, missing: 0 }; } diff --git a/server/src/nest/integrations/dawarich.controller.ts b/server/src/nest/integrations/dawarich.controller.ts index e591cdc11..0c37b342a 100644 --- a/server/src/nest/integrations/dawarich.controller.ts +++ b/server/src/nest/integrations/dawarich.controller.ts @@ -97,8 +97,8 @@ export class DawarichController { @Delete('settings') @HttpCode(200) - disconnect(@CurrentUser() user: User, @Req() req: Request) { - this.dawarich.disconnect(user.id, getClientIp(req)); + async disconnect(@CurrentUser() user: User, @Req() req: Request) { + await this.dawarich.disconnect(user.id, getClientIp(req)); this.tracks.forget(user.id); return { success: true }; } @@ -138,7 +138,10 @@ export class DawarichController { @Body() body: DawarichAcceptDto, @Req() req: Request, ) { - return this.guard(() => + // guardAsync, not guard: accept() is async now, so its AcceptError arrives as + // a rejection a synchronous try/catch cannot see — and the domain's own code + // would be dropped from the body. + return this.guardAsync(() => this.suggestions.accept(user.id, parseId(id), body, socketId(req)), ); } diff --git a/server/src/nest/integrations/dawarich.service.ts b/server/src/nest/integrations/dawarich.service.ts index 6740bff6a..43f559fd3 100644 --- a/server/src/nest/integrations/dawarich.service.ts +++ b/server/src/nest/integrations/dawarich.service.ts @@ -122,7 +122,7 @@ export class DawarichService { return { success: false, code: 'invalid_url', error: ssrf.error ?? 'Invalid Dawarich URL' }; } if (ssrf.isPrivate) { - this.audit.writeAudit({ + await this.audit.writeAudit({ userId, action: 'dawarich.private_ip_configured', ip: clientIp, @@ -212,9 +212,9 @@ export class DawarichService { * their journal because they revoked an API key would be the integration * destroying user content on its way out. What goes is the credential. */ - disconnect(userId: number, clientIp: string | null): void { + async disconnect(userId: number, clientIp: string | null): Promise { this.db.run('DELETE FROM dawarich_connections WHERE user_id = ?', userId); - this.audit.writeAudit({ userId, action: 'dawarich.disconnected', ip: clientIp, details: {} }); + await this.audit.writeAudit({ userId, action: 'dawarich.disconnected', ip: clientIp, details: {} }); } /** diff --git a/server/src/nest/journey/journal.rpc.ts b/server/src/nest/journey/journal.rpc.ts index f84fb3fa3..e0982f311 100644 --- a/server/src/nest/journey/journal.rpc.ts +++ b/server/src/nest/journey/journal.rpc.ts @@ -50,17 +50,17 @@ export class JournalRpc { ) {} @PluginMethod('journal.listMine', { permission: 'db:read:journal' }) - listMine(_params: Record, ctx: PluginRpcContext): unknown { + async listMine(_params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireJournalUser(ctx, 'reads'); - this.requireJourneyAddon(); + await this.requireJourneyAddon(); return this.journey.listJourneys(userId); } @PluginMethod('journal.getEntries', { permission: 'db:read:journal' }) - getEntries(params: Record, ctx: PluginRpcContext): unknown { + async getEntries(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireJournalUser(ctx, 'reads'); const journeyId = num(params.journeyId, 'journeyId'); - this.requireJourneyAddon(); + await this.requireJourneyAddon(); // listEntries self-gates via canAccessJourney and returns null when the user // cannot see it. const entries = this.journey.listEntries(journeyId, userId); @@ -69,32 +69,32 @@ export class JournalRpc { } @PluginMethod('journal.createEntry', { permission: 'db:write:journal' }) - createEntry(params: Record, ctx: PluginRpcContext): unknown { + async createEntry(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireJournalUser(ctx, 'writes'); const input = asPayload(params.input); if (typeof input.entry_date !== 'string' || input.entry_date === '') throw new BadParams('entry_date is required'); const journeyId = num(params.journeyId, 'journeyId'); - this.requireJourneyAddon(); + await this.requireJourneyAddon(); const entry = this.journey.createEntry(journeyId, userId, input as never); if (!entry) throw new ForbiddenResource(`no editable journey ${journeyId} for this user`); return entry; } @PluginMethod('journal.updateEntry', { permission: 'db:write:journal' }) - updateEntry(params: Record, ctx: PluginRpcContext): unknown { + async updateEntry(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireJournalUser(ctx, 'writes'); const entryId = num(params.entryId, 'entryId'); - this.requireJourneyAddon(); + await this.requireJourneyAddon(); const entry = this.journey.updateEntry(entryId, userId, asPayload(params.input) as never); if (!entry) throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`); return entry; } @PluginMethod('journal.deleteEntry', { permission: 'db:write:journal' }) - deleteEntry(params: Record, ctx: PluginRpcContext): unknown { + async deleteEntry(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireJournalUser(ctx, 'writes'); const entryId = num(params.entryId, 'entryId'); - this.requireJourneyAddon(); + await this.requireJourneyAddon(); if (!this.journey.deleteEntry(entryId, userId)) { throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`); } @@ -102,10 +102,10 @@ export class JournalRpc { } @PluginMethod('journal.createJourney', { permission: 'db:write:journal' }) - createJourney(params: Record, ctx: PluginRpcContext): unknown { + async createJourney(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireJournalUser(ctx, 'writes'); const input = asPayload(params.input); - this.requireJourneyAddon(); + await this.requireJourneyAddon(); const title = typeof input.title === 'string' ? input.title.trim() : ''; if (!title) throw new BadParams('journal title is required'); return this.journey.createJourney(userId, { @@ -137,7 +137,7 @@ export class JournalRpc { const entryId = num(params.entryId, 'entryId'); // num() accepts "3" and 1.5; a row id is neither. if (!Number.isInteger(entryId) || entryId <= 0) throw new BadParams('entryId must be a positive integer'); - this.requireJourneyAddon(); + await this.requireJourneyAddon(); const parsed = journalPluginPhotoInputSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid photo input: ${parsed.error.issues[0]?.message ?? 'unknown'}`); @@ -188,10 +188,10 @@ export class JournalRpc { } @PluginMethod('journal.deleteJourney', { permission: 'db:write:journal' }) - deleteJourney(params: Record, ctx: PluginRpcContext): unknown { + async deleteJourney(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireJournalUser(ctx, 'writes'); const journeyId = num(params.journeyId, 'journeyId'); - this.requireJourneyAddon(); + await this.requireJourneyAddon(); if (!this.journey.deleteJourney(journeyId, userId)) { throw new ForbiddenResource(`no deletable journal ${journeyId} for this user`); } @@ -205,7 +205,7 @@ export class JournalRpc { return ctx.actingUserId; } - private requireJourneyAddon(): void { - this.guards.requireAddon(ADDON_IDS.JOURNEY, 'journey'); + private async requireJourneyAddon(): Promise { + await this.guards.requireAddon(ADDON_IDS.JOURNEY, 'journey'); } } diff --git a/server/src/nest/journey/journey.service.ts b/server/src/nest/journey/journey.service.ts index 4c2f64b6b..942fc1081 100644 --- a/server/src/nest/journey/journey.service.ts +++ b/server/src/nest/journey/journey.service.ts @@ -31,7 +31,7 @@ export class JourneyService { return this.dbs.connection; } - journeyAddonEnabled(): boolean { + async journeyAddonEnabled(): Promise { return this.addons.isAddonEnabled(ADDON_IDS.JOURNEY); } diff --git a/server/src/nest/llm-parse/llm-config.resolver.ts b/server/src/nest/llm-parse/llm-config.resolver.ts index 0e428f9b5..39d71a57c 100644 --- a/server/src/nest/llm-parse/llm-config.resolver.ts +++ b/server/src/nest/llm-parse/llm-config.resolver.ts @@ -28,8 +28,8 @@ export class LlmConfigResolver { * else null. This is the single place the API key is decrypted, and the single * place that decides which endpoint the server is allowed to call (#1772). */ - resolve(userId: number): ResolvedLlmConfig | null { - if (!this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING)) return null; + async resolve(userId: number): Promise { + if (!(await this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING))) return null; return this.readInstanceConfig() ?? this.readUserConfig(userId); } diff --git a/server/src/nest/llm-parse/llm-parse.service.ts b/server/src/nest/llm-parse/llm-parse.service.ts index ae559aa88..85c5e2474 100644 --- a/server/src/nest/llm-parse/llm-parse.service.ts +++ b/server/src/nest/llm-parse/llm-parse.service.ts @@ -33,12 +33,12 @@ export class LlmParseService { ) {} /** True when the addon is enabled AND a usable config resolves for this user. */ - isAvailable(userId: number): boolean { - return this.llmConfig.resolve(userId) !== null; + async isAvailable(userId: number): Promise { + return (await this.llmConfig.resolve(userId)) !== null; } async parse(file: { buffer: Buffer; originalName: string }, userId: number): Promise { - const config = this.llmConfig.resolve(userId); + const config = await this.llmConfig.resolve(userId); if (!config) return { kiItems: [], warnings: ['AI parsing is not configured'] }; const warnings: string[] = []; diff --git a/server/src/nest/mcp-shared/mcp-tool-guards.service.ts b/server/src/nest/mcp-shared/mcp-tool-guards.service.ts index d8a9bfba4..5e10db571 100644 --- a/server/src/nest/mcp-shared/mcp-tool-guards.service.ts +++ b/server/src/nest/mcp-shared/mcp-tool-guards.service.ts @@ -65,7 +65,7 @@ export class McpToolGuardsService { * matching REST route uses. Returns true when the user may perform `action` * on `tripId`. */ - hasTripPermission(action: string, tripId: number | string, userId: number): boolean { + async hasTripPermission(action: string, tripId: number | string, userId: number): Promise { const trip = this.db.get<{ user_id?: number }>('SELECT user_id FROM trips WHERE id = ?', tripId); if (!trip) return false; const userRow = this.db.get<{ role?: string }>('SELECT role FROM users WHERE id = ?', userId); diff --git a/server/src/nest/mcp-transport/mcp-transport.service.ts b/server/src/nest/mcp-transport/mcp-transport.service.ts index e8e3b96fa..4ff1d8d21 100644 --- a/server/src/nest/mcp-transport/mcp-transport.service.ts +++ b/server/src/nest/mcp-transport/mcp-transport.service.ts @@ -159,7 +159,7 @@ export class McpTransportService { } async handle(req: Request, res: Response): Promise { - if (!this.addons.isAddonEnabled(ADDON_IDS.MCP)) { + if (!(await this.addons.isAddonEnabled(ADDON_IDS.MCP))) { res.status(403).json({ error: 'MCP is not enabled' }); return; } @@ -281,22 +281,25 @@ export class McpTransportService { const transportHolder: { current: StreamableHTTPServerTransport | null } = { current: null }; const onInvoke = (info: { kind: string; name: string }): void => { if (info.kind !== 'tool') return; - try { + // The registry fires this from the SDK's tool callback and cannot await it, + // so the now-async write runs on its own and reports its own failure — + // which is what the try/catch here always did (recipe R1.5). + void (async () => { const sid = transportHolder.current?.sessionId; const ip = (sid ? sessions.get(sid)?.lastClientIp : null) ?? createIp; - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: user.id, action: 'mcp.tool_call', resource: info.name, details: { clientId: clientId ?? 'native' }, ip, }); - } catch (err) { + })().catch((err: unknown) => { console.error('[MCP] tool-call audit failed:', (err as Error | undefined)?.message ?? err); - } + }); }; - registerTools(this.registry, server, user.id, scopes, isStaticToken, getDeprecationNotice, onInvoke); + await registerTools(this.registry, server, user.id, scopes, isStaticToken, getDeprecationNotice, onInvoke); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), diff --git a/server/src/nest/memories/immich.service.ts b/server/src/nest/memories/immich.service.ts index 5c9e54f0f..264cf63a3 100644 --- a/server/src/nest/memories/immich.service.ts +++ b/server/src/nest/memories/immich.service.ts @@ -105,7 +105,7 @@ export class ImmichService { userId ); if (ssrf.isPrivate) { - this.audit.writeAudit({ + await this.audit.writeAudit({ userId, action: 'immich.private_ip_configured', ip: clientIp, diff --git a/server/src/nest/memories/memories.mcp.ts b/server/src/nest/memories/memories.mcp.ts index e416d6e05..864dbbc64 100644 --- a/server/src/nest/memories/memories.mcp.ts +++ b/server/src/nest/memories/memories.mcp.ts @@ -38,8 +38,8 @@ const SYNOLOGY_DEFAULT_LIMIT = 100; * provider counts as on, whatever its row says. Which of the two providers is * on is a per-call question, answered by providerRefusal(). */ -const anyPhotoProviderEnabled = (_ctx: McpContext, self: MemoriesMcp): boolean => - self.enabledProviderIds().length > 0; +const anyPhotoProviderEnabled = async (_ctx: McpContext, self: MemoriesMcp): Promise => + (await self.enabledProviderIds()).length > 0; /** * Memories MCP surface: finding photos in a connected Immich or Synology Photos @@ -73,8 +73,8 @@ export class MemoriesMcp { * Public because the `when:` gate above is a module-level function rather than * a method, the same reason the addon gates need a public `addons`. */ - enabledProviderIds(): string[] { - if (!this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return []; + async enabledProviderIds(): Promise { + if (!(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return []; return this.db.all<{ id: string }>('SELECT id FROM photo_providers WHERE enabled = 1').map((row) => row.id); } @@ -84,10 +84,10 @@ export class MemoriesMcp { * disabled provider refuses the same way on both surfaces. The browse routes * themselves never checked it, so this only ever narrows what REST allows. */ - private providerRefusal(provider: ProviderId) { + private async providerRefusal(provider: ProviderId) { const row = this.db.get<{ enabled: number }>('SELECT enabled FROM photo_providers WHERE id = ?', provider); if (!row) return errorResult(`Provider: "${provider}" is not supported`); - if (row.enabled !== 1 || !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) + if (row.enabled !== 1 || !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return errorResult(`Provider: "${provider}" is not enabled, contact server administrator`); return null; } @@ -111,8 +111,8 @@ export class MemoriesMcp { { provider, from, to, page, size, utc_offset_minutes }: { provider: ProviderId; from?: string; to?: string; page?: number; size?: number; utc_offset_minutes?: number }, ctx: McpContext, ) { - const refused = this.providerRefusal(provider); - if (refused) return refused; + const refused = await this.providerRefusal(provider); + if ((await refused)) return refused; if (provider === 'immich') { // Same coercion the REST route performs on the body before calling. @@ -144,8 +144,8 @@ export class MemoriesMcp { access: { group: 'journey', mode: 'read' }, }) async listProviderAlbums({ provider }: { provider: ProviderId }, ctx: McpContext) { - const refused = this.providerRefusal(provider); - if (refused) return refused; + const refused = await this.providerRefusal(provider); + if ((await refused)) return refused; if (provider === 'immich') { const result = await this.immich.listAlbums(ctx.userId); @@ -177,8 +177,8 @@ export class MemoriesMcp { { provider, album_id, passphrase }: { provider: ProviderId; album_id: string; passphrase?: string }, ctx: McpContext, ) { - const refused = this.providerRefusal(provider); - if (refused) return refused; + const refused = await this.providerRefusal(provider); + if ((await refused)) return refused; if (provider === 'immich') { const result = await this.immich.getAlbumPhotos(ctx.userId, album_id); diff --git a/server/src/nest/memories/thumbnail.service.ts b/server/src/nest/memories/thumbnail.service.ts index e70443ba6..b0cce8df4 100644 --- a/server/src/nest/memories/thumbnail.service.ts +++ b/server/src/nest/memories/thumbnail.service.ts @@ -37,7 +37,7 @@ export class ThumbnailService { async ensureLocalThumbnail( originalRelPath: string, ): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> { - if (!this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return null + if (!(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return null // The DB stores uploads-relative paths ('journey/'); anything else // is not a local journey photo and has no thumbnail to derive. @@ -88,7 +88,7 @@ export class ThumbnailService { * (airtrail-sync.job.ts precedent). */ async sweepOrphanThumbs(): Promise { - if (!this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return 0 + if (!(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return 0 const live = new Set() for (const row of this.db.all<{ file_path: string }>( diff --git a/server/src/nest/memories/unified-memories.service.ts b/server/src/nest/memories/unified-memories.service.ts index 46ad05639..d029f9bc1 100644 --- a/server/src/nest/memories/unified-memories.service.ts +++ b/server/src/nest/memories/unified-memories.service.ts @@ -36,17 +36,17 @@ export class UnifiedMemoriesService { private readonly addons: AddonsService, ) {} - private _providers(): Array<{id: string; enabled: boolean}> { + private async _providers(): Promise> { // A provider only counts as enabled while the journey addon is — its whole // surface lives inside journeys. Covers rows left enabled from before // updateAddon cascaded the journey disable. - const journeyOn = this.addons.isAddonEnabled(ADDON_IDS.JOURNEY); + const journeyOn = await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY); const rows = this.db.prepare('SELECT id, enabled FROM photo_providers').all() as Array<{id: string; enabled: number}>; return rows.map(r => ({ id: r.id, enabled: journeyOn && r.enabled === 1 })); } - private _validProvider(provider: string): ServiceResult { - const providers = this._providers(); + private async _validProvider(provider: string): Promise> { + const providers = await this._providers(); const found = providers.find(p => p.id === provider); if (!found) { return fail(`Provider: "${provider}" is not supported`, 400); @@ -60,7 +60,7 @@ export class UnifiedMemoriesService { - listTripPhotos(tripId: string, userId: number): ServiceResult { + async listTripPhotos(tripId: string, userId: number): Promise> { const access = this.db.canAccessTrip(tripId, userId); if (!access) { return fail('Trip not found or access denied', 404); @@ -68,7 +68,7 @@ export class UnifiedMemoriesService { try { - const enabledProviders = this._providers().filter(p => p.enabled).map(p => p.id); + const enabledProviders = (await this._providers()).filter(p => p.enabled).map(p => p.id); if (enabledProviders.length === 0) { return fail('No photo providers enabled', 400); @@ -92,14 +92,14 @@ export class UnifiedMemoriesService { } } - listTripAlbumLinks(tripId: string, userId: number): ServiceResult { + async listTripAlbumLinks(tripId: string, userId: number): Promise> { const access = this.db.canAccessTrip(tripId, userId); if (!access) { return fail('Trip not found or access denied', 404); } - const enabledProviders = this._providers().filter(p => p.enabled).map(p => p.id); + const enabledProviders = (await this._providers()).filter(p => p.enabled).map(p => p.id); if (enabledProviders.length === 0) { return fail('No photo providers enabled', 400); @@ -133,8 +133,8 @@ export class UnifiedMemoriesService { //----------------------------------------------- // managing photos in trip - private _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string, passphrase?: string): ServiceResult { - const providerResult = this._validProvider(provider); + private async _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string, passphrase?: string): Promise> { + const providerResult = await this._validProvider(provider); if (!providerResult.success) { return providerResult as ServiceResult; } @@ -169,14 +169,14 @@ export class UnifiedMemoriesService { let added = 0; for (const selection of selections) { - const providerResult = this._validProvider(selection.provider); + const providerResult = await this._validProvider(selection.provider); if (!providerResult.success) { return providerResult as ServiceResult<{ added: number; shared: boolean }>; } for (const raw of selection.asset_ids) { const assetId = String(raw || '').trim(); if (!assetId) continue; - const result = this._addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId, selection.passphrase); + const result = await this._addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId, selection.passphrase); if (!result.success) { return result as ServiceResult<{ added: number; shared: boolean }>; } @@ -252,7 +252,7 @@ export class UnifiedMemoriesService { // ---------------------------------------------- // managing album links in trip - createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): ServiceResult { + async createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): Promise> { const access = this.db.canAccessTrip(tripId, userId); if (!access) { return fail('Trip not found or access denied', 404); @@ -270,7 +270,7 @@ export class UnifiedMemoriesService { } - const providerResult = this._validProvider(provider); + const providerResult = await this._validProvider(provider); if (!providerResult.success) { return providerResult as ServiceResult; } diff --git a/server/src/nest/memories/unified.controller.ts b/server/src/nest/memories/unified.controller.ts index ce5524fcd..f310a3bb2 100644 --- a/server/src/nest/memories/unified.controller.ts +++ b/server/src/nest/memories/unified.controller.ts @@ -22,8 +22,8 @@ export class UnifiedMemoriesController { constructor(private readonly memories: MemoriesService) {} @Get('trips/:tripId/photos') - listPhotos(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): void { - const result = this.memories.listTripPhotos(tripId, user.id); + async listPhotos(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): Promise { + const result = await this.memories.listTripPhotos(tripId, user.id); if ('error' in result) { res.status(result.error.status).json({ error: result.error.message }); return; @@ -81,8 +81,8 @@ export class UnifiedMemoriesController { } @Get('trips/:tripId/album-links') - listAlbumLinks(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): void { - const result = this.memories.listTripAlbumLinks(tripId, user.id); + async listAlbumLinks(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): Promise { + const result = await this.memories.listTripAlbumLinks(tripId, user.id); if ('error' in result) { res.status(result.error.status).json({ error: result.error.message }); return; @@ -92,14 +92,14 @@ export class UnifiedMemoriesController { @Post('trips/:tripId/album-links') @HttpCode(200) - createAlbumLink( + async createAlbumLink( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CreateAlbumLinkDto, @Res() res: Response, - ): void { + ): Promise { const passphrase = body?.passphrase ? String(body.passphrase) : undefined; - const result = this.memories.createTripAlbumLink(tripId, user.id, body?.provider, body?.album_id, body?.album_name, passphrase); + const result = await this.memories.createTripAlbumLink(tripId, user.id, body?.provider, body?.album_id, body?.album_name, passphrase); if ('error' in result) { res.status(result.error.status).json({ error: result.error.message }); return; diff --git a/server/src/nest/oauth/oauth-api.controller.ts b/server/src/nest/oauth/oauth-api.controller.ts index bd3138fc7..e6b0a40cb 100644 --- a/server/src/nest/oauth/oauth-api.controller.ts +++ b/server/src/nest/oauth/oauth-api.controller.ts @@ -25,25 +25,25 @@ const MIN = 60_000; export class OauthApiController { constructor(private readonly oauth: OauthService, private readonly rl: RateLimitService) {} - private requireMcp403(): void { - if (!this.oauth.mcpEnabled()) { + private async requireMcp403(): Promise { + if (!(await this.oauth.mcpEnabled())) { throw new HttpException({ error: 'MCP is not enabled' }, 403); } } @Get('authorize/validate') @UseGuards(OptionalJwtGuard) - validate(@Req() req: Request, @Query() params: Partial, @Res({ passthrough: true }) res: Response) { + async validate(@Req() req: Request, @Query() params: Partial, @Res({ passthrough: true }) res: Response) { if (!this.rl.check('oauth_validate', req.ip || 'unknown', 30, MIN, Date.now())) { throw new HttpException({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' }, 429); } - if (!this.oauth.mcpEnabled()) { + if (!(await this.oauth.mcpEnabled())) { // 404 (not 403) with an empty body so anonymous callers can't fingerprint the feature. res.status(404).end(); return undefined; } const userId = (req.user as User | undefined)?.id ?? null; - const result = this.oauth.validateAuthorizeRequest( + const result = await this.oauth.validateAuthorizeRequest( { response_type: params.response_type || '', client_id: params.client_id || '', @@ -68,9 +68,9 @@ export class OauthApiController { @Post('authorize') @HttpCode(200) // Express answers consent with res.json (200), not the POST-default 201. @UseGuards(CookieAuthGuard) - authorize(@CurrentUser() user: User, @Body() body: OauthConsentDto, @Req() req: Request) { + async authorize(@CurrentUser() user: User, @Body() body: OauthConsentDto, @Req() req: Request) { const ip = getClientIp(req); - if (!this.oauth.mcpEnabled()) { + if (!(await this.oauth.mcpEnabled())) { throw new HttpException({ error: 'MCP is not enabled' }, 403); } const params: AuthorizeParams = { @@ -87,7 +87,7 @@ export class OauthApiController { // of body.redirect_uri too, and only validateAuthorizeRequest checks it // against the client's registered URIs. A string that is not a URL at all // would additionally throw out of `new URL()` as a 500. - const validation = this.oauth.validateAuthorizeRequest(params, user.id); + const validation = await this.oauth.validateAuthorizeRequest(params, user.id); if (!validation.valid) { throw new HttpException({ error: validation.error, error_description: validation.error_description }, 400); } @@ -99,7 +99,7 @@ export class OauthApiController { return { redirect: url.toString() }; } const scopes = validation.scopes!; - this.oauth.saveConsent(body.client_id, user.id, scopes, ip); + await this.oauth.saveConsent(body.client_id, user.id, scopes, ip); const code = this.oauth.createAuthCode({ clientId: body.client_id, userId: user.id, @@ -120,17 +120,17 @@ export class OauthApiController { @Get('clients') @UseGuards(JwtAuthGuard) - listClients(@CurrentUser() user: User) { - this.requireMcp403(); + async listClients(@CurrentUser() user: User) { + await this.requireMcp403(); return { clients: this.oauth.listOAuthClients(user.id) }; } @Post('clients') @HttpCode(201) @UseGuards(CookieAuthGuard) - createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) { - this.requireMcp403(); - const result = this.oauth.createOAuthClient(user.id, body.name, body.redirect_uris ?? [], body.allowed_scopes, getClientIp(req), { allowsClientCredentials: body.allows_client_credentials }); + async createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) { + await this.requireMcp403(); + const result = await this.oauth.createOAuthClient(user.id, body.name, body.redirect_uris ?? [], body.allowed_scopes, getClientIp(req), { allowsClientCredentials: body.allows_client_credentials }); if (result.error) { throw new HttpException({ error: result.error }, result.status || 400); } @@ -140,9 +140,9 @@ export class OauthApiController { @Post('clients/:id/rotate') @HttpCode(200) @UseGuards(CookieAuthGuard) - rotateClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { - this.requireMcp403(); - const result = this.oauth.rotateOAuthClientSecret(user.id, id, getClientIp(req)); + async rotateClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { + await this.requireMcp403(); + const result = await this.oauth.rotateOAuthClientSecret(user.id, id, getClientIp(req)); if (result.error) { throw new HttpException({ error: result.error }, result.status || 400); } @@ -151,9 +151,9 @@ export class OauthApiController { @Delete('clients/:id') @UseGuards(CookieAuthGuard) - deleteClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { - this.requireMcp403(); - const result = this.oauth.deleteOAuthClient(user.id, id, getClientIp(req)); + async deleteClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { + await this.requireMcp403(); + const result = await this.oauth.deleteOAuthClient(user.id, id, getClientIp(req)); if (result.error) { throw new HttpException({ error: result.error }, result.status || 400); } @@ -162,16 +162,16 @@ export class OauthApiController { @Get('sessions') @UseGuards(JwtAuthGuard) - listSessions(@CurrentUser() user: User) { - this.requireMcp403(); + async listSessions(@CurrentUser() user: User) { + await this.requireMcp403(); return { sessions: this.oauth.listOAuthSessions(user.id) }; } @Delete('sessions/:id') @UseGuards(CookieAuthGuard) - revokeSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { - this.requireMcp403(); - const result = this.oauth.revokeSession(user.id, Number(id), getClientIp(req)); + async revokeSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { + await this.requireMcp403(); + const result = await this.oauth.revokeSession(user.id, Number(id), getClientIp(req)); if (result.error) { throw new HttpException({ error: result.error }, result.status || 400); } diff --git a/server/src/nest/oauth/oauth-public.controller.ts b/server/src/nest/oauth/oauth-public.controller.ts index bd2b425eb..6d9c74302 100644 --- a/server/src/nest/oauth/oauth-public.controller.ts +++ b/server/src/nest/oauth/oauth-public.controller.ts @@ -33,8 +33,8 @@ export class OauthPublicController { @Post('token') @HttpCode(200) // token success uses res.json without an explicit status; Express defaults to 200 (Nest POST would default to 201). - token(@Req() req: Request, @Res() res: Response): void { - if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; } + async token(@Req() req: Request, @Res() res: Response): Promise { + if (!(await this.oauth.mcpEnabled())) { res.status(404).end(); return; } const body: Record = typeof req.body === 'object' && req.body ? req.body : {}; if (!this.rl.check('oauth_token', `${req.ip}|${body.client_id ?? ''}`, 30, MIN, Date.now())) { @@ -59,8 +59,8 @@ export class OauthPublicController { return; } const pending = this.oauth.consumeAuthCode(code); - const invalidGrant = (reason: string, userId: number | null) => { - this.audit.writeAudit({ userId, action: 'oauth.token.grant_failed', details: { client_id, reason }, ip }); + const invalidGrant = async (reason: string, userId: number | null) => { + await this.audit.writeAudit({ userId, action: 'oauth.token.grant_failed', details: { client_id, reason }, ip }); res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' }); }; if (!pending) return invalidGrant('code_invalid_or_expired', null); @@ -69,13 +69,13 @@ export class OauthPublicController { if (pending.resource && resource && pending.resource !== stripTrailingSlashes(resource)) return invalidGrant('resource_mismatch', pending.userId); if (!this.oauth.authenticateClient(client_id, client_secret)) { logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`); - this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip }); + await this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip }); res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' }); return; } if (!this.oauth.verifyPKCE(code_verifier, pending.codeChallenge)) return invalidGrant('pkce_failed', pending.userId); const tokens = this.oauth.issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null); - this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip }); + await this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip }); res.json(tokens); return; } @@ -85,7 +85,7 @@ export class OauthPublicController { res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' }); return; } - const result = this.oauth.refreshTokens(refresh_token, client_id, client_secret, ip); + const result = await this.oauth.refreshTokens(refresh_token, client_id, client_secret, ip); if (result.error) { if (result.error === 'invalid_client') logWarn(`[OAuth] Invalid client credentials on refresh for client_id=${client_id} ip=${ip ?? '-'}`); res.status(result.status || 400).json({ error: result.error, error_description: result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired' }); @@ -103,12 +103,12 @@ export class OauthPublicController { const client = this.oauth.authenticateClient(client_id, client_secret); if (!client) { logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`); - this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip }); + await this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip }); res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' }); return; } if (client.is_public || !client.allows_client_credentials || client.user_id == null) { - this.audit.writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip }); + await this.audit.writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip }); res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' }); return; } @@ -127,7 +127,7 @@ export class OauthPublicController { } const audience = resource ? stripTrailingSlashes(resource) : `${stripTrailingSlashes(this.oauth.mcpSafeUrl())}/mcp`; const tokens = this.oauth.issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience); - this.audit.writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip }); + await this.audit.writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip }); res.json(tokens); return; } @@ -136,8 +136,8 @@ export class OauthPublicController { } @Get('userinfo') - userinfo(@Headers('authorization') auth: string | undefined, @Res() res: Response): void { - if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; } + async userinfo(@Headers('authorization') auth: string | undefined, @Res() res: Response): Promise { + if (!(await this.oauth.mcpEnabled())) { res.status(404).end(); return; } if (!auth || !auth.toLowerCase().startsWith('bearer ')) { res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"'); res.status(401).json({ error: 'invalid_token' }); @@ -153,8 +153,8 @@ export class OauthPublicController { } @Post('revoke') - revoke(@Req() req: Request, @Res() res: Response): void { - if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; } + async revoke(@Req() req: Request, @Res() res: Response): Promise { + if (!(await this.oauth.mcpEnabled())) { res.status(404).end(); return; } if (!this.rl.check('oauth_revoke', req.ip || 'unknown', 10, MIN, Date.now())) { res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' }); return; @@ -168,11 +168,11 @@ export class OauthPublicController { } if (!this.oauth.authenticateClient(client_id, client_secret)) { logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`); - this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip }); + await this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip }); res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' }); return; } - this.oauth.revokeToken(token, client_id, undefined, ip); + await this.oauth.revokeToken(token, client_id, undefined, ip); res.status(200).json({}); } } diff --git a/server/src/nest/oauth/oauth-sdk.provider.ts b/server/src/nest/oauth/oauth-sdk.provider.ts index c5b9b9c2a..1f2147584 100644 --- a/server/src/nest/oauth/oauth-sdk.provider.ts +++ b/server/src/nest/oauth/oauth-sdk.provider.ts @@ -86,7 +86,7 @@ export class TrekClientsStore implements OAuthRegisteredClientsStore { const scopes = rawScopes.filter(s => (ALL_SCOPES as string[]).includes(s)); if (scopes.length === 0) throw new InvalidClientMetadataError('No valid scopes requested'); - const result = this.oauth.createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' }); + const result = await this.oauth.createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' }); if (result.error) throw new InvalidClientMetadataError(result.error); const c = result.client!; @@ -179,7 +179,7 @@ export class TrekOAuthProvider implements OAuthServerProvider { throw new Error('Authorization grant is invalid.'); const tokens = this.oauth.issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null); - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id: client.client_id, scopes: pending.scopes, audience: pending.resource ?? null }, @@ -194,7 +194,7 @@ export class TrekOAuthProvider implements OAuthServerProvider { _scopes?: string[], _resource?: URL, ): Promise { - const result = this.oauth.refreshTokens(refreshToken, client.client_id, client.client_secret, null); + const result = await this.oauth.refreshTokens(refreshToken, client.client_id, client.client_secret, null); if (result.error) throw new Error(result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired'); return result.tokens!; } @@ -214,6 +214,6 @@ export class TrekOAuthProvider implements OAuthServerProvider { client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest, ): Promise { - this.oauth.revokeToken(request.token, client.client_id, undefined, null); + await this.oauth.revokeToken(request.token, client.client_id, undefined, null); } } diff --git a/server/src/nest/oauth/oauth.service.ts b/server/src/nest/oauth/oauth.service.ts index 972f189a5..6d81e33a3 100644 --- a/server/src/nest/oauth/oauth.service.ts +++ b/server/src/nest/oauth/oauth.service.ts @@ -85,7 +85,7 @@ export class OauthService { private readonly audit: AuditService, ) {} - mcpEnabled(): boolean { return this.addons.isAddonEnabled(ADDON_IDS.MCP); } + async mcpEnabled(): Promise { return this.addons.isAddonEnabled(ADDON_IDS.MCP); } mcpSafeUrl(): string { return getMcpSafeUrl(); } // ------------------------------------------------------------------------- @@ -106,14 +106,14 @@ export class OauthService { })); } - createOAuthClient( + async createOAuthClient( userId: number | null, name: string, redirectUris: string[], allowedScopes: string[], ip?: string | null, options?: { isPublic?: boolean; createdVia?: string; allowsClientCredentials?: boolean }, - ): { error?: string; status?: number; client?: Record } { + ): Promise<{ error?: string; status?: number; client?: Record }> { if (!name?.trim()) return { error: 'Name is required', status: 400 }; if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 }; const isMachineClient = Boolean(options?.allowsClientCredentials); @@ -163,7 +163,7 @@ export class OauthService { id, )!; - this.audit.writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip }); + await this.audit.writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip }); return { client: { @@ -183,11 +183,11 @@ export class OauthService { }; } - rotateOAuthClientSecret( + async rotateOAuthClientSecret( userId: number, clientRowId: string, ip?: string | null, - ): { error?: string; status?: number; client_secret?: string } { + ): Promise<{ error?: string; status?: number; client_secret?: string }> { const row = this.db.get('SELECT id, client_id, is_public FROM oauth_clients WHERE id = ? AND user_id = ?', clientRowId, userId); if (!row) return { error: 'Client not found', status: 404 }; if (row.is_public) return { error: 'Public clients do not use a client secret', status: 400 }; @@ -203,20 +203,20 @@ export class OauthService { // Terminate active MCP sessions for this (user, client) pair revokeUserSessionsForClient(userId, row.client_id); - this.audit.writeAudit({ userId, action: 'oauth.client.rotate_secret', details: { client_id: row.client_id }, ip }); + await this.audit.writeAudit({ userId, action: 'oauth.client.rotate_secret', details: { client_id: row.client_id }, ip }); return { client_secret: rawSecret }; } - deleteOAuthClient( + async deleteOAuthClient( userId: number, clientRowId: string, ip?: string | null, - ): { error?: string; status?: number; success?: boolean } { + ): Promise<{ error?: string; status?: number; success?: boolean }> { const row = this.db.get('SELECT id, client_id FROM oauth_clients WHERE id = ? AND user_id = ?', clientRowId, userId); if (!row) return { error: 'Client not found', status: 404 }; this.db.run('DELETE FROM oauth_clients WHERE id = ?', clientRowId); - this.audit.writeAudit({ userId, action: 'oauth.client.delete', details: { client_id: row.client_id }, ip }); + await this.audit.writeAudit({ userId, action: 'oauth.client.delete', details: { client_id: row.client_id }, ip }); return { success: true }; } @@ -253,7 +253,7 @@ export class OauthService { return row ? JSON.parse(row.scopes) : null; } - saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): void { + async saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): Promise { // Union existing consent with newly approved scopes (M5: never narrow stored consent) const existing = this.getConsent(clientId, userId) ?? []; const merged = Array.from(new Set([...existing, ...scopes])); @@ -261,7 +261,7 @@ export class OauthService { 'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)', clientId, userId, JSON.stringify(merged), ); - this.audit.writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip }); + await this.audit.writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip }); } isConsentSufficient(existingScopes: string[], requestedScopes: string[]): boolean { @@ -446,12 +446,12 @@ export class OauthService { return !!successor; } - refreshTokens( + async refreshTokens( rawRefreshToken: string, clientId: string, clientSecret: string | undefined, ip?: string | null, - ): { error?: string; status?: number; tokens?: ReturnType } { + ): Promise<{ error?: string; status?: number; tokens?: ReturnType }> { const client = this.db.get('SELECT client_id, client_secret_hash, is_public FROM oauth_clients WHERE client_id = ?', clientId); if (!client) return { error: 'invalid_client', status: 401 }; if (!client.is_public) { @@ -478,7 +478,7 @@ export class OauthService { // parent so each client walks away with its own token. if (this.isConcurrentRotation(row)) { const tokens = this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null); - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId, concurrent: true }, @@ -493,7 +493,7 @@ export class OauthService { revokeUserSessionsForClient(row.user_id, clientId); - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: row.user_id, action: 'oauth.token.replay_detected', details: { client_id: clientId }, @@ -515,7 +515,7 @@ export class OauthService { this.db.run('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?', row.id); const tokens = this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null); - this.audit.writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip }); + await this.audit.writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip }); return { tokens }; } @@ -524,7 +524,7 @@ export class OauthService { // Token revocation // ------------------------------------------------------------------------- - revokeToken(rawToken: string, clientId: string, userId?: number, ip?: string | null): void { + async revokeToken(rawToken: string, clientId: string, userId?: number, ip?: string | null): Promise { const hash = hashToken(rawToken); // Get the user_id for the token so we can revoke its MCP sessions @@ -542,7 +542,7 @@ export class OauthService { const affectedUserId = row?.user_id ?? userId; if (affectedUserId) { revokeUserSessionsForClient(affectedUserId, clientId); - this.audit.writeAudit({ userId: affectedUserId, action: 'oauth.token.revoke', details: { client_id: clientId, method: 'token' }, ip }); + await this.audit.writeAudit({ userId: affectedUserId, action: 'oauth.token.revoke', details: { client_id: clientId, method: 'token' }, ip }); } } @@ -564,11 +564,11 @@ export class OauthService { return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes as string) })); } - revokeSession( + async revokeSession( userId: number, sessionId: number, ip?: string | null, - ): { error?: string; status?: number; success?: boolean } { + ): Promise<{ error?: string; status?: number; success?: boolean }> { const row = this.db.get<{ id: number; client_id: string }>('SELECT id, client_id FROM oauth_tokens WHERE id = ? AND user_id = ?', sessionId, userId); if (!row) return { error: 'Session not found', status: 404 }; @@ -576,7 +576,7 @@ export class OauthService { revokeUserSessionsForClient(userId, row.client_id); - this.audit.writeAudit({ userId, action: 'oauth.token.revoke', details: { client_id: row.client_id, method: 'session' }, ip }); + await this.audit.writeAudit({ userId, action: 'oauth.token.revoke', details: { client_id: row.client_id, method: 'session' }, ip }); return { success: true }; } @@ -585,11 +585,11 @@ export class OauthService { // Authorize request validation (option A: called by SPA via GET /api/oauth/authorize/validate) // ------------------------------------------------------------------------- - validateAuthorizeRequest( + async validateAuthorizeRequest( params: AuthorizeParams, userId: number | null, - ): ValidateAuthorizeResult { - if (!this.addons.isAddonEnabled(ADDON_IDS.MCP)) { + ): Promise { + if (!(await this.addons.isAddonEnabled(ADDON_IDS.MCP))) { return { valid: false, error: 'mcp_disabled', error_description: 'MCP is not enabled on this server' }; } diff --git a/server/src/nest/oidc/oidc.controller.ts b/server/src/nest/oidc/oidc.controller.ts index 8bd69ae62..bd34c4fe8 100644 --- a/server/src/nest/oidc/oidc.controller.ts +++ b/server/src/nest/oidc/oidc.controller.ts @@ -165,14 +165,14 @@ export class OidcController { userInfo.picture = idVerify.claims.picture; } - const result = this.oidc.findOrCreateUser(userInfo, config, pending.inviteToken); + const result = await this.oidc.findOrCreateUser(userInfo, config, pending.inviteToken); if ('error' in result) return f('/login?oidc_error=' + result.error); if (result.roleChange) { // The claim mapping changing someone's privileges is a security event, and // the row is written here because this is where the client IP is. The claim // NAME goes in the details, never its value: that column is readable by // every admin and a claim can carry group memberships and worse. - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: result.user.id, action: 'oidc.role_change', resource: String(result.user.id), @@ -251,14 +251,14 @@ export class AdminOidcController { @ManagedForbidden('an instance-supplied issuer could assert any address as verified') @Put() - update(@CurrentUser() user: User, @Body() body: AdminOidcUpdateDto, @Req() req: Request) { + async update(@CurrentUser() user: User, @Body() body: AdminOidcUpdateDto, @Req() req: Request) { const result = this.oidc.updateOidcSettings(body); if (result.error) { throw new HttpException({ error: result.error }, result.status || 400); } // Only whether an issuer was set, never the value: the details column is readable // by every admin and the issuer identifies the customer's IdP tenant. - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: user.id, action: 'admin.oidc_update', ip: getClientIp(req), diff --git a/server/src/nest/oidc/oidc.service.ts b/server/src/nest/oidc/oidc.service.ts index e90f9268e..ffb172056 100644 --- a/server/src/nest/oidc/oidc.service.ts +++ b/server/src/nest/oidc/oidc.service.ts @@ -13,6 +13,7 @@ import { TripMembershipService } from '../trip-membership/trip-membership.servic import { setAuthCookie, RememberOption } from '../common/cookie'; import { AuthService } from '../auth/auth.service'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { safeFetchAdminConfigured } from '../../utils/ssrfGuard'; // --------------------------------------------------------------------------- @@ -228,6 +229,7 @@ export class OidcService implements OnModuleDestroy { private readonly db: DatabaseService, private readonly auth: AuthService, private readonly membership: TripMembershipService, + private readonly uow: UnitOfWork, ) { this.stateSweeper = setInterval(() => { const now = Date.now(); @@ -607,11 +609,11 @@ export class OidcService implements OnModuleDestroy { // Find or create user by OIDC sub / email // ------------------------------------------------------------------------- - findOrCreateUser( + async findOrCreateUser( userInfo: OidcUserInfo, config: OidcConfig, inviteToken?: string, - ): { user: User; roleChange?: OidcRoleChange } | { error: string } { + ): Promise<{ user: User; roleChange?: OidcRoleChange } | { error: string }> { // Defense-in-depth for direct callers — the controller redirects on a // missing email before it ever calls this; the same code flows through its // `oidc_error=' + result.error` pass-through if reached here. @@ -731,7 +733,7 @@ export class OidcService implements OnModuleDestroy { // both pass the earlier SELECT-based check and each create a user. const inviteRaceError = new Error('invite_exhausted'); try { - const result = this.db.transaction(() => { + const result = (await this.uow.transactional(async () => { if (validInvite) { const updated = this.db.prepare( 'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)', @@ -744,10 +746,10 @@ export class OidcService implements OnModuleDestroy { // Trip-bound invite (#1402): auto-add the new SSO user to the trip inside the // same atomic step as the invite consume. Idempotent + owner-safe. if (validInvite?.trip_id) { - this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(ins.lastInsertRowid), validInvite.created_by ?? null); + await this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(ins.lastInsertRowid), validInvite.created_by ?? null); } return ins; - }) as { lastInsertRowid: number | bigint }; + })) as { lastInsertRowid: number | bigint }; // Re-select so the returned User carries the real row (password_version, // is_guest, created_at, …) instead of a hand-built partial — same shape // the existing-user branch returns. diff --git a/server/src/nest/packing/admin-packing-templates.controller.ts b/server/src/nest/packing/admin-packing-templates.controller.ts index b31e7e869..9e2c0c6d4 100644 --- a/server/src/nest/packing/admin-packing-templates.controller.ts +++ b/server/src/nest/packing/admin-packing-templates.controller.ts @@ -50,9 +50,9 @@ export class AdminPackingTemplatesController { @Post() @HttpCode(201) - create(@CurrentUser() user: User, @Body() body: AdminTemplateNameDto, @Req() req: Request) { + async create(@CurrentUser() user: User, @Body() body: AdminTemplateNameDto, @Req() req: Request) { const result = ok(this.packing.createPackingTemplate(body.name, user.id)); - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: user.id, action: 'admin.packing_template_create', resource: String((result.template as { id?: number } | undefined)?.id ?? ''), @@ -68,9 +68,9 @@ export class AdminPackingTemplatesController { } @Delete(':id') - remove(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { + async remove(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { const result = ok(this.packing.deletePackingTemplate(id)); - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: user.id, action: 'admin.packing_template_delete', resource: String(id), diff --git a/server/src/nest/packing/packing.mcp.ts b/server/src/nest/packing/packing.mcp.ts index 047bc2cf5..e85c44393 100644 --- a/server/src/nest/packing/packing.mcp.ts +++ b/server/src/nest/packing/packing.mcp.ts @@ -81,7 +81,7 @@ export class PackingMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const item = this.packing.createItem(tripId, { name, category: category || 'General', @@ -118,7 +118,7 @@ export class PackingMcp { async togglePackingItem({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const item = this.packing.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked'], undefined, ctx.userId); if (!item) return errorResult('Packing item not found.'); // Scoped to the people who may see it, exactly as the REST route does @@ -141,7 +141,7 @@ export class PackingMcp { async deletePackingItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const deleted = this.packing.deleteItem(tripId, itemId, ctx.userId); if (!deleted) return errorResult('Packing item not found.'); // deleteItem hands back the row it removed, so the delete can be scoped to @@ -176,7 +176,7 @@ export class PackingMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const fields = { name, category, bag_id, quantity, weight_grams, is_private }; // The service reads presence from bodyKeys, so a field has to be named there // for an explicit null to clear it rather than read as "leave it alone". @@ -232,7 +232,7 @@ export class PackingMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const item = this.packing.setItemSharing(tripId, itemId, ctx.userId, visibility, recipient_ids ?? []); if (!item) return errorResult('Packing item not found.'); if ((item as { forbidden?: boolean }).forbidden) return errorResult('Only the owner can change sharing.'); @@ -260,7 +260,7 @@ export class PackingMcp { async reorderPackingItems({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); this.packing.reorderItems(tripId, orderedIds); this.guards.safeBroadcast(tripId, 'packing:reordered', { orderedIds }); return ok({ success: true }); @@ -298,7 +298,7 @@ export class PackingMcp { async createPackingBag({ tripId, name, color, weight_limit_grams }: { tripId: number; name: string; color?: string; weight_limit_grams?: number | null }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); // createBag returns a bare row; hydrate with the empty members array that // listBags and the schema always carry, so the client/AI consumer matches. const bag = { ...(this.packing.createBag(tripId, { name, color, weight_limit_grams }) as object), members: [] }; @@ -327,7 +327,7 @@ export class PackingMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const fields: { name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null } = {}; const bodyKeys: string[] = []; if (name !== undefined) { fields.name = name; bodyKeys.push('name'); } @@ -358,7 +358,7 @@ export class PackingMcp { async deletePackingBag({ tripId, bagId }: { tripId: number; bagId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); this.packing.deleteBag(tripId, bagId); // { bagId } matches the REST route and the plugin host (the legacy // registrar's { id } was the odd one out). @@ -384,7 +384,7 @@ export class PackingMcp { async setBagMembers({ tripId, bagId, userIds }: { tripId: number; bagId: number; userIds: number[] }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const members = this.packing.setBagMembers(tripId, bagId, userIds); if (!members) return errorResult('Bag not found.'); this.guards.safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, members }); @@ -422,7 +422,7 @@ export class PackingMcp { async setPackingCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const assignees = this.packing.updateCategoryAssignees(tripId, categoryName, userIds); this.guards.safeBroadcast(tripId, 'packing:assignees', { category: categoryName, assignees }); return ok({ assignees }); @@ -442,7 +442,7 @@ export class PackingMcp { async applyPackingTemplate({ tripId, templateId }: { tripId: number; templateId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const items = this.packing.applyTemplate(tripId, templateId); if (items === null) return errorResult('Template not found.'); this.guards.safeBroadcast(tripId, 'packing:template-applied', { items }); @@ -479,7 +479,7 @@ export class PackingMcp { async savePackingTemplate({ tripId, templateName }: { tripId: number; templateName: string }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); // Templates are global; the REST route restricts saving to admins. Match it. if (!this.guards.isAdminUser(ctx.userId)) return adminRequired(); const template = this.packing.saveAsTemplate(tripId, ctx.userId, templateName); @@ -530,7 +530,7 @@ export class PackingMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const created = this.packing.bulkImport(tripId, items, ctx.userId); for (const item of created) { this.guards.safeBroadcast(tripId, 'packing:created', { item }, this.packing.viewersOf(item)); diff --git a/server/src/nest/packing/packing.rpc.ts b/server/src/nest/packing/packing.rpc.ts index a09067e46..4c3ae0b7b 100644 --- a/server/src/nest/packing/packing.rpc.ts +++ b/server/src/nest/packing/packing.rpc.ts @@ -41,12 +41,12 @@ export class PackingRpc { } @PluginMethod('packing.create', { permission: 'db:write:packing' }) - create(params: Record, ctx: PluginRpcContext): unknown { + async create(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.guards.requireActor(ctx, 'packing item'); const parsed = packingCreateItemRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid packing item: ${schemaMessage(parsed.error)}`); - this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); const item = this.packing.createItem(String(tripId), parsed.data as never, actor) as PrivacyItem; // A referenced bag must exist on this trip (#2154), as on the REST route. if (isInvalidBagRef(item)) throw new BadParams(`no packing bag ${parsed.data.bag_id} on trip ${tripId}`); @@ -56,13 +56,13 @@ export class PackingRpc { } @PluginMethod('packing.update', { permission: 'db:write:packing' }) - update(params: Record, ctx: PluginRpcContext): unknown { + async update(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const itemId = num(params.itemId, 'itemId'); const actor = this.guards.requireActor(ctx, 'packing item'); const parsed = packingUpdateItemRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid packing item: ${schemaMessage(parsed.error)}`); - this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); // Read the privacy BEFORE the write, so a public/private toggle routes correctly. const before = this.packing.getItemPrivacy(tripId, itemId); const input = parsed.data as Record; @@ -79,11 +79,11 @@ export class PackingRpc { } @PluginMethod('packing.delete', { permission: 'db:write:packing' }) - delete(params: Record, ctx: PluginRpcContext): unknown { + async delete(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const itemId = num(params.itemId, 'itemId'); const actor = this.guards.requireActor(ctx, 'packing item'); - this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); const deleted = this.packing.deleteItem(String(tripId), String(itemId), actor) as PrivacyItem | null; if (!deleted) throw new ForbiddenResource(`no packing item ${itemId} on trip ${tripId}`); this.packing.emitToViewers(String(tripId), 'packing:deleted', { itemId }, deleted, undefined); @@ -99,12 +99,12 @@ export class PackingRpc { } @PluginMethod('packing.createBag', { permission: 'db:write:packing' }) - createBag(params: Record, ctx: PluginRpcContext): unknown { + async createBag(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.guards.requireActor(ctx, 'packing bag'); const input = asPayload(params.input); if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bag name is required'); - this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); const bag = this.packing.createBag(String(tripId), { name: input.name, color: typeof input.color === 'string' ? input.color : undefined, @@ -115,11 +115,11 @@ export class PackingRpc { } @PluginMethod('packing.updateBag', { permission: 'db:write:packing' }) - updateBag(params: Record, ctx: PluginRpcContext): unknown { + async updateBag(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const bagId = num(params.bagId, 'bagId'); const actor = this.guards.requireActor(ctx, 'packing bag'); - this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); const input = asPayload(params.input); const bag = this.packing.updateBag(String(tripId), String(bagId), input as never, Object.keys(input)); if (!bag) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`); @@ -128,11 +128,11 @@ export class PackingRpc { } @PluginMethod('packing.deleteBag', { permission: 'db:write:packing' }) - deleteBag(params: Record, ctx: PluginRpcContext): unknown { + async deleteBag(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const bagId = num(params.bagId, 'bagId'); const actor = this.guards.requireActor(ctx, 'packing bag'); - this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); if (!this.packing.deleteBag(String(tripId), String(bagId))) { throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`); } @@ -143,11 +143,11 @@ export class PackingRpc { } @PluginMethod('packing.setBagMembers', { permission: 'db:write:packing' }) - setBagMembers(params: Record, ctx: PluginRpcContext): unknown { + async setBagMembers(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const bagId = num(params.bagId, 'bagId'); const actor = this.guards.requireActor(ctx, 'packing bag'); - this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); // userIds sits on the params object itself, not under `input`. const raw = asPayload(params).userIds; const userIds = Array.isArray(raw) ? raw.filter((x): x is number => typeof x === 'number') : []; diff --git a/server/src/nest/packing/packing.service.ts b/server/src/nest/packing/packing.service.ts index 0d2616b33..45512cb40 100644 --- a/server/src/nest/packing/packing.service.ts +++ b/server/src/nest/packing/packing.service.ts @@ -67,7 +67,7 @@ export class PackingService { } /** Mirrors the inline checkPermission('packing_edit', ...) the legacy route runs. */ - canEdit(trip: Trip, user: User): boolean { + async canEdit(trip: Trip, user: User): Promise { return this.permissions.checkPermission('packing_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id); } diff --git a/server/src/nest/permissions/permissions.service.ts b/server/src/nest/permissions/permissions.service.ts index cd964e921..becd7e710 100644 --- a/server/src/nest/permissions/permissions.service.ts +++ b/server/src/nest/permissions/permissions.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { logError } from '../audit/audit-log.logger'; import { getPermissionsCache, @@ -73,9 +74,12 @@ const ACTIONS_MAP = new Map(PERMISSION_ACTIONS.map(a => [a.key, a])); @Injectable() export class PermissionsService { - constructor(private readonly dbs: DatabaseService) {} + constructor( + private readonly dbs: DatabaseService, + private readonly uow: UnitOfWork, + ) {} - private loadPermissions(): Map { + private async loadPermissions(): Promise> { const cached = getPermissionsCache(); if (cached) return cached; const cache = new Map(); @@ -113,16 +117,16 @@ export class PermissionsService { invalidateSharedCache(); } - getPermissionLevel(actionKey: string): PermissionLevel { - const perms = this.loadPermissions(); + async getPermissionLevel(actionKey: string): Promise { + const perms = await this.loadPermissions(); const stored = perms.get(actionKey); if (stored) return stored; const action = ACTIONS_MAP.get(actionKey); return action?.defaultLevel ?? 'trip_owner'; } - getAllPermissions(): Record { - const perms = this.loadPermissions(); + async getAllPermissions(): Promise> { + const perms = await this.loadPermissions(); const result: Record = {}; for (const action of PERMISSION_ACTIONS) { result[action.key] = perms.get(action.key) ?? action.defaultLevel; @@ -130,7 +134,7 @@ export class PermissionsService { return result; } - savePermissions(settings: Record): { skipped: string[] } { + async savePermissions(settings: Record): Promise<{ skipped: string[] }> { const skipped: string[] = []; const valid: Array<[string, string]> = []; for (const [actionKey, level] of Object.entries(settings)) { @@ -144,7 +148,7 @@ export class PermissionsService { // Nothing valid to write → no prepare, no transaction, no cache flush. if (valid.length === 0) return { skipped }; const upsert = this.dbs.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)'); - this.dbs.transaction(() => { + await this.uow.transactional(async () => { for (const [actionKey, level] of valid) { upsert.run(`perm_${actionKey}`, level); } @@ -162,17 +166,17 @@ export class PermissionsService { * @param userId - The requesting user's ID * @param isMember - Whether the user is a trip member (not owner) */ - checkPermission( + async checkPermission( actionKey: string, userRole: string, tripUserId: number | null, userId: number, isMember: boolean - ): boolean { + ): Promise { // Admins always pass if (userRole === 'admin') return true; - const required = this.getPermissionLevel(actionKey); + const required = await this.getPermissionLevel(actionKey); switch (required) { case 'admin': diff --git a/server/src/nest/permissions/trip-access.guard.ts b/server/src/nest/permissions/trip-access.guard.ts index a8db1baf6..5b4aad2fb 100644 --- a/server/src/nest/permissions/trip-access.guard.ts +++ b/server/src/nest/permissions/trip-access.guard.ts @@ -47,7 +47,7 @@ export class TripAccessGuard implements CanActivate { private readonly reflector: Reflector, ) {} - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const user = request.user; // JwtAuthGuard runs first and 401s an anonymous request, so a missing user here @@ -67,7 +67,7 @@ export class TripAccessGuard implements CanActivate { ]); if (action) { const shared = trip.user_id !== user.id; - if (!this.permissions.checkPermission(action, user.role, trip.user_id, user.id, shared)) { + if (!(await this.permissions.checkPermission(action, user.role, trip.user_id, user.id, shared))) { throw new HttpException({ error: 'No permission' }, 403); } } diff --git a/server/src/nest/permissions/trip-owner.guard.ts b/server/src/nest/permissions/trip-owner.guard.ts index a69ec9675..9624a9652 100644 --- a/server/src/nest/permissions/trip-owner.guard.ts +++ b/server/src/nest/permissions/trip-owner.guard.ts @@ -51,7 +51,7 @@ export class TripOwnerGuard implements CanActivate { private readonly reflector: Reflector, ) {} - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const user = request.user; // JwtAuthGuard runs first and 401s an anonymous request, so a missing user diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts index 2ee824448..90cf375a9 100644 --- a/server/src/nest/places/places.controller.ts +++ b/server/src/nest/places/places.controller.ts @@ -126,26 +126,26 @@ export class PlacesController { return trip; } - private requireEdit(trip: NonNullable>, user: User): void { - if (!this.places.canEdit(trip, user)) { + private async requireEdit(trip: NonNullable>, user: User): Promise { + if (!(await this.places.canEdit(trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } } @Get() @UseGuards(TripAccessGuard) - list( + async list( @CurrentUser() user: User, @Param('tripId') tripId: string, @Query('search') search?: string, @Query('category') category?: string, @Query('tag') tag?: string, ) { - return { places: this.places.list(tripId, { search, category, tag }) }; + return { places: await this.places.list(tripId, { search, category, tag }) }; } @Post() - create( + async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: PlaceCreateDto, @@ -155,7 +155,7 @@ export class PlacesController { validateLengths(body); validateRouteColor(body); validateUrlFields(body); - this.requireEdit(trip, user); + await this.requireEdit(trip, user); const place = this.places.create(tripId, body as never); this.places.broadcast(tripId, 'place:created', { place }, socketId); this.places.onCreated(tripId, place.id); @@ -164,7 +164,7 @@ export class PlacesController { @Post('import/gpx') @UseInterceptors(FileInterceptor('file', UPLOAD)) - importGpx( + async importGpx( @CurrentUser() user: User, @Param('tripId') tripId: string, @UploadedFile() file: Express.Multer.File | undefined, @@ -172,7 +172,7 @@ export class PlacesController { @Headers('x-socket-id') socketId?: string, ) { const trip = this.requireTrip(tripId, user); - this.requireEdit(trip, user); + await this.requireEdit(trip, user); if (!file) { throw new HttpException({ error: 'No file uploaded' }, 400); } @@ -230,7 +230,7 @@ export class PlacesController { @Headers('x-socket-id') socketId?: string, ) { const trip = this.requireTrip(tripId, user); - this.requireEdit(trip, user); + await this.requireEdit(trip, user); if (!file) { throw new HttpException({ error: 'No file uploaded' }, 400); } @@ -268,7 +268,7 @@ export class PlacesController { /** Shared google/naver list import — identical flow, different provider + error string. */ private async importList(provider: 'google' | 'naver', user: User, tripId: string, body: PlaceImportListDto, socketId?: string) { const trip = this.requireTrip(tripId, user); - this.requireEdit(trip, user); + await this.requireEdit(trip, user); const { url, enrich } = body; // Opt-in: re-resolve each imported place via the Places API to fill in // photo / address / website / phone and persist a google_place_id (#886). @@ -307,7 +307,7 @@ export class PlacesController { @Headers('x-socket-id') socketId?: string, ) { const trip = this.requireTrip(tripId, user); - this.requireEdit(trip, user); + await this.requireEdit(trip, user); const { ids } = body; if (ids.length === 0) { return { deleted: [], count: 0 }; @@ -351,7 +351,7 @@ export class PlacesController { @Headers('x-socket-id') socketId?: string, ) { const trip = this.requireTrip(tripId, user); - this.requireEdit(trip, user); + await this.requireEdit(trip, user); const { ids } = body; if (ids.length === 0) { return { updated: [], count: 0 }; @@ -390,7 +390,7 @@ export class PlacesController { @Headers('x-socket-id') socketId?: string, ) { const trip = this.requireTrip(tripId, user); - this.requireEdit(trip, user); + await this.requireEdit(trip, user); // Inline rather than DemoWriteGuard: requireEdit above answers 404/403 for a // trip the caller cannot reach, and a guard would run before it. if (isDemoWriteBlocked(this.env, user.email)) { @@ -473,7 +473,7 @@ export class PlacesController { validateLengths(body); validateRouteColor(body); validateUrlFields(body); - this.requireEdit(trip, user); + await this.requireEdit(trip, user); const result = await this.places.update(tripId, id, body as never, ifMatch); if (!result) { throw new HttpException({ error: 'Place not found' }, 404); diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 740559c5a..9d17ffcaf 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -21,6 +21,7 @@ import { AssignmentsService } from '../assignments/assignments.service'; import { JourneyDomainService } from '../journey/journey-domain.service'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { MapsService } from '../maps/maps.service'; import { PlacesService } from './places.service'; import { isDirectionsUrl } from './maps-dir.helpers'; @@ -57,6 +58,7 @@ export class PlacesMcp { private readonly journey: JourneyDomainService, private readonly assignments: AssignmentsService, private readonly guards: McpToolGuardsService, + private readonly uow: UnitOfWork, ) {} @Tool({ @@ -96,7 +98,7 @@ export class PlacesMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, image_url, price, currency, stop_type }); this.guards.safeBroadcast(tripId, 'place:created', { place }); return ok({ place }); @@ -141,12 +143,12 @@ export class PlacesMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; try { - const result = this.db.transaction(() => { + const result = await this.uow.transactional(async () => { const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes: place_notes, website, phone, image_url, price, currency, stop_type }); - const assignment = this.assignments.createAssignment(dayId, place.id, assignment_notes ?? null); + const assignment = await this.assignments.createAssignment(dayId, place.id, assignment_notes ?? null); return { place, assignment }; }); this.guards.safeBroadcast(tripId, 'place:created', { place: result.place }); @@ -204,7 +206,7 @@ export class PlacesMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent }); if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; this.guards.safeBroadcast(tripId, 'place:updated', { place }); @@ -248,7 +250,7 @@ export class PlacesMcp { async deletePlace({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); // Scope the id to the trip before the hook: onPlaceDeleted keys on the place // id alone, so a foreign id would detach that trip's journey entries even // though the delete below refuses it. @@ -290,7 +292,7 @@ export class PlacesMcp { ctx: McpContext, ) { if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - const places = this.places.list(String(tripId), { search, category, tag, assignment }); + const places = await this.places.list(String(tripId), { search, category, tag, assignment }); return ok({ places }); } @@ -342,7 +344,7 @@ export class PlacesMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); // Same opts the REST route builds: the enrichment pass is keyed on the calling // user because it spends that user's Places credential. @@ -373,7 +375,7 @@ export class PlacesMcp { async importGpx(input: RoadtripGpxImport, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('place_edit', input.tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('place_edit', input.tripId, ctx.userId))) return permissionDenied(); if (!input.importWaypoints && !input.importRoutes && !input.importTracks) return errorResult('No import types selected.'); try { const imported = this.places.importGpx(String(input.tripId), Buffer.from(input.gpx, 'utf8'), { importWaypoints: input.importWaypoints, importRoutes: input.importRoutes, importTracks: input.importTracks, defaultName: input.name }); @@ -424,7 +426,7 @@ export class PlacesMcp { async bulkDeletePlaces({ tripId, placeIds }: { tripId: number; placeIds: number[] }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); // Trip-scoped, and ahead of the DELETE: journey_entries.source_place_id is // ON DELETE SET NULL, so a hook that ran afterwards found nothing left to @@ -481,7 +483,7 @@ export class PlacesMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); const fields = { category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, image_url, description, stop_type, fill_percent }; if (Object.values(fields).every(v => v === undefined)) { @@ -512,7 +514,7 @@ export class PlacesMcp { }; } const assignment = uri.searchParams.get('assignment') as 'all' | 'unassigned' | 'assigned' | null; - const places = this.places.list(String(id), { assignment: assignment ?? undefined }); + const places = await this.places.list(String(id), { assignment: assignment ?? undefined }); return { contents: [{ uri: uri.href, diff --git a/server/src/nest/places/places.rpc.ts b/server/src/nest/places/places.rpc.ts index ecb1710d8..5c24e2c46 100644 --- a/server/src/nest/places/places.rpc.ts +++ b/server/src/nest/places/places.rpc.ts @@ -52,14 +52,14 @@ export class PlacesRpc { ) {} @PluginMethod('places.create', { permission: 'db:write:places' }) - create(params: Record, ctx: PluginRpcContext): unknown { + async create(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.guards.requireActor(ctx, 'place'); const parsed = placeCreateRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid place: ${schemaMessage(parsed.error)}`); this.guards.capStrings(parsed.data as Record, PLACE_STR_LIMITS); capUrls(parsed.data as Record); - this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION); const place = this.places.create(String(tripId), parsed.data as unknown as PlaceCreateInput); this.realtime.broadcast(tripId, 'place:created', { place }); this.mirrorJourneys(() => this.journey.onPlaceCreated(tripId, place.id)); @@ -75,7 +75,7 @@ export class PlacesRpc { if (!parsed.success) throw new BadParams(`invalid place: ${schemaMessage(parsed.error)}`); this.guards.capStrings(parsed.data as Record, PLACE_STR_LIMITS); capUrls(parsed.data as Record); - this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION); // update is async (it may delete a superseded storage object): await it so the // null-refusal check compares against the resolved place, not an always-truthy // Promise, and the broadcast below carries the actual place. @@ -91,7 +91,7 @@ export class PlacesRpc { const tripId = num(params.tripId, 'tripId'); const placeId = num(params.placeId, 'placeId'); const actor = this.guards.requireActor(ctx, 'place'); - this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION); // Scope the id to the trip before anything else: onPlaceDeleted keys on the place // alone, so an id belonging to a foreign trip would detach THAT trip's journey // entries even though the delete below refuses it. diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 6bc360182..71d0e23cc 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -4,6 +4,7 @@ import { TRACK_COLORS, placeMatchStrategies, type PlaceMatchCandidate } from '@t import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService, type TripAccess } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import type { PlaceWithTags } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; import { MapsService, GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../maps/maps.service'; @@ -133,13 +134,14 @@ export class PlacesService { private readonly journey: JourneyDomainService, private readonly storage: StorageService, private readonly accommodations: AccommodationsService, + private readonly uow: UnitOfWork, ) {} verifyTripAccess(tripId: string, userId: number) { return this.dbs.canAccessTrip(Number(tripId), userId); } - canEdit(trip: Trip, user: User): boolean { + async canEdit(trip: Trip, user: User): Promise { return this.permissions.checkPermission('place_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id); } @@ -174,7 +176,7 @@ export class PlacesService { // List places // ------------------------------------------------------------------------- - list( + async list( tripId: string, filters: { search?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' }, ) { @@ -217,8 +219,8 @@ export class PlacesService { const places = this.dbs.prepare(query).all(...params) as PlaceWithCategory[]; const placeIds = places.map(p => p.id); - const tagsByPlaceId = this.queryHelpers.loadTagsByPlaceIds(placeIds); - const ratingsByPlaceId = this.queryHelpers.loadRatingsByPlaceIds(placeIds); + const tagsByPlaceId = await this.queryHelpers.loadTagsByPlaceIds(placeIds); + const ratingsByPlaceId = await this.queryHelpers.loadRatingsByPlaceIds(placeIds); return places.map(p => ({ ...p, @@ -449,12 +451,12 @@ export class PlacesService { * * Runs inside the caller's transaction. */ - private cancelStaysAt(tripId: string | number, placeId: string | number, into: CancelledStays): void { + private async cancelStaysAt(tripId: string | number, placeId: string | number, into: CancelledStays): Promise { const stays = this.dbs.all<{ id: number }>( 'SELECT id FROM day_accommodations WHERE trip_id = ? AND place_id = ?', tripId, placeId, ); for (const stay of stays) { - const gone = this.accommodations.deleteAccommodation(stay.id); + const gone = await this.accommodations.deleteAccommodation(stay.id); // What went down with the night is what the caller has to announce. The // partner booking and its expense are rows the Bookings list and the Costs // total are still holding; place:deleted says nothing about either, and a @@ -473,8 +475,8 @@ export class PlacesService { // The linked expense goes with the place, the same way a booking takes its // expense with it (#1298). One transaction, so a place can never survive // half-detached from its money. - this.dbs.transaction(() => { - this.cancelStaysAt(tripId, placeId, cancelled); + await this.uow.transactional(async () => { + await this.cancelStaysAt(tripId, placeId, cancelled); this.dbs.run('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?', tripId, placeId); this.dbs.run('DELETE FROM places WHERE id = ?', placeId); }); @@ -491,11 +493,11 @@ export class PlacesService { const deleteExpenseStmt = this.dbs.prepare('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?'); const deleted: number[] = []; const reclaimable: { google_place_id: string | null; image_url: string | null }[] = []; - this.dbs.transaction(() => { + await this.uow.transactional(async () => { for (const id of ids) { const row = selectStmt.get(id, tripId) as { google_place_id: string | null; image_url: string | null } | undefined; if (!row) continue; - this.cancelStaysAt(tripId, id, cancelled); + await this.cancelStaysAt(tripId, id, cancelled); deleteExpenseStmt.run(tripId, id); deleteStmt.run(id); deleted.push(id); diff --git a/server/src/nest/platform/discovery.controller.ts b/server/src/nest/platform/discovery.controller.ts index b3d32fa8d..4bb8c2c24 100644 --- a/server/src/nest/platform/discovery.controller.ts +++ b/server/src/nest/platform/discovery.controller.ts @@ -44,8 +44,8 @@ export class DiscoveryController { // them with invalid_target — showing the user the TREK home page instead of the // consent form. @Get('oauth-protected-resource') - protectedResource(@Res() res: Response): void { - if (!this.addons.isAddonEnabled(ADDON_IDS.MCP)) { + async protectedResource(@Res() res: Response): Promise { + if (!(await this.addons.isAddonEnabled(ADDON_IDS.MCP))) { res.status(404).end(); return; } diff --git a/server/src/nest/platform/mcp-metadata.middleware.ts b/server/src/nest/platform/mcp-metadata.middleware.ts index a62894e3e..a9e77b0e5 100644 --- a/server/src/nest/platform/mcp-metadata.middleware.ts +++ b/server/src/nest/platform/mcp-metadata.middleware.ts @@ -23,12 +23,17 @@ export function createMcpMetadataMiddleware( meta: DiscoveryMetadataService, addons: AddonsService, ): RequestHandler { + // Express cannot await a middleware, so the now-async addon check runs in a + // helper and its rejection is handed to next() — the same error path a + // synchronous throw took before (recipe R1.5). return (req, res, next) => { - if (req.path.startsWith('/.well-known/') && !addons.isAddonEnabled(ADDON_IDS.MCP)) { - res.status(404).end(); - return; - } - meta.getMetaRouter()(req, res, next); + void (async () => { + if (req.path.startsWith('/.well-known/') && !(await addons.isAddonEnabled(ADDON_IDS.MCP))) { + res.status(404).end(); + return; + } + meta.getMetaRouter()(req, res, next); + })().catch(next); }; } diff --git a/server/src/nest/plugins/contributions/journal-entry-rows.controller.ts b/server/src/nest/plugins/contributions/journal-entry-rows.controller.ts index a2c565da9..0ff138ed9 100644 --- a/server/src/nest/plugins/contributions/journal-entry-rows.controller.ts +++ b/server/src/nest/plugins/contributions/journal-entry-rows.controller.ts @@ -78,7 +78,7 @@ export class JournalEntryRowsController { @Param('entryId') entryIdRaw: string, @Req() req: Request & { user?: { id: number } }, ): Promise<{ providers: ProviderResult[] }> { - if (!pluginsEnabled() || !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return { providers: [] }; + if (!pluginsEnabled() || !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return { providers: [] }; const entryId = Number(entryIdRaw); const userId = req.user?.id; if (!Number.isFinite(entryId) || userId == null) return { providers: [] }; diff --git a/server/src/nest/plugins/dependencies.ts b/server/src/nest/plugins/dependencies.ts index 18ede9c0f..67388bc44 100644 --- a/server/src/nest/plugins/dependencies.ts +++ b/server/src/nest/plugins/dependencies.ts @@ -52,8 +52,17 @@ export function parseDependencies(raw: string | null | undefined): PluginDepende } /** Required addon ids that are currently NOT enabled — these block activation. */ -export function disabledRequiredAddons(deps: PluginDependencies, isAddonEnabled: (id: string) => boolean): string[] { - return deps.requiredAddons.filter((a) => !isAddonEnabled(a)); +export async function disabledRequiredAddons( + deps: PluginDependencies, + isAddonEnabled: (id: string) => Promise, +): Promise { + // A `filter` cannot await, so the same predicate runs as an explicit loop — + // same order, same result. + const disabled: string[] = []; + for (const a of deps.requiredAddons) { + if (!(await isAddonEnabled(a))) disabled.push(a); + } + return disabled; } export interface VersionMismatch { diff --git a/server/src/nest/plugins/host/plugin-guards.service.ts b/server/src/nest/plugins/host/plugin-guards.service.ts index 5dcab0bd6..56fdd04c1 100644 --- a/server/src/nest/plugins/host/plugin-guards.service.ts +++ b/server/src/nest/plugins/host/plugin-guards.service.ts @@ -56,9 +56,9 @@ export class PluginGuards { } /** A write is allowed only if the acting user can access AND edit the trip. */ - requireTripEdit(tripId: number, userId: number, action: string): void { + async requireTripEdit(tripId: number, userId: number, action: string): Promise { if (!this.db.canAccessTrip(tripId, userId)) throw new ForbiddenResource(`no access to trip ${tripId}`); - if (!this.canEditAs(action, tripId, userId)) throw new ForbiddenResource(`no permission to edit trip ${tripId}`); + if (!(await this.canEditAs(action, tripId, userId))) throw new ForbiddenResource(`no permission to edit trip ${tripId}`); } /** @@ -66,7 +66,7 @@ export class PluginGuards { * per-domain canEdit. Returns false and never throws, so the caller decides which * message the refusal carries. */ - canEditAs(action: string, tripId: number, userId: number): boolean { + async canEditAs(action: string, tripId: number, userId: number): Promise { const trip = this.db.canAccessTrip(tripId, userId); if (!trip) return false; const user = this.db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined; @@ -79,7 +79,7 @@ export class PluginGuards { * requireTripEdit. `trip_create` is the only one today: it has no trip to check * against yet, which is why the owner id is passed as null. */ - canCreateAs(action: string, userId: number): boolean { + async canCreateAs(action: string, userId: number): Promise { const user = this.db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined; return this.permissions.checkPermission(action, user?.role ?? 'user', null, userId, false); } @@ -88,8 +88,8 @@ export class PluginGuards { * A subsystem read is refused when its addon is off, matching the app, where a * disabled addon means there is simply nothing to read. */ - requireAddon(addonId: string, noun: string): void { - if (!this.addons.isAddonEnabled(addonId)) throw new ForbiddenResource(`the ${noun} addon is disabled`); + async requireAddon(addonId: string, noun: string): Promise { + if (!(await this.addons.isAddonEnabled(addonId))) throw new ForbiddenResource(`the ${noun} addon is disabled`); } /** diff --git a/server/src/nest/plugins/host/rpc/host-surface.rpc.ts b/server/src/nest/plugins/host/rpc/host-surface.rpc.ts index bec031df2..b0f836625 100644 --- a/server/src/nest/plugins/host/rpc/host-surface.rpc.ts +++ b/server/src/nest/plugins/host/rpc/host-surface.rpc.ts @@ -122,7 +122,7 @@ export class HostSurfaceRpc { @PluginMethod('ai.complete', { permission: 'ai:invoke' }) async aiComplete(params: Record, ctx: PluginRpcContext): Promise { const actor = this.guards.requireActor(ctx, 'AI'); - const config = this.requireLlm(actor); + const config = await this.requireLlm(actor); const prompt = typeof params.prompt === 'string' ? params.prompt : ''; if (prompt.trim() === '') throw new BadParams('prompt is required'); if (prompt.length > AI_TEXT_MAX) throw new BadParams(`prompt exceeds the ${AI_TEXT_MAX}-char cap`); @@ -143,7 +143,7 @@ export class HostSurfaceRpc { @PluginMethod('ai.extract', { permission: 'ai:invoke' }) async aiExtract(params: Record, ctx: PluginRpcContext): Promise { const actor = this.guards.requireActor(ctx, 'AI'); - const config = this.requireLlm(actor); + const config = await this.requireLlm(actor); const text = typeof params.text === 'string' ? params.text : ''; if (text.trim() === '') throw new BadParams('text is required'); if (text.length > AI_TEXT_MAX) throw new BadParams(`text exceeds the ${AI_TEXT_MAX}-char cap`); @@ -221,9 +221,9 @@ export class HostSurfaceRpc { .get(actingUserId, targetUserId, actingUserId, targetUserId); } - private requireLlm(userId: number) { - const config = this.llmConfig.resolve(userId); - if (!config) throw new BadParams('no AI provider is configured for this user'); + private async requireLlm(userId: number) { + const config = await this.llmConfig.resolve(userId); + if (!(await config)) throw new BadParams('no AI provider is configured for this user'); return config; } diff --git a/server/src/nest/plugins/host/rpc/meta.rpc.ts b/server/src/nest/plugins/host/rpc/meta.rpc.ts index cbd40b7d8..3de10d6f1 100644 --- a/server/src/nest/plugins/host/rpc/meta.rpc.ts +++ b/server/src/nest/plugins/host/rpc/meta.rpc.ts @@ -49,8 +49,8 @@ export class MetaRpc { ) {} @PluginMethod('meta.get', { permission: 'db:meta' }) - get(params: Record, ctx: PluginRpcContext): unknown { - const { entityType, entityId } = this.resolveEntity(params, ctx, false); + async get(params: Record, ctx: PluginRpcContext): Promise { + const { entityType, entityId } = await this.resolveEntity(params, ctx, false); const row = this.db .prepare('SELECT value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?') .get(ctx.pluginId, entityType, entityId, str(params.key, 'key')) as { value: string } | undefined; @@ -63,8 +63,8 @@ export class MetaRpc { } @PluginMethod('meta.set', { permission: 'db:meta' }) - set(params: Record, ctx: PluginRpcContext): unknown { - const { entityType, entityId } = this.resolveEntity(params, ctx, true); + async set(params: Record, ctx: PluginRpcContext): Promise { + const { entityType, entityId } = await this.resolveEntity(params, ctx, true); const key = str(params.key, 'key'); if (key.length > META_KEY_MAX) throw new BadParams(`metadata key too long (>${META_KEY_MAX} chars)`); const json = JSON.stringify(params.value ?? null); @@ -88,8 +88,8 @@ export class MetaRpc { } @PluginMethod('meta.list', { permission: 'db:meta' }) - list(params: Record, ctx: PluginRpcContext): unknown { - const { entityType, entityId } = this.resolveEntity(params, ctx, false); + async list(params: Record, ctx: PluginRpcContext): Promise { + const { entityType, entityId } = await this.resolveEntity(params, ctx, false); const rows = this.db .prepare('SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key') .all(ctx.pluginId, entityType, entityId) as Array<{ key: string; value: string }>; @@ -105,8 +105,8 @@ export class MetaRpc { } @PluginMethod('meta.delete', { permission: 'db:meta' }) - delete(params: Record, ctx: PluginRpcContext): unknown { - const { entityType, entityId } = this.resolveEntity(params, ctx, true); + async delete(params: Record, ctx: PluginRpcContext): Promise { + const { entityType, entityId } = await this.resolveEntity(params, ctx, true); const res = this.db .prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?') .run(ctx.pluginId, entityType, entityId, str(params.key, 'key')); @@ -118,11 +118,11 @@ export class MetaRpc { * resolves to a trip the acting user can access, and for a write that entity's own * edit permission. */ - private resolveEntity( + private async resolveEntity( params: Record, ctx: PluginRpcContext, write: boolean, - ): { entityType: string; entityId: number } { + ): Promise<{ entityType: string; entityId: number }> { const entityType = str(params.entityType, 'entityType'); if (!META_ENTITY_TYPES.has(entityType)) { throw new BadParams(`invalid entityType "${entityType}" (${[...META_ENTITY_TYPES].join('|')})`); @@ -133,7 +133,7 @@ export class MetaRpc { if (tripId === undefined || !this.db.canAccessTrip(tripId, ctx.actingUserId)) { throw new ForbiddenResource(`no access to ${entityType} ${entityId}`); } - if (write && !this.guards.canEditAs(EDIT_ACTION[entityType], tripId, ctx.actingUserId)) { + if (write && !(await this.guards.canEditAs(EDIT_ACTION[entityType], tripId, ctx.actingUserId))) { throw new ForbiddenResource(`no permission to edit ${entityType} ${entityId}`); } return { entityType, entityId }; diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index a1eb870bc..780f6d7ed 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -473,7 +473,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes // blocked dependency never leaves the chain half-activated. Only the target // may consent-widen; dependencies are auto-enabled at their existing grant. const toCheck = rootInstalled ? order : [id]; - for (const nodeId of toCheck) this.assertActivatable(nodeId, installed, nodeId === id ? consentWiden : false); + for (const nodeId of toCheck) await this.assertActivatable(nodeId, installed, nodeId === id ? consentWiden : false); // Enable dependencies first (skip ones already enabled), then the target. for (const nodeId of order) { @@ -494,7 +494,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes * plugin-API version compatibility → permission re-consent → required addon disabled → * missing/mismatched plugin dependency. */ - private assertActivatable(id: string, installed: Map, consentWiden: boolean): void { + private async assertActivatable(id: string, installed: Map, consentWiden: boolean): Promise { const row = this.db.prepare('SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?').get(id) as | { permissions: string; granted_permissions: string; dependencies: string | null; trek_range: string | null; api_version: number | null } | undefined; @@ -548,7 +548,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes } const deps = parseDependencies(row.dependencies); - const disabledAddons = disabledRequiredAddons(deps, (id) => this.addons.isAddonEnabled(id)); + const disabledAddons = await disabledRequiredAddons(deps, (id) => this.addons.isAddonEnabled(id)); if (disabledAddons.length) { throw new PluginDependencyError(`plugin ${id} requires disabled addon(s): ${disabledAddons.join(', ')}`, 'ADDON_DISABLED', { addons: disabledAddons, @@ -774,7 +774,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes // reconstructible after an incident. This goes to the ADMIN audit log, not the // plugin capability log — that one answers "what have plugins done in my name?" // and is shown to end users; a lifecycle action by an admin does not belong there. - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: actor.userId, action: 'admin.plugin_retrust', resource: id, diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index b9da6aaec..a4c936e83 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -156,7 +156,7 @@ export class PluginsService { } } - list(): { enabled: boolean; devLink: boolean; ignoreTrekRange: boolean; plugins: PluginListItem[] } { + async list(): Promise<{ enabled: boolean; devLink: boolean; ignoreTrekRange: boolean; plugins: PluginListItem[] }> { const rows = this.db .prepare( `SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo, @@ -169,9 +169,12 @@ export class PluginsService { const installed = new Map( rows.map((r) => [r.id, { id: r.id, version: r.version, enabled: r.enabled, dependencies: r.dependencies }]), ); - const plugins: PluginListItem[] = rows.map((r) => { + // A `map` callback cannot await the addon gate, so the projection runs as an + // explicit loop — same order, same rows. + const plugins: PluginListItem[] = []; + for (const r of rows) { const deps = parseDependencies(r.dependencies); - const disabledAddons = disabledRequiredAddons(deps, (id) => this.addons.isAddonEnabled(id)); + const disabledAddons = await disabledRequiredAddons(deps, (id) => this.addons.isAddonEnabled(id)); const state = resolveDependencyState(deps, installed); // Mirrors the order of assertActivatable's gate, so the card explains the same // blocker the activate call would hit rather than a second, lesser one. @@ -194,7 +197,7 @@ export class PluginsService { update_hold, ...rest } = r as PluginRawRow & { operator_egress?: number }; - return { + plugins.push({ ...rest, operatorEgress: _oe === 1, egressHostCount: this.egressHostCount(r.id), @@ -212,8 +215,8 @@ export class PluginsService { ? { code: update_block_code, detail: update_block_detail, version: update_block_version } : null, updateHold: update_hold === 1, - }; - }); + }); + } return { enabled: pluginsEnabled(), devLink: devLinkEnabled(), ignoreTrekRange: trekRangeBypassed(), plugins }; } diff --git a/server/src/nest/public-api/public-api.controller.ts b/server/src/nest/public-api/public-api.controller.ts index c0a7db22e..f3e12cd30 100644 --- a/server/src/nest/public-api/public-api.controller.ts +++ b/server/src/nest/public-api/public-api.controller.ts @@ -52,10 +52,10 @@ export class PublicApiController { /** Every trip the token's owner can reach, without itineraries. */ @Get('trips') - listTrips(@Req() req: Request): PublicApiTripList { + async listTrips(@Req() req: Request): Promise { this.limit(req); requireScope(req, 'trips'); - return { trips: this.api.listTrips(requireUserId(req)) }; + return { trips: await this.api.listTrips(requireUserId(req)) }; } /** diff --git a/server/src/nest/public-api/public-api.service.ts b/server/src/nest/public-api/public-api.service.ts index e212015d6..022d132e7 100644 --- a/server/src/nest/public-api/public-api.service.ts +++ b/server/src/nest/public-api/public-api.service.ts @@ -41,8 +41,8 @@ export class PublicApiService { ) {} /** Every trip the token's owner may read, newest first, without itineraries. */ - listTrips(userId: number): PublicApiTripSummary[] { - const ids = this.membership.listAccessibleTripIds(userId); + async listTrips(userId: number): Promise { + const ids = await this.membership.listAccessibleTripIds(userId); if (ids.length === 0) return []; const rows = this.db.all( `SELECT id, title, description, start_date, end_date, currency, is_archived, updated_at diff --git a/server/src/nest/query-helpers/query-helpers.service.ts b/server/src/nest/query-helpers/query-helpers.service.ts index 311ffb31e..53679286e 100644 --- a/server/src/nest/query-helpers/query-helpers.service.ts +++ b/server/src/nest/query-helpers/query-helpers.service.ts @@ -34,7 +34,7 @@ export class QueryHelpersService { constructor(private readonly db: DatabaseService) {} /** Batch-load tags for multiple places in a single query, indexed by place ID. */ - loadTagsByPlaceIds(placeIds: number[], { compact }: { compact?: boolean } = {}): Record[]> { + async loadTagsByPlaceIds(placeIds: number[], { compact }: { compact?: boolean } = {}): Promise[]>> { const tagsByPlaceId: Record[]> = {}; if (placeIds.length > 0) { const placeholders = placeIds.map(() => '?').join(','); @@ -59,7 +59,7 @@ export class QueryHelpersService { } /** Batch-load collaborative ratings (#1435) for multiple places in one query, indexed by place ID. */ - loadRatingsByPlaceIds(placeIds: number[]): Record { + async loadRatingsByPlaceIds(placeIds: number[]): Promise> { const ratingsByPlaceId: Record = {}; if (placeIds.length > 0) { const rows = this.db.all(` @@ -77,7 +77,7 @@ export class QueryHelpersService { } /** Batch-load participants for multiple day-assignments in a single query, indexed by assignment ID. */ - loadParticipantsByAssignmentIds(assignmentIds: number[]): Record { + async loadParticipantsByAssignmentIds(assignmentIds: number[]): Promise> { const participantsByAssignment: Record = {}; if (assignmentIds.length > 0) { const allParticipants = this.db.all( diff --git a/server/src/nest/realtime/realtime.gateway.ts b/server/src/nest/realtime/realtime.gateway.ts index eabbbf848..21d66fee2 100644 --- a/server/src/nest/realtime/realtime.gateway.ts +++ b/server/src/nest/realtime/realtime.gateway.ts @@ -92,7 +92,7 @@ export class RealtimeGateway * unchanged — a missing token is refused before the store is touched, and the * password-version gate runs before the MFA one. */ - handleConnection(socket: TrekWebSocket, request: IncomingMessage): void { + async handleConnection(socket: TrekWebSocket, request: IncomingMessage): Promise { const url = new URL(request.url ?? '/', 'http://localhost'); const token = url.searchParams.get('token'); if (!token) { @@ -150,10 +150,10 @@ export class RealtimeGateway } @SubscribeMessage('join') - handleJoin( + async handleJoin( @MessageBody() message: { tripId?: number | string }, @ConnectedSocket() socket: TrekWebSocket, - ): { type: string; tripId?: number; message?: string } | undefined { + ): Promise<{ type: string; tripId?: number; message?: string } | undefined> { const user = userOf(socket); if (!user || !message?.tripId) return undefined; @@ -174,10 +174,10 @@ export class RealtimeGateway */ @SubscribeMessage('book:join') - handleBookJoin( + async handleBookJoin( @MessageBody() message: { journeyId?: number | string }, @ConnectedSocket() socket: TrekWebSocket, - ): { type: string; journeyId?: number; message?: string } | undefined { + ): Promise<{ type: string; journeyId?: number; message?: string } | undefined> { const user = userOf(socket); if (!user || !message?.journeyId) return undefined; diff --git a/server/src/nest/reservation-import/reservation-import.controller.ts b/server/src/nest/reservation-import/reservation-import.controller.ts index af77fb9d7..8cba50204 100644 --- a/server/src/nest/reservation-import/reservation-import.controller.ts +++ b/server/src/nest/reservation-import/reservation-import.controller.ts @@ -94,13 +94,13 @@ export class ReservationImportController { /** Shared validation for both the sync and async import endpoints; returns the parsed mode. */ - private validateImport(tripId: string, user: User, files: Express.Multer.File[] | undefined, rawMode?: string): BookingImportMode { + private async validateImport(tripId: string, user: User, files: Express.Multer.File[] | undefined, rawMode?: string): Promise { const modeResult = bookingImportModeSchema.safeParse(rawMode ?? 'no-ai'); if (!modeResult.success) throw new HttpException({ error: 'Invalid mode' }, 400); const mode = modeResult.data; - if (mode === 'force-ai' && !this.bookingImport.aiAvailable(user.id)) { + if (mode === 'force-ai' && !(await this.bookingImport.aiAvailable(user.id))) { throw new HttpException({ error: 'AI parsing is not configured' }, 409); } if (mode === 'no-ai' && !this.bookingImport.isAvailable()) { @@ -130,7 +130,7 @@ export class ReservationImportController { @UploadedFiles() files: Express.Multer.File[] | undefined, @Body() body: BookingImportPreviewDto, ): Promise { - const mode = this.validateImport(tripId, user, files, body?.mode); + const mode = await this.validateImport(tripId, user, files, body?.mode); return this.bookingImport.preview(files!, mode, user.id); } @@ -150,7 +150,7 @@ export class ReservationImportController { @UploadedFiles() files: Express.Multer.File[] | undefined, @Body() body: BookingImportPreviewDto, ): Promise<{ jobId: string }> { - const mode = this.validateImport(tripId, user, files, body?.mode); + const mode = await this.validateImport(tripId, user, files, body?.mode); const jobId = this.importJobs.start(tripId, files!, mode, user.id); return { jobId }; } diff --git a/server/src/nest/reservation-import/reservation-import.mcp.ts b/server/src/nest/reservation-import/reservation-import.mcp.ts index 2f412fd69..17b4268a7 100644 --- a/server/src/nest/reservation-import/reservation-import.mcp.ts +++ b/server/src/nest/reservation-import/reservation-import.mcp.ts @@ -71,7 +71,7 @@ export class ReservationImportMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); if (flightIds.length > MAX_MCP_AIRTRAIL_FLIGHTS) { return errorResult( diff --git a/server/src/nest/reservations/reservations.controller.ts b/server/src/nest/reservations/reservations.controller.ts index af8509e21..482f83fa2 100644 --- a/server/src/nest/reservations/reservations.controller.ts +++ b/server/src/nest/reservations/reservations.controller.ts @@ -61,7 +61,7 @@ export class ReservationsController { @RequirePermission('reservation_edit') @Post() - create( + async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() rawBody: ReservationCreateDto, @@ -69,7 +69,7 @@ export class ReservationsController { ) { const body = rawBody as ReservationBody & { title: string }; this.rejectForeignReferences(tripId, body); - const { reservation, accommodationCreated } = this.reservations.create(tripId, body as never); + const { reservation, accommodationCreated } = await this.reservations.create(tripId, body as never); if (accommodationCreated) { this.reservations.broadcast(tripId, 'accommodation:created', {}, socketId); } @@ -96,7 +96,7 @@ export class ReservationsController { @RequirePermission('reservation_edit') @Put(':id') - update( + async update( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -109,7 +109,7 @@ export class ReservationsController { throw new HttpException({ error: 'Reservation not found' }, 404); } this.rejectForeignReferences(tripId, body); - const { reservation, accommodationChanged } = this.reservations.update(id, tripId, body as never, current as never); + const { reservation, accommodationChanged } = await this.reservations.update(id, tripId, body as never, current as never); if (accommodationChanged) { this.reservations.broadcast(tripId, 'accommodation:updated', {}, socketId); } @@ -144,13 +144,13 @@ export class ReservationsController { @RequirePermission('reservation_edit') @Delete(':id') - remove( + async remove( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { - const { deleted, accommodationDeleted, deletedBudgetItemId } = this.reservations.remove(id, tripId); + const { deleted, accommodationDeleted, deletedBudgetItemId } = await this.reservations.remove(id, tripId); if (!deleted) { throw new HttpException({ error: 'Reservation not found' }, 404); } diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index fba5bb671..3c59365e4 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -363,7 +363,7 @@ export class ReservationsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); // Validate that all referenced IDs belong to this trip if (day_id && !this.days.getDay(day_id, tripId)) @@ -383,7 +383,7 @@ export class ReservationsMcp { const metadata = price != null ? { price: String(price) } : undefined; - const { reservation, accommodationCreated } = this.reservations.create(tripId, { + const { reservation, accommodationCreated } = await this.reservations.create(tripId, { title, type, reservation_time, reservation_end_time, url, location, confirmation_number, notes, day_id, place_id, assignment_id, create_accommodation: createAccommodation, @@ -439,7 +439,7 @@ export class ReservationsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); const existing = this.reservations.getReservation(reservationId, tripId); if (!existing) return errorResult('Reservation not found.'); @@ -448,7 +448,7 @@ export class ReservationsMcp { if (assignment_id != null && !this.assignments.getAssignmentForTrip(assignment_id, tripId)) return errorResult('assignment_id does not belong to this trip.'); - const { reservation } = this.reservations.update(reservationId, tripId, { + const { reservation } = await this.reservations.update(reservationId, tripId, { title, type, reservation_time, reservation_end_time, url, location, confirmation_number, notes, status, place_id: place_id !== undefined ? place_id ?? undefined : undefined, assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined, @@ -470,8 +470,8 @@ export class ReservationsMcp { async deleteReservation({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); - const { deleted, accommodationDeleted } = this.reservations.remove(reservationId, tripId); + if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); + const { deleted, accommodationDeleted } = await this.reservations.remove(reservationId, tripId); if (!deleted) return errorResult('Reservation not found.'); if (accommodationDeleted) { this.guards.safeBroadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }); @@ -497,7 +497,7 @@ export class ReservationsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); // The service filters the ids against the trip roster on its own, so an // off-trip id cannot be attached; a missing booking is the only failure. @@ -535,7 +535,7 @@ export class ReservationsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); // The service scopes the write to the trip on its own, so a foreign id is // already harmless — say so rather than reporting a success that moved @@ -572,7 +572,7 @@ export class ReservationsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); const current = this.reservations.getReservation(reservationId, tripId); if (!current) return errorResult('Reservation not found.'); if (current.type !== 'hotel') return errorResult('Reservation is not of type hotel.'); @@ -585,7 +585,7 @@ export class ReservationsMcp { return errorResult('end_day_id does not belong to this trip.'); const isNewAccommodation = !current.accommodation_id; - const { reservation } = this.reservations.update(reservationId, tripId, { + const { reservation } = await this.reservations.update(reservationId, tripId, { place_id, type: current.type, status: current.status as string, @@ -696,7 +696,7 @@ export class ReservationsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); if (metadata && 'legs' in metadata) return errorResult(LEGS_IN_METADATA_ERROR); @@ -739,7 +739,7 @@ export class ReservationsMcp { if (price != null) meta.price = String(price); - const { reservation } = this.reservations.create(tripId, { + const { reservation } = await this.reservations.create(tripId, { title, type, reservation_time: departureTime, @@ -804,7 +804,7 @@ export class ReservationsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); if (metadata && 'legs' in metadata) return errorResult(LEGS_IN_METADATA_ERROR); @@ -868,7 +868,7 @@ export class ReservationsMcp { if (applied.endpointsChanged) resolvedEndpoints = applied.endpoints; } - const { reservation } = this.reservations.update(reservationId, tripId, { + const { reservation } = await this.reservations.update(reservationId, tripId, { title, type, reservation_time: departureTime, @@ -900,8 +900,8 @@ export class ReservationsMcp { async deleteTransport({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); - const { deleted } = this.reservations.remove(reservationId, tripId); + if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); + const { deleted } = await this.reservations.remove(reservationId, tripId); if (!deleted) return errorResult('Transport not found.'); this.guards.safeBroadcast(tripId, 'reservation:deleted', { reservationId }); return ok({ success: true }); diff --git a/server/src/nest/reservations/reservations.rpc.ts b/server/src/nest/reservations/reservations.rpc.ts index 73a664d49..e9f7f50e1 100644 --- a/server/src/nest/reservations/reservations.rpc.ts +++ b/server/src/nest/reservations/reservations.rpc.ts @@ -30,16 +30,16 @@ export class ReservationsRpc { ) {} @PluginMethod('reservations.create', { permission: 'db:write:reservations' }) - create(params: Record, ctx: PluginRpcContext): unknown { + async create(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.guards.requireActor(ctx, 'reservation'); const parsed = reservationCreateRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid reservation: ${schemaMessage(parsed.error)}`); const input = parsed.data as Record; this.requireValidEndpoints(input.endpoints); - this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION); this.requireOwnReferences(tripId, input); - const { reservation, accommodationCreated } = this.reservations.create(String(tripId), input as never); + const { reservation, accommodationCreated } = await this.reservations.create(String(tripId), input as never); if (accommodationCreated) this.realtime.broadcast(tripId, 'accommodation:created', {}, undefined); const i = input as { title?: string; type?: string; create_budget_entry?: unknown }; this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, i.create_budget_entry as never, undefined); @@ -49,7 +49,7 @@ export class ReservationsRpc { } @PluginMethod('reservations.update', { permission: 'db:write:reservations' }) - update(params: Record, ctx: PluginRpcContext): unknown { + async update(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const reservationId = num(params.reservationId, 'reservationId'); const actor = this.guards.requireActor(ctx, 'reservation'); @@ -57,11 +57,11 @@ export class ReservationsRpc { if (!parsed.success) throw new BadParams(`invalid reservation: ${schemaMessage(parsed.error)}`); const input = parsed.data as Record; this.requireValidEndpoints(input.endpoints); - this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION); const current = this.reservations.getReservation(String(reservationId), String(tripId)); if (!current) throw new ForbiddenResource(`no reservation ${reservationId} on trip ${tripId}`); this.requireOwnReferences(tripId, input); - const { reservation, accommodationChanged } = this.reservations.update(String(reservationId), String(tripId), input as never, current as never); + const { reservation, accommodationChanged } = await this.reservations.update(String(reservationId), String(tripId), input as never, current as never); if (accommodationChanged) this.realtime.broadcast(tripId, 'accommodation:updated', {}, undefined); const cur = current as { title: string; type?: string }; const i = input as { title?: string; type?: string; create_budget_entry?: unknown }; @@ -72,12 +72,12 @@ export class ReservationsRpc { } @PluginMethod('reservations.delete', { permission: 'db:write:reservations' }) - delete(params: Record, ctx: PluginRpcContext): unknown { + async delete(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const reservationId = num(params.reservationId, 'reservationId'); const actor = this.guards.requireActor(ctx, 'reservation'); - this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION); - const { deleted, accommodationDeleted, deletedBudgetItemId } = this.reservations.remove(String(reservationId), String(tripId)); + await this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION); + const { deleted, accommodationDeleted, deletedBudgetItemId } = await this.reservations.remove(String(reservationId), String(tripId)); if (!deleted) throw new ForbiddenResource(`no reservation ${reservationId} on trip ${tripId}`); if (accommodationDeleted) this.realtime.broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }, undefined); if (deletedBudgetItemId) this.realtime.broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, undefined); diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index eb4ae18a1..4752e03d0 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { DatabaseService, type TripAccess } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; @@ -151,13 +152,14 @@ export class ReservationsService { private readonly notifications: NotificationsService, private readonly reads: ReservationsReadRepository, private readonly accommodations: AccommodationsService, + private readonly uow: UnitOfWork, ) {} verifyTripAccess(tripId: string | number, userId: number) { return this.db.canAccessTrip(tripId, userId); } - canEdit(trip: Trip, user: User): boolean { + async canEdit(trip: Trip, user: User): Promise { return this.permissions.checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id); } @@ -178,8 +180,8 @@ export class ReservationsService { * booking event on this surface is echo-suppressed because the client already * drew what it sent, and it never sent this. */ - private announceStayMirror(tripId: string | number, mirror: AccommodationMirror): void { - this.accommodations.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload)); + private async announceStayMirror(tripId: string | number, mirror: AccommodationMirror): Promise { + await this.accommodations.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload)); } /** Fire-and-forget booking-change notification, mirroring the legacy dynamic import. */ @@ -665,13 +667,13 @@ export class ReservationsService { /** The accommodation insert, the reservation insert, the endpoint save and * the metadata sync are one logical write — all-or-nothing. */ - create(tripId: string | number, data: CreateReservationData): { reservation: ReservationRow; accommodationCreated: boolean } { - const { stayMirror, ...written } = this.db.transaction(() => this.createInTx(tripId, data)); - this.announceStayMirror(tripId, stayMirror); + async create(tripId: string | number, data: CreateReservationData): Promise<{ reservation: ReservationRow; accommodationCreated: boolean }> { + const { stayMirror, ...written } = await this.uow.transactional(() => this.createInTx(tripId, data)); + await this.announceStayMirror(tripId, stayMirror); return written; } - private createInTx(tripId: string | number, data: CreateReservationData): { reservation: ReservationRow; accommodationCreated: boolean; stayMirror: AccommodationMirror } { + private async createInTx(tripId: string | number, data: CreateReservationData): Promise<{ reservation: ReservationRow; accommodationCreated: boolean; stayMirror: AccommodationMirror }> { const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, url, day_id, end_day_id, place_id, assignment_id, @@ -703,7 +705,7 @@ export class ReservationsService { // night entered under Days. Without it the hotel booked on this form is the // one place the drive does not know about, which is the duplicate entry this // whole change exists to remove. - stayMirror = this.accommodations.attachStayStop(resolvedAccommodationId, accPlaceId || null, start_day_id, check_in); + stayMirror = await this.accommodations.attachStayStop(resolvedAccommodationId, accPlaceId || null, start_day_id, check_in); } } @@ -816,13 +818,13 @@ export class ReservationsService { /** The accommodation upsert, the reservation update, the endpoint replace * and the metadata sync are one logical write — all-or-nothing. */ - update(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): { reservation: ReservationRow; accommodationChanged: boolean } { - const { stayMirror, ...written } = this.db.transaction(() => this.updateInTx(id, tripId, data, current)); - this.announceStayMirror(tripId, stayMirror); + async update(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): Promise<{ reservation: ReservationRow; accommodationChanged: boolean }> { + const { stayMirror, ...written } = await this.uow.transactional(() => this.updateInTx(id, tripId, data, current)); + await this.announceStayMirror(tripId, stayMirror); return written; } - private updateInTx(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): { reservation: ReservationRow; accommodationChanged: boolean; stayMirror: AccommodationMirror } { + private async updateInTx(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): Promise<{ reservation: ReservationRow; accommodationChanged: boolean; stayMirror: AccommodationMirror }> { const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, url, day_id, end_day_id, place_id, assignment_id, @@ -854,7 +856,7 @@ export class ReservationsService { // The stay just moved. Its stop moves with it, or it is left sitting on a // day nobody sleeps there any more, hidden from the day list because it // still carries this booking's id and stranded in the middle of the drive. - stayMirror = this.accommodations.moveStayStop(resolvedAccId, accPlaceId || null, start_day_id, check_in, { + stayMirror = await this.accommodations.moveStayStop(resolvedAccId, accPlaceId || null, start_day_id, check_in, { checkInChanged: (check_in || null) !== (prior?.check_in ?? null), }); } else if (accPlaceId) { @@ -863,7 +865,7 @@ export class ReservationsService { tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null ); resolvedAccId = Number(accResult.lastInsertRowid); - stayMirror = this.accommodations.attachStayStop(resolvedAccId, accPlaceId, start_day_id, check_in); + stayMirror = await this.accommodations.attachStayStop(resolvedAccId, accPlaceId, start_day_id, check_in); } accommodationChanged = true; } @@ -987,8 +989,8 @@ export class ReservationsService { /** The accommodation + budget-item + reservation deletes are one logical * cascade — all-or-nothing. */ - remove(id: string | number, tripId: string | number): { deleted: { id: number; title: string; type: string; accommodation_id: number | null } | undefined; accommodationDeleted: boolean; deletedBudgetItemId: number | null } { - const removed = this.db.transaction(() => { + async remove(id: string | number, tripId: string | number): Promise<{ deleted: { id: number; title: string; type: string; accommodation_id: number | null } | undefined; accommodationDeleted: boolean; deletedBudgetItemId: number | null }> { + const removed = await this.uow.transactional(async () => { const reservation = this.db.get<{ id: number; title: string; type: string; accommodation_id: number | null }>( 'SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?', id, tripId ); @@ -1009,7 +1011,7 @@ export class ReservationsService { // by accommodation id, and that pointer is cleared the moment the stay is // deleted. Reversed, the stop stands with nothing left to remove it, and // the day list hides it for carrying a booking id. - stayMirror = this.accommodations.dropStayStops(reservation.accommodation_id); + stayMirror = await this.accommodations.dropStayStops(reservation.accommodation_id); this.db.run('DELETE FROM day_accommodations WHERE id = ? AND trip_id = ?', reservation.accommodation_id, tripId); accommodationDeleted = true; } @@ -1024,7 +1026,7 @@ export class ReservationsService { return { deleted: reservation, accommodationDeleted, deletedBudgetItemId: linkedBudget ? linkedBudget.id : null, stayMirror }; }); const { stayMirror, ...answer } = removed; - this.announceStayMirror(tripId, stayMirror); + await this.announceStayMirror(tripId, stayMirror); return answer; } diff --git a/server/src/nest/roadtrip/day-boundaries.mcp.ts b/server/src/nest/roadtrip/day-boundaries.mcp.ts index a96b5284f..0767d1ac5 100644 --- a/server/src/nest/roadtrip/day-boundaries.mcp.ts +++ b/server/src/nest/roadtrip/day-boundaries.mcp.ts @@ -44,7 +44,7 @@ export class DayBoundariesMcp { async save({ tripId, dayNumber, boundary }: { tripId: number; dayNumber: number; boundary: RoadtripDayBoundary | null }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); // A stop from another trip is refused by the service, with the reason. return answeringRefusals(() => { const boundaries = boundary diff --git a/server/src/nest/roadtrip/google-route.mcp.ts b/server/src/nest/roadtrip/google-route.mcp.ts index 3a5e6a566..c37e678fb 100644 --- a/server/src/nest/roadtrip/google-route.mcp.ts +++ b/server/src/nest/roadtrip/google-route.mcp.ts @@ -27,6 +27,6 @@ export class GoogleRouteMcp { access: { group: 'places', mode: 'write' }, when: addonGate(ADDON_IDS.ROADTRIP) }) import(input: GoogleRouteImport & { tripId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - return answeringRefusals(() => ok(this.routes.import(input.tripId, ctx.userId, input))); + return answeringRefusals(async () => ok(await this.routes.import(input.tripId, ctx.userId, input))); } } diff --git a/server/src/nest/roadtrip/google-route.service.ts b/server/src/nest/roadtrip/google-route.service.ts index 291f06253..1cd817b74 100644 --- a/server/src/nest/roadtrip/google-route.service.ts +++ b/server/src/nest/roadtrip/google-route.service.ts @@ -4,6 +4,7 @@ import { MapsService, GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../maps/maps. import { isDirectionsUrl, parseDirectionsUrl, MAX_DIR_WAYPOINTS } from '../places/maps-dir.helpers'; import { safeFetchFollow } from '../../utils/ssrfGuard'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { PlacesService } from '../places/places.service'; import { AssignmentsService } from '../assignments/assignments.service'; import { PermissionsService } from '../permissions/permissions.service'; @@ -12,7 +13,8 @@ import { PermissionsService } from '../permissions/permissions.service'; export class GoogleRouteService { constructor(private readonly maps: MapsService, private readonly db: DatabaseService, private readonly places: PlacesService, private readonly assignments: AssignmentsService, - private readonly permissions: PermissionsService) {} + private readonly permissions: PermissionsService, + private readonly uow: UnitOfWork) {} async preview(raw: string): Promise { let url = new URL(raw); @@ -53,18 +55,28 @@ export class GoogleRouteService { return { stops }; } - import(tripId: number, userId: number, input: GoogleRouteImport, socketId?: string) { + async import(tripId: number, userId: number, input: GoogleRouteImport, socketId?: string) { const access = this.db.canAccessTrip(tripId, userId); if (!access) throw new HttpException({ error: 'Trip not found' }, 404); const role = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId)?.role ?? 'user'; - if (!['place_edit', 'day_edit'].every(action => this.permissions.checkPermission(action, role, access.user_id, userId, access.user_id !== userId))) - throw new HttpException({ error: 'Permission denied' }, 403); + // `every` cannot await the permission check, so the same all-of test runs as + // an explicit loop — same actions, same order, same short-circuit. + for (const action of ['place_edit', 'day_edit']) { + if (!(await this.permissions.checkPermission(action, role, access.user_id, userId, access.user_id !== userId))) + throw new HttpException({ error: 'Permission denied' }, 403); + } if (!this.assignments.dayExists(String(input.dayId), String(tripId))) throw new HttpException({ error: 'Day not found' }, 404); - const imported = this.db.transaction(() => input.stops.map(stop => { - const place = this.places.create(String(tripId), { ...stop, transport_mode: 'car', duration_minutes: 0 }); - const assignment = this.assignments.createAssignment(input.dayId, place.id); - return { place, assignment }; - })); + // `map` cannot await the now-async assignment write, so the same per-stop + // sequence runs as an explicit loop inside the transaction. + const imported = await this.uow.transactional(async () => { + const rows: { place: ReturnType; assignment: Awaited> }[] = []; + for (const stop of input.stops) { + const place = this.places.create(String(tripId), { ...stop, transport_mode: 'car', duration_minutes: 0 }); + const assignment = await this.assignments.createAssignment(input.dayId, place.id); + rows.push({ place, assignment }); + } + return rows; + }); for (const { place, assignment } of imported) { this.places.broadcast(String(tripId), 'place:created', { place }, socketId); this.assignments.broadcast(String(tripId), 'assignment:created', { assignment }, socketId); diff --git a/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts index 4d1f1e843..5fbd63480 100644 --- a/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts @@ -58,7 +58,7 @@ export class RoadtripPreferencesMcp { async update({ tripId, settings }: { tripId: number; settings: RoadtripPreferences }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); // A day window that ends before it starts is refused by the service, with the reason. return answeringRefusals(() => ok({ tripId, settings: this.preferences.update(tripId, settings), scope: 'trip' })); } diff --git a/server/src/nest/roadtrip/roadtrip.mcp.ts b/server/src/nest/roadtrip/roadtrip.mcp.ts index c584bbab6..3028e4418 100644 --- a/server/src/nest/roadtrip/roadtrip.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip.mcp.ts @@ -76,7 +76,7 @@ export class RoadtripMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); const via = this.roadtrip.create(dayId, { after_order_index, lat, lng }); this.announce(tripId, dayId); @@ -118,7 +118,7 @@ export class RoadtripMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); // The same check the REST route makes, in the same change: permission alone would let // a place id from another trip, or a place that is not a track at all, become this @@ -156,7 +156,7 @@ export class RoadtripMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); const next = this.roadtrip.reanchor(dayId, { vias, remove }); this.announce(tripId, dayId); @@ -178,7 +178,7 @@ export class RoadtripMcp { async removeVia({ tripId, dayId, viaId }: { tripId: number; dayId: number; viaId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); if (!this.roadtrip.remove(viaId, dayId)) return noAccess(); this.announce(tripId, dayId); @@ -194,7 +194,7 @@ export class RoadtripMcp { async updateVia(input: RoadtripViaUpdateRequest & { tripId: number; dayId: number; viaId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('day_edit', input.tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('day_edit', input.tripId, ctx.userId))) return permissionDenied(); if (!this.roadtrip.dayExists(input.dayId, input.tripId)) return noAccess(); const via = this.roadtrip.move(input.viaId, input.dayId, input.lat, input.lng, input.after_order_index); if (!via) return noAccess(); diff --git a/server/src/nest/settings/settings.controller.ts b/server/src/nest/settings/settings.controller.ts index a00389f7a..400250837 100644 --- a/server/src/nest/settings/settings.controller.ts +++ b/server/src/nest/settings/settings.controller.ts @@ -113,7 +113,7 @@ export class AdminDefaultUserSettingsController { } @Put() - update(@CurrentUser() user: User, @Body() body: AdminDefaultUserSettingsDto, @Req() req: Request) { + async update(@CurrentUser() user: User, @Body() body: AdminDefaultUserSettingsDto, @Req() req: Request) { try { // Deliberately no managed_keys in the response here: the route answers // with the raw defaults map the admin panel renders from, so an extra @@ -124,7 +124,7 @@ export class AdminDefaultUserSettingsController { this.env.isManaged(), ); this.settings.setAdminUserDefaults(allowed); - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: user.id, action: 'admin.default_user_settings_update', ip: getClientIp(req), diff --git a/server/src/nest/share/share.controller.ts b/server/src/nest/share/share.controller.ts index 2a789d629..42bb1ccd2 100644 --- a/server/src/nest/share/share.controller.ts +++ b/server/src/nest/share/share.controller.ts @@ -24,24 +24,24 @@ import { Public } from '../auth/public.decorator'; export class TripShareController { constructor(private readonly share: ShareService) {} - private requireManage(tripId: string, user: User) { + private async requireManage(tripId: string, user: User) { const trip = this.share.verifyTripAccess(tripId, user.id); if (!trip) { throw new HttpException({ error: 'Trip not found' }, 404); } - if (!this.share.canManage(trip, user)) { + if (!(await this.share.canManage(trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } } @Post() - create( + async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: ShareLinkDto, @Res({ passthrough: true }) res: Response, ) { - this.requireManage(tripId, user); + await this.requireManage(tripId, user); const result = this.share.createOrUpdate(tripId, user.id, { share_map: body.share_map, share_bookings: body.share_bookings, @@ -55,20 +55,20 @@ export class TripShareController { } @Get() - get(@CurrentUser() user: User, @Param('tripId') tripId: string) { + async get(@CurrentUser() user: User, @Param('tripId') tripId: string) { // The token is the whole credential for the anonymous /api/shared/:token // page, so reading it needs the same share_manage permission as creating or // deleting it, not just trip access. Trip membership lets someone read the // trip while signed in; it does not let them hand out a copy that works // without an account and outlives their membership. - this.requireManage(tripId, user); + await this.requireManage(tripId, user); const info = this.share.get(tripId); return info ? info : { token: null }; } @Delete() - remove(@CurrentUser() user: User, @Param('tripId') tripId: string) { - this.requireManage(tripId, user); + async remove(@CurrentUser() user: User, @Param('tripId') tripId: string) { + await this.requireManage(tripId, user); this.share.remove(tripId); return { success: true }; } @@ -143,9 +143,9 @@ export class SharedController { } @Get(':token') - read(@Param('token') token: string) { - const data = this.share.getSharedTripData(token); - if (!data) { + async read(@Param('token') token: string) { + const data = await this.share.getSharedTripData(token); + if (!(await data)) { throw new HttpException({ error: 'Invalid or expired link' }, 404); } return data; diff --git a/server/src/nest/share/share.mcp.ts b/server/src/nest/share/share.mcp.ts index 525fc00ee..3b4bee79f 100644 --- a/server/src/nest/share/share.mcp.ts +++ b/server/src/nest/share/share.mcp.ts @@ -41,7 +41,7 @@ export class ShareMcp { // token itself, and a token is an anonymous copy of the trip. Leaving this // one on membership alone would just move the same hole to MCP. if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('share_manage', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied(); const link = this.share.get(String(tripId)); return ok({ link }); } @@ -68,7 +68,7 @@ export class ShareMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('share_manage', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied(); // The zod .default()s above fill omitted flags, and ShareService applies // the same defaults again for undefined — no re-defaulting needed here. const { token, created } = this.share.createOrUpdate(String(tripId), ctx.userId, { @@ -89,7 +89,7 @@ export class ShareMcp { async deleteShareLink({ tripId }: { tripId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('share_manage', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied(); this.share.remove(String(tripId)); return ok({ success: true }); } diff --git a/server/src/nest/share/share.service.ts b/server/src/nest/share/share.service.ts index 87d8851ef..cb53f53a5 100644 --- a/server/src/nest/share/share.service.ts +++ b/server/src/nest/share/share.service.ts @@ -161,7 +161,7 @@ export class ShareService { return this.dbs.canAccessTrip(tripId, userId); } - canManage(trip: Trip, user: User): boolean { + async canManage(trip: Trip, user: User): Promise { return this.permissions.checkPermission('share_manage', user.role, trip.user_id, user.id, trip.user_id !== user.id); } @@ -258,7 +258,7 @@ export class ShareService { return out; } - getSharedTripData(token: string): Record | null { + async getSharedTripData(token: string): Promise | null> { const shareRow = this.dbs.get( "SELECT * FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))", token, @@ -309,7 +309,7 @@ export class ShareService { `, ...dayIds); const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))]; - const tagsByPlace = this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true }); + const tagsByPlace = await this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true }); const byDay: Record = {}; for (const a of allAssignments as any[]) { diff --git a/server/src/nest/storage/storage-admin.controller.ts b/server/src/nest/storage/storage-admin.controller.ts index 6f065656d..8b5ec3774 100644 --- a/server/src/nest/storage/storage-admin.controller.ts +++ b/server/src/nest/storage/storage-admin.controller.ts @@ -42,7 +42,7 @@ export class StorageAdminController { } @Put() - update(@CurrentUser() user: User, @Body() body: StorageConfigDto, @Req() req: Request) { + async update(@CurrentUser() user: User, @Body() body: StorageConfigDto, @Req() req: Request) { try { this.service.applyConfig(body); } catch (err) { @@ -53,7 +53,7 @@ export class StorageAdminController { if (err instanceof StorageConflictError) throw new HttpException({ error: err.message }, 409); throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400); } - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: user.id, action: 'admin.storage_update', ip: getClientIp(req), @@ -73,7 +73,7 @@ export class StorageAdminController { throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400); } // Audited because the probe writes and deletes an object; names only, never options. - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: user.id, action: 'admin.storage_test', ip: getClientIp(req), @@ -90,7 +90,7 @@ export class StorageAdminController { /** Start a replica catch-up for a routed mirror. One at a time, globally. */ @Post('backends/:name/backfill') @HttpCode(200) - backfillStart(@CurrentUser() user: User, @Param('name') name: string, @Req() req: Request): { started: true } { + async backfillStart(@CurrentUser() user: User, @Param('name') name: string, @Req() req: Request): Promise<{ started: true }> { try { this.service.startBackfill(name); } catch (err) { @@ -98,7 +98,7 @@ export class StorageAdminController { if (err instanceof BackfillBusyError) throw new HttpException({ error: err.message }, 409); throw err; } - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: user.id, action: 'admin.storage_backfill', ip: getClientIp(req), @@ -108,11 +108,11 @@ export class StorageAdminController { } @Delete('backends/:name/backfill') - backfillCancel(@CurrentUser() user: User, @Param('name') name: string, @Req() req: Request): { cancelled: true } { + async backfillCancel(@CurrentUser() user: User, @Param('name') name: string, @Req() req: Request): Promise<{ cancelled: true }> { if (!this.service.cancelBackfill(name)) { throw new HttpException({ error: `no active sync for '${name}'` }, 404); } - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: user.id, action: 'admin.storage_backfill_cancel', ip: getClientIp(req), @@ -124,7 +124,7 @@ export class StorageAdminController { /** Start a category migration: copy → flip → delta sweep. One storage job at a time. */ @Post('migrations') @HttpCode(200) - migrationStart(@CurrentUser() user: User, @Body() body: StorageMigrationRequestDto, @Req() req: Request): { started: true } { + async migrationStart(@CurrentUser() user: User, @Body() body: StorageMigrationRequestDto, @Req() req: Request): Promise<{ started: true }> { const { category, to } = body; try { this.service.startMigration(category, to); @@ -134,7 +134,7 @@ export class StorageAdminController { if (err instanceof BackfillBusyError) throw new HttpException({ error: err.message }, 409); throw err; } - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: user.id, action: 'admin.storage_migration', ip: getClientIp(req), @@ -144,11 +144,11 @@ export class StorageAdminController { } @Delete('migrations/:category') - migrationCancel(@CurrentUser() user: User, @Param('category') category: string, @Req() req: Request): { cancelled: true } { + async migrationCancel(@CurrentUser() user: User, @Param('category') category: string, @Req() req: Request): Promise<{ cancelled: true }> { if (!this.service.cancelMigration(category)) { throw new HttpException({ error: `no running migration for '${category}'` }, 404); } - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: user.id, action: 'admin.storage_migration_cancel', ip: getClientIp(req), @@ -167,7 +167,7 @@ export class StorageAdminController { if (err instanceof StatsBusyError) throw new HttpException({ error: err.message }, 409); throw err; } - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: user.id, action: 'admin.storage_stats_refresh', ip: getClientIp(req), diff --git a/server/src/nest/system-notices/system-notices.controller.ts b/server/src/nest/system-notices/system-notices.controller.ts index e31f990b3..9c5d70017 100644 --- a/server/src/nest/system-notices/system-notices.controller.ts +++ b/server/src/nest/system-notices/system-notices.controller.ts @@ -30,18 +30,18 @@ export class SystemNoticesController { constructor(private readonly notices: SystemNoticesService) {} @Get('active') - active( + async active( @CurrentUser() user: User, @Query('supports') supports?: string | string[], @Query('ui') ui?: string | string[], - ): SystemNoticeDto[] { + ): Promise { return this.notices.getActiveFor(user.id, parseSupports(supports), typeof ui === 'string' ? ui.trim() : undefined); } @Post(':id/dismiss') @HttpCode(204) - dismiss(@CurrentUser() user: User, @Param('id') id: string): void { - const dismissed = this.notices.dismiss(user.id, id); + async dismiss(@CurrentUser() user: User, @Param('id') id: string): Promise { + const dismissed = await this.notices.dismiss(user.id, id); if (!dismissed) { throw new HttpException({ error: 'NOTICE_NOT_FOUND' }, 404); } diff --git a/server/src/nest/system-notices/system-notices.service.ts b/server/src/nest/system-notices/system-notices.service.ts index 02f5cae4a..9e2e22b4d 100644 --- a/server/src/nest/system-notices/system-notices.service.ts +++ b/server/src/nest/system-notices/system-notices.service.ts @@ -33,12 +33,12 @@ export class SystemNoticesService { * neither loses only the notice it could not read anyway. Nothing is spent by * holding it back: a notice is used up by a dismissal and by nothing else. */ - getActiveFor(userId: number, supports: ReadonlySet = new Set(), uiVersion?: string): SystemNoticeDto[] { - const notices = getActiveNoticesFor( + async getActiveFor(userId: number, supports: ReadonlySet = new Set(), uiVersion?: string): Promise { + const notices = (await getActiveNoticesFor( userId, (addonId) => this.addons.isAddonEnabled(addonId), this.env.isManaged(), - ) as SystemNoticeDto[]; + )) as SystemNoticeDto[]; return supports.has('release') && this.bundleMatchesServer(uiVersion) ? notices : notices.filter(n => !n.release); @@ -51,7 +51,7 @@ export class SystemNoticesService { return !!ui && !!app && ui === app; } - dismiss(userId: number, noticeId: string): boolean { + async dismiss(userId: number, noticeId: string): Promise { return dismissNotice(userId, noticeId); } } diff --git a/server/src/nest/tags/tags.controller.ts b/server/src/nest/tags/tags.controller.ts index 3dd8160e1..e755691d6 100644 --- a/server/src/nest/tags/tags.controller.ts +++ b/server/src/nest/tags/tags.controller.ts @@ -21,12 +21,12 @@ export class TagsController { constructor(private readonly tags: TagsService) {} @Get() - list(@CurrentUser() user: User): TagListResponse { - return { tags: this.tags.list(user.id) }; + async list(@CurrentUser() user: User): Promise { + return { tags: await this.tags.list(user.id) }; } @Post() - create(@CurrentUser() user: User, @Body() body: TagCreateDto): { tag: Tag } { + async create(@CurrentUser() user: User, @Body() body: TagCreateDto): Promise<{ tag: Tag }> { // Cast rather than validate: the legacy route bound whatever arrived // straight into SQLite, and a contract that rejected a numeric name here // would be a new 400 for anything already sending one. @@ -34,28 +34,28 @@ export class TagsController { if (!name) { throw new HttpException({ error: 'Tag name is required' }, 400); } - return { tag: this.tags.create(user.id, name, color) }; + return { tag: await this.tags.create(user.id, name, color) }; } @Put(':id') - update( + async update( @CurrentUser() user: User, @Param('id') id: string, @Body() body: TagUpdateDto, - ): { tag: Tag } { + ): Promise<{ tag: Tag }> { const { name, color } = body as { name?: string; color?: string }; - if (!this.tags.getByIdAndUser(id, user.id)) { + if (!(await this.tags.getByIdAndUser(id, user.id))) { throw new HttpException({ error: 'Tag not found' }, 404); } - return { tag: this.tags.update(id, name, color) }; + return { tag: await this.tags.update(id, name, color) }; } @Delete(':id') - remove(@CurrentUser() user: User, @Param('id') id: string): { success: boolean } { - if (!this.tags.getByIdAndUser(id, user.id)) { + async remove(@CurrentUser() user: User, @Param('id') id: string): Promise<{ success: boolean }> { + if (!(await this.tags.getByIdAndUser(id, user.id))) { throw new HttpException({ error: 'Tag not found' }, 404); } - this.tags.remove(id); + await this.tags.remove(id); return { success: true }; } } diff --git a/server/src/nest/tags/tags.mcp.ts b/server/src/nest/tags/tags.mcp.ts index c898c30dc..fbc12e23b 100644 --- a/server/src/nest/tags/tags.mcp.ts +++ b/server/src/nest/tags/tags.mcp.ts @@ -26,7 +26,7 @@ export class TagsMcp { access: { group: 'places', mode: 'read' }, }) async listTags(_args: Record, ctx: McpContext) { - const tags = this.tags.list(ctx.userId); + const tags = await this.tags.list(ctx.userId); return ok({ tags }); } @@ -42,7 +42,7 @@ export class TagsMcp { }) async createTag({ name, color }: { name: string; color?: string }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const tag = this.tags.create(ctx.userId, name, color); + const tag = await this.tags.create(ctx.userId, name, color); return ok({ tag }); } @@ -59,9 +59,9 @@ export class TagsMcp { }) async updateTag({ tagId, name, color }: { tagId: number; name?: string; color?: string }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.tags.getByIdAndUser(tagId, ctx.userId)) return errorResult('Tag not found.'); - const tag = this.tags.update(tagId, name, color); - if (!tag) return errorResult('Tag not found.'); + if (!(await this.tags.getByIdAndUser(tagId, ctx.userId))) return errorResult('Tag not found.'); + const tag = await this.tags.update(tagId, name, color); + if (!(await tag)) return errorResult('Tag not found.'); return ok({ tag }); } @@ -76,8 +76,8 @@ export class TagsMcp { }) async deleteTag({ tagId }: { tagId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.tags.getByIdAndUser(tagId, ctx.userId)) return errorResult('Tag not found.'); - this.tags.remove(tagId); + if (!(await this.tags.getByIdAndUser(tagId, ctx.userId))) return errorResult('Tag not found.'); + await this.tags.remove(tagId); return ok({ success: true }); } } diff --git a/server/src/nest/tags/tags.rpc.ts b/server/src/nest/tags/tags.rpc.ts index 6a0e53fab..f0833ef49 100644 --- a/server/src/nest/tags/tags.rpc.ts +++ b/server/src/nest/tags/tags.rpc.ts @@ -19,15 +19,15 @@ export class TagsRpc { constructor(private readonly tags: TagsService) {} @PluginMethod('tags.list', { permission: 'db:read:tags' }) - list(_params: Record, ctx: PluginRpcContext): unknown[] { + async list(_params: Record, ctx: PluginRpcContext): Promise { if (ctx.actingUserId === undefined) { throw new ForbiddenResource('tag reads require an authenticated user context'); } - return this.tags.list(ctx.actingUserId) as unknown[]; + return (await this.tags.list(ctx.actingUserId)) as unknown[]; } @PluginMethod('tags.create', { permission: 'db:write:tags' }) - create(params: Record, ctx: PluginRpcContext): unknown { + async create(params: Record, ctx: PluginRpcContext): Promise { if (ctx.actingUserId === undefined) { throw new ForbiddenResource('tag writes require an authenticated user context'); } @@ -37,13 +37,13 @@ export class TagsRpc { } @PluginMethod('tags.update', { permission: 'db:write:tags' }) - update(params: Record, ctx: PluginRpcContext): unknown { + async update(params: Record, ctx: PluginRpcContext): Promise { if (ctx.actingUserId === undefined) { throw new ForbiddenResource('tag writes require an authenticated user context'); } const input = asPayload(params.input); const tagId = num(params.tagId, 'tagId'); - this.requireOwnTag(tagId, ctx.actingUserId); + await this.requireOwnTag(tagId, ctx.actingUserId); return this.tags.update( tagId, typeof input.name === 'string' ? input.name : undefined, @@ -52,19 +52,19 @@ export class TagsRpc { } @PluginMethod('tags.delete', { permission: 'db:write:tags' }) - delete(params: Record, ctx: PluginRpcContext): unknown { + async delete(params: Record, ctx: PluginRpcContext): Promise { if (ctx.actingUserId === undefined) { throw new ForbiddenResource('tag writes require an authenticated user context'); } const tagId = num(params.tagId, 'tagId'); - this.requireOwnTag(tagId, ctx.actingUserId); - this.tags.remove(tagId); + await this.requireOwnTag(tagId, ctx.actingUserId); + await this.tags.remove(tagId); return { deleted: true }; } /** Ownership is re-checked per write, so a plugin cannot edit another user's tag. */ - private requireOwnTag(tagId: number, userId: number): void { - if (!this.tags.getByIdAndUser(tagId, userId)) { + private async requireOwnTag(tagId: number, userId: number): Promise { + if (!(await this.tags.getByIdAndUser(tagId, userId))) { throw new ForbiddenResource(`no tag ${tagId} for this user`); } } diff --git a/server/src/nest/tags/tags.service.ts b/server/src/nest/tags/tags.service.ts index d805bd0a1..0d4cc020a 100644 --- a/server/src/nest/tags/tags.service.ts +++ b/server/src/nest/tags/tags.service.ts @@ -12,15 +12,15 @@ import { DatabaseService } from '../database/database.service'; export class TagsService { constructor(private readonly db: DatabaseService) {} - list(userId: number): Tag[] { + async list(userId: number): Promise { return this.db.all('SELECT * FROM tags WHERE user_id = ? ORDER BY name ASC', userId); } - getByIdAndUser(id: string | number, userId: number): Tag | undefined { + async getByIdAndUser(id: string | number, userId: number): Promise { return this.db.get('SELECT * FROM tags WHERE id = ? AND user_id = ?', id, userId); } - create(userId: number, name: string, color?: string): Tag { + async create(userId: number, name: string, color?: string): Promise { const result = this.db.run( 'INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)', userId, @@ -30,7 +30,7 @@ export class TagsService { return this.db.get('SELECT * FROM tags WHERE id = ?', result.lastInsertRowid) as Tag; } - update(id: string | number, name?: string, color?: string): Tag { + async update(id: string | number, name?: string, color?: string): Promise { this.db.run( 'UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) WHERE id = ?', name || null, @@ -40,7 +40,7 @@ export class TagsService { return this.db.get('SELECT * FROM tags WHERE id = ?', id) as Tag; } - remove(id: string | number): void { + async remove(id: string | number): Promise { this.db.run('DELETE FROM tags WHERE id = ?', id); } } diff --git a/server/src/nest/todo/todo.mcp.ts b/server/src/nest/todo/todo.mcp.ts index 6b01ef985..398b4328c 100644 --- a/server/src/nest/todo/todo.mcp.ts +++ b/server/src/nest/todo/todo.mcp.ts @@ -79,7 +79,7 @@ export class TodoMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const item = this.todos.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority }); this.guards.safeBroadcast(tripId, 'todo:created', { item }); return ok({ item }); @@ -110,7 +110,7 @@ export class TodoMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); // Build bodyKeys to signal which nullable fields were explicitly provided const bodyKeys: string[] = []; if (due_date !== undefined) bodyKeys.push('due_date'); @@ -138,7 +138,7 @@ export class TodoMcp { async toggleTodo({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const item = this.todos.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, []); if (!item) return errorResult('To-do item not found.'); this.guards.safeBroadcast(tripId, 'todo:updated', { item }); @@ -159,7 +159,7 @@ export class TodoMcp { async deleteTodo({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const deleted = this.todos.deleteItem(tripId, itemId); if (!deleted) return errorResult('To-do item not found.'); this.guards.safeBroadcast(tripId, 'todo:deleted', { itemId }); @@ -180,7 +180,7 @@ export class TodoMcp { async reorderTodos({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); this.todos.reorderItems(tripId, orderedIds); return ok({ success: true }); } @@ -216,7 +216,7 @@ export class TodoMcp { async setTodoCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const assignees = this.todos.updateCategoryAssignees(tripId, categoryName, userIds); this.guards.safeBroadcast(tripId, 'todo:assignees', { category: categoryName, assignees }); return ok({ assignees }); diff --git a/server/src/nest/todo/todo.rpc.ts b/server/src/nest/todo/todo.rpc.ts index a15c4a685..207a2a62e 100644 --- a/server/src/nest/todo/todo.rpc.ts +++ b/server/src/nest/todo/todo.rpc.ts @@ -32,23 +32,23 @@ export class TodoRpc { } @PluginMethod('todos.create', { permission: 'db:write:todos' }) - create(params: Record, ctx: PluginRpcContext): unknown { + async create(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.guards.requireActor(ctx, 'todo'); const input = asPayload(params.input); if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('todo name is required'); - this.guards.requireTripEdit(tripId, actor, TODO_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, TODO_EDIT_ACTION); const item = this.todos.createItem(String(tripId), input as never); this.realtime.broadcast(tripId, 'todo:created', { item }, undefined); return item; } @PluginMethod('todos.update', { permission: 'db:write:todos' }) - update(params: Record, ctx: PluginRpcContext): unknown { + async update(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const todoId = num(params.todoId, 'todoId'); const actor = this.guards.requireActor(ctx, 'todo'); - this.guards.requireTripEdit(tripId, actor, TODO_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, TODO_EDIT_ACTION); const input = asPayload(params.input); const updated = this.todos.updateItem(String(tripId), String(todoId), input as never, Object.keys(input)); if (!updated) throw new ForbiddenResource(`no todo ${todoId} on trip ${tripId}`); @@ -57,11 +57,11 @@ export class TodoRpc { } @PluginMethod('todos.delete', { permission: 'db:write:todos' }) - delete(params: Record, ctx: PluginRpcContext): unknown { + async delete(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const todoId = num(params.todoId, 'todoId'); const actor = this.guards.requireActor(ctx, 'todo'); - this.guards.requireTripEdit(tripId, actor, TODO_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, TODO_EDIT_ACTION); if (!this.todos.deleteItem(String(tripId), String(todoId))) { throw new ForbiddenResource(`no todo ${todoId} on trip ${tripId}`); } diff --git a/server/src/nest/todo/todo.service.ts b/server/src/nest/todo/todo.service.ts index 31bf80953..849963e5a 100644 --- a/server/src/nest/todo/todo.service.ts +++ b/server/src/nest/todo/todo.service.ts @@ -28,7 +28,7 @@ export class TodoService { return this.db.canAccessTrip(tripId, userId); } - canEdit(trip: Trip, user: User): boolean { + async canEdit(trip: Trip, user: User): Promise { return this.permissions.checkPermission('packing_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id); } diff --git a/server/src/nest/transit/google-transit.provider.ts b/server/src/nest/transit/google-transit.provider.ts index 623e087c1..de2b2d981 100644 --- a/server/src/nest/transit/google-transit.provider.ts +++ b/server/src/nest/transit/google-transit.provider.ts @@ -246,8 +246,8 @@ export class GoogleTransitProvider { * behaviour and the safe one: the alternative is every transit search 403ing * on an install that flipped the switch before pasting a key. */ - isActive(userId: number): boolean { - if (readTransitProvider(this.database) !== 'google') return false; + async isActive(userId: number): Promise { + if ((await readTransitProvider(this.database)) !== 'google') return false; return !!this.resolveKey(userId).key; } diff --git a/server/src/nest/transit/transit-provider.ts b/server/src/nest/transit/transit-provider.ts index 56fd613c4..25751a4b9 100644 --- a/server/src/nest/transit/transit-provider.ts +++ b/server/src/nest/transit/transit-provider.ts @@ -23,12 +23,12 @@ function isTransitProvider(value: unknown): value is TransitProvider { * this one does not know, resolves to Transitous. The alternative is billing an * admin's Google key because a string did not parse. */ -export function readTransitProvider(db: DatabaseService): TransitProvider { +export async function readTransitProvider(db: DatabaseService): Promise { const row = db.get<{ value: string | null }>('SELECT value FROM app_settings WHERE key = ?', TRANSIT_PROVIDER_SETTING); return isTransitProvider(row?.value) ? row.value : DEFAULT_TRANSIT_PROVIDER; } -export function writeTransitProvider(db: DatabaseService, provider: TransitProvider): TransitProvider { +export async function writeTransitProvider(db: DatabaseService, provider: TransitProvider): Promise { db.run( `INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, diff --git a/server/src/nest/transit/transit.mcp.ts b/server/src/nest/transit/transit.mcp.ts index a76408d4a..55e52fc28 100644 --- a/server/src/nest/transit/transit.mcp.ts +++ b/server/src/nest/transit/transit.mcp.ts @@ -185,7 +185,7 @@ export class TransitMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); const day = this.days.getDay(dayId, tripId); if (!day) { return { content: [{ type: 'text' as const, text: 'dayId does not belong to this trip.' }], isError: true }; @@ -228,14 +228,14 @@ export class TransitMcp { isError: true, }; } - const endDay = this.days.list(tripId).days.find((d) => d.date === arrival.local_date); + const endDay = (await this.days.list(tripId)).days.find((d) => d.date === arrival.local_date); if (!endDay) { return { content: [{ type: 'text' as const, text: `No trip day exists for the arrival date ${arrival.local_date}.` }], isError: true, }; } - const { reservation } = this.reservations.create(tripId, { + const { reservation } = await this.reservations.create(tripId, { title: `${from.name} → ${to.name}`, type: 'transit', status: 'confirmed', diff --git a/server/src/nest/transit/transit.service.ts b/server/src/nest/transit/transit.service.ts index e634b4de8..451eb4f39 100644 --- a/server/src/nest/transit/transit.service.ts +++ b/server/src/nest/transit/transit.service.ts @@ -172,7 +172,7 @@ export class TransitService { const text = (query || '').trim(); // Answered before either backend is consulted, so it reports the one that // WOULD have been asked rather than claiming nobody was. - const provider: TransitProvider = this.google.isActive(userId) ? 'google' : 'transitous'; + const provider: TransitProvider = (await this.google.isActive(userId)) ? 'google' : 'transitous'; if (text.length < 2) return { results: [], provider }; if (text.length > 200) { const e = new Error('Query too long') as Error & { status: number }; @@ -253,7 +253,7 @@ export class TransitService { // After validation on purpose: whichever backend answers, the caller is held // to the same coordinate/mode/transfer contract and gets the same 400s. - if (this.google.isActive(userId)) { + if ((await this.google.isActive(userId))) { return { ...(await this.google.plan(q, language, userId)), provider: 'google' }; } diff --git a/server/src/nest/trip-invite/trip-invite.controller.ts b/server/src/nest/trip-invite/trip-invite.controller.ts index 505ea5845..f78a7099f 100644 --- a/server/src/nest/trip-invite/trip-invite.controller.ts +++ b/server/src/nest/trip-invite/trip-invite.controller.ts @@ -23,42 +23,42 @@ const RL_WINDOW = 15 * 60 * 1000; export class TripInviteLinkController { constructor(private readonly invites: TripInviteService, private readonly audit: AuditService) {} - private requireManage(tripId: string, user: User) { + private async requireManage(tripId: string, user: User) { const trip = this.invites.verifyTripAccess(tripId, user.id); if (!trip) throw new HttpException({ error: 'Trip not found' }, 404); - if (!this.invites.canManage(trip, user)) throw new HttpException({ error: 'No permission' }, 403); + if (!(await this.invites.canManage(trip, user))) throw new HttpException({ error: 'No permission' }, 403); } @Get() - get(@CurrentUser() user: User, @Param('tripId') tripId: string) { + async get(@CurrentUser() user: User, @Param('tripId') tripId: string) { // The token grants trip membership, so reading it needs the same // share_manage permission as creating/rotating it — not just trip access. - this.requireManage(tripId, user); + await this.requireManage(tripId, user); const info = this.invites.get(tripId); return info ? info : { token: null }; } @Post() - create( + async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: TripInviteLinkCreateDto, @Req() req: Request, ) { - this.requireManage(tripId, user); + await this.requireManage(tripId, user); const days = body?.expires_in_days != null && String(body.expires_in_days).trim() !== '' ? Number.parseInt(String(body.expires_in_days)) : null; const info = this.invites.createOrRotate(tripId, user.id, Number.isFinite(days as number) ? days : null); - this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_create', resource: tripId, ip: getClientIp(req), details: { expires_in_days: days } }); + await this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_create', resource: tripId, ip: getClientIp(req), details: { expires_in_days: days } }); return info; } @Delete() - remove(@CurrentUser() user: User, @Param('tripId') tripId: string, @Req() req: Request) { - this.requireManage(tripId, user); + async remove(@CurrentUser() user: User, @Param('tripId') tripId: string, @Req() req: Request) { + await this.requireManage(tripId, user); this.invites.remove(tripId); - this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_delete', resource: tripId, ip: getClientIp(req) }); + await this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_delete', resource: tripId, ip: getClientIp(req) }); return { success: true }; } } @@ -91,12 +91,12 @@ export class TripInviteController { @Post(':token/accept') @HttpCode(200) - accept(@CurrentUser() user: User, @Param('token') token: string, @Req() req: Request) { + async accept(@CurrentUser() user: User, @Param('token') token: string, @Req() req: Request) { this.limit(req, 20); const resolved = this.invites.resolve(token); if (!resolved) throw new HttpException({ error: 'Invalid or expired invite link' }, 404); - const result = this.invites.join(resolved.trip_id, user.id); - this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_join', resource: String(resolved.trip_id), ip: getClientIp(req), details: { joined: result.joined } }); + const result = await this.invites.join(resolved.trip_id, user.id); + await this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_join', resource: String(resolved.trip_id), ip: getClientIp(req), details: { joined: result.joined } }); return { trip_id: resolved.trip_id, joined: result.joined }; } } diff --git a/server/src/nest/trip-invite/trip-invite.mcp.ts b/server/src/nest/trip-invite/trip-invite.mcp.ts index ab3c6f9b6..91d67c983 100644 --- a/server/src/nest/trip-invite/trip-invite.mcp.ts +++ b/server/src/nest/trip-invite/trip-invite.mcp.ts @@ -46,9 +46,9 @@ export class TripInviteMcp { } /** Trip access first (404-equivalent), then share_manage, which is requireManage() in the controller. */ - private denyManage(tripId: number, userId: number) { + private async denyManage(tripId: number, userId: number) { if (!this.invites.verifyTripAccess(String(tripId), userId)) return noAccess(); - if (!this.guards.hasTripPermission('share_manage', tripId, userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('share_manage', tripId, userId))) return permissionDenied(); return null; } @@ -67,8 +67,8 @@ export class TripInviteMcp { access: (ctx) => canShareTrips(ctx.scopes) && canWrite(ctx.scopes, 'trips'), }) async getTripInviteLink({ tripId }: { tripId: number }, ctx: McpContext) { - const denied = this.denyManage(tripId, ctx.userId); - if (denied) return denied; + const denied = await this.denyManage(tripId, ctx.userId); + if ((await denied)) return denied; const info = this.invites.get(tripId); return ok({ invite_link: info ? this.describe(info) : null }); } @@ -89,8 +89,8 @@ export class TripInviteMcp { ctx: McpContext, ) { if (this.isDemoUser(ctx.userId)) return demoDenied(); - const denied = this.denyManage(tripId, ctx.userId); - if (denied) return denied; + const denied = await this.denyManage(tripId, ctx.userId); + if ((await denied)) return denied; // The route's own coercion, kept verbatim: the shared contract admits a // digits-only string as well as a number, and anything that does not parse // to a finite value means no expiry rather than an error. @@ -102,7 +102,7 @@ export class TripInviteMcp { // Minting a membership credential is audited wherever it happens, so an // admin reading the log sees the same row for a link made through an // assistant as for one made in the planner. No request here, hence no ip. - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: ctx.userId, action: 'trip.invite_link_create', resource: String(tripId), @@ -123,10 +123,10 @@ export class TripInviteMcp { }) async deleteTripInviteLink({ tripId }: { tripId: number }, ctx: McpContext) { if (this.isDemoUser(ctx.userId)) return demoDenied(); - const denied = this.denyManage(tripId, ctx.userId); - if (denied) return denied; + const denied = await this.denyManage(tripId, ctx.userId); + if ((await denied)) return denied; this.invites.remove(tripId); - this.audit.writeAudit({ + await this.audit.writeAudit({ userId: ctx.userId, action: 'trip.invite_link_delete', resource: String(tripId), diff --git a/server/src/nest/trip-invite/trip-invite.service.ts b/server/src/nest/trip-invite/trip-invite.service.ts index 8aa66fbfd..ca7d816a3 100644 --- a/server/src/nest/trip-invite/trip-invite.service.ts +++ b/server/src/nest/trip-invite/trip-invite.service.ts @@ -38,7 +38,7 @@ export class TripInviteService { return this.dbs.canAccessTrip(tripId, userId); } - canManage(trip: Trip, user: User): boolean { + async canManage(trip: Trip, user: User): Promise { return this.permissions.checkPermission('share_manage', user.role, trip.user_id, user.id, trip.user_id !== user.id); } diff --git a/server/src/nest/trip-members/trip-members.controller.ts b/server/src/nest/trip-members/trip-members.controller.ts index d2a758e1b..107338953 100644 --- a/server/src/nest/trip-members/trip-members.controller.ts +++ b/server/src/nest/trip-members/trip-members.controller.ts @@ -58,13 +58,13 @@ export class TripMembersController { @Post(':id/members') @HttpCode(201) - addMember(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripAddMemberDto) { + async addMember(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripAddMemberDto) { const { identifier } = body; const access = this.roster.canAccessTrip(id, user.id); if (!access) { throw new HttpException({ error: 'Trip not found' }, 404); } - if (!this.roster.can('member_manage', user.role, access.user_id, user.id, access.user_id !== user.id)) { + if (!(await this.roster.can('member_manage', user.role, access.user_id, user.id, access.user_id !== user.id))) { throw new HttpException({ error: 'No permission to manage members' }, 403); } try { @@ -79,13 +79,13 @@ export class TripMembersController { } @Delete(':id/members/:userId') - removeMember(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) { + async removeMember(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) { const access = this.roster.canAccessTrip(id, user.id); if (!access) { throw new HttpException({ error: 'Trip not found' }, 404); } const targetId = Number.parseInt(userId); - if (targetId !== user.id && !this.roster.can('member_manage', user.role, access.user_id, user.id, access.user_id !== user.id)) { + if (targetId !== user.id && !(await this.roster.can('member_manage', user.role, access.user_id, user.id, access.user_id !== user.id))) { throw new HttpException({ error: 'No permission to remove members' }, 403); } this.roster.removeMember(id, targetId); @@ -95,7 +95,7 @@ export class TripMembersController { @Post(':id/transfer') @UseGuards(TripOwnerGuard) @RequireTripOwner('Only the owner can transfer ownership', { param: 'id' }) - transferOwnership( + async transferOwnership( @CurrentUser() user: User, @Param('id') id: string, @Body() body: TripTransferOwnershipDto, @@ -105,7 +105,7 @@ export class TripMembersController { const { newOwnerId } = body; try { const result = this.roster.transferOwnership(id, newOwnerId, user.id); - this.audit.writeAudit({ userId: user.id, action: 'trip.transfer_ownership', ip: getClientIp(req), details: { tripId: Number(id), trip: result.tripTitle, from: result.fromEmail, to: result.toEmail } }); + await this.audit.writeAudit({ userId: user.id, action: 'trip.transfer_ownership', ip: getClientIp(req), details: { tripId: Number(id), trip: result.tripTitle, from: result.fromEmail, to: result.toEmail } }); // Nudge everyone viewing the trip to re-read it so the new ownership and the // recomputed permissions take effect live. const updatedTrip = this.roster.getTripForViewer(id, user.id); diff --git a/server/src/nest/trip-members/trip-members.service.ts b/server/src/nest/trip-members/trip-members.service.ts index c3b1ef0e9..c6d972875 100644 --- a/server/src/nest/trip-members/trip-members.service.ts +++ b/server/src/nest/trip-members/trip-members.service.ts @@ -76,7 +76,7 @@ export class TripMembersService { return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined; } - can(action: string, role: string, ownerId: number | null, userId: number, isMember: boolean): boolean { + async can(action: string, role: string, ownerId: number | null, userId: number, isMember: boolean): Promise { return this.permissions.checkPermission(action, role, ownerId, userId, isMember); } diff --git a/server/src/nest/trip-membership/trip-membership.service.ts b/server/src/nest/trip-membership/trip-membership.service.ts index a1880a22c..2ba1ad1b3 100644 --- a/server/src/nest/trip-membership/trip-membership.service.ts +++ b/server/src/nest/trip-membership/trip-membership.service.ts @@ -19,13 +19,13 @@ export class TripMembershipService { constructor(private readonly db: DatabaseService) {} /** The trip owner's user id, or null when the trip does not exist. */ - getOwnerId(tripId: string | number): number | null { + async getOwnerId(tripId: string | number): Promise { const row = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId); return row ? row.user_id : null; } /** Member user ids (owner excluded), in added_at order like listMembers. */ - listMemberUserIds(tripId: string | number): number[] { + async listMemberUserIds(tripId: string | number): Promise { return this.db .all<{ user_id: number }>('SELECT user_id FROM trip_members WHERE trip_id = ? ORDER BY added_at ASC', tripId) .map((r) => r.user_id); @@ -35,7 +35,7 @@ export class TripMembershipService { * Ids of every trip the user owns or is a member of, newest first — the id * half of TripsService.list(userId, null), same WHERE and ORDER BY. */ - listAccessibleTripIds(userId: number): number[] { + async listAccessibleTripIds(userId: number): Promise { return this.db .prepare(` SELECT t.id FROM trips t @@ -58,11 +58,11 @@ export class TripMembershipService { * * Returns whether a new membership row was actually created. */ - joinTripAsMember( + async joinTripAsMember( tripId: number, userId: number, invitedBy: number | null, - ): { joined: boolean; tripId: number } { + ): Promise<{ joined: boolean; tripId: number }> { const trip = this.db.get<{ id: number; user_id: number }>('SELECT id, user_id FROM trips WHERE id = ?', tripId); if (!trip) return { joined: false, tripId }; // The owner already has full access; never add them as a member. diff --git a/server/src/nest/trip-read-model/trip-read-model.service.ts b/server/src/nest/trip-read-model/trip-read-model.service.ts index 39deb1952..289c868dd 100644 --- a/server/src/nest/trip-read-model/trip-read-model.service.ts +++ b/server/src/nest/trip-read-model/trip-read-model.service.ts @@ -47,7 +47,7 @@ export class TripReadModelService { // ── Trip summary (used by MCP get_trip_summary tool) ────────────────────── - getTripSummary(tripId: number, viewerUserId?: number) { + async getTripSummary(tripId: number, viewerUserId?: number) { const trip = withoutFeedToken( this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record | undefined, ); @@ -57,7 +57,7 @@ export class TripReadModelService { if (!ownerRow) return null; const { owner, members } = this.members.listMembers(tripId, ownerRow.user_id); - const { days: rawDays } = this.days.list(tripId); + const { days: rawDays } = await this.days.list(tripId); const days = rawDays.map(({ notes_items, ...day }) => ({ ...day, notes: notes_items })); const accommodations = this.accommodations.list(tripId); @@ -97,13 +97,13 @@ export class TripReadModelService { // ── Bundle / notifications (route helpers) ──────────────────────────────── /** Aggregates every trip sub-collection for offline caching (legacy /:id/bundle). */ - bundle(tripId: string, trip: { user_id: number }, viewerId: number) { - const { days } = this.days.list(tripId); + async bundle(tripId: string, trip: { user_id: number }, viewerId: number) { + const { days } = await this.days.list(tripId); const { owner, members } = this.members.listMembers(tripId, trip.user_id); return { trip, days, - places: this.places.list(String(tripId), {}), + places: await this.places.list(String(tripId), {}), // Scope to the requesting member so other members' private packing items // (#858) never land in this viewer's offline cache. packingItems: this.packing.listItems(tripId, viewerId), diff --git a/server/src/nest/trips/trip-prompts.mcp.ts b/server/src/nest/trips/trip-prompts.mcp.ts index 2f1506a10..095598e05 100644 --- a/server/src/nest/trips/trip-prompts.mcp.ts +++ b/server/src/nest/trips/trip-prompts.mcp.ts @@ -43,11 +43,11 @@ export class TripPromptsMcp { if (!this.trips.canAccessTrip(tripId, ctx.userId)) { return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] }; } - const summary = this.readModel.getTripSummary(tripId, ctx.userId); + const summary = await this.readModel.getTripSummary(tripId, ctx.userId); if (!summary) { return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] }; } - const { trip, budget } = summary; + const { trip, budget } = await summary; const currency = trip?.currency || 'EUR'; const byCategory = (budget?.items || []).reduce((acc: Record, item: { category?: string; total_price?: number }) => { const cat = item.category || 'Uncategorized'; @@ -94,7 +94,7 @@ export class TripPromptsMcp { const lines = Object.entries(grouped).map(([cat, catItems]) => `## ${cat}\n${(catItems as { checked?: unknown; name?: string }[]).map((i) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}` ).join('\n\n'); - const { trip } = this.readModel.getTripSummary(tripId, ctx.userId) || {}; + const { trip } = await this.readModel.getTripSummary(tripId, ctx.userId) || {}; return { description: `Packing list for "${trip?.title || tripId}"`, messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }], diff --git a/server/src/nest/trips/trips.controller.ts b/server/src/nest/trips/trips.controller.ts index 91c4d36b0..dc520282c 100644 --- a/server/src/nest/trips/trips.controller.ts +++ b/server/src/nest/trips/trips.controller.ts @@ -103,8 +103,8 @@ export class TripsController { @Post() @HttpCode(201) - create(@CurrentUser() user: User, @Body() body: TripCreateDto, @Req() req: Request) { - if (!this.trips.can('trip_create', user.role, null, user.id, false)) { + async create(@CurrentUser() user: User, @Body() body: TripCreateDto, @Req() req: Request) { + if (!(await this.trips.can('trip_create', user.role, null, user.id, false))) { throw new HttpException({ error: 'No permission to create trips' }, 403); } // Presence/shape validation happens in the ZodValidationPipe (tripCreateRequestSchema). @@ -119,7 +119,7 @@ export class TripsController { const parsedDayCount = day_count ? Math.min(Math.max(Number(day_count) || 7, 1), MAX_TRIP_DAYS) : undefined; try { const { trip, tripId, reminderDays } = this.trips.create(user.id, { title, description, start_date, end_date, currency, reminder_days, day_count: parsedDayCount }); - this.audit.writeAudit({ userId: user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } }); + await this.audit.writeAudit({ userId: user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } }); if (reminderDays > 0) logInfo(`${user.email} set ${reminderDays}-day reminder for trip "${title}"`); return { trip }; } catch (e: unknown) { @@ -145,14 +145,14 @@ export class TripsController { } const ownerId = access.user_id; const isMember = ownerId !== user.id; - if (body.is_archived !== undefined && !this.trips.can('trip_archive', user.role, ownerId, user.id, isMember)) { + if (body.is_archived !== undefined && !(await this.trips.can('trip_archive', user.role, ownerId, user.id, isMember))) { throw new HttpException({ error: 'No permission to archive/unarchive this trip' }, 403); } - if (body.cover_image !== undefined && !this.trips.can('trip_cover_upload', user.role, ownerId, user.id, isMember)) { + if (body.cover_image !== undefined && !(await this.trips.can('trip_cover_upload', user.role, ownerId, user.id, isMember))) { throw new HttpException({ error: 'No permission to change cover image' }, 403); } const editFields = ['title', 'description', 'start_date', 'end_date', 'currency', 'reminder_days', 'day_count']; - if (editFields.some((f) => body[f] !== undefined) && !this.trips.can('trip_edit', user.role, ownerId, user.id, isMember)) { + if (editFields.some((f) => body[f] !== undefined) && !(await this.trips.can('trip_edit', user.role, ownerId, user.id, isMember))) { throw new HttpException({ error: 'No permission to edit this trip' }, 403); } // A chosen Unsplash cover arrives as an images.unsplash.com hot-link; download @@ -175,7 +175,7 @@ export class TripsController { await this.trips.deleteOldCover(oldCover); } if (Object.keys(result.changes).length > 0) { - this.audit.writeAudit({ userId: user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes } }); + await this.audit.writeAudit({ userId: user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes } }); if (result.isAdminEdit && result.ownerEmail) logInfo(`Admin ${user.email} edited trip "${result.newTitle}" owned by ${result.ownerEmail}`); } if (result.newReminder !== result.oldReminder) { @@ -201,7 +201,7 @@ export class TripsController { if (!access?.user_id) { throw new HttpException({ error: 'Trip not found' }, 404); } - if (!this.trips.can('trip_cover_upload', user.role, access.user_id, user.id, access.user_id !== user.id)) { + if (!(await this.trips.can('trip_cover_upload', user.role, access.user_id, user.id, access.user_id !== user.id))) { throw new HttpException({ error: 'No permission to change the cover image' }, 403); } const trip = this.trips.getRaw(id) as { cover_image: string | null } | undefined; @@ -222,8 +222,8 @@ export class TripsController { @Post(':id/copy') @HttpCode(201) - copy(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripCopyDto, @Req() req: Request) { - if (!this.trips.can('trip_create', user.role, null, user.id, false)) { + async copy(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripCopyDto, @Req() req: Request) { + if (!(await this.trips.can('trip_create', user.role, null, user.id, false))) { throw new HttpException({ error: 'No permission to create trips' }, 403); } if (!this.trips.canAccessTrip(id, user.id)) { @@ -232,7 +232,7 @@ export class TripsController { const { title } = body; try { const newTripId = this.trips.copy(id, user.id, title); - this.audit.writeAudit({ userId: user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(id), newTripId, title } }); + await this.audit.writeAudit({ userId: user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(id), newTripId, title } }); return { trip: this.trips.getCopiedTrip(newTripId, user.id) }; } catch { throw new HttpException({ error: 'Failed to copy trip' }, 500); @@ -240,7 +240,7 @@ export class TripsController { } @Delete(':id') - remove(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request, @Headers('x-socket-id') socketId?: string) { + async remove(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request, @Headers('x-socket-id') socketId?: string) { const owner = this.trips.getOwner(id); if (!owner) { throw new HttpException({ error: 'Trip not found' }, 404); @@ -252,11 +252,11 @@ export class TripsController { if (user.role !== 'admin' && !this.trips.canAccessTrip(id, user.id)) { throw new HttpException({ error: 'Trip not found' }, 404); } - if (!this.trips.can('trip_delete', user.role, owner.user_id, user.id, owner.user_id !== user.id)) { + if (!(await this.trips.can('trip_delete', user.role, owner.user_id, user.id, owner.user_id !== user.id))) { throw new HttpException({ error: 'No permission to delete this trip' }, 403); } const info = this.trips.remove(id, user.id, user.role); - this.audit.writeAudit({ userId: user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: info.tripId, trip: info.title, ...(info.ownerEmail ? { owner: info.ownerEmail } : {}) } }); + await this.audit.writeAudit({ userId: user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: info.tripId, trip: info.title, ...(info.ownerEmail ? { owner: info.ownerEmail } : {}) } }); if (info.isAdminDelete && info.ownerEmail) logInfo(`Admin ${user.email} deleted trip "${info.title}" owned by ${info.ownerEmail}`); this.trips.broadcast(String(info.tripId), 'trip:deleted', { id: info.tripId }, socketId); return { success: true }; diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index efb20d16f..63e65a680 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -161,7 +161,7 @@ export class TripsMcp { ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); - if (!this.guards.hasTripPermission('trip_edit', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('trip_edit', tripId, ctx.userId))) return permissionDenied(); if (clear_dates && (start_date || end_date)) return errorResult('clear_dates cannot be combined with start_date or end_date.'); if (start_date) { @@ -264,14 +264,14 @@ export class TripsMcp { }) async getTripSummary({ tripId }: { tripId: number }, ctx: McpContext) { if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); - const summary = this.readModel.getTripSummary(tripId, ctx.userId); + const summary = await this.readModel.getTripSummary(tripId, ctx.userId); if (!summary) return noAccess(); const R = canReadTrips(ctx.scopes); // Addon availability gates - const packingEnabled = this.addons.isAddonEnabled(ADDON_IDS.PACKING); - const budgetEnabled = this.addons.isAddonEnabled(ADDON_IDS.BUDGET); - const collabEnabled = this.addons.isAddonEnabled(ADDON_IDS.COLLAB); - const collabFeatures = collabEnabled ? this.addons.getCollabFeatures() : null; + const packingEnabled = await this.addons.isAddonEnabled(ADDON_IDS.PACKING); + const budgetEnabled = await this.addons.isAddonEnabled(ADDON_IDS.BUDGET); + const collabEnabled = await this.addons.isAddonEnabled(ADDON_IDS.COLLAB); + const collabFeatures = collabEnabled ? await this.addons.getCollabFeatures() : null; // Scope gates — sections not covered by the client's OAuth scopes are omitted. // Core trip data (metadata, days, members, accommodations) is always included // because this tool is always registered and needed for navigation. @@ -357,7 +357,7 @@ export class TripsMcp { // member_manage rather than a hardcoded owner test: the action is admin-lowerable // in the permission matrix, and POST /api/trips/:id/members has always honoured // that setting (and the admin bypass inside checkPermission) where this did not. - if (!this.guards.hasTripPermission('member_manage', tripId, ctx.userId)) return permissionDenied(); + if (!(await this.guards.hasTripPermission('member_manage', tripId, ctx.userId))) return permissionDenied(); try { const result = this.members.addMember(tripId, identifier, ownerRow.user_id, ctx.userId); this.guards.safeBroadcast(tripId, 'member:added', { member: result.member }); @@ -383,7 +383,7 @@ export class TripsMcp { if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); // Giving up your own access is not member management, so it carries no permission // requirement: the same self-removal bypass DELETE /api/trips/:id/members/:userId has. - if (memberId !== ctx.userId && !this.guards.hasTripPermission('member_manage', tripId, ctx.userId)) + if (memberId !== ctx.userId && !(await this.guards.hasTripPermission('member_manage', tripId, ctx.userId))) return permissionDenied(); this.members.removeMember(tripId, memberId); this.guards.safeBroadcast(tripId, 'member:removed', { userId: memberId }); @@ -598,8 +598,8 @@ export class TripsMcp { if (!this.trips.canAccessTrip(tripId, ctx.userId)) { return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] }; } - const summary = this.readModel.getTripSummary(tripId, ctx.userId); - if (!summary) { + const summary = await this.readModel.getTripSummary(tripId, ctx.userId); + if (!(await summary)) { return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] }; } const { trip, days, members, budget, packing, reservations, collab_notes } = summary as any; diff --git a/server/src/nest/trips/trips.rpc.ts b/server/src/nest/trips/trips.rpc.ts index c73cae9f4..82844444c 100644 --- a/server/src/nest/trips/trips.rpc.ts +++ b/server/src/nest/trips/trips.rpc.ts @@ -77,7 +77,7 @@ export class TripsRpc { return this.guards.tripRead( params, ctx, - () => (this.days.list(num(params.tripId, 'tripId')) as { days: unknown[] }).days, + async () => ((await this.days.list(num(params.tripId, 'tripId'))) as { days: unknown[] }).days, ); } @@ -113,20 +113,20 @@ export class TripsRpc { } @PluginMethod('trips.update', { permission: 'db:write:trips' }) - update(params: Record, ctx: PluginRpcContext): unknown { + async update(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.guards.requireActor(ctx, 'trip'); const parsed = tripUpdateRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid trip: ${schemaMessage(parsed.error)}`); this.guards.capStrings(parsed.data as Record, TRIP_STR_LIMITS); - this.guards.requireTripEdit(tripId, actor, TRIP_EDIT_ACTION); + await this.guards.requireTripEdit(tripId, actor, TRIP_EDIT_ACTION); const input = parsed.data as Record; // Two fields carry their own permissions on top of trip_edit, exactly as the REST // controller gates them. - if ('is_archived' in input && !this.guards.canEditAs('trip_archive', tripId, actor)) { + if ('is_archived' in input && !(await this.guards.canEditAs('trip_archive', tripId, actor))) { throw new ForbiddenResource(`no permission to archive trip ${tripId}`); } - if ('cover_image' in input && !this.guards.canEditAs('trip_cover_upload', tripId, actor)) { + if ('cover_image' in input && !(await this.guards.canEditAs('trip_cover_upload', tripId, actor))) { throw new ForbiddenResource(`no permission to change the cover of trip ${tripId}`); } const user = this.db.prepare('SELECT role FROM users WHERE id = ?').get(actor) as { role?: string } | undefined; @@ -144,14 +144,14 @@ export class TripsRpc { } @PluginMethod('trips.create', { permission: 'db:create:trips' }) - create(params: Record, ctx: PluginRpcContext): unknown { + async create(params: Record, ctx: PluginRpcContext): Promise { // The capability that unlocks importers (MyMaps, booking dumps, calendar sync). // No broadcast: a new trip is only visible to its owner, who refetches. const actor = this.guards.requireActor(ctx, 'trip'); const parsed = tripCreateRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid trip: ${schemaMessage(parsed.error)}`); this.guards.capStrings(parsed.data as Record, TRIP_STR_LIMITS); - if (!this.canCreateTrip(actor)) throw new ForbiddenResource('no permission to create trips'); + if (!(await this.canCreateTrip(actor))) throw new ForbiddenResource('no permission to create trips'); try { return this.trips.create(actor, parsed.data as unknown as Parameters[1]).trip; } catch (e) { @@ -161,16 +161,16 @@ export class TripsRpc { } /** trip_create is not trip-scoped, so it cannot go through requireTripEdit. */ - private canCreateTrip(userId: number): boolean { + private async canCreateTrip(userId: number): Promise { return this.guards.canCreateAs('trip_create', userId); } @PluginMethod('trips.addMember', { permission: 'db:write:members' }) - addMember(params: Record, ctx: PluginRpcContext): unknown { + async addMember(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const targetUserId = num(params.userId, 'userId'); const actor = this.guards.requireActor(ctx, 'trip member'); - this.guards.requireTripEdit(tripId, actor, MEMBER_MANAGE_ACTION); + await this.guards.requireTripEdit(tripId, actor, MEMBER_MANAGE_ACTION); const target = this.db.prepare('SELECT id FROM users WHERE id = ?').get(targetUserId) as { id: number } | undefined; if (!target) throw new ForbiddenResource(`no user ${targetUserId}`); // The acting user is recorded as the inviter. @@ -178,11 +178,11 @@ export class TripsRpc { } @PluginMethod('trips.removeMember', { permission: 'db:write:members' }) - removeMember(params: Record, ctx: PluginRpcContext): unknown { + async removeMember(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const targetUserId = num(params.userId, 'userId'); const actor = this.guards.requireActor(ctx, 'trip member'); - this.guards.requireTripEdit(tripId, actor, MEMBER_MANAGE_ACTION); + await this.guards.requireTripEdit(tripId, actor, MEMBER_MANAGE_ACTION); // Never remove the OWNER through this path: that would orphan the trip. // Ownership transfer is a separate, deliberate action. const trip = this.db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined; diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index f500def9d..143c641d7 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -173,7 +173,7 @@ export class TripsService { return this.dbs.isOwner(tripId, userId); } - can(action: string, role: string, ownerId: number | null, userId: number, isMember: boolean): boolean { + async can(action: string, role: string, ownerId: number | null, userId: number, isMember: boolean): Promise { return this.permissions.checkPermission(action, role, ownerId, userId, isMember); } diff --git a/server/src/nest/vacay/vacay.rpc.ts b/server/src/nest/vacay/vacay.rpc.ts index 422f386c6..c7266ce78 100644 --- a/server/src/nest/vacay/vacay.rpc.ts +++ b/server/src/nest/vacay/vacay.rpc.ts @@ -20,26 +20,26 @@ export class VacayRpc { ) {} @PluginMethod('vacay.mine', { permission: 'db:read:vacay' }) - mine(_params: Record, ctx: PluginRpcContext): unknown { + async mine(_params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireVacayUser(ctx, 'reads'); - this.requireVacayAddon(); + await this.requireVacayAddon(); return this.vacay.getPlanData(userId); } @PluginMethod('vacay.toggleEntry', { permission: 'db:write:vacay' }) - toggleEntry(params: Record, ctx: PluginRpcContext): unknown { + async toggleEntry(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireVacayUser(ctx, 'writes'); const date = this.dateStr(params.date); - this.requireVacayAddon(); + await this.requireVacayAddon(); return this.vacay.toggleEntry(userId, this.vacay.getActivePlanId(userId), date, 1, 'vacation', undefined); } @PluginMethod('vacay.toggleCompanyHoliday', { permission: 'db:write:vacay' }) - toggleCompanyHoliday(params: Record, ctx: PluginRpcContext): unknown { + async toggleCompanyHoliday(params: Record, ctx: PluginRpcContext): Promise { const userId = this.requireVacayUser(ctx, 'writes'); const date = this.dateStr(params.date); const note = typeof params.note === 'string' ? params.note.slice(0, 256) : undefined; - this.requireVacayAddon(); + await this.requireVacayAddon(); return this.vacay.toggleCompanyHoliday(this.vacay.getActivePlanId(userId), date, note, undefined); } @@ -50,8 +50,8 @@ export class VacayRpc { return ctx.actingUserId; } - private requireVacayAddon(): void { - this.guards.requireAddon(ADDON_IDS.VACAY, 'vacay'); + private async requireVacayAddon(): Promise { + await this.guards.requireAddon(ADDON_IDS.VACAY, 'vacay'); } private dateStr(value: unknown): string { diff --git a/server/src/systemNotices/service.ts b/server/src/systemNotices/service.ts index 71398f081..d5ab23c02 100644 --- a/server/src/systemNotices/service.ts +++ b/server/src/systemNotices/service.ts @@ -35,11 +35,11 @@ function severityWeight(s: string): number { return s === 'critical' ? 2 : s === 'warn' ? 1 : 0; } -export function getActiveNoticesFor( +export async function getActiveNoticesFor( userId: number, - addonEnabled: (addonId: string) => boolean, + addonEnabled: (addonId: string) => Promise, managed = false -): SystemNoticeDTO[] { +): Promise { const user = db.prepare( 'SELECT login_count, first_seen_version, role FROM users WHERE id = ?' ).get(userId) as { login_count: number; first_seen_version: string; role: string } | undefined; @@ -59,7 +59,23 @@ export function getActiveNoticesFor( const now = new Date(); const currentAppVersion = getCurrentAppVersion(); - const ctx = { user: { ...user, noTrips: tripCount }, currentAppVersion, now, addonEnabled, managed }; + // `evaluate` runs inside a .filter(), which cannot await, so the addon flags + // the registry is able to ask about are resolved up front and handed to it as + // a lookup. The ids come from the notices themselves, so every question the + // conditions can ask has an answer here and the fallback is unreachable. + const addonFlags = new Map(); + for (const condition of SYSTEM_NOTICES.flatMap(n => n.conditions)) { + if (condition.kind === 'addonEnabled' && !addonFlags.has(condition.addonId)) { + addonFlags.set(condition.addonId, await addonEnabled(condition.addonId)); + } + } + const ctx = { + user: { ...user, noTrips: tripCount }, + currentAppVersion, + now, + addonEnabled: (addonId: string) => addonFlags.get(addonId) ?? false, + managed, + }; const appVer = semver.coerce(currentAppVersion)?.version ?? '0.0.0'; const isStillDismissed = (n: SystemNotice): boolean => { @@ -89,7 +105,7 @@ export function getActiveNoticesFor( .map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, recurring: _rc, ...dto }) => dto); } -export function dismissNotice(userId: number, noticeId: string): boolean { +export async function dismissNotice(userId: number, noticeId: string): Promise { const exists = SYSTEM_NOTICES.some(n => n.id === noticeId); if (!exists) return false; // Record the app version at dismissal so per-version notices can re-appear on the next diff --git a/server/tests/e2e/addons.e2e.test.ts b/server/tests/e2e/addons.e2e.test.ts index 64acef2cc..4d2378504 100644 --- a/server/tests/e2e/addons.e2e.test.ts +++ b/server/tests/e2e/addons.e2e.test.ts @@ -40,13 +40,16 @@ vi.mock('../../src/nest/memories/memories.helpers', () => ({ getPhotoProviderCon import { AddonsModule } from '../../src/nest/addons/addons.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AddonsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, AddonsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/admin.e2e.test.ts b/server/tests/e2e/admin.e2e.test.ts index f7ef5bd4d..653a68fc2 100644 --- a/server/tests/e2e/admin.e2e.test.ts +++ b/server/tests/e2e/admin.e2e.test.ts @@ -92,13 +92,16 @@ import { SettingsModule } from '../../src/nest/settings/settings.module'; import { NotificationsModule } from '../../src/nest/notifications/notifications.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Admin e2e (real auth + admin guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, AdminModule, OidcModule, SettingsModule, NotificationsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, AdminModule, OidcModule, SettingsModule, NotificationsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); // Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate diff --git a/server/tests/e2e/airports.e2e.test.ts b/server/tests/e2e/airports.e2e.test.ts index 834223abe..06ce7116e 100644 --- a/server/tests/e2e/airports.e2e.test.ts +++ b/server/tests/e2e/airports.e2e.test.ts @@ -30,6 +30,9 @@ vi.mock('../../src/nest/airports/airports.data', async (importActual) => { import { AirportsModule } from '../../src/nest/airports/airports.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; const BER = { iata: 'BER', icao: 'EDDB', name: 'Berlin Brandenburg', city: 'Berlin', @@ -41,7 +44,7 @@ describe('Airports e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [AirportsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), AirportsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/assignments.e2e.test.ts b/server/tests/e2e/assignments.e2e.test.ts index 36d53a7a4..fdda2c713 100644 --- a/server/tests/e2e/assignments.e2e.test.ts +++ b/server/tests/e2e/assignments.e2e.test.ts @@ -75,13 +75,16 @@ let checkPermission: MockInstance; import { AssignmentsModule } from '../../src/nest/assignments/assignments.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Assignments e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, AssignmentsModule] }) + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, AssignmentsModule] }) .overrideProvider(JourneyDomainService) .useValue({ reconcileTripSkeletons }) .compile(); diff --git a/server/tests/e2e/atlas.e2e.test.ts b/server/tests/e2e/atlas.e2e.test.ts index e327db6ab..fc9993e32 100644 --- a/server/tests/e2e/atlas.e2e.test.ts +++ b/server/tests/e2e/atlas.e2e.test.ts @@ -41,6 +41,9 @@ import { AtlasModule } from '../../src/nest/atlas/atlas.module'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { let server: Server; @@ -48,7 +51,7 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { let userId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AtlasModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, AtlasModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/auth.e2e.test.ts b/server/tests/e2e/auth.e2e.test.ts index abb0c8a61..7170901dc 100644 --- a/server/tests/e2e/auth.e2e.test.ts +++ b/server/tests/e2e/auth.e2e.test.ts @@ -56,6 +56,9 @@ import { SessionRenewalInterceptor } from '../../src/nest/auth/session-renewal.i import { DatabaseModule } from '../../src/nest/database/database.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Auth e2e (real auth guard + real service + real cookie service + temp SQLite)', () => { let server: Server; @@ -65,7 +68,7 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp let userPassword: string; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AuthModule] }) + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, AuthModule] }) // The mailer is a provider since the notifications fold; overriding it is // the DI-native replacement for the old services/notifications module mock. .overrideProvider(MailerService) diff --git a/server/tests/e2e/backup.e2e.test.ts b/server/tests/e2e/backup.e2e.test.ts index 16446d2c1..cbaf2a31e 100644 --- a/server/tests/e2e/backup.e2e.test.ts +++ b/server/tests/e2e/backup.e2e.test.ts @@ -47,13 +47,16 @@ vi.mock('../../src/nest/backup/backup.impl', () => backupSvc); import { BackupModule } from '../../src/nest/backup/backup.module'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Backup e2e (real auth + admin guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, BackupModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, BackupModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/budget.e2e.test.ts b/server/tests/e2e/budget.e2e.test.ts index 04c4efd09..283a2a3b5 100644 --- a/server/tests/e2e/budget.e2e.test.ts +++ b/server/tests/e2e/budget.e2e.test.ts @@ -46,6 +46,9 @@ import { BudgetModule } from '../../src/nest/budget/budget.module'; import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => { let server: Server; @@ -53,7 +56,7 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => { let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, BudgetModule] }) + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, BudgetModule] }) // The settlement read awaits live FX rates; the trip here is all-EUR, so a // null result is the identity — and the test never touches the network. .overrideProvider(ExchangeRatesService) diff --git a/server/tests/e2e/calendar-feed-visibility.e2e.test.ts b/server/tests/e2e/calendar-feed-visibility.e2e.test.ts index 184491759..74a5afc1e 100644 --- a/server/tests/e2e/calendar-feed-visibility.e2e.test.ts +++ b/server/tests/e2e/calendar-feed-visibility.e2e.test.ts @@ -36,6 +36,9 @@ import { runMigrations } from '../../src/db/migrations'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { FeedsModule } from '../../src/nest/feeds/feeds.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Calendar feed visibility e2e (real CalendarService over temp SQLite)', () => { let server: Server; @@ -43,7 +46,7 @@ describe('Calendar feed visibility e2e (real CalendarService over temp SQLite)', let feedToken: string; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, FeedsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, FeedsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.useGlobalFilters(new TrekExceptionFilter()); await nest.init(); diff --git a/server/tests/e2e/categories.e2e.test.ts b/server/tests/e2e/categories.e2e.test.ts index 1a872cc17..bd48cd8cc 100644 --- a/server/tests/e2e/categories.e2e.test.ts +++ b/server/tests/e2e/categories.e2e.test.ts @@ -37,6 +37,9 @@ import { DatabaseModule } from '../../src/nest/database/database.module'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; function insertCategory(name: string, color = '#6366f1', icon = '📍', userId = 1): number { const res = db @@ -53,7 +56,7 @@ describe('Categories e2e (real JwtAuthGuard + AdminGuard + temp SQLite)', () => // RealtimeModule is @Global in the app graph but not in a partial container, // and CategoriesModule now pulls McpSharedModule in for the admin tools, whose // guard service takes it. days.e2e.test.ts imports it for the same reason. - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, CategoriesModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, CategoriesModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/collab.e2e.test.ts b/server/tests/e2e/collab.e2e.test.ts index 96edf1a99..52c41fb7c 100644 --- a/server/tests/e2e/collab.e2e.test.ts +++ b/server/tests/e2e/collab.e2e.test.ts @@ -76,13 +76,16 @@ import { CollabModule } from '../../src/nest/collab/collab.module'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { RateLimitService } from '../../src/nest/common/rate-limit.service'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Collab e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, CollabModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, CollabModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/collections.e2e.test.ts b/server/tests/e2e/collections.e2e.test.ts index 623018455..3fb693690 100644 --- a/server/tests/e2e/collections.e2e.test.ts +++ b/server/tests/e2e/collections.e2e.test.ts @@ -45,6 +45,9 @@ import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { AddonsService } from '../../src/nest/addons/addons.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Collections e2e (real auth guard + real service + temp SQLite)', () => { let server: Server; @@ -54,7 +57,7 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, CollectionsModule] }) + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, CollectionsModule] }) .overrideProvider(AddonsService) .useValue({ isAddonEnabled }) .compile(); diff --git a/server/tests/e2e/dawarich.e2e.test.ts b/server/tests/e2e/dawarich.e2e.test.ts index eae7e327f..2c6509712 100644 --- a/server/tests/e2e/dawarich.e2e.test.ts +++ b/server/tests/e2e/dawarich.e2e.test.ts @@ -59,6 +59,9 @@ import { DatabaseModule } from '../../src/nest/database/database.module'; import { AddonsService } from '../../src/nest/addons/addons.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; /** * Every outbound call an instance would ever receive, stubbed to throw. A route @@ -99,7 +102,7 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp let strangerSuggestionId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, DawarichModule] }) + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, DawarichModule] }) .overrideProvider(AddonsService) .useValue({ isAddonEnabled }) .overrideProvider(DawarichClient) diff --git a/server/tests/e2e/days.e2e.test.ts b/server/tests/e2e/days.e2e.test.ts index cf345162e..59a2e8c9a 100644 --- a/server/tests/e2e/days.e2e.test.ts +++ b/server/tests/e2e/days.e2e.test.ts @@ -90,13 +90,16 @@ import { DaysModule } from '../../src/nest/days/days.module'; import { DayNotesModule } from '../../src/nest/day-notes/day-notes.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, DaysModule, DayNotesModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, DaysModule, DayNotesModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalPipes(new ZodValidationPipe()); diff --git a/server/tests/e2e/doc-sync.e2e.test.ts b/server/tests/e2e/doc-sync.e2e.test.ts index 22c66cf03..7239f0488 100644 --- a/server/tests/e2e/doc-sync.e2e.test.ts +++ b/server/tests/e2e/doc-sync.e2e.test.ts @@ -83,6 +83,9 @@ import { SynologyDriveDocumentProvider } from '../../src/nest/doc-sync/providers import { NextcloudDocumentProvider, OpencloudDocumentProvider } from '../../src/nest/doc-sync/providers/webdav.provider'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; /** A provider that answers plausibly but never opens a socket. */ function fakeProvider(id: string) { @@ -136,7 +139,7 @@ describe('Document sync e2e (real guards + real services + temp SQLite)', () => [OpencloudDocumentProvider, 'opencloud'], [SynologyDriveDocumentProvider, 'synologydrive'], ] as const; - let builder = Test.createTestingModule({ imports: [DatabaseModule, DocSyncModule] }) + let builder = Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, DocSyncModule] }) .overrideProvider(AddonsService) .useValue({ isAddonEnabled }); const fakes = providers.map(([, id]) => fakeProvider(id)); @@ -401,7 +404,7 @@ describe('Document sync e2e (real guards + real services + temp SQLite)', () => // The assistant reads the same bindings through its own tool. const ctx = { userId: memberId, scopes: null, isStaticToken: false } as McpContext; - const result = app.get(DocSyncMcp).getTripDocumentSync({ tripId }, ctx); + const result = await app.get(DocSyncMcp).getTripDocumentSync({ tripId }, ctx); const status = JSON.parse(result.content[0].text) as { links: Array> }; expect(status.links[0]).toMatchObject({ providerId: 'paperless', providerName: 'Paperless-ngx' }); } finally { diff --git a/server/tests/e2e/feeds.e2e.test.ts b/server/tests/e2e/feeds.e2e.test.ts index 77a1ddfb4..c541c9deb 100644 --- a/server/tests/e2e/feeds.e2e.test.ts +++ b/server/tests/e2e/feeds.e2e.test.ts @@ -77,6 +77,9 @@ import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter import { AppConfigModule } from '../../src/nest/app-config/app-config.module'; import { GlobalAuthGuard } from '../../src/nest/auth/global-auth.guard'; import { MfaPolicyGuard } from '../../src/nest/auth/mfa-policy.guard'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; const BASE = 'https://trek.example.test'; @@ -92,7 +95,7 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => { // on the feed controller passed here while production 401'd every calendar // client). const moduleRef = await Test.createTestingModule({ - imports: [AppConfigModule, DatabaseModule, RealtimeModule, FeedsModule], + imports: [await TestUnitOfWorkModule.forRoot(db), AppConfigModule, DatabaseModule, RealtimeModule, FeedsModule], providers: [ { provide: APP_GUARD, useClass: GlobalAuthGuard }, { provide: APP_GUARD, useClass: MfaPolicyGuard }, diff --git a/server/tests/e2e/files.e2e.test.ts b/server/tests/e2e/files.e2e.test.ts index 19f7ce5e4..0998d8aa7 100644 --- a/server/tests/e2e/files.e2e.test.ts +++ b/server/tests/e2e/files.e2e.test.ts @@ -81,13 +81,16 @@ import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { FilesModule } from '../../src/nest/files/files.module'; import { PhotosModule } from '../../src/nest/photos/photos.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Files + photos e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, FilesModule, PhotosModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, FilesModule, PhotosModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/journey.e2e.test.ts b/server/tests/e2e/journey.e2e.test.ts index f7434ca55..0db0ce545 100644 --- a/server/tests/e2e/journey.e2e.test.ts +++ b/server/tests/e2e/journey.e2e.test.ts @@ -70,13 +70,16 @@ import { JourneyModule } from '../../src/nest/journey/journey.module'; import { AddonsService } from '../../src/nest/addons/addons.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Journey e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, JourneyModule] }) + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, JourneyModule] }) .overrideProvider(JourneyDomainService) .useValue(jsvc) .overrideProvider(JourneyShareService) diff --git a/server/tests/e2e/maps.e2e.test.ts b/server/tests/e2e/maps.e2e.test.ts index 981d97db7..b26dd4120 100644 --- a/server/tests/e2e/maps.e2e.test.ts +++ b/server/tests/e2e/maps.e2e.test.ts @@ -30,13 +30,16 @@ import { MapsService } from '../../src/nest/maps/maps.service'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Maps e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, MapsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, MapsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/memories.e2e.test.ts b/server/tests/e2e/memories.e2e.test.ts index 818769461..e0167f3e8 100644 --- a/server/tests/e2e/memories.e2e.test.ts +++ b/server/tests/e2e/memories.e2e.test.ts @@ -67,6 +67,9 @@ import { SynologyService } from '../../src/nest/memories/synology.service'; import { MemoriesAccessService } from '../../src/nest/memories/memories-access.service'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; const BASE = '/api/integrations/memories'; const UNIFIED = `${BASE}/unified`; @@ -78,7 +81,7 @@ describe('Memories e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, MemoriesModule] }) + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, MemoriesModule] }) .overrideProvider(UnifiedMemoriesService).useValue(unified) .overrideProvider(ImmichService).useValue(immich) .overrideProvider(SynologyService).useValue(synology) diff --git a/server/tests/e2e/notifications.e2e.test.ts b/server/tests/e2e/notifications.e2e.test.ts index e0724ea30..2bb9d296a 100644 --- a/server/tests/e2e/notifications.e2e.test.ts +++ b/server/tests/e2e/notifications.e2e.test.ts @@ -70,6 +70,9 @@ import { NotificationPreferencesService } from '../../src/nest/notifications/not import { NtfyService } from '../../src/nest/notifications/transports/ntfy.service'; import { WebhookService } from '../../src/nest/notifications/transports/webhook.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; function seedNotification(recipientId: number, overrides: { is_read?: number } = {}): number { const r = db.prepare( @@ -85,7 +88,7 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => { async function build() { const moduleRef = await Test.createTestingModule({ - imports: [DatabaseModule, RealtimeModule, NotificationsModule], + imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, NotificationsModule], }) .overrideProvider(NotificationPreferencesService).useValue(prefs) .overrideProvider(MailerService).useValue(mailer) diff --git a/server/tests/e2e/oauth.e2e.test.ts b/server/tests/e2e/oauth.e2e.test.ts index 3635b0bde..2f186168c 100644 --- a/server/tests/e2e/oauth.e2e.test.ts +++ b/server/tests/e2e/oauth.e2e.test.ts @@ -56,13 +56,16 @@ import { OauthService } from '../../src/nest/oauth/oauth.service'; import { AddonsService } from '../../src/nest/addons/addons.service'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('OAuth e2e (real guards + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, OauthModule] }) + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, OauthModule] }) .overrideProvider(AddonsService) .useValue({ isAddonEnabled }) .overrideProvider(OauthService) diff --git a/server/tests/e2e/oidc.e2e.test.ts b/server/tests/e2e/oidc.e2e.test.ts index df91f24f5..ef1d66af7 100644 --- a/server/tests/e2e/oidc.e2e.test.ts +++ b/server/tests/e2e/oidc.e2e.test.ts @@ -50,6 +50,9 @@ import { OidcService } from '../../src/nest/oidc/oidc.service'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { AuthService } from '../../src/nest/auth/auth.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('OIDC e2e (real cookie service)', () => { let server: Server; @@ -57,7 +60,7 @@ describe('OIDC e2e (real cookie service)', () => { let consumeAuthCode: MockInstance; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, OidcModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, OidcModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/packing.e2e.test.ts b/server/tests/e2e/packing.e2e.test.ts index a754a7cc7..7f94b6d60 100644 --- a/server/tests/e2e/packing.e2e.test.ts +++ b/server/tests/e2e/packing.e2e.test.ts @@ -125,6 +125,9 @@ import { DatabaseModule } from '../../src/nest/database/database.module'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; function insertItem(tripId: number, name: string, extra: Partial<{ sort_order: number; category: string; is_private: number; owner_id: number }> = {}): number { const res = db @@ -139,7 +142,7 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, PackingModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, PackingModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/place-enrichment.e2e.test.ts b/server/tests/e2e/place-enrichment.e2e.test.ts index a23305f44..af0373944 100644 --- a/server/tests/e2e/place-enrichment.e2e.test.ts +++ b/server/tests/e2e/place-enrichment.e2e.test.ts @@ -37,6 +37,9 @@ import { DatabaseModule } from '../../src/nest/database/database.module'; import { RateLimitService } from '../../src/nest/common/rate-limit.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; const BODY = { lat: 50.9, lng: 6.96, name: 'Museum Ludwig', placeId: 'way:12345' }; @@ -46,7 +49,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () => let maps: MapsService; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, PlaceEnrichmentModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, PlaceEnrichmentModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/place-shadow.e2e.test.ts b/server/tests/e2e/place-shadow.e2e.test.ts index f6021a6af..6af0b3dfa 100644 --- a/server/tests/e2e/place-shadow.e2e.test.ts +++ b/server/tests/e2e/place-shadow.e2e.test.ts @@ -40,6 +40,9 @@ vi.mock('../../src/db/database', () => ({ import { PlaceShadowModule } from '../../src/nest/place-shadow/place-shadow.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; const PICK = { query: 'kaffee bar am dobi', @@ -66,7 +69,7 @@ describe('/api/place-shadow e2e (real guards + temp SQLite)', () => { async function build() { const moduleRef = await Test.createTestingModule({ - imports: [DatabaseModule, PlaceShadowModule], + imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, PlaceShadowModule], providers: [{ provide: APP_PIPE, useClass: ZodValidationPipe }], }).compile(); const nest = moduleRef.createNestApplication(); diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts index e181a7ef3..07631fdbb 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -85,13 +85,16 @@ import { PlacesModule } from '../../src/nest/places/places.module'; import { PlacesService } from '../../src/nest/places/places.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Places e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, PlacesModule] }) + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, PlacesModule] }) .overrideProvider(JourneyDomainService) .useValue({ onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn(), onPlaceDeleted: vi.fn() }) .compile(); diff --git a/server/tests/e2e/public-api.e2e.test.ts b/server/tests/e2e/public-api.e2e.test.ts index da99c184f..9ea7eff2d 100644 --- a/server/tests/e2e/public-api.e2e.test.ts +++ b/server/tests/e2e/public-api.e2e.test.ts @@ -86,6 +86,9 @@ import { TokensModule } from '../../src/nest/tokens/tokens.module'; import { PublicStatsController } from '../../src/nest/atlas/public-stats.controller'; import { AtlasService } from '../../src/nest/atlas/atlas.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; /** Mints a token the way TokenService does, so the guard's hash lookup is real. */ function seedToken(userId: number, raw: string, kind: 'api' | 'mcp' = 'api'): string { @@ -151,7 +154,7 @@ describe('Public API v1 e2e (real guard + real SQL)', () => { async function build() { const moduleRef = await Test.createTestingModule({ - imports: [DatabaseModule, RateLimitModule, TokensModule, PublicApiModule], + imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RateLimitModule, TokensModule, PublicApiModule], // `/api/v1/stats` lives in atlas/ because its figures do, but it is guarded // and scoped by this directory's code — so it is mounted here with the real // guard and a stubbed AtlasService. Importing AtlasModule instead would pull diff --git a/server/tests/e2e/reservations.e2e.test.ts b/server/tests/e2e/reservations.e2e.test.ts index 3a8320e60..2a6ef4495 100644 --- a/server/tests/e2e/reservations.e2e.test.ts +++ b/server/tests/e2e/reservations.e2e.test.ts @@ -57,6 +57,9 @@ let checkPermission: MockInstance; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { NotificationsService } from '../../src/nest/notifications/notifications.service'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real reservation SQL)', () => { let server: Server; @@ -64,7 +67,7 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, ReservationsModule, AccommodationsModule] }) + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, ReservationsModule, AccommodationsModule] }) .overrideProvider(NotificationsService) .useValue({ send: notificationSend }) .compile(); diff --git a/server/tests/e2e/roadtrip.e2e.test.ts b/server/tests/e2e/roadtrip.e2e.test.ts index fd3324462..21a2bf516 100644 --- a/server/tests/e2e/roadtrip.e2e.test.ts +++ b/server/tests/e2e/roadtrip.e2e.test.ts @@ -28,6 +28,9 @@ import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import request from 'supertest'; import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; const { db } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -95,7 +98,7 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => { async function build() { const moduleRef = await Test.createTestingModule({ - imports: [DatabaseModule, RealtimeModule, RoadtripModule], + imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, RoadtripModule], }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); @@ -169,7 +172,7 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => { it('validates Google route preview and import before executing either service', async () => { const routes = app.get(GoogleRouteService); const preview = vi.spyOn(routes, 'preview').mockResolvedValue({ stops: [] }); - const save = vi.spyOn(routes, 'import').mockReturnValue({ imported: 2 }); + const save = vi.spyOn(routes, 'import').mockResolvedValue({ imported: 2 }); const input = { dayId: 3, stops: [{ name: 'A', lat: 48, lng: 11 }, { name: 'B', lat: 41, lng: 12 }] }; try { await request(server).post('/api/roadtrip/google-maps-preview').send({ url: 'https://google.com/maps/dir/A/B' }).expect(401); diff --git a/server/tests/e2e/school-holidays.e2e.test.ts b/server/tests/e2e/school-holidays.e2e.test.ts index 51755e374..900a0b286 100644 --- a/server/tests/e2e/school-holidays.e2e.test.ts +++ b/server/tests/e2e/school-holidays.e2e.test.ts @@ -22,6 +22,9 @@ import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter import { validateBodyContracts } from '../../src/nest/common/validate-body-contracts'; import { createTestRegistry } from '../../src/nest-mcp'; import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; const base = '/api/school-holiday-catalog'; const winter = { name: 'Winter break', startDate: '2026-12-20', endDate: '2027-01-06' }; @@ -32,7 +35,7 @@ beforeAll(async () => { createTables(db); runMigrations(db); db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'admin', 'admin@test.local', '', 'admin'), (2, 'member', 'member@test.local', '', 'user')").run(); - const module = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, SchoolHolidaysModule] }).compile(); + const module = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, SchoolHolidaysModule] }).compile(); app = module.createNestApplication(); app.use(cookieParser()); app.useGlobalPipes(new ZodValidationPipe()); diff --git a/server/tests/e2e/settings.e2e.test.ts b/server/tests/e2e/settings.e2e.test.ts index 866b4a8d5..08b3df828 100644 --- a/server/tests/e2e/settings.e2e.test.ts +++ b/server/tests/e2e/settings.e2e.test.ts @@ -31,13 +31,16 @@ vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: ( import { SettingsModule } from '../../src/nest/settings/settings.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Settings e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, SettingsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, SettingsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/share.e2e.test.ts b/server/tests/e2e/share.e2e.test.ts index 09ffd59ac..02bd65eaa 100644 --- a/server/tests/e2e/share.e2e.test.ts +++ b/server/tests/e2e/share.e2e.test.ts @@ -58,6 +58,9 @@ import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo- import { DatabaseModule } from '../../src/nest/database/database.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => { let server: Server; @@ -65,7 +68,7 @@ describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => { let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, ShareModule] }) + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, ShareModule] }) .overrideProvider(PlacePhotoCacheService).useValue({ serveKey }) .compile(); const nest = moduleRef.createNestApplication(); diff --git a/server/tests/e2e/storage-admin.e2e.test.ts b/server/tests/e2e/storage-admin.e2e.test.ts index 03a82af78..5f170fa2e 100644 --- a/server/tests/e2e/storage-admin.e2e.test.ts +++ b/server/tests/e2e/storage-admin.e2e.test.ts @@ -57,6 +57,9 @@ vi.mock('../../src/config', () => ({ ENCRYPTION_KEY: 'e2e-storage-key', JWT_SECR import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Storage admin e2e (real auth + admin guard + managed guard + temp SQLite)', () => { let server: Server; @@ -66,7 +69,7 @@ describe('Storage admin e2e (real auth + admin guard + managed guard + temp SQLi async function build() { const moduleRef = await Test.createTestingModule({ - imports: [DatabaseModule, StorageModule], + imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, StorageModule], providers: [{ provide: APP_GUARD, useClass: ManagedGuard }], }).compile(); const nest = moduleRef.createNestApplication(); diff --git a/server/tests/e2e/system-notices.e2e.test.ts b/server/tests/e2e/system-notices.e2e.test.ts index 50d9e863d..b948095f7 100644 --- a/server/tests/e2e/system-notices.e2e.test.ts +++ b/server/tests/e2e/system-notices.e2e.test.ts @@ -33,6 +33,9 @@ vi.mock('../../src/systemNotices/service', () => ({ import { SystemNoticesModule } from '../../src/nest/system-notices/system-notices.module'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; const notice = { id: 'welcome', display: 'modal', severity: 'info', @@ -47,7 +50,7 @@ describe('System-notices e2e (real auth guard + temp SQLite)', () => { // DatabaseModule is @Global in the real app; a partial graph has to // provide it for SystemNoticesModule's AddonsModule import (the // addons.e2e precedent). - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, SystemNoticesModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, SystemNoticesModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/tags.e2e.test.ts b/server/tests/e2e/tags.e2e.test.ts index 51b9512ab..f2007c3e0 100644 --- a/server/tests/e2e/tags.e2e.test.ts +++ b/server/tests/e2e/tags.e2e.test.ts @@ -37,6 +37,9 @@ vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: ( import { DatabaseModule } from '../../src/nest/database/database.module'; import { TagsModule } from '../../src/nest/tags/tags.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; function insertTag(userId: number, name: string, color = '#10b981'): number { const res = db.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(userId, name, color); @@ -48,7 +51,7 @@ describe('Tags e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, TagsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, TagsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/todo.e2e.test.ts b/server/tests/e2e/todo.e2e.test.ts index 585125d11..58b1c4843 100644 --- a/server/tests/e2e/todo.e2e.test.ts +++ b/server/tests/e2e/todo.e2e.test.ts @@ -77,6 +77,9 @@ import { DatabaseModule } from '../../src/nest/database/database.module'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; function insertItem(tripId: number, name: string, extra: Partial<{ sort_order: number; due_date: string }> = {}): number { const res = db @@ -91,7 +94,7 @@ describe('To-do e2e (real auth guard + real SQL over temp SQLite)', () => { let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, TodoModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, TodoModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/transit-provider.e2e.test.ts b/server/tests/e2e/transit-provider.e2e.test.ts index 3827680e9..669fdd29e 100644 --- a/server/tests/e2e/transit-provider.e2e.test.ts +++ b/server/tests/e2e/transit-provider.e2e.test.ts @@ -32,6 +32,9 @@ import { DatabaseModule } from '../../src/nest/database/database.module'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { clearGoogleTransitCache } from '../../src/nest/transit/google-transit.provider'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; const ADMIN = 1; @@ -48,7 +51,7 @@ describe('Transit backend switch e2e (#1699)', () => { async function build() { const moduleRef = await Test.createTestingModule({ - imports: [DatabaseModule, RealtimeModule, TransitModule], + imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, TransitModule], }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); diff --git a/server/tests/e2e/transit.e2e.test.ts b/server/tests/e2e/transit.e2e.test.ts index 4234ac6a6..8c7def042 100644 --- a/server/tests/e2e/transit.e2e.test.ts +++ b/server/tests/e2e/transit.e2e.test.ts @@ -30,6 +30,9 @@ import { TransitService } from '../../src/nest/transit/transit.service'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Transit proxy e2e (real auth guard + temp SQLite)', () => { let server: Server; @@ -41,7 +44,7 @@ describe('Transit proxy e2e (real auth guard + temp SQLite)', () => { const moduleRef = await Test.createTestingModule({ // DatabaseModule + RealtimeModule are @Global in the app graph; // TransitModule's DaysModule/ReservationsModule imports need them here. - imports: [DatabaseModule, RealtimeModule, TransitModule], + imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, TransitModule], }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); diff --git a/server/tests/e2e/trip-invite.e2e.test.ts b/server/tests/e2e/trip-invite.e2e.test.ts index 279346b2a..3b8cc365d 100644 --- a/server/tests/e2e/trip-invite.e2e.test.ts +++ b/server/tests/e2e/trip-invite.e2e.test.ts @@ -62,13 +62,16 @@ import { TripInviteModule } from '../../src/nest/trip-invite/trip-invite.module' import { TripMembershipService } from '../../src/nest/trip-membership/trip-membership.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Trip invite-link e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, TripInviteModule] }) + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, TripInviteModule] }) .overrideProvider(TripMembershipService).useValue({ joinTripAsMember }) .compile(); const nest = moduleRef.createNestApplication(); diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index 21ba5a733..da10bd23b 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -132,13 +132,16 @@ let checkPermission: MockInstance; import { BudgetService } from '../../src/nest/budget/budget.service'; import { TripsModule } from '../../src/nest/trips/trips.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Trips e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, TripsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, TripsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/vacay.e2e.test.ts b/server/tests/e2e/vacay.e2e.test.ts index acddcf551..b871d9dbb 100644 --- a/server/tests/e2e/vacay.e2e.test.ts +++ b/server/tests/e2e/vacay.e2e.test.ts @@ -79,13 +79,16 @@ import { VacayModule } from '../../src/nest/vacay/vacay.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { broadcastToUser } from '../../src/websocket'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Vacay e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, VacayModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, VacayModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/weather.e2e.test.ts b/server/tests/e2e/weather.e2e.test.ts index 7d8cc9769..2b1c7120e 100644 --- a/server/tests/e2e/weather.e2e.test.ts +++ b/server/tests/e2e/weather.e2e.test.ts @@ -30,13 +30,16 @@ vi.mock('../../src/nest/weather/weather.impl', async (importActual) => { import { WeatherModule } from '../../src/nest/weather/weather.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; describe('Weather e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [WeatherModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), WeatherModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/helpers/accommodations-service.ts b/server/tests/helpers/accommodations-service.ts index 6864028a3..04e7e7f92 100644 --- a/server/tests/helpers/accommodations-service.ts +++ b/server/tests/helpers/accommodations-service.ts @@ -7,6 +7,8 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi import { QueryHelpersService } from '../../src/nest/query-helpers/query-helpers.service'; import { RealtimeService } from '../../src/nest/realtime/realtime.service'; import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository'; +import { createTestUnitOfWork } from './test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; /** * AccommodationsService over a test connection. @@ -16,7 +18,7 @@ import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.reposito * back, and it has to come out of the same connection. Five collaborators deep is * why this is a helper and not five copies across the suites. */ -export function makeAccommodationsService(conn: Database): AccommodationsService { +export async function makeAccommodationsService(conn: Database): Promise { return accommodationsOver(new DatabaseService(conn)); } @@ -27,13 +29,13 @@ export function makeAccommodationsService(conn: Database): AccommodationsService * the same stop a night entered under Days does, and every suite that builds * that service by hand needs one to hand it. */ -export function accommodationsOver(dbs: DatabaseService): AccommodationsService { - const permissions = new PermissionsService(dbs); +export async function accommodationsOver(dbs: DatabaseService): Promise { + const permissions = new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)); const realtime = new RealtimeService(); const assignments = new AssignmentsService( dbs, permissions, realtime, new QueryHelpersService(dbs), new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)), ); - return new AccommodationsService(dbs, permissions, realtime, assignments); + return new AccommodationsService(dbs, permissions, realtime, assignments, await createTestUnitOfWork(dbs.connection)); } diff --git a/server/tests/helpers/mcp-harness.ts b/server/tests/helpers/mcp-harness.ts index 22388b706..24e2ac962 100644 --- a/server/tests/helpers/mcp-harness.ts +++ b/server/tests/helpers/mcp-harness.ts @@ -59,7 +59,7 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise { const dbService = new DatabaseService(db); const generalStorage = makeStorageFixture('').storage; - const permissionsService = new PermissionsService(dbService); + const permissionsService = new PermissionsService(dbService, await createTestUnitOfWork(dbService.connection)); // Same argument list as auth.bridge.ts. AtlasService used to sit in third // place; when getTravelStats moved onto AtlasService itself the edge was // dropped and four collaborators took its place, but this call site kept the @@ -143,7 +145,7 @@ export function createMcpTestRegistry(): McpRegistry { new UserCleanupService(dbService, budgetService), new MailerService(dbService), new EphemeralTokenService(), - new AllowedFileTypesService(dbService), + new AllowedFileTypesService(dbService), await createTestUnitOfWork(dbService.connection), ); const queryHelpersService = new QueryHelpersService(dbService); const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService); @@ -163,7 +165,7 @@ export function createMcpTestRegistry(): McpRegistry { // One instance, four consumers: AssignmentsMcp, ReservationsMcp, PlacesMcp and // AccommodationsService, which writes the day stop a booked night implies. const assignmentsService = new AssignmentsService(dbService, permissionsService, realtimeService, queryHelpersService, journeyDomain); - const accommodationsService = new AccommodationsService(dbService, permissionsService, realtimeService, assignmentsService); + const accommodationsService = new AccommodationsService(dbService, permissionsService, realtimeService, assignmentsService, await createTestUnitOfWork(dbService.connection)); // Built after it: deleting a place cancels the nights booked at it through this one. const placesService = new PlacesService( dbService, permissionsService, realtimeService, mapsService, queryHelpersService, @@ -171,10 +173,10 @@ export function createMcpTestRegistry(): McpRegistry { placePhotoCache, journeyDomain, generalStorage, - accommodationsService, + accommodationsService, await createTestUnitOfWork(dbService.connection), ); // Built after it: a hotel booking writes the stay's day stop through this one. - const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub(), new ReservationsReadRepository(dbService), accommodationsService); + const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub(), new ReservationsReadRepository(dbService), accommodationsService, await createTestUnitOfWork(dbService.connection)); const membersService = new TripMembersService(dbService, budgetService, new UserCleanupService(dbService, budgetService), permissionsService, realtimeService, notificationsStub()); const tripsService = new TripsService( dbService, @@ -223,7 +225,7 @@ export function createMcpTestRegistry(): McpRegistry { new DaysMcp(daysService, authService, guards), new RoadtripMcp(new RoadtripService(dbService, realtimeService), dbService, guards, authService, addonsService), new FilesMcp(new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage), authService, guards), - new AccommodationsMcp(accommodationsService, dbService, placesService, authService, guards), + new AccommodationsMcp(accommodationsService, dbService, placesService, authService, guards, await createTestUnitOfWork(dbService.connection)), new AssignmentsMcp(assignmentsService, daysService, authService, guards), new CollabMcp(collabService, authService, addonsService, guards), new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService), @@ -234,7 +236,7 @@ export function createMcpTestRegistry(): McpRegistry { new FeedsMcp(new FeedsService(dbService, calendarService), dbService, new RuntimeEnvService(), guards), new TripInviteMcp(new TripInviteService(dbService, permissionsService, new TripMembershipService(dbService)), dbService, new RuntimeEnvService(), guards, new AuditService(dbService)), new MapsMcp(mapsService), - new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain, assignmentsService, guards), + new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain, assignmentsService, guards, await createTestUnitOfWork(dbService.connection)), new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage), dbService, authService, addonsService), new TransitMcp(new TransitService(new GoogleTransitProvider(dbService)), daysService, reservationsService, dbService, authService, guards), new AtlasMcp(new AtlasService(dbService), addonsService, authService), diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index bc3ef6703..bcc90fdf5 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -72,6 +72,8 @@ import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo- import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository'; import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service'; import { makeStorageFixture } from './storage-fixture'; +import { createTestUnitOfWork } from './test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; /** * Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests @@ -83,9 +85,9 @@ import { makeStorageFixture } from './storage-fixture'; * domains, so what it builds is the same set of `@PluginController()` instances the * container would discover, handed to the host factory as a registry. */ -export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostFactory { +export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise { const generalStorage = makeStorageFixture('').storage; - const permissions = new PermissionsService(dbs); + const permissions = new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)); const exchangeRates = new ExchangeRatesService(); const realtime = new RealtimeService(); const budget = new BudgetService(dbs, permissions, exchangeRates, realtime); @@ -108,11 +110,11 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF const notifications = makeNotificationsService(dbs, realtime); const llmConfig = new LlmConfigResolver(new SettingsService(dbs), dbs, addons); const oauth = new PluginOAuthService(dbs); - const accommodations = new AccommodationsService(dbs, permissions, realtime, assignments); + const accommodations = new AccommodationsService(dbs, permissions, realtime, assignments, await createTestUnitOfWork(dbs.connection)); // After it: deleting a place cancels the nights booked at it through this one. - const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs, photoCache), queryHelpers, unsplash, photoCache, journey, generalStorage, accommodations); + const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs, photoCache), queryHelpers, unsplash, photoCache, journey, generalStorage, accommodations, await createTestUnitOfWork(dbs.connection)); // After accommodations: a hotel booking writes the stay's day stop through it. - const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub(), new ReservationsReadRepository(dbs), accommodations); + const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub(), new ReservationsReadRepository(dbs), accommodations, await createTestUnitOfWork(dbs.connection)); const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash, generalStorage); const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs, budget), permissions, realtime, notificationsStub()); const guards = new PluginGuards(dbs, permissions, addons); @@ -149,6 +151,6 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF } /** A PluginRuntimeService constructed the way Nest would: with a real host factory. */ -export function createPluginRuntime(dbs: DatabaseService, registry?: PluginRegistryService): PluginRuntimeService { - return new PluginRuntimeService(dbs, new AuditService(dbs), new AddonsService(dbs), new PluginUserSettingsService(dbs), registry, createPluginRpcHostFactory(dbs)); +export async function createPluginRuntime(dbs: DatabaseService, registry?: PluginRegistryService): Promise { + return new PluginRuntimeService(dbs, new AuditService(dbs), new AddonsService(dbs), new PluginUserSettingsService(dbs), registry, await createPluginRpcHostFactory(dbs)); } diff --git a/server/tests/helpers/test-uow.ts b/server/tests/helpers/test-uow.ts index 40e2b6fd9..a451f820a 100644 --- a/server/tests/helpers/test-uow.ts +++ b/server/tests/helpers/test-uow.ts @@ -1,3 +1,4 @@ +import { Module, type DynamicModule } from '@nestjs/common'; import type Database from 'better-sqlite3'; import { UnitOfWork } from '../../src/nest/database/unit-of-work'; import { createTestOrm } from './test-orm'; @@ -22,3 +23,25 @@ export function createTestUnitOfWork(db: Database.Database): Promise perHandle.set(db, pending); return pending; } + +/** + * The same `UnitOfWork`, as a **global** Nest module. + * + * A `Test.createTestingModule({ providers: [...] })` entry only reaches the root + * testing module, so a service resolved inside its own feature module (the + * `PermissionsService` in `PermissionsModule`, say) still finds no `UnitOfWork`. + * `OrmModule` is `@Global()` in the app graph for exactly that reason, and the + * partial containers the e2e suites build need the same shape. + */ +@Module({}) +export class TestUnitOfWorkModule { + static async forRoot(db: Database.Database): Promise { + const uow = await createTestUnitOfWork(db); + return { + module: TestUnitOfWorkModule, + global: true, + providers: [{ provide: UnitOfWork, useValue: uow }], + exports: [UnitOfWork], + }; + } +} diff --git a/server/tests/integration/dayReorder.test.ts b/server/tests/integration/dayReorder.test.ts index b12d011ea..c59006998 100644 --- a/server/tests/integration/dayReorder.test.ts +++ b/server/tests/integration/dayReorder.test.ts @@ -4,7 +4,7 @@ * to slots while content rides along by id, booking-date re-stamp, permutation * validation, the accommodation-inversion guard, and insert (dated + dateless). */ -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); @@ -25,8 +25,13 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi import { DaysService, DayReorderError } from '../../src/nest/days/days.service'; import { RealtimeService } from '../../src/nest/realtime/realtime.service'; import { QueryHelpersService } from '../../src/nest/query-helpers/query-helpers.service'; +import { createTestUnitOfWork } from '../helpers/test-uow'; +import { UnitOfWork } from '../../src/nest/database/unit-of-work'; -const svc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), new QueryHelpersService(new DatabaseService(testDb))); +let svc: DaysService; +beforeAll(async () => { + svc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), new QueryHelpersService(new DatabaseService(testDb))); +}); const reorderDays = (tripId: number, orderedIds: number[]) => svc.reorder(tripId, orderedIds); const insertDay = (tripId: number, position?: number) => svc.insert(tripId, position); @@ -44,14 +49,14 @@ const orderedDays = (tripId: number) => { id: number; day_number: number; date: string | null }[]; describe('reorderDays', () => { - it('permutes positions, pins dates to slots, and content rides along by id', () => { + it('permutes positions, pins dates to slots, and content rides along by id', async () => { const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' }); const [d1, d2, d3] = orderedDays(trip.id); const place = createPlace(testDb, trip.id); createDayAssignment(testDb, d2.id, place.id); // place sits on day 2 // Move day 2 to the front: [d2, d1, d3] - reorderDays(trip.id, [d2.id, d1.id, d3.id]); + await reorderDays(trip.id, [d2.id, d1.id, d3.id]); const after = orderedDays(trip.id); expect(after.map(d => d.id)).toEqual([d2.id, d1.id, d3.id]); @@ -62,13 +67,13 @@ describe('reorderDays', () => { expect(onD2).toHaveLength(1); }); - it('re-stamps a booking\'s date onto its day\'s new date, keeping the time', () => { + it('re-stamps a booking\'s date onto its day\'s new date, keeping the time', async () => { const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' }); const [d1, d2, d3] = orderedDays(trip.id); const res = createReservation(testDb, trip.id, { day_id: d2.id, type: 'restaurant' }); testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2026-03-02T19:00', res.id); - reorderDays(trip.id, [d2.id, d1.id, d3.id]); // d2 moves to the 2026-03-01 slot + await reorderDays(trip.id, [d2.id, d1.id, d3.id]); // d2 moves to the 2026-03-01 slot const r = testDb.prepare('SELECT reservation_time FROM reservations WHERE id = ?').get(res.id) as { reservation_time: string }; expect(r.reservation_time).toBe('2026-03-01T19:00'); diff --git a/server/tests/integration/mcp.test.ts b/server/tests/integration/mcp.test.ts index e9de21c4a..3831bb059 100644 --- a/server/tests/integration/mcp.test.ts +++ b/server/tests/integration/mcp.test.ts @@ -45,8 +45,8 @@ const oauthDbs = new DatabaseService(testDb); const oauthSvc = new OauthService(oauthDbs, new AddonsService(oauthDbs), new AuditService(oauthDbs)); /** Mint a trekoa_ access token for the user via a fresh OAuth client. */ -function mintOauthToken(userId: number, audience: string | null, scopes: string[] = ['trips:read']): { accessToken: string; clientId: string } { - const created = oauthSvc.createOAuthClient(userId, 'MCP Test Client', ['https://client.example.com/cb'], scopes); +async function mintOauthToken(userId: number, audience: string | null, scopes: string[] = ['trips:read']): Promise<{ accessToken: string; clientId: string }> { + const created = await oauthSvc.createOAuthClient(userId, 'MCP Test Client', ['https://client.example.com/cb'], scopes); const clientId = (created.client as { client_id: string }).client_id; const tokens = oauthSvc.issueTokens(clientId, userId, scopes, null, audience); return { accessToken: tokens.access_token, clientId }; @@ -399,7 +399,7 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => { it('MCP-P04 — a trekoa_ token with the wrong audience is rejected with a challenge', async () => { const { user } = createUser(testDb); - const { accessToken } = mintOauthToken(user.id, 'https://other.example.com/api'); + const { accessToken } = await mintOauthToken(user.id, 'https://other.example.com/api'); const res = await request(app) .post('/mcp') .set('Authorization', `Bearer ${accessToken}`) @@ -410,7 +410,7 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => { it('MCP-P05 — a trekoa_ token with the MCP audience authenticates', async () => { const { user } = createUser(testDb); - const { accessToken } = mintOauthToken(user.id, MCP_AUDIENCE); + const { accessToken } = await mintOauthToken(user.id, MCP_AUDIENCE); const res = await request(app) .post('/mcp') .set('Authorization', `Bearer ${accessToken}`) @@ -449,7 +449,7 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => { it('MCP-P08 — a session created via JWT rejects resumption by an OAuth client', async () => { const { user } = createUser(testDb); const sessionId = await createSession(generateToken(user.id)); - const { accessToken } = mintOauthToken(user.id, MCP_AUDIENCE); + const { accessToken } = await mintOauthToken(user.id, MCP_AUDIENCE); const res = await request(app) .post('/mcp') @@ -463,7 +463,7 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => { it('MCP-P08b — a narrower token cannot resume a session that was created with wider scopes', async () => { const { user } = createUser(testDb); - const created = oauthSvc.createOAuthClient(user.id, 'Scope Test Client', ['https://client.example.com/cb'], ['trips:read', 'trips:write']); + const created = await oauthSvc.createOAuthClient(user.id, 'Scope Test Client', ['https://client.example.com/cb'], ['trips:read', 'trips:write']); const clientId = (created.client as { client_id: string }).client_id; const wide = oauthSvc.issueTokens(clientId, user.id, ['trips:read', 'trips:write'], null, MCP_AUDIENCE); const narrow = oauthSvc.issueTokens(clientId, user.id, ['trips:read'], null, MCP_AUDIENCE); @@ -483,7 +483,7 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => { it('MCP-P08c — the same scopes in a different order still resume', async () => { const { user } = createUser(testDb); - const created = oauthSvc.createOAuthClient(user.id, 'Scope Order Client', ['https://client.example.com/cb'], ['trips:read', 'trips:write']); + const created = await oauthSvc.createOAuthClient(user.id, 'Scope Order Client', ['https://client.example.com/cb'], ['trips:read', 'trips:write']); const clientId = (created.client as { client_id: string }).client_id; const first = oauthSvc.issueTokens(clientId, user.id, ['trips:read', 'trips:write'], null, MCP_AUDIENCE); const reordered = oauthSvc.issueTokens(clientId, user.id, ['trips:write', 'trips:read'], null, MCP_AUDIENCE); @@ -525,7 +525,7 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => { it('MCP-P13 — an authorized tools/call writes exactly one mcp.tool_call audit row', async () => { const { user } = createUser(testDb); - const { accessToken, clientId } = mintOauthToken(user.id, MCP_AUDIENCE); + const { accessToken, clientId } = await mintOauthToken(user.id, MCP_AUDIENCE); const sessionId = await createSession(accessToken); testDb.prepare("DELETE FROM audit_log WHERE action = 'mcp.tool_call'").run(); @@ -643,7 +643,7 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => { it('MCP-P17 — a contributed tool call writes an mcp.tool_call audit row like any other', async () => { const { user } = createUser(testDb); setPluginMcpToolSource(() => [dynamicTool('plugin_demo_echo', 'contributed')]); - const { accessToken, clientId } = mintOauthToken(user.id, MCP_AUDIENCE, ['trips:read', 'plugins:use']); + const { accessToken, clientId } = await mintOauthToken(user.id, MCP_AUDIENCE, ['trips:read', 'plugins:use']); const sessionId = await createSession(accessToken); testDb.prepare("DELETE FROM audit_log WHERE action = 'mcp.tool_call'").run(); @@ -671,7 +671,7 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => { setPluginMcpToolSource(() => [dynamicTool('plugin_demo_echo', 'contributed')]); // trips:read only. The declarative access marker on every contributed tool // resolves through the same policy as a built-in's. - const { accessToken } = mintOauthToken(user.id, MCP_AUDIENCE, ['trips:read']); + const { accessToken } = await mintOauthToken(user.id, MCP_AUDIENCE, ['trips:read']); const sessionId = await createSession(accessToken); const list = await request(app) diff --git a/server/tests/integration/plugins/dev-link.test.ts b/server/tests/integration/plugins/dev-link.test.ts index 0de9f8221..fdb5a9f94 100644 --- a/server/tests/integration/plugins/dev-link.test.ts +++ b/server/tests/integration/plugins/dev-link.test.ts @@ -53,7 +53,7 @@ function writeSource(id: string, opts: { index?: string; native?: boolean; noBui return dir; } -beforeAll(() => { +beforeAll(async () => { codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-link-code-')); dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-link-data-')); srcRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-link-src-')); @@ -61,7 +61,7 @@ beforeAll(() => { process.env.TREK_PLUGINS_DATA_DIR = dataRoot; process.env.TREK_PLUGINS_ENABLED = 'true'; process.env.TREK_PLUGINS_DEV_LINK = '1'; - runtime = createPluginRuntime(new DatabaseService(dbConn)); + runtime = await createPluginRuntime(new DatabaseService(dbConn)); }); afterAll(async () => { diff --git a/server/tests/integration/plugins/plugin-runtime.test.ts b/server/tests/integration/plugins/plugin-runtime.test.ts index f72508708..345140d2c 100644 --- a/server/tests/integration/plugins/plugin-runtime.test.ts +++ b/server/tests/integration/plugins/plugin-runtime.test.ts @@ -55,7 +55,7 @@ let codeRoot: string; let dataRoot: string; let runtime: PluginRuntimeService; -beforeAll(() => { +beforeAll(async () => { codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-rt-code-')); dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-rt-data-')); process.env.TREK_PLUGINS_DIR = codeRoot; @@ -80,7 +80,7 @@ beforeAll(() => { ); testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('counter','active','[\"db:own\"]','{}')").run(); - runtime = createPluginRuntime(new DatabaseService(dbConn)); + runtime = await createPluginRuntime(new DatabaseService(dbConn)); }); afterAll(async () => { @@ -150,7 +150,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { it('onApplicationBootstrap is a no-op when the runtime is disabled', () => { process.env.TREK_PLUGINS_ENABLED = 'false'; - expect(() => createPluginRuntime(new DatabaseService(dbConn)).onApplicationBootstrap()).not.toThrow(); + expect(async () => (await createPluginRuntime(new DatabaseService(dbConn))).onApplicationBootstrap()).not.toThrow(); process.env.TREK_PLUGINS_ENABLED = 'true'; }); @@ -173,7 +173,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { fs.mkdirSync(path.join(codeRoot, 'messy', 'server'), { recursive: true }); fs.writeFileSync(path.join(codeRoot, 'messy', 'server', 'index.js'), 'module.exports = { async onLoad() {} };'); testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('messy','inactive','not-json','not-json')").run(); - const rt = createPluginRuntime(new DatabaseService(dbConn)); + const rt = await createPluginRuntime(new DatabaseService(dbConn)); await rt.activate('messy'); // must not throw despite the garbage JSON expect(rt.isActive('messy')).toBe(true); await rt.deactivate('messy'); @@ -252,7 +252,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { // status='error' from a previous crash, but enabled=1 → must still boot testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('booter','error',1,'[]','{}')").run(); - const rt = createPluginRuntime(new DatabaseService(dbConn)); + const rt = await createPluginRuntime(new DatabaseService(dbConn)); rt.onApplicationBootstrap(); // fire-and-forget spawn for (let i = 0; i < 40 && !rt.isActive('booter'); i++) await new Promise((r) => setTimeout(r, 50)); expect(rt.isActive('booter')).toBe(true); @@ -264,15 +264,15 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { fs.writeFileSync(path.join(codeRoot, 'sleeper', 'server', 'index.js'), 'module.exports = { async onLoad() {} };'); testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('sleeper','inactive',0,'[]','{}')").run(); - const rt = createPluginRuntime(new DatabaseService(dbConn)); + const rt = await createPluginRuntime(new DatabaseService(dbConn)); rt.onApplicationBootstrap(); await new Promise((r) => setTimeout(r, 300)); expect(rt.isActive('sleeper')).toBe(false); }); - it('outboundHostsOf extracts declared http:outbound hosts', () => { + it('outboundHostsOf extracts declared http:outbound hosts', async () => { testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('net','inactive','[\"db:own\",\"http:outbound:api.x.com\",\"http:outbound:*.y.com\"]','{}')").run(); - const rt = createPluginRuntime(new DatabaseService(dbConn)); + const rt = await createPluginRuntime(new DatabaseService(dbConn)); expect(rt.outboundHostsOf('net')).toEqual(['api.x.com', '*.y.com']); expect(rt.outboundHostsOf('missing')).toEqual([]); }); @@ -294,7 +294,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { testDb.prepare("INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES ('gone', 'poll', 0)").run(); testDb.prepare("INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES ('gone', 7)").run(); - await createPluginRuntime(new DatabaseService(dbConn)).uninstall('gone', true); + await (await createPluginRuntime(new DatabaseService(dbConn))).uninstall('gone', true); expect(fs.existsSync(path.join(codeRoot, 'gone'))).toBe(false); expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id='gone'").get()).toMatchObject({ c: 0 }); @@ -313,7 +313,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { // a deleted user's erasure is still pending for this plugin testDb.prepare("INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES ('keepdata', 42)").run(); - await createPluginRuntime(new DatabaseService(dbConn)).uninstall('keepdata', false); // keep the plugin's data + await (await createPluginRuntime(new DatabaseService(dbConn))).uninstall('keepdata', false); // keep the plugin's data // the plugin row is gone, but the erasure obligation is retained — a reinstall of the // same id will drain it and finally honour the deletion, instead of silently keeping @@ -328,12 +328,12 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { "module.exports = { async onLoad() { await fetch('https://blocked.example/x'); } };"); testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('nettry','inactive','[]','{}')").run(); // no http:outbound granted -> egress guard blocks all outbound -> onLoad throws - await expect(createPluginRuntime(new DatabaseService(dbConn)).activate('nettry')).rejects.toThrow(/egress/); - await createPluginRuntime(new DatabaseService(dbConn)).deactivate('nettry').catch(() => {}); + await expect((await createPluginRuntime(new DatabaseService(dbConn))).activate('nettry')).rejects.toThrow(/egress/); + await (await createPluginRuntime(new DatabaseService(dbConn))).deactivate('nettry').catch(() => {}); }); it('onModuleDestroy tears down cleanly', async () => { - await expect(createPluginRuntime(new DatabaseService(dbConn)).onModuleDestroy()).resolves.toBeUndefined(); + await expect((await createPluginRuntime(new DatabaseService(dbConn))).onModuleDestroy()).resolves.toBeUndefined(); }); }); @@ -355,7 +355,7 @@ describe('PluginRuntimeService.update (re-consent gate)', () => { it('restarts transparently when the new version requests no new permissions', async () => { seed('upd-same', 1, ['db:own'], ['db:own']); - const rt = createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {})); // declared perms unchanged + const rt = await createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {})); // declared perms unchanged await rt.activate('upd-same'); const res = await rt.update('upd-same'); expect(res).toMatchObject({ activated: true, newPermissions: [], newEgress: [] }); @@ -365,7 +365,7 @@ describe('PluginRuntimeService.update (re-consent gate)', () => { it('leaves the plugin inactive and reports the delta when new rights are requested', async () => { seed('upd-wider', 1, ['db:own'], ['db:own']); - const rt = createPluginRuntime( + const rt = await createPluginRuntime( new DatabaseService(dbConn), fakeRegistry(() => { testDb.prepare("UPDATE plugins SET permissions = ? WHERE id = 'upd-wider'") @@ -383,17 +383,17 @@ describe('PluginRuntimeService.update (re-consent gate)', () => { it('a disabled plugin stays disabled even with no new permissions', async () => { seed('upd-off', 0, ['db:own'], ['db:own']); - const res = await createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {})).update('upd-off'); + const res = await (await createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {}))).update('upd-off'); expect(res).toMatchObject({ activated: false, newPermissions: [], newEgress: [] }); }); it('throws for an unknown plugin id', async () => { - await expect(createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {})).update('ghost-upd')).rejects.toThrow(/not found/); + await expect((await createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {}))).update('ghost-upd')).rejects.toThrow(/not found/); }); it('throws if no registry service is wired', async () => { seed('upd-noreg', 0, ['db:own'], ['db:own']); - await expect(createPluginRuntime(new DatabaseService(dbConn)).update('upd-noreg')).rejects.toThrow(/registry service unavailable/); + await expect((await createPluginRuntime(new DatabaseService(dbConn))).update('upd-noreg')).rejects.toThrow(/registry service unavailable/); }); }); @@ -560,7 +560,7 @@ describe('PluginRuntimeService.retrust', () => { it('re-pins AND installs in one call: the new key is pinned, the version moved, the block gone', async () => { seed('rt-ok', 'OLDKEY'); const registry = registryFor('rt-ok'); - const rt = createPluginRuntime(new DatabaseService(dbConn), registry); + const rt = await createPluginRuntime(new DatabaseService(dbConn), registry); await rt.activate('rt-ok'); await rt.retrust('rt-ok', '2.0.0', 'NEWKEY', { userId: 1, ip: '10.0.0.1' }); @@ -605,7 +605,7 @@ describe('PluginRuntimeService.retrust', () => { seed('rt-no', 'OLDKEY'); const registry = registryFor('rt-no'); vi.mocked(registry.assertRetrustable).mockRejectedValue(new Error('nothing to re-trust')); - const rt = createPluginRuntime(new DatabaseService(dbConn), registry); + const rt = await createPluginRuntime(new DatabaseService(dbConn), registry); await expect(rt.retrust('rt-no', '2.0.0', 'NEWKEY', { userId: 1 })).rejects.toThrow(/nothing to re-trust/); @@ -631,7 +631,7 @@ describe('PluginRuntimeService.retrust', () => { // exactly the "quietly stops updating" failure the block exists to prevent. it('activate does NOT clear a recorded update block', async () => { seed('rt-act', 'OLDKEY'); - const rt = createPluginRuntime(new DatabaseService(dbConn), registryFor('rt-act')); + const rt = await createPluginRuntime(new DatabaseService(dbConn), registryFor('rt-act')); await rt.deactivate('rt-act'); await rt.activate('rt-act'); @@ -667,7 +667,7 @@ describe('TREK host-version gating', () => { it('refuses to activate a plugin this TREK has outgrown, and says so with a code', async () => { process.env.APP_VERSION = '4.0.0'; seedRanged('outgrown', '>=3.2.0 <4.0.0'); - const err = await createPluginRuntime(new DatabaseService(dbConn)).activate('outgrown').catch((e) => e); + const err = await (await createPluginRuntime(new DatabaseService(dbConn))).activate('outgrown').catch((e) => e); expect(err).toBeInstanceOf(PluginDependencyError); expect(err).toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' }); expect(err.detail).toMatchObject({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' }); @@ -679,14 +679,14 @@ describe('TREK host-version gating', () => { it('refuses a plugin that never declared a range at all', async () => { process.env.APP_VERSION = '3.3.0'; seedRanged('undeclared', null); - await expect(createPluginRuntime(new DatabaseService(dbConn)).activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' }); + await expect((await createPluginRuntime(new DatabaseService(dbConn))).activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' }); cleanup('undeclared'); }); it('activates normally when the range admits the host', async () => { process.env.APP_VERSION = '3.3.0'; seedRanged('fits', '>=3.2.0 <4.0.0'); - const rt = createPluginRuntime(new DatabaseService(dbConn)); + const rt = await createPluginRuntime(new DatabaseService(dbConn)); await rt.activate('fits'); expect(rt.isActive('fits')).toBe(true); await rt.deactivate('fits'); @@ -700,7 +700,7 @@ describe('TREK host-version gating', () => { // panel showing a green, running plugin that does not exist. process.env.APP_VERSION = '4.0.0'; seedRanged('boot-broken', '>=3.2.0 <4.0.0', 1); - const rt = createPluginRuntime(new DatabaseService(dbConn)); + const rt = await createPluginRuntime(new DatabaseService(dbConn)); expect(() => rt.onApplicationBootstrap()).not.toThrow(); for (let i = 0; i < 40; i++) { const row = testDb.prepare("SELECT enabled FROM plugins WHERE id='boot-broken'").get() as { enabled: number }; @@ -720,7 +720,7 @@ describe('TREK host-version gating', () => { process.env.APP_VERSION = '4.0.0'; seedRanged('outgrown-ok', '>=3.2.0 <4.0.0'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const rt = createPluginRuntime(new DatabaseService(dbConn)); + const rt = await createPluginRuntime(new DatabaseService(dbConn)); await rt.activate('outgrown-ok'); expect(rt.isActive('outgrown-ok')).toBe(true); expect(warn.mock.calls.some((c) => /outgrown-ok.*>=3\.2\.0 <4\.0\.0.*4\.0\.0/.test(String(c[0])))).toBe(true); @@ -733,7 +733,7 @@ describe('TREK host-version gating', () => { process.env.APP_VERSION = '3.3.0'; seedRanged('undeclared-ok', null); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const rt = createPluginRuntime(new DatabaseService(dbConn)); + const rt = await createPluginRuntime(new DatabaseService(dbConn)); await rt.activate('undeclared-ok'); expect(rt.isActive('undeclared-ok')).toBe(true); warn.mockRestore(); @@ -748,7 +748,7 @@ describe('TREK host-version gating', () => { fs.mkdirSync(path.join(codeRoot, 'both-wrong', 'server'), { recursive: true }); fs.writeFileSync(path.join(codeRoot, 'both-wrong', 'server', 'index.js'), 'module.exports = {};'); testDb.prepare("INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range) VALUES ('both-wrong','inactive',0,'[\"db:own\"]','[]','{}','>=3.2.0 <4.0.0')").run(); - await expect(createPluginRuntime(new DatabaseService(dbConn)).activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' }); + await expect((await createPluginRuntime(new DatabaseService(dbConn))).activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' }); cleanup('both-wrong'); }); }); @@ -772,7 +772,7 @@ describe('plugin-API version gating', () => { it('refuses to activate a plugin whose apiVersion this TREK does not support', async () => { seedApi('future-api', 2); - const err = await createPluginRuntime(new DatabaseService(dbConn)).activate('future-api').catch((e) => e); + const err = await (await createPluginRuntime(new DatabaseService(dbConn))).activate('future-api').catch((e) => e); expect(err).toBeInstanceOf(PluginDependencyError); expect(err).toMatchObject({ code: 'API_VERSION_INCOMPATIBLE' }); expect(err.message).toBe('plugin requires plugin-API v2; this TREK supports v1'); @@ -783,7 +783,7 @@ describe('plugin-API version gating', () => { process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1'; try { seedApi('future-api-bypass', 2); - await expect(createPluginRuntime(new DatabaseService(dbConn)).activate('future-api-bypass')).rejects.toMatchObject({ code: 'API_VERSION_INCOMPATIBLE' }); + await expect((await createPluginRuntime(new DatabaseService(dbConn))).activate('future-api-bypass')).rejects.toMatchObject({ code: 'API_VERSION_INCOMPATIBLE' }); } finally { delete process.env.TREK_PLUGINS_IGNORE_TREK_RANGE; cleanup('future-api-bypass'); @@ -792,7 +792,7 @@ describe('plugin-API version gating', () => { it('activates normally when apiVersion is within what this TREK supports', async () => { seedApi('current-api', 1); - const rt = createPluginRuntime(new DatabaseService(dbConn)); + const rt = await createPluginRuntime(new DatabaseService(dbConn)); await rt.activate('current-api'); expect(rt.isActive('current-api')).toBe(true); await rt.deactivate('current-api'); diff --git a/server/tests/unit/mcp/tools-airtrail.test.ts b/server/tests/unit/mcp/tools-airtrail.test.ts index 1d2e975af..8cb8dce93 100644 --- a/server/tests/unit/mcp/tools-airtrail.test.ts +++ b/server/tests/unit/mcp/tools-airtrail.test.ts @@ -49,11 +49,18 @@ import { AirtrailClient, AirtrailRequestError, type AirtrailFlightRaw } from '.. import { AirtrailImportService } from '../../../src/nest/integrations/airtrail-import.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { DatabaseService } from '../../../src/nest/database/database.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; // The permissions cache is module-scoped, so a write through any instance is // what the tool's own checkPermission call reads back. -const permissionsService = new PermissionsService(new DatabaseService(testDb)); -const savePermissions = permissionsService.savePermissions.bind(permissionsService); + +let permissionsService: PermissionsService; +let savePermissions: typeof permissionsService.savePermissions; +beforeAll(async () => { + permissionsService = new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)); + savePermissions = permissionsService.savePermissions.bind(permissionsService); +}); // The registry builds its own AirtrailClient per harness, so the stub goes on // the prototype. Everything below listFlights (creds, mapper, dedupe) is real. @@ -64,7 +71,7 @@ beforeAll(() => { runMigrations(testDb); }); -beforeEach(() => { +beforeEach(async () => { resetTestDb(testDb); broadcastMock.mockClear(); listFlightsMock.mockReset(); @@ -73,7 +80,7 @@ beforeEach(() => { // resetTestDb leaves the addons table alone, and both tools are gated on the // airtrail addon, so every case restates the toggle it needs. setAddonEnabled(testDb, ADDON_IDS.AIRTRAIL, true); - savePermissions({ reservation_edit: 'trip_member' }); + await savePermissions({ reservation_edit: 'trip_member' }); }); afterAll(() => { @@ -435,7 +442,7 @@ describe('Tool: import_airtrail_flights', () => { expect((result.content as any)[0].text).toContain('access denied'); }); - savePermissions({ reservation_edit: 'trip_owner' }); + await savePermissions({ reservation_edit: 'trip_owner' }); await withHarness(member.id, async (h) => { const result = await h.client.callTool({ name: 'import_airtrail_flights', arguments: { tripId: trip.id, flightIds: ['11'] } }); expect(result.isError).toBe(true); diff --git a/server/tests/unit/mcp/tools-budget-advanced.test.ts b/server/tests/unit/mcp/tools-budget-advanced.test.ts index 4138d840b..7175a8295 100644 --- a/server/tests/unit/mcp/tools-budget-advanced.test.ts +++ b/server/tests/unit/mcp/tools-budget-advanced.test.ts @@ -47,6 +47,8 @@ import { PermissionsService } from '../../../src/nest/permissions/permissions.se import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import type { TripAccess } from '../../../src/nest/database/database.service'; import type { User } from '../../../src/types'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; beforeAll(() => { createTables(testDb); @@ -356,7 +358,7 @@ describe('Settlement tools', () => { const dbService = new DatabaseService(testDb); const controller = new BudgetController( - new BudgetService(dbService, new PermissionsService(dbService), new ExchangeRatesService(), new RealtimeService()), + new BudgetService(dbService, new PermissionsService(dbService, await createTestUnitOfWork(dbService.connection)), new ExchangeRatesService(), new RealtimeService()), ); const rest = await controller.settlement( { id: user.id } as User, diff --git a/server/tests/unit/mcp/tools-categories.test.ts b/server/tests/unit/mcp/tools-categories.test.ts index 63dde98a7..f5371d335 100644 --- a/server/tests/unit/mcp/tools-categories.test.ts +++ b/server/tests/unit/mcp/tools-categories.test.ts @@ -50,6 +50,8 @@ import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.serv import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; beforeAll(() => { createTables(testDb); @@ -79,16 +81,19 @@ async function withHarness( // file's DB, which is what lets the admin gate and the demo gate be driven from // the users table instead of from a stub. const categoriesDb = new DatabaseService(testDb); -const categoriesMcp = new CategoriesMcp( +let categoriesMcp: CategoriesMcp; +beforeAll(async () => { + categoriesMcp = new CategoriesMcp( new CategoriesService(categoriesDb), categoriesDb, new RuntimeEnvService(), - new McpToolGuardsService(categoriesDb, new PermissionsService(categoriesDb), new RealtimeService()), + new McpToolGuardsService(categoriesDb, new PermissionsService(categoriesDb, await createTestUnitOfWork(categoriesDb.connection)), new RealtimeService()), ); +}); async function withWriteHarness(userId: number, fn: (client: Client) => Promise) { const server = new McpServer({ name: 'trek-test', version: '1.0.0' }); - createTestRegistry([categoriesMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }) + await createTestRegistry([categoriesMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }) .attach(server, { userId, scopes: null, isStaticToken: false }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: 'test-client', version: '1.0.0' }); diff --git a/server/tests/unit/mcp/tools-help-addons.test.ts b/server/tests/unit/mcp/tools-help-addons.test.ts index 1a71ece2c..1c27cab82 100644 --- a/server/tests/unit/mcp/tools-help-addons.test.ts +++ b/server/tests/unit/mcp/tools-help-addons.test.ts @@ -363,14 +363,14 @@ describe('Tool: list_addons', () => { const addonsService = new AddonsService(new DatabaseService(testDb)); const { user } = createUser(testDb); await withHarness(user.id, async (h) => { - addonsService.updateCollabFeatures({ polls: false }); + await addonsService.updateCollabFeatures({ polls: false }); const off = parseToolResult( await h.client.callTool({ name: 'list_addons', arguments: {} }), ) as AddonsPayload; expect(off.collabFeatures.polls).toBe(false); expect(off.collabFeatures.chat).toBe(true); - addonsService.updateCollabFeatures({ polls: true }); + await addonsService.updateCollabFeatures({ polls: true }); const on = parseToolResult( await h.client.callTool({ name: 'list_addons', arguments: {} }), ) as AddonsPayload; diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index 3caa8ba43..f7f4a63cc 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -87,6 +87,8 @@ import type { TodoService } from '../../../src/nest/todo/todo.service'; import type { CollabService } from '../../../src/nest/collab/collab.service'; import { AddonsService } from '../../../src/nest/addons/addons.service'; import { notificationsStub } from '../../helpers/notifications'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; // The trip-summary prompt moved to the DI-discovered TripsMcp — its cases below // exercise it through a hand-built registry over a stub TripsService whose @@ -99,8 +101,28 @@ const tripsStub = { const readModelStub = { getTripSummary: (tripId: number, viewerUserId?: number) => mockGetTripSummary(tripId, viewerUserId), } as never; -const promptGuards = new McpToolGuardsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); -const tripsMcp = new TripsMcp( + + + +// The three remaining prompts moved to their domains' @McpController classes: +// packing-list, budget-overview and the static-token notice. Built over the same +// in-memory DB so the cases below keep asserting real rows. +const promptDbs = () => new DatabaseService(testDb); +const authStub = { isDemoUser: () => false } as unknown as AuthService; + + + +// The packing-list / budget-overview prompts live here since the trips.bridge +// fold; the summary rides the same readModelStub the trip-summary prompt uses. +let promptGuards: McpToolGuardsService; +let tripsMcp: TripsMcp; +let promptPackingService: PackingService; +let packingMcp: PackingMcp; +let budgetMcp: BudgetMcp; +let tripPromptsMcp: TripPromptsMcp; +beforeAll(async () => { + promptGuards = new McpToolGuardsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService()); + tripsMcp = new TripsMcp( tripsStub, { listItems: () => [] } as unknown as TodoService, { listPolls: () => [], countMessages: () => 0 } as unknown as CollabService, @@ -111,16 +133,10 @@ const tripsMcp = new TripsMcp( addonsStub, promptGuards, ); - -// The three remaining prompts moved to their domains' @McpController classes: -// packing-list, budget-overview and the static-token notice. Built over the same -// in-memory DB so the cases below keep asserting real rows. -const promptDbs = () => new DatabaseService(testDb); -const authStub = { isDemoUser: () => false } as unknown as AuthService; -const promptPackingService = new PackingService(promptDbs(), new PermissionsService(promptDbs()), new RealtimeService(), notificationsStub()); -const packingMcp = new PackingMcp(promptPackingService, authStub, addonsStub, promptGuards); -const budgetMcp = new BudgetMcp( - new BudgetService(promptDbs(), new PermissionsService(promptDbs()), new ExchangeRatesService(), new RealtimeService()), + promptPackingService = new PackingService(promptDbs(), new PermissionsService(promptDbs(), await createTestUnitOfWork(promptDbs().connection)), new RealtimeService(), notificationsStub()); + packingMcp = new PackingMcp(promptPackingService, authStub, addonsStub, promptGuards); + budgetMcp = new BudgetMcp( + new BudgetService(promptDbs(), new PermissionsService(promptDbs(), await createTestUnitOfWork(promptDbs().connection)), new ExchangeRatesService(), new RealtimeService()), new ExchangeRatesService(), promptDbs(), new RuntimeEnvService(), @@ -128,9 +144,8 @@ const budgetMcp = new BudgetMcp( addonsStub, promptGuards, ); -// The packing-list / budget-overview prompts live here since the trips.bridge -// fold; the summary rides the same readModelStub the trip-summary prompt uses. -const tripPromptsMcp = new TripPromptsMcp(tripsStub, readModelStub, promptPackingService, addonsStub); + tripPromptsMcp = new TripPromptsMcp(tripsStub, readModelStub, promptPackingService, addonsStub); +}); const authMcp = new AuthMcp(); beforeAll(() => { @@ -193,7 +208,7 @@ async function buildServer(userId: number, opts: { isStaticToken?: boolean } = { const server = new McpServer({ name: 'trek-test', version: '1.0.0' }); // Every prompt is DI-discovered now; attach them the way registerTools does in // production, including the isStaticToken flag the notice's `when` gate reads. - createTestRegistry([tripsMcp, tripPromptsMcp, packingMcp, budgetMcp, authMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }) + await createTestRegistry([tripsMcp, tripPromptsMcp, packingMcp, budgetMcp, authMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }) .attach(server, { userId, scopes: null, isStaticToken: opts.isStaticToken ?? false }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: 'test-client', version: '1.0.0' }); diff --git a/server/tests/unit/mcp/tools-roadtrip.test.ts b/server/tests/unit/mcp/tools-roadtrip.test.ts index 0c8ed0dbf..de94de44f 100644 --- a/server/tests/unit/mcp/tools-roadtrip.test.ts +++ b/server/tests/unit/mcp/tools-roadtrip.test.ts @@ -48,12 +48,16 @@ import { addTripMember } from '../../helpers/factories'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { DEMO_EMAIL_PRIMARY } from '../../../src/nest/common/demo'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; // The permissions cache is module-scoped, so a write through any instance is // what the tool's own check reads back. -const savePermissions = new PermissionsService(new DatabaseService(testDb)).savePermissions.bind( - new PermissionsService(new DatabaseService(testDb)), -); +let savePermissions: PermissionsService['savePermissions']; +beforeAll(async () => { + const permissionsService = new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)); + savePermissions = permissionsService.savePermissions.bind(permissionsService); +}); beforeAll(() => { createTables(testDb); @@ -254,7 +258,7 @@ describe('road-trip MCP tools', () => { const { user, trip, day } = scenario(); const member = createUser(testDb, { email: 'member@example.test' }); addTripMember(testDb, trip.id, member.user.id); - savePermissions({ day_edit: 'trip_owner' }); + await savePermissions({ day_edit: 'trip_owner' }); try { await withHarness(member.user.id, async (h) => { @@ -276,7 +280,7 @@ describe('road-trip MCP tools', () => { }); expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_vias').get()).toEqual({ c: 0 }); } finally { - savePermissions({ day_edit: 'trip_member' }); + await savePermissions({ day_edit: 'trip_member' }); } // The owner is unaffected. await withHarness(user.id, async (h) => { diff --git a/server/tests/unit/mcp/tools-transit.test.ts b/server/tests/unit/mcp/tools-transit.test.ts index 991841adc..9bc2c0ceb 100644 --- a/server/tests/unit/mcp/tools-transit.test.ts +++ b/server/tests/unit/mcp/tools-transit.test.ts @@ -45,12 +45,19 @@ vi.mock('../../../src/config', () => ({ import { ReservationsService } from '../../../src/nest/reservations/reservations.service'; import type { TransitPlace } from '../../../src/nest/transit/transit.helpers'; import { TransitService } from '../../../src/nest/transit/transit.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; // savePermissions is no longer bridged; write through a service instance — the // permissions cache is module-scoped, so the MCP _shared checkPermission path // sees the write immediately. -const permissionsService = new PermissionsService(new DatabaseService(testDb)); -const savePermissions = permissionsService.savePermissions.bind(permissionsService); + +let permissionsService: PermissionsService; +let savePermissions: typeof permissionsService.savePermissions; +beforeAll(async () => { + permissionsService = new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)); + savePermissions = permissionsService.savePermissions.bind(permissionsService); +}); // The transit tools live on the DI-discovered transit.mcp.ts since the transit // fold; the test registry builds a real TransitService (and injects a real @@ -444,7 +451,7 @@ describe('MCP transit tools', () => { expect((result.content[0] as any).text).toContain('access denied'); }); - savePermissions({ reservation_edit: 'trip_owner' }); + await savePermissions({ reservation_edit: 'trip_owner' }); await withHarness(member.id, ['reservations:write'], async (harness) => { const result = await harness.client.callTool({ name: 'create_transit_journey', diff --git a/server/tests/unit/nest-mcp/attach.test.ts b/server/tests/unit/nest-mcp/attach.test.ts index ba5e826e0..a87ef9b7c 100644 --- a/server/tests/unit/nest-mcp/attach.test.ts +++ b/server/tests/unit/nest-mcp/attach.test.ts @@ -282,17 +282,15 @@ describe('McpRegistry.attach', () => { ); }); - it('attach itself still guards declarative access without a policy (defense in depth)', () => { + it('attach itself still guards declarative access without a policy (defense in depth)', async () => { // Bypass createTestRegistry's validate() by assembling the registry by hand. const registry = new McpRegistry(); registry.register(new DeclarativeOnly()); - expect(() => - registry.attach( + await expect(registry.attach( // attach never gets far enough to need a live server here {} as Parameters[0], { userId: 1 } as McpContext, - ), - ).toThrow(/declares declarative access but no accessPolicy was configured/); + )).rejects.toThrow(/declares declarative access but no accessPolicy was configured/); }); }); diff --git a/server/tests/unit/nest-mcp/create-test-registry.test.ts b/server/tests/unit/nest-mcp/create-test-registry.test.ts index 211a7b281..8c89e0dd6 100644 --- a/server/tests/unit/nest-mcp/create-test-registry.test.ts +++ b/server/tests/unit/nest-mcp/create-test-registry.test.ts @@ -35,7 +35,7 @@ describe('createTestRegistry', () => { ).toThrow(/invalid access declarations: tool "two_tool" \(Two\.two\): unknown group 'trips' \(mode 'write'\)/); }); - it('forwards the accessPolicy option', () => { + it('forwards the accessPolicy option', async () => { const calls: string[] = []; const policy: McpAccessPolicy = (access, ctx) => { calls.push(`${access.group}:${access.mode}:${(ctx as TestCtx).userId}`); @@ -43,7 +43,7 @@ describe('createTestRegistry', () => { }; const registry = createTestRegistry([new Two()], { accessPolicy: policy }); // A denied entry is filtered before any server interaction, so a stub suffices. - registry.attach({} as Parameters[0], { userId: 1 } as Parameters[1]); + await registry.attach({} as Parameters[0], { userId: 1 } as Parameters[1]); expect(calls).toEqual(['trips:write:1']); }); }); diff --git a/server/tests/unit/nest-mcp/harness.ts b/server/tests/unit/nest-mcp/harness.ts index f54d13f1a..d894c1e9d 100644 --- a/server/tests/unit/nest-mcp/harness.ts +++ b/server/tests/unit/nest-mcp/harness.ts @@ -26,7 +26,7 @@ export async function createAttachHarness( opts?: McpAttachOptions, ): Promise { const server = new McpServer({ name: 'nest-mcp-test', version: '1.0.0' }); - registry.attach(server, asCtx(ctx), opts); + await registry.attach(server, asCtx(ctx), opts); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: 'test-client', version: '1.0.0' }); diff --git a/server/tests/unit/nest/accommodations.controller.test.ts b/server/tests/unit/nest/accommodations.controller.test.ts index 7c6235867..751eab7fe 100644 --- a/server/tests/unit/nest/accommodations.controller.test.ts +++ b/server/tests/unit/nest/accommodations.controller.test.ts @@ -50,23 +50,23 @@ describe('AccommodationsController (parity with the legacy accommodations sub-ro expect(thrown(() => new AccommodationsController(svc).create(user, '5', refs))).toEqual({ status: 404, body: { error: 'Place not found' } }); }); - it('creates and emits accommodation:created + reservation:created', () => { + it('creates and emits accommodation:created + reservation:created', async () => { const create = vi.fn().mockReturnValue({ accommodation: { id: 9 }, mirror: noMirror }); const broadcast = vi.fn(); const svc = makeService({ create, broadcast } as Partial); - expect(new AccommodationsController(svc).create(user, '5', refs, 'sock')).toEqual({ accommodation: { id: 9 }, assignment: null }); + expect(await new AccommodationsController(svc).create(user, '5', refs, 'sock')).toEqual({ accommodation: { id: 9 }, assignment: null }); expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:created', { accommodation: { id: 9 } }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', {}, 'sock'); }); - it('ACC-CTL-001 hands the day stop back in the answer and announces it', () => { + it('ACC-CTL-001 hands the day stop back in the answer and announces it', async () => { // The stop reaches the session that booked the night over the socket as well; // the answer is what it has left when that socket is down. const stop = { id: 77, day_id: 10 }; const create = vi.fn().mockReturnValue({ accommodation: { id: 9 }, mirror: { created: stop, removed: [], stamped: null } }); const announceMirror = vi.fn(); const svc = makeService({ create, announceMirror } as Partial); - expect(new AccommodationsController(svc).create(user, '5', refs, 'sock')).toEqual({ accommodation: { id: 9 }, assignment: stop }); + expect(await new AccommodationsController(svc).create(user, '5', refs, 'sock')).toEqual({ accommodation: { id: 9 }, assignment: stop }); expect(announceMirror).toHaveBeenCalledWith('5', { created: stop, removed: [], stamped: null }, expect.any(Function), 'sock'); }); }); @@ -77,22 +77,22 @@ describe('AccommodationsController (parity with the legacy accommodations sub-ro expect(thrown(() => new AccommodationsController(svc).update(user, '5', '9', refs))).toEqual({ status: 404, body: { error: 'Accommodation not found' } }); }); - it('updates and broadcasts', () => { + it('updates and broadcasts', async () => { const get = vi.fn().mockReturnValue({ id: 9 }); const update = vi.fn().mockReturnValue({ accommodation: { id: 9, notes: 'x' }, mirror: noMirror }); const broadcast = vi.fn(); const svc = makeService({ get, update, broadcast } as Partial); - expect(new AccommodationsController(svc).update(user, '5', '9', refs, 'sock')).toEqual({ accommodation: { id: 9, notes: 'x' }, assignment: null, removedAssignments: [] }); + expect(await new AccommodationsController(svc).update(user, '5', '9', refs, 'sock')).toEqual({ accommodation: { id: 9, notes: 'x' }, assignment: null, removedAssignments: [] }); expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:updated', { accommodation: { id: 9, notes: 'x' } }, 'sock'); }); - it('ACC-CTL-002 a booking moved to another day reports the stop it took with it', () => { + it('ACC-CTL-002 a booking moved to another day reports the stop it took with it', async () => { const get = vi.fn().mockReturnValue({ id: 9 }); const mirror = { created: { id: 78, day_id: 11 }, removed: [{ id: 77, dayId: 10 }], stamped: null }; const update = vi.fn().mockReturnValue({ accommodation: { id: 9 }, mirror }); const announceMirror = vi.fn(); const svc = makeService({ get, update, announceMirror } as Partial); - expect(new AccommodationsController(svc).update(user, '5', '9', refs, 'sock')) + expect(await new AccommodationsController(svc).update(user, '5', '9', refs, 'sock')) .toEqual({ accommodation: { id: 9 }, assignment: mirror.created, removedAssignments: mirror.removed }); expect(announceMirror).toHaveBeenCalledWith('5', mirror, expect.any(Function), 'sock'); }); @@ -107,7 +107,7 @@ describe('AccommodationsController (parity with the legacy accommodations sub-ro expect(update).not.toHaveBeenCalled(); }); - it('ACC-CTL-004 the mirror sender is the same broadcast, reaching the sender too', () => { + it('ACC-CTL-004 the mirror sender is the same broadcast, reaching the sender too', async () => { // announceMirror decides which events a stay write implies; the controller only // hands it the door out. That door must not skip the sender's socket: the day's // new order and its re-pinned vias are not in the answer, and a session that @@ -118,14 +118,14 @@ describe('AccommodationsController (parity with the legacy accommodations sub-ro const update = vi.fn().mockReturnValue({ accommodation: { id: 9 }, mirror }); const broadcast = vi.fn(); type Send = (event: string, payload: unknown) => void; - const announceMirror = vi.fn((_tripId: string, m: typeof mirror, send: Send) => { + const announceMirror = vi.fn(async (_tripId: string, m: typeof mirror, send: Send) => { for (const stop of m.removed) send('assignment:deleted', { assignmentId: stop.id, dayId: stop.dayId }); if (m.created) send('assignment:created', { assignment: m.created }); send('assignment:reordered', { dayId: 11, orderedIds: [70, 78, 71] }); send('roadtripVia:changed', { dayId: 11, vias: [] }); }); const svc = makeService({ get, update, broadcast, announceMirror } as Partial); - new AccommodationsController(svc).update(user, '5', '9', refs, 'sock'); + await new AccommodationsController(svc).update(user, '5', '9', refs, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'assignment:deleted', { assignmentId: 77, dayId: 10 }, undefined); expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: mirror.created }, undefined); expect(broadcast).toHaveBeenCalledWith('5', 'assignment:reordered', { dayId: 11, orderedIds: [70, 78, 71] }, undefined); @@ -142,7 +142,7 @@ describe('AccommodationsController (parity with the legacy accommodations sub-ro expect(thrown(() => new AccommodationsController(svc).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Accommodation not found' } }); }); - it('emits the linked reservation/budget cascade then accommodation:deleted', () => { + it('emits the linked reservation/budget cascade then accommodation:deleted', async () => { const get = vi.fn().mockReturnValue({ id: 9 }); const remove = vi.fn().mockReturnValue({ linkedReservationId: 4, deletedBudgetItemId: 7, @@ -150,13 +150,13 @@ describe('AccommodationsController (parity with the legacy accommodations sub-ro }); const broadcast = vi.fn(); const svc = makeService({ get, remove, broadcast } as Partial); - expect(new AccommodationsController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true, removedAssignments: [] }); + expect(await new AccommodationsController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true, removedAssignments: [] }); expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 4 }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:deleted', { accommodationId: 9 }, 'sock'); }); - it('emits one event per booking when a stay carried more than one (#1869)', () => { + it('emits one event per booking when a stay carried more than one (#1869)', async () => { const get = vi.fn().mockReturnValue({ id: 9 }); const remove = vi.fn().mockReturnValue({ linkedReservationId: 4, deletedBudgetItemId: null, @@ -164,12 +164,12 @@ describe('AccommodationsController (parity with the legacy accommodations sub-ro }); const broadcast = vi.fn(); const svc = makeService({ get, remove, broadcast } as Partial); - new AccommodationsController(svc).remove(user, '5', '9', 'sock'); + await new AccommodationsController(svc).remove(user, '5', '9', 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 4 }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 5 }, 'sock'); }); - it('ACC-CTL-005 ?keepStop=true is what turns a night back into an ordinary pause', () => { + it('ACC-CTL-005 ?keepStop=true is what turns a night back into an ordinary pause', async () => { // The road trip popup switching the night off: the booking goes, the stop it // brought stays where it is in the drive. Nothing else may pass that flag, so // only the literal string counts. @@ -181,10 +181,10 @@ describe('AccommodationsController (parity with the legacy accommodations sub-ro const svc = makeService({ get, remove } as Partial); const controller = new AccommodationsController(svc); - controller.remove(user, '5', '9', 'sock', 'true'); + await controller.remove(user, '5', '9', 'sock', 'true'); expect(remove).toHaveBeenLastCalledWith('9', { keepStop: true }); - controller.remove(user, '5', '9', 'sock'); + await controller.remove(user, '5', '9', 'sock'); expect(remove).toHaveBeenLastCalledWith('9', { keepStop: false }); }); }); diff --git a/server/tests/unit/nest/accommodations.service.test.ts b/server/tests/unit/nest/accommodations.service.test.ts index d017e2332..860ef8c1a 100644 --- a/server/tests/unit/nest/accommodations.service.test.ts +++ b/server/tests/unit/nest/accommodations.service.test.ts @@ -49,9 +49,14 @@ import { AccommodationsModule } from '../../../src/nest/accommodations/accommoda import { AccommodationsDomainModule } from '../../../src/nest/accommodations/accommodations-domain.module'; import { AccommodationsController } from '../../../src/nest/accommodations/accommodations.controller'; import { expectRegisteredProvider, expectRegisteredController } from '../../helpers/module-providers'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; // Named `svc` so the moved cases read exactly as they did on DaysService. -const svc = makeAccommodationsService(testDb); +let svc: Awaited>; +beforeAll(async () => { + svc = await makeAccommodationsService(testDb); +}); beforeAll(() => { createTables(testDb); @@ -113,33 +118,33 @@ describe('validateAccommodationRefs', () => { }); describe('createAccommodation', () => { - it('DAY-SVC-019 — creates accommodation and returns it with place info', () => { + it('DAY-SVC-019 — creates accommodation and returns it with place info', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' }) as any; - const { accommodation: accom } = svc.createAccommodation(trip.id, { + const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, check_in: '15:00', check_out: '11:00', - }) as any; + })) as any; expect(accom).toBeDefined(); expect(accom.place_name).toBe('Grand Hotel'); }); - it('DAY-SVC-020 — auto-creates a linked reservation', () => { + it('DAY-SVC-020 — auto-creates a linked reservation', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'City Hotel' }) as any; - const { accommodation: accom } = svc.createAccommodation(trip.id, { + const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, - }) as any; + })) as any; const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; expect(reservation).toBeDefined(); @@ -147,7 +152,7 @@ describe('createAccommodation', () => { expect(reservation.status).toBe('confirmed'); }); - it('ACC-005 — titles the partner reservation "Hotel" when the stay has no place', () => { + it('ACC-005 — titles the partner reservation "Hotel" when the stay has no place', async () => { // day_accommodations.place_id is nullable (ON DELETE SET NULL) while // reservations.title is NOT NULL: without the fallback the reservation insert // fails and takes the whole transaction — and the stay — down with it. @@ -155,9 +160,9 @@ describe('createAccommodation', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; - const { accommodation: accom } = svc.createAccommodation(trip.id, { + const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: null as unknown as number, start_day_id: day.id, end_day_id: day.id, - }) as any; + })) as any; expect(accom.place_name).toBeNull(); const reservation = testDb.prepare('SELECT title FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; @@ -185,17 +190,17 @@ describe('getAccommodation', () => { }); describe('updateAccommodation', () => { - it('DAY-SVC-023 — updates check-in and check-out times', () => { + it('DAY-SVC-023 — updates check-in and check-out times', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; - const { accommodation: accom } = svc.createAccommodation(trip.id, { + const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, - }) as any; + })) as any; const existing = svc.getAccommodation(accom.id, trip.id)!; - const { accommodation: updated } = svc.updateAccommodation(accom.id, existing as any, { check_in: '16:00', check_out: '12:00' }) as any; + const { accommodation: updated } = (await svc.updateAccommodation(accom.id, existing as any, { check_in: '16:00', check_out: '12:00' })) as any; expect(updated).toBeDefined(); // Verify linked reservation metadata was synced @@ -206,24 +211,24 @@ describe('updateAccommodation', () => { expect(meta.check_out_time).toBe('12:00'); }); - it('DAY-SVC-024 — preserves existing fields when not updated', () => { + it('DAY-SVC-024 — preserves existing fields when not updated', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; - const { accommodation: accom } = svc.createAccommodation(trip.id, { + const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, confirmation: 'ABC123', - }) as any; + })) as any; const existing = svc.getAccommodation(accom.id, trip.id)!; - svc.updateAccommodation(accom.id, existing as any, { check_in: '14:00' }); + await svc.updateAccommodation(accom.id, existing as any, { check_in: '14:00' }); const row = svc.getAccommodation(accom.id, trip.id) as any; expect(row.confirmation).toBe('ABC123'); }); - it('ACC-006 — updates a stay that has no linked reservation', () => { + it('ACC-006 — updates a stay that has no linked reservation', async () => { // Stays imported before the auto-reservation existed have no partner row. The sync // block must be skipped for them, not throw on a missing reservation. const { user } = createUser(testDb); @@ -234,13 +239,13 @@ describe('updateAccommodation', () => { const accom = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any; const existing = svc.getAccommodation(accom.id, trip.id)!; - const { accommodation: updated } = svc.updateAccommodation(accom.id, existing as any, { check_in: '15:00' }) as any; + const { accommodation: updated } = (await svc.updateAccommodation(accom.id, existing as any, { check_in: '15:00' })) as any; expect(updated.check_in).toBe('15:00'); expect(testDb.prepare('SELECT COUNT(*) as n FROM reservations WHERE accommodation_id = ?').get(accom.id)).toMatchObject({ n: 0 }); }); - it('ACC-007 — merges into the reservation metadata instead of replacing it', () => { + it('ACC-007 — merges into the reservation metadata instead of replacing it', async () => { // The stay is created with a check-in, so the reservation already carries metadata. // An update that only moves the check-out must parse and extend that object — // writing a fresh one would silently drop the check-in window. @@ -248,12 +253,12 @@ describe('updateAccommodation', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; - const { accommodation: accom } = svc.createAccommodation(trip.id, { + const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, check_in: '15:00', - }) as any; + })) as any; const existing = svc.getAccommodation(accom.id, trip.id)!; - svc.updateAccommodation(accom.id, existing as any, { check_in_end: '20:00', check_out: '11:00' }); + await svc.updateAccommodation(accom.id, existing as any, { check_in_end: '20:00', check_out: '11:00' }); const reservation = testDb.prepare('SELECT metadata FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; expect(JSON.parse(reservation.metadata)).toEqual({ @@ -261,20 +266,20 @@ describe('updateAccommodation', () => { }); }); - it('ACC-008 — leaves the reservation confirmation number alone when the stay has none', () => { + it('ACC-008 — leaves the reservation confirmation number alone when the stay has none', async () => { // COALESCE(?, confirmation_number): a stay without a confirmation must not null out // a number that was typed on the reservation itself. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; - const { accommodation: accom } = svc.createAccommodation(trip.id, { + const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, - }) as any; + })) as any; testDb.prepare('UPDATE reservations SET confirmation_number = ? WHERE accommodation_id = ?').run('RES-9', accom.id); const existing = svc.getAccommodation(accom.id, trip.id)!; - svc.updateAccommodation(accom.id, existing as any, { check_in: '15:00' }); + await svc.updateAccommodation(accom.id, existing as any, { check_in: '15:00' }); const reservation = testDb.prepare('SELECT confirmation_number FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; expect(reservation.confirmation_number).toBe('RES-9'); @@ -282,18 +287,18 @@ describe('updateAccommodation', () => { }); describe('deleteAccommodation', () => { - it('DAY-SVC-025 — deletes accommodation and its linked reservation', () => { + it('DAY-SVC-025 — deletes accommodation and its linked reservation', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; - const { accommodation: accom } = svc.createAccommodation(trip.id, { + const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, - }) as any; + })) as any; const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; - const result = svc.deleteAccommodation(accom.id); + const result = await svc.deleteAccommodation(accom.id); expect(result.linkedReservationId).toBe(reservation.id); // Accommodation is gone @@ -304,7 +309,7 @@ describe('deleteAccommodation', () => { expect(deletedRes).toBeUndefined(); }); - it('DAY-SVC-026 — returns null linkedReservationId when no reservation was linked', () => { + it('DAY-SVC-026 — returns null linkedReservationId when no reservation was linked', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; @@ -314,11 +319,11 @@ describe('deleteAccommodation', () => { // Remove the auto-created reservation so there's no linked one testDb.prepare('DELETE FROM reservations WHERE accommodation_id = ?').run(accom.id); - const result = svc.deleteAccommodation(accom.id); + const result = await svc.deleteAccommodation(accom.id); expect(result.linkedReservationId).toBeNull(); }); - it('ACC-009 — also deletes the budget item that hangs off the linked reservation', () => { + it('ACC-009 — also deletes the budget item that hangs off the linked reservation', async () => { // The cost row references the reservation, not the stay, so the delete has to // cascade twice. Skipping the second hop strands a budget line whose reservation // is gone, and the costs view can no longer reach it to remove it. @@ -326,16 +331,16 @@ describe('deleteAccommodation', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; - const { accommodation: accom } = svc.createAccommodation(trip.id, { + const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, - }) as any; + })) as any; const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; const budgetItemId = testDb.prepare( 'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)' ).run(trip.id, 'Hotel stay', 'Accommodation', 240, reservation.id).lastInsertRowid as number; - const result = svc.deleteAccommodation(accom.id); + const result = await svc.deleteAccommodation(accom.id); expect(result).toEqual({ linkedReservationId: reservation.id, @@ -347,7 +352,7 @@ describe('deleteAccommodation', () => { expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(budgetItemId)).toBeUndefined(); }); - it('ACC-010 — a second booking pointed at the same stay goes too, instead of being orphaned', () => { + it('ACC-010 — a second booking pointed at the same stay goes too, instead of being orphaned', async () => { // reservations.accommodation_id carries no foreign key and no unique constraint, // and the booking form lets a hotel booking pick an existing stay. Deleting only // the first left the second behind, referencing a stay that no longer exists. @@ -355,36 +360,36 @@ describe('deleteAccommodation', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; - const { accommodation: accom } = svc.createAccommodation(trip.id, { + const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, - }) as any; + })) as any; const first = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; const second = testDb.prepare( 'INSERT INTO reservations (trip_id, type, title, accommodation_id) VALUES (?, ?, ?, ?)' ).run(trip.id, 'hotel', 'Second booking', accom.id).lastInsertRowid as number; - const result = svc.deleteAccommodation(accom.id); + const result = await svc.deleteAccommodation(accom.id); expect(result.linkedReservationIds).toEqual([first.id, second]); expect(result.linkedReservationId).toBe(first.id); expect(testDb.prepare('SELECT COUNT(*) as n FROM reservations WHERE accommodation_id = ?').get(accom.id)).toMatchObject({ n: 0 }); }); - it('ACC-011 — updating the stay syncs the times onto every linked booking', () => { + it('ACC-011 — updating the stay syncs the times onto every linked booking', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; - const { accommodation: accom } = svc.createAccommodation(trip.id, { + const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, - }) as any; + })) as any; testDb.prepare( 'INSERT INTO reservations (trip_id, type, title, accommodation_id) VALUES (?, ?, ?, ?)' ).run(trip.id, 'hotel', 'Second booking', accom.id); const existing = testDb.prepare('SELECT * FROM day_accommodations WHERE id = ?').get(accom.id) as any; - svc.updateAccommodation(accom.id, existing, { check_in: '15:00', check_out: '11:00' }); + await svc.updateAccommodation(accom.id, existing, { check_in: '15:00', check_out: '11:00' }); const rows = testDb.prepare('SELECT metadata FROM reservations WHERE accommodation_id = ?').all(accom.id) as Array<{ metadata: string | null }>; expect(rows).toHaveLength(2); @@ -408,17 +413,17 @@ describe('listAccommodations', () => { }); describe('quirk fixes', () => { - it('DAY-SVC-035 — deleteAccommodation is atomic: a failed stay delete keeps the linked reservation', () => { + it('DAY-SVC-035 — deleteAccommodation is atomic: a failed stay delete keeps the linked reservation', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel' }); - const { accommodation: accom } = svc.createAccommodation(trip.id, { + const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, - }) as { accommodation: { id: number } }; + })) as { accommodation: { id: number } }; testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON day_accommodations BEGIN SELECT RAISE(ABORT, 'boom'); END"); try { - expect(() => svc.deleteAccommodation(accom.id)).toThrow(); + await expect(svc.deleteAccommodation(accom.id)).rejects.toThrow(); // The earlier reservation delete inside the transaction rolled back. expect(testDb.prepare('SELECT COUNT(*) as n FROM reservations WHERE accommodation_id = ?').get(accom.id)).toMatchObject({ n: 1 }); } finally { @@ -528,15 +533,15 @@ describe('trip access and edit permission', () => { expect(svc.verifyTripAccess(trip.id, stranger.id)).toBeUndefined(); }); - it('ACC-017 — canEdit lets the owner through on the default day_edit level', () => { + it('ACC-017 — canEdit lets the owner through on the default day_edit level', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const access = svc.verifyTripAccess(trip.id, user.id)!; - expect(svc.canEdit(access, user as any)).toBe(true); + expect(await svc.canEdit(access, user as any)).toBe(true); }); - it('ACC-018 — canEdit lets a member through: it derives isMember from the trip owner id', () => { + it('ACC-018 — canEdit lets a member through: it derives isMember from the trip owner id', async () => { // day_edit defaults to trip_member. canEdit passes `trip.user_id !== user.id` as the // isMember flag, so inverting that comparison would lock every invited traveller out // of the day plan while the owner still edits fine. @@ -546,21 +551,21 @@ describe('trip access and edit permission', () => { addTripMember(testDb, trip.id, member.id); const access = svc.verifyTripAccess(trip.id, member.id)!; - expect(svc.canEdit(access, member as any)).toBe(true); + expect(await svc.canEdit(access, member as any)).toBe(true); }); - it('ACC-019 — canEdit denies a member once day_edit is raised to trip_owner', () => { + it('ACC-019 — canEdit denies a member once day_edit is raised to trip_owner', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); - const permissions = new PermissionsService(new DatabaseService(testDb)); + const permissions = new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)); try { - permissions.savePermissions({ day_edit: 'trip_owner' }); - expect(svc.canEdit(svc.verifyTripAccess(trip.id, member.id)!, member as any)).toBe(false); + await permissions.savePermissions({ day_edit: 'trip_owner' }); + expect(await svc.canEdit(svc.verifyTripAccess(trip.id, member.id)!, member as any)).toBe(false); // The owner keeps the right, otherwise the setting would lock out the whole trip. - expect(svc.canEdit(svc.verifyTripAccess(trip.id, owner.id)!, owner as any)).toBe(true); + expect(await svc.canEdit(svc.verifyTripAccess(trip.id, owner.id)!, owner as any)).toBe(true); } finally { // The permission cache is module-scoped and outlives resetTestDb — drop the row and // the cache together, or every later case in this file inherits the raised level. @@ -593,16 +598,16 @@ describe('the day stop a booking implies', () => { testDb.prepare('SELECT id, place_id, order_index, accommodation_id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(dayId) as { id: number; place_id: number; order_index: number; accommodation_id: number | null }[]; - const book = (tripId: number, placeId: number | null, startDayId: number, endDayId: number) => - svc.createAccommodation(tripId, { place_id: placeId as number, start_day_id: startDayId, end_day_id: endDayId }) as any; + const book = async (tripId: number, placeId: number | null, startDayId: number, endDayId: number) => + (await svc.createAccommodation(tripId, { place_id: placeId as number, start_day_id: startDayId, end_day_id: endDayId })) as any; - it('ACC-021 the booked night puts its place on the check-in day and marks the stop as its own', () => { + it('ACC-021 the booked night puts its place on the check-in day and marks the stop as its own', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - const { accommodation, mirror } = book(trip.id, place.id, day.id, day.id); + const { accommodation, mirror } = await book(trip.id, place.id, day.id, day.id); const stops = stopsOn(day.id); expect(stops).toHaveLength(1); @@ -615,7 +620,7 @@ describe('the day stop a booking implies', () => { expect(mirror.created.accommodation_id).toBe(accommodation.id); }); - it('ACC-022 the stop lands first: the hotel is where the day is based', () => { + it('ACC-022 the stop lands first: the hotel is where the day is based', async () => { // It used to go last, and a night booked for the morning sat behind a whole day // of unpinned stops. The stops the traveller placed without an hour follow the // hotel; only a clock of their own can put one ahead of it (ACC-022c). @@ -626,13 +631,13 @@ describe('the day stop a booking implies', () => { const hotel = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); createDayAssignment(testDb, day.id, museum.id); - book(trip.id, hotel.id, day.id, day.id); + await book(trip.id, hotel.id, day.id, day.id); expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, museum.id]); expect(stopsOn(day.id).map(a => a.order_index)).toEqual([0, 1]); }); - it('ACC-022b a check-in puts the night before whatever is pinned to a later hour', () => { + it('ACC-022b a check-in puts the night before whatever is pinned to a later hour', async () => { // A stop pinned to the afternoon cannot stand ahead of a check-in at eleven. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -642,12 +647,12 @@ describe('the day stop a booking implies', () => { const pinned = createDayAssignment(testDb, day.id, afternoon.id); testDb.prepare("UPDATE day_assignments SET assignment_time = '16:00' WHERE id = ?").run(pinned.id); - svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' }); + await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' }); expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, afternoon.id]); }); - it('ACC-022c a stop pinned to an earlier hour stays ahead of the check-in', () => { + it('ACC-022c a stop pinned to an earlier hour stays ahead of the check-in', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); @@ -656,12 +661,12 @@ describe('the day stop a booking implies', () => { const pinned = createDayAssignment(testDb, day.id, morning.id); testDb.prepare("UPDATE day_assignments SET assignment_time = '09:00' WHERE id = ?").run(pinned.id); - svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' }); + await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' }); expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, hotel.id]); }); - it('ACC-022d a stop without an hour of its own follows the night, whatever the check-in', () => { + it('ACC-022d a stop without an hour of its own follows the night, whatever the check-in', async () => { // A stop with no clock is planned from the hotel, not the other way round. This // is the day from Discord: two unpinned stops and a night booked for ten in the // morning, which the drive used to reach at a quarter past twelve. @@ -674,12 +679,12 @@ describe('the day stop a booking implies', () => { createDayAssignment(testDb, day.id, fuel.id); createDayAssignment(testDb, day.id, farm.id); - svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); + await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, fuel.id, farm.id]); }); - it('ACC-022f the night goes behind the last stop whose own hour is at or before the check-in', () => { + it('ACC-022f the night goes behind the last stop whose own hour is at or before the check-in', async () => { // Pinned eight, unpinned, pinned two: a check-in at ten sits behind the eight and // ahead of everything else, the unpinned stop included. const { user } = createUser(testDb); @@ -692,19 +697,19 @@ describe('the day stop a booking implies', () => { testDb.prepare("UPDATE day_assignments SET assignment_time = '08:00' WHERE id = ?").run(first.id); testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(third.id); - svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); + await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); expect(stopsOn(day.id).map(a => a.place_id)).toEqual([early.id, hotel.id, loose.id, late.id]); }); - it('ACC-022g a new check-in seats the night afresh, a change of notes leaves a dragged night alone', () => { + it('ACC-022g a new check-in seats the night afresh, a change of notes leaves a dragged night alone', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const [harbour, market, hotel] = ['Hafen', 'Markt', 'Rostock'].map(name => createPlace(testDb, trip.id, { name })); createDayAssignment(testDb, day.id, harbour.id); createDayAssignment(testDb, day.id, market.id); - const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id }) as any; + const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id })) as any; expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, market.id]); // Dragged to the end of the day by hand. @@ -714,17 +719,17 @@ describe('the day stop a booking implies', () => { expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, market.id, hotel.id]); let existing = svc.getAccommodation(accommodation.id, trip.id)!; - const quiet = svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' }) as any; + const quiet = (await svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' })) as any; expect(quiet.mirror.moved).toBeNull(); expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, market.id, hotel.id]); existing = svc.getAccommodation(accommodation.id, trip.id)!; - const reseated = svc.updateAccommodation(accommodation.id, existing, { check_in: '10:00' }) as any; + const reseated = (await svc.updateAccommodation(accommodation.id, existing, { check_in: '10:00' })) as any; expect(reseated.mirror.moved).toMatchObject({ oldDayId: day.id, assignment: { id: own.id } }); expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, market.id]); }); - it('ACC-022e moving the check-in later re-seats the night', () => { + it('ACC-022e moving the check-in later re-seats the night', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); @@ -732,32 +737,32 @@ describe('the day stop a booking implies', () => { const hotel = createPlace(testDb, trip.id, { name: 'Billstedt' }); const pinned = createDayAssignment(testDb, day.id, afternoon.id); testDb.prepare("UPDATE day_assignments SET assignment_time = '16:00' WHERE id = ?").run(pinned.id); - const { accommodation } = svc.createAccommodation(trip.id, { + const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00', - }) as any; + })) as any; expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, afternoon.id]); const existing = svc.getAccommodation(accommodation.id, trip.id)!; - svc.updateAccommodation(accommodation.id, existing, { check_in: '20:00' }); + await svc.updateAccommodation(accommodation.id, existing, { check_in: '20:00' }); expect(stopsOn(day.id).map(a => a.place_id)).toEqual([afternoon.id, hotel.id]); }); - it('ACC-022i a second night on the day counts by its check-in', () => { + it('ACC-022i a second night on the day counts by its check-in', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const [noon, morning] = ['Rue de Paris', 'Brandenburger Tor'].map(name => createPlace(testDb, trip.id, { name })); - svc.createAccommodation(trip.id, { place_id: noon.id, start_day_id: day.id, end_day_id: day.id, check_in: '12:00' }); - svc.createAccommodation(trip.id, { place_id: morning.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); + await svc.createAccommodation(trip.id, { place_id: noon.id, start_day_id: day.id, end_day_id: day.id, check_in: '12:00' }); + await svc.createAccommodation(trip.id, { place_id: morning.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, noon.id]); const evening = createPlace(testDb, trip.id, { name: 'Late' }); - svc.createAccommodation(trip.id, { place_id: evening.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' }); + await svc.createAccommodation(trip.id, { place_id: evening.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' }); expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, noon.id, evening.id]); }); - it('ACC-022h an edit that leaves the check-in alone still corrects a night the clocks contradict', () => { + it('ACC-022h an edit that leaves the check-in alone still corrects a night the clocks contradict', async () => { // Dragged behind a stop pinned to the afternoon, with a check-in at ten: a change of // notes puts it back ahead of that stop, because a night sitting after an afternoon it // was booked before is the plan reading back wrong. Dragged behind a stop without an @@ -769,7 +774,7 @@ describe('the day stop a booking implies', () => { createDayAssignment(testDb, day.id, harbour.id); const pinned = createDayAssignment(testDb, day.id, museum.id); testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(pinned.id); - const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }) as any; + const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' })) as any; expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]); const own = stopsOn(day.id).find(a => a.place_id === hotel.id)!; @@ -777,7 +782,7 @@ describe('the day stop a booking implies', () => { testDb.prepare('UPDATE day_assignments SET order_index = 0 WHERE day_id = ? AND place_id = ?').run(day.id, harbour.id); testDb.prepare('UPDATE day_assignments SET order_index = 1 WHERE id = ?').run(own.id); let existing = svc.getAccommodation(accommodation.id, trip.id)!; - expect((svc.updateAccommodation(accommodation.id, existing, { notes: 'late' }) as any).mirror.moved).toBeNull(); + expect(((await svc.updateAccommodation(accommodation.id, existing, { notes: 'late' })) as any).mirror.moved).toBeNull(); expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, hotel.id, museum.id]); // Behind the afternoon: contradicted by the clocks, and seated afresh, which is @@ -785,17 +790,17 @@ describe('the day stop a booking implies', () => { testDb.prepare('UPDATE day_assignments SET order_index = 1 WHERE day_id = ? AND place_id = ?').run(day.id, museum.id); testDb.prepare('UPDATE day_assignments SET order_index = 2 WHERE id = ?').run(own.id); existing = svc.getAccommodation(accommodation.id, trip.id)!; - expect((svc.updateAccommodation(accommodation.id, existing, { notes: 'later' }) as any).mirror.moved).not.toBeNull(); + expect(((await svc.updateAccommodation(accommodation.id, existing, { notes: 'later' })) as any).mirror.moved).not.toBeNull(); expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]); }); - it('ACC-023 the place is typed as lodging, so the rail draws it as a service stop', () => { + it('ACC-023 the place is typed as lodging, so the rail draws it as a service stop', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - const { mirror } = book(trip.id, place.id, day.id, day.id); + const { mirror } = await book(trip.id, place.id, day.id, day.id); expect(testDb.prepare('SELECT stop_type FROM places WHERE id = ?').get(place.id)).toMatchObject({ stop_type: 'hotel' }); // Announced, or the places list keeps the stale null until a reload. @@ -804,20 +809,20 @@ describe('the day stop a booking implies', () => { expect(mirror.created.place.stop_type).toBe('hotel'); }); - it('ACC-024 a stop type the traveller picked is never overwritten', () => { + it('ACC-024 a stop type the traveller picked is never overwritten', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Camping Isar' }); testDb.prepare("UPDATE places SET stop_type = 'campsite' WHERE id = ?").run(place.id); - const { mirror } = book(trip.id, place.id, day.id, day.id); + const { mirror } = await book(trip.id, place.id, day.id, day.id); expect(testDb.prepare('SELECT stop_type FROM places WHERE id = ?').get(place.id)).toMatchObject({ stop_type: 'campsite' }); expect(mirror.stamped).toBeNull(); }); - it('ACC-025 a place already planned for that day gets no second stop, and the booking claims neither', () => { + it('ACC-025 a place already planned for that day gets no second stop, and the booking claims neither', async () => { // This is the road-trip flow: it assigns the place to the day and only then books // the night. A second stop would draw the hotel twice, and claiming the existing // one would let cancelling the booking delete a stop the traveller placed. @@ -827,13 +832,13 @@ describe('the day stop a booking implies', () => { const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); const own = createDayAssignment(testDb, day.id, place.id); - const { mirror } = book(trip.id, place.id, day.id, day.id); + const { mirror } = await book(trip.id, place.id, day.id, day.id); expect(stopsOn(day.id)).toEqual([expect.objectContaining({ id: own.id, accommodation_id: null })]); expect(mirror.created).toBeNull(); }); - it('ACC-026 only the check-in day, not every night of a long stay', () => { + it('ACC-026 only the check-in day, not every night of a long stay', async () => { // You drive there once. The later nights ride on the stay row, which is where the // rail reads check-out from. const { user } = createUser(testDb); @@ -843,37 +848,37 @@ describe('the day stop a booking implies', () => { const leave = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - book(trip.id, place.id, arrive.id, leave.id); + await book(trip.id, place.id, arrive.id, leave.id); expect(stopsOn(arrive.id)).toHaveLength(1); expect(stopsOn(middle.id)).toHaveLength(0); expect(stopsOn(leave.id)).toHaveLength(0); }); - it('ACC-027 a stay with no place writes no stop instead of throwing', () => { + it('ACC-027 a stay with no place writes no stop instead of throwing', async () => { // day_accommodations.place_id is nullable (ON DELETE SET NULL) and the booking form // writes stays that never had one. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); - const { mirror } = book(trip.id, null, day.id, day.id); + const { mirror } = await book(trip.id, null, day.id, day.id); expect(mirror).toEqual({ created: null, moved: null, updated: [], removed: [], stamped: null }); expect(stopsOn(day.id)).toHaveLength(0); }); - it('ACC-028 moving the booking to another day moves its stop with it', () => { + it('ACC-028 moving the booking to another day moves its stop with it', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day1 = createDay(testDb, trip.id); const day2 = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - const { accommodation } = book(trip.id, place.id, day1.id, day1.id); + const { accommodation } = await book(trip.id, place.id, day1.id, day1.id); const before = stopsOn(day1.id)[0]; const existing = svc.getAccommodation(accommodation.id, trip.id)!; - const { mirror } = svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id }) as any; + const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id })) as any; expect(stopsOn(day1.id)).toHaveLength(0); // The same row, carried over. Not a delete and a fresh insert: everything the @@ -884,55 +889,55 @@ describe('the day stop a booking implies', () => { expect(mirror.moved).toMatchObject({ oldDayId: day1.id, assignment: { id: before.id, day_id: day2.id } }); }); - it('ACC-028b moving the booking keeps the note, the hour and the end-of-day flag on the stop', () => { + it('ACC-028b moving the booking keeps the note, the hour and the end-of-day flag on the stop', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day1 = createDay(testDb, trip.id); const day2 = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - const { accommodation } = book(trip.id, place.id, day1.id, day1.id); + const { accommodation } = await book(trip.id, place.id, day1.id, day1.id); const stop = stopsOn(day1.id)[0]; testDb.prepare('UPDATE day_assignments SET notes = ?, assignment_time = ?, end_day = 1 WHERE id = ?') .run('ask for the quiet side', '15:30', stop.id); const existing = svc.getAccommodation(accommodation.id, trip.id)!; - svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id }); + await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id }); const after = testDb.prepare('SELECT * FROM day_assignments WHERE id = ?').get(stop.id) as Record; expect(after).toMatchObject({ day_id: day2.id, notes: 'ask for the quiet side', assignment_time: '15:30', end_day: 1 }); }); - it('ACC-028c moving the booking keeps the people assigned to the stop', () => { + it('ACC-028c moving the booking keeps the people assigned to the stop', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day1 = createDay(testDb, trip.id); const day2 = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - const { accommodation } = book(trip.id, place.id, day1.id, day1.id); + const { accommodation } = await book(trip.id, place.id, day1.id, day1.id); const stop = stopsOn(day1.id)[0]; // assignment_participants.assignment_id is ON DELETE CASCADE, so a delete and // re-insert would take these with it without a word. testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(stop.id, user.id); const existing = svc.getAccommodation(accommodation.id, trip.id)!; - svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id }); + await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id }); const kept = testDb.prepare('SELECT user_id FROM assignment_participants WHERE assignment_id = ?').all(stop.id); expect(kept).toEqual([{ user_id: user.id }]); }); - it('ACC-028d a move onto a day that already holds the place by hand drops the booking stop instead of doubling it', () => { + it('ACC-028d a move onto a day that already holds the place by hand drops the booking stop instead of doubling it', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day1 = createDay(testDb, trip.id); const day2 = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - const { accommodation } = book(trip.id, place.id, day1.id, day1.id); + const { accommodation } = await book(trip.id, place.id, day1.id, day1.id); const own = stopsOn(day1.id)[0]; const byHand = createDayAssignment(testDb, day2.id, place.id) as { id: number }; const existing = svc.getAccommodation(accommodation.id, trip.id)!; - const { mirror } = svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id }) as any; + const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id })) as any; expect(stopsOn(day1.id)).toHaveLength(0); expect(stopsOn(day2.id).map((row: { id: number }) => row.id)).toEqual([byHand.id]); @@ -940,38 +945,38 @@ describe('the day stop a booking implies', () => { expect(mirror.moved).toBeNull(); }); - it('ACC-028e the day the stop left closes the gap it made in the order', () => { + it('ACC-028e the day the stop left closes the gap it made in the order', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day1 = createDay(testDb, trip.id); const day2 = createDay(testDb, trip.id); const hotel = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); const museum = createPlace(testDb, trip.id, { name: 'Pergamon' }); - const { accommodation } = book(trip.id, hotel.id, day1.id, day1.id); + const { accommodation } = await book(trip.id, hotel.id, day1.id, day1.id); createDayAssignment(testDb, day1.id, museum.id); const existing = svc.getAccommodation(accommodation.id, trip.id)!; - svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id }); + await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id }); expect(stopsOn(day1.id).map((row: { order_index: number }) => row.order_index)).toEqual([0]); }); - it('ACC-029 editing an unrelated field leaves the stop exactly where it is', () => { + it('ACC-029 editing an unrelated field leaves the stop exactly where it is', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - const { accommodation } = book(trip.id, place.id, day.id, day.id); + const { accommodation } = await book(trip.id, place.id, day.id, day.id); const before = stopsOn(day.id); const existing = svc.getAccommodation(accommodation.id, trip.id)!; - const { mirror } = svc.updateAccommodation(accommodation.id, existing, { check_in: '16:00' }) as any; + const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { check_in: '16:00' })) as any; expect(stopsOn(day.id)).toEqual(before); expect(mirror).toEqual({ created: null, moved: null, updated: [], removed: [], stamped: null }); }); - it('ACC-029b a night sitting behind a later afternoon is re-seated, keeping its row', () => { + it('ACC-029b a night sitting behind a later afternoon is re-seated, keeping its row', async () => { // The state the migration backfill leaves: every pre-existing stay's stop was // appended last, whatever its check-in says. Giving the booking an hour has to // move it ahead of the evening it was booked around. @@ -982,7 +987,7 @@ describe('the day stop a booking implies', () => { const hotel = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); testDb.prepare("UPDATE places SET place_time = '19:00' WHERE id = ?").run(dinner.id); createDayAssignment(testDb, day.id, dinner.id); - const { accommodation } = book(trip.id, hotel.id, day.id, day.id); + const { accommodation } = await book(trip.id, hotel.id, day.id, day.id); const stop = stopsOn(day.id).find((row: { place_id: number }) => row.place_id === hotel.id)!; // The backfill's placement, written by hand: the night behind the dinner. testDb.prepare('UPDATE day_assignments SET order_index = 0 WHERE day_id = ? AND place_id = ?').run(day.id, dinner.id); @@ -990,7 +995,7 @@ describe('the day stop a booking implies', () => { expect(stopsOn(day.id).map((row: { place_id: number }) => row.place_id)).toEqual([dinner.id, hotel.id]); const existing = svc.getAccommodation(accommodation.id, trip.id)!; - const { mirror } = svc.updateAccommodation(accommodation.id, existing, { check_in: '15:00' }) as any; + const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { check_in: '15:00' })) as any; expect(stopsOn(day.id).map((row: { place_id: number }) => row.place_id)).toEqual([hotel.id, dinner.id]); // Re-seated, not rebuilt: the same row, one place further forward. @@ -998,7 +1003,7 @@ describe('the day stop a booking implies', () => { expect(mirror.removed).toEqual([]); }); - it('ACC-030 a stay whose stop belongs to the traveller does not get a second one', () => { + it('ACC-030 a stay whose stop belongs to the traveller does not get a second one', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); @@ -1010,29 +1015,29 @@ describe('the day stop a booking implies', () => { const stay = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any; const existing = svc.getAccommodation(stay.id, trip.id)!; - svc.updateAccommodation(stay.id, existing, { start_day_id: second.id, end_day_id: second.id }); + await svc.updateAccommodation(stay.id, existing, { start_day_id: second.id, end_day_id: second.id }); expect(stopsOn(second.id)).toEqual([]); expect(stopsOn(day.id)).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: null })]); }); - it('ACC-031 cancelling a booking takes back its own stop and leaves the other one', () => { + it('ACC-031 cancelling a booking takes back its own stop and leaves the other one', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const hotel = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); const museum = createPlace(testDb, trip.id, { name: 'Pergamon' }); const own = createDayAssignment(testDb, day.id, museum.id); - const { accommodation } = book(trip.id, hotel.id, day.id, day.id); + const { accommodation } = await book(trip.id, hotel.id, day.id, day.id); const mirrored = stopsOn(day.id).find(a => a.place_id === hotel.id)!; - const { mirror } = svc.deleteAccommodation(accommodation.id); + const { mirror } = await svc.deleteAccommodation(accommodation.id); expect(stopsOn(day.id)).toEqual([expect.objectContaining({ id: own.id })]); expect(mirror.removed).toEqual([{ id: mirrored.id, dayId: day.id }]); }); - it('ACC-032 cancelling a night booked in the road trip keeps the stop standing', () => { + it('ACC-032 cancelling a night booked in the road trip keeps the stop standing', async () => { // The rail turns a night back into a pause by deleting the stay alone; the stop is // the thing being edited there and has to survive. const { user } = createUser(testDb); @@ -1040,15 +1045,15 @@ describe('the day stop a booking implies', () => { const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); const own = createDayAssignment(testDb, day.id, place.id); - const { accommodation } = book(trip.id, place.id, day.id, day.id); + const { accommodation } = await book(trip.id, place.id, day.id, day.id); - const { mirror } = svc.deleteAccommodation(accommodation.id); + const { mirror } = await svc.deleteAccommodation(accommodation.id); expect(stopsOn(day.id)).toEqual([expect.objectContaining({ id: own.id })]); expect(mirror.removed).toEqual([]); }); - it('ACC-032b keepStop hands the mirrored stop to the traveller instead of taking it away', () => { + it('ACC-032b keepStop hands the mirrored stop to the traveller instead of taking it away', async () => { // The road trip popup switching a night back to an ordinary pause: the booking is // what was cancelled, not the place, and the stop sits mid-drive where re-adding it // would land it at the end of the day instead. @@ -1056,10 +1061,10 @@ describe('the day stop a booking implies', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - const { accommodation } = book(trip.id, place.id, day.id, day.id); + const { accommodation } = await book(trip.id, place.id, day.id, day.id); const mirrored = stopsOn(day.id)[0]; - const { mirror } = svc.deleteAccommodation(accommodation.id, { keepStop: true }); + const { mirror } = await svc.deleteAccommodation(accommodation.id, { keepStop: true }); // Still there, and now unmarked: nothing may move or delete it on the next edit. expect(stopsOn(day.id)).toEqual([expect.objectContaining({ id: mirrored.id, place_id: place.id, accommodation_id: null })]); @@ -1068,23 +1073,23 @@ describe('the day stop a booking implies', () => { expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(accommodation.id)).toBeUndefined(); }); - it('ACC-032c remove() forwards keepStop, which is the only door the REST route uses', () => { + it('ACC-032c remove() forwards keepStop, which is the only door the REST route uses', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - const { accommodation } = book(trip.id, place.id, day.id, day.id); + const { accommodation } = await book(trip.id, place.id, day.id, day.id); - svc.remove(accommodation.id, { keepStop: true }); + await svc.remove(accommodation.id, { keepStop: true }); expect(stopsOn(day.id)).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: null })]); }); - it('ACC-033 announceMirror sends the removal before the arrival', () => { + it('ACC-033 announceMirror sends the removal before the arrival', async () => { // Order matters on a move: the day plan would briefly hold the place twice if the // arrival went first. const sent: string[] = []; - svc.announceMirror(5, { + await svc.announceMirror(5, { created: { id: 78, day_id: 11 } as never, moved: null, updated: [], @@ -1098,9 +1103,9 @@ describe('the day stop a booking implies', () => { ]); }); - it('ACC-033b announceMirror reports a move as one event, not a delete and a create', () => { + it('ACC-033b announceMirror reports a move as one event, not a delete and a create', async () => { const sent: Array<{ event: string; payload: unknown }> = []; - svc.announceMirror(5, { + await svc.announceMirror(5, { created: null, moved: { assignment: { id: 78, day_id: 11 } as never, oldDayId: 10 }, updated: [], @@ -1111,12 +1116,12 @@ describe('the day stop a booking implies', () => { expect(sent[0].payload).toMatchObject({ oldDayId: 10, newDayId: 11 }); }); - it('ACC-033c a kept stop is announced as updated, so the day list stops hiding it', () => { + it('ACC-033c a kept stop is announced as updated, so the day list stops hiding it', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - const { accommodation } = book(trip.id, place.id, day.id, day.id); + const { accommodation } = await book(trip.id, place.id, day.id, day.id); const stop = stopsOn(day.id)[0]; const { mirror } = svc.remove(accommodation.id, { keepStop: true }) as any; @@ -1125,7 +1130,7 @@ describe('the day stop a booking implies', () => { // the old row keeps the place invisible on a day it is standing on. expect(mirror.updated).toEqual([expect.objectContaining({ id: stop.id, accommodation_id: null })]); const sent: string[] = []; - svc.announceMirror(trip.id, mirror, event => { sent.push(event); }); + await svc.announceMirror(trip.id, mirror, event => { sent.push(event); }); expect(sent).toContain('assignment:updated'); }); }); @@ -1163,7 +1168,7 @@ describe('the drawn roads a booking moves', () => { return { trip, day, a, b, c, hotel }; } - it('ACC-034 a night seated ahead of the afternoon carries the roads behind it along', () => { + it('ACC-034 a night seated ahead of the afternoon carries the roads behind it along', async () => { const { trip, day, a, b, c, hotel } = afternoonDay(); const afterA = addVia(day.id, 0); const afterB = addVia(day.id, 1); @@ -1171,7 +1176,7 @@ describe('the drawn roads a booking moves', () => { // still last, rather than being dropped as a leg C never had. const intoTomorrow = addVia(day.id, 2); - const { mirror } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' }) as any; + const { mirror } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any; expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id, c.id]); expect(viaAnchors(day.id)).toEqual([ @@ -1191,23 +1196,23 @@ describe('the drawn roads a booking moves', () => { }]); }); - it('ACC-034b a night seated behind every drawn road moves none and reports none', () => { + it('ACC-034b a night seated behind every drawn road moves none and reports none', async () => { const { trip, day, a, b, c, hotel } = afternoonDay(); const afterA = addVia(day.id, 0); const afterB = addVia(day.id, 1); - const { mirror } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' }) as any; + const { mirror } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' })) as any; expect(stopsOn(day.id)).toEqual([a.id, b.id, hotel.id, c.id]); expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 0 }, { id: afterB, after_order_index: 1 }]); expect(mirror.vias).toBeUndefined(); }); - it('ACC-034c re-seating the night by a later check-in re-pins the roads around it', () => { + it('ACC-034c re-seating the night by a later check-in re-pins the roads around it', async () => { const { trip, day, a, b, c, hotel } = afternoonDay(); // C pinned to the afternoon as well, so an evening check-in puts the night last. testDb.prepare("UPDATE day_assignments SET assignment_time = '15:00' WHERE day_id = ? AND place_id = ?").run(day.id, c.id); - const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' }) as any; + const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any; expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id, c.id]); const afterA = addVia(day.id, 0); const afterHotel = addVia(day.id, 1); @@ -1215,7 +1220,7 @@ describe('the drawn roads a booking moves', () => { const afterC = addVia(day.id, 3); const existing = svc.getAccommodation(accommodation.id, trip.id)!; - const { mirror } = svc.updateAccommodation(accommodation.id, existing, { check_in: '20:00' }) as any; + const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { check_in: '20:00' })) as any; // The night is last now. The road out of it has no leg left and goes; the roads // behind B and C follow them one number up, and the one into the next day is @@ -1231,14 +1236,14 @@ describe('the drawn roads a booking moves', () => { expect(mirror.vias).toEqual([{ dayId: day.id, vias: expect.arrayContaining([expect.objectContaining({ id: afterB, after_order_index: 1 })]) }]); }); - it('ACC-034d cancelling the booking takes its stop out and re-pins the roads behind it', () => { + it('ACC-034d cancelling the booking takes its stop out and re-pins the roads behind it', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const [a, b, hotel] = ['Harbour', 'Mercure', 'Billstedt'].map(name => createPlace(testDb, trip.id, { name })); pin(createDayAssignment(testDb, day.id, a.id).id, '09:00'); pin(createDayAssignment(testDb, day.id, b.id).id, '12:00'); - const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' }) as any; + const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any; expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id]); // The road out of the hotel is drawn first: by id alone it would lead the leg // it ends up sharing with A's road. @@ -1246,7 +1251,7 @@ describe('the drawn roads a booking moves', () => { const afterA = addVia(day.id, 0); const intoTomorrow = addVia(day.id, 2); - const { mirror } = svc.deleteAccommodation(accommodation.id); + const { mirror } = await svc.deleteAccommodation(accommodation.id); expect(stopsOn(day.id)).toEqual([a.id, b.id]); // The two legs around the hotel are one leg now, A to B, and the road out of the @@ -1264,7 +1269,7 @@ describe('the drawn roads a booking moves', () => { ] }]); }); - it('ACC-034e moving the booking to another day re-pins the roads on both days', () => { + it('ACC-034e moving the booking to another day re-pins the roads on both days', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day1 = createDay(testDb, trip.id); @@ -1273,14 +1278,14 @@ describe('the drawn roads a booking moves', () => { pin(createDayAssignment(testDb, day1.id, a.id).id, '09:00'); pin(createDayAssignment(testDb, day1.id, b.id).id, '12:00'); pin(createDayAssignment(testDb, day2.id, c.id).id, '14:00'); - const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day1.id, end_day_id: day1.id, check_in: '11:00' }) as any; + const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day1.id, end_day_id: day1.id, check_in: '11:00' })) as any; expect(stopsOn(day1.id)).toEqual([a.id, hotel.id, b.id]); const afterA = addVia(day1.id, 0); const afterHotel = addVia(day1.id, 1); const outOfC = addVia(day2.id, 0); const existing = svc.getAccommodation(accommodation.id, trip.id)!; - const { mirror } = svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id }) as any; + const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id })) as any; expect(stopsOn(day1.id)).toEqual([a.id, b.id]); expect(stopsOn(day2.id)).toEqual([hotel.id, c.id]); @@ -1294,23 +1299,23 @@ describe('the drawn roads a booking moves', () => { expect(mirror.vias.map((day: { dayId: number }) => day.dayId).sort()).toEqual([day1.id, day2.id].sort()); }); - it('ACC-034f a stop the booking hands to the traveller moves no road', () => { + it('ACC-034f a stop the booking hands to the traveller moves no road', async () => { const { trip, day, hotel } = afternoonDay(); - const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' }) as any; + const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any; const afterB = addVia(day.id, 2); - const { mirror } = svc.deleteAccommodation(accommodation.id, { keepStop: true }); + const { mirror } = await svc.deleteAccommodation(accommodation.id, { keepStop: true }); expect(viaAnchors(day.id)).toEqual([{ id: afterB, after_order_index: 2 }]); expect(mirror.vias).toBeUndefined(); }); - it('ACC-034h a booking rebuilt from two stops reports its day once, as it stands at the end', () => { + it('ACC-034h a booking rebuilt from two stops reports its day once, as it stands at the end', async () => { // A stay that owns two stops on one day cannot be carried across, so both go and // one comes back, and the day's roads are re-pinned at each step. What the mirror // reports is the state that was left behind, not both steps. const { trip, day, a, b, c, hotel } = afternoonDay(); - const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' }) as any; + const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any; testDb.prepare('INSERT INTO day_assignments (day_id, place_id, order_index, accommodation_id) VALUES (?, ?, 4, ?)').run(day.id, hotel.id, accommodation.id); const afterA = addVia(day.id, 0); const afterHotel = addVia(day.id, 1); @@ -1318,7 +1323,7 @@ describe('the drawn roads a booking moves', () => { addVia(day.id, 3); const existing = svc.getAccommodation(accommodation.id, trip.id)!; - const { mirror } = svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' }) as any; + const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' })) as any; expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id, c.id]); expect(mirror.removed).toHaveLength(2); @@ -1333,9 +1338,9 @@ describe('the drawn roads a booking moves', () => { ] }]); }); - it('ACC-034g announceMirror sends the re-pinned roads behind the day order', () => { + it('ACC-034g announceMirror sends the re-pinned roads behind the day order', async () => { const sent: Array<{ event: string; payload: unknown }> = []; - svc.announceMirror(5, { + await svc.announceMirror(5, { created: { id: 78, day_id: 11 } as never, moved: null, updated: [], diff --git a/server/tests/unit/nest/addon-guard.test.ts b/server/tests/unit/nest/addon-guard.test.ts index 70b75a543..6a1861ed3 100644 --- a/server/tests/unit/nest/addon-guard.test.ts +++ b/server/tests/unit/nest/addon-guard.test.ts @@ -36,9 +36,9 @@ function addons(enabled: boolean): AddonsService { return { isAddonEnabled: vi.fn(() => enabled) } as unknown as AddonsService; } -function thrown(fn: () => unknown) { +async function thrown(fn: () => unknown) { try { - fn(); + await fn(); return undefined; } catch (err) { return err instanceof HttpException ? { status: err.getStatus(), body: err.getResponse() } : err; @@ -53,38 +53,38 @@ function decorated(addonId: string, label: string) { } describe('AddonGuard', () => { - it('ADDON-GUARD-001: 404s with the decorator label when the addon is disabled', () => { + it('ADDON-GUARD-001: 404s with the decorator label when the addon is disabled', async () => { const cls = decorated(ADDON_IDS.JOURNEY, 'Journey'); const guard = new AddonGuard(addons(false), new Reflector()); - expect(thrown(() => guard.canActivate(ctxFor(() => {}, cls)))) + expect(await thrown(() => guard.canActivate(ctxFor(() => {}, cls)))) .toEqual({ status: 404, body: { error: 'Journey addon is not enabled' } }); }); - it('ADDON-GUARD-002: passes when the addon is enabled, asking for the declared id', () => { + it('ADDON-GUARD-002: passes when the addon is enabled, asking for the declared id', async () => { const cls = decorated(ADDON_IDS.COLLECTIONS, 'Collections'); const svc = addons(true); const guard = new AddonGuard(svc, new Reflector()); - expect(guard.canActivate(ctxFor(() => {}, cls))).toBe(true); + expect(await guard.canActivate(ctxFor(() => {}, cls))).toBe(true); expect(svc.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.COLLECTIONS); }); - it('ADDON-GUARD-003: is a no-op on a route that never asked for an addon', () => { + it('ADDON-GUARD-003: is a no-op on a route that never asked for an addon', async () => { const svc = addons(false); const guard = new AddonGuard(svc, new Reflector()); - expect(guard.canActivate(ctxFor(() => {}, class {}))).toBe(true); + expect(await guard.canActivate(ctxFor(() => {}, class {}))).toBe(true); expect(svc.isAddonEnabled).not.toHaveBeenCalled(); }); - it('ADDON-GUARD-004: a handler-level decorator overrides the controller-level one', () => { + it('ADDON-GUARD-004: a handler-level decorator overrides the controller-level one', async () => { const cls = decorated(ADDON_IDS.JOURNEY, 'Journey'); const handler = () => {}; RequireAddon(ADDON_IDS.AIRTRAIL, 'AirTrail')(handler); const svc = addons(false); - expect(thrown(() => new AddonGuard(svc, new Reflector()).canActivate(ctxFor(handler, cls)))) + expect(await thrown(() => new AddonGuard(svc, new Reflector()).canActivate(ctxFor(handler, cls)))) .toEqual({ status: 404, body: { error: 'AirTrail addon is not enabled' } }); expect(svc.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.AIRTRAIL); }); diff --git a/server/tests/unit/nest/addons.controller.test.ts b/server/tests/unit/nest/addons.controller.test.ts index 6fa34ae2b..13dec725c 100644 --- a/server/tests/unit/nest/addons.controller.test.ts +++ b/server/tests/unit/nest/addons.controller.test.ts @@ -10,7 +10,7 @@ function makeService(overrides: Partial = {}): AddonsService { } describe('AddonsController (parity with the legacy GET /api/addons route)', () => { - it('GET / delegates straight to the service and returns its feed', () => { + it('GET / delegates straight to the service and returns its feed', async () => { const feed = { collabFeatures: { comments: true }, bagTracking: true, @@ -19,7 +19,7 @@ describe('AddonsController (parity with the legacy GET /api/addons route)', () = const list = vi.fn().mockReturnValue(feed); const svc = makeService({ list } as Partial); - expect(new AddonsController(svc).list()).toBe(feed); + expect(await new AddonsController(svc).list()).toBe(feed); expect(list).toHaveBeenCalledTimes(1); expect(list).toHaveBeenCalledWith(); }); diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts index 87e3809bb..9b32ca8d7 100644 --- a/server/tests/unit/nest/addons.service.test.ts +++ b/server/tests/unit/nest/addons.service.test.ts @@ -23,7 +23,7 @@ function svc() { return new AddonsService(new DatabaseService(dbConn)); } -type ListAddon = ReturnType['addons'][number]; +type ListAddon = Awaited>['addons'][number]; type PhotoProviderField = { key: string; @@ -74,16 +74,16 @@ beforeEach(() => { }); describe('AddonsService.list', () => { - it('returns the collab features and the bag-tracking flag from app_settings', () => { + it('returns the collab features and the bag-tracking flag from app_settings', async () => { feedReads([], [], [], [{ key: 'collab_chat_enabled', value: 'false' }], { value: 'true' }); - const res = svc().list(); + const res = await svc().list(); expect(res.collabFeatures).toEqual({ chat: false, notes: true, links: true, polls: true, whatsnext: true }); expect(res.bagTracking).toBe(true); expect(res.addons).toEqual([]); }); - it('coerces the addon enabled column to a boolean (both 1 and 0)', () => { + it('coerces the addon enabled column to a boolean (both 1 and 0)', async () => { feedReads( [ { id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }, @@ -93,14 +93,14 @@ describe('AddonsService.list', () => { [], ); - const res = svc().list(); + const res = await svc().list(); expect(res.addons).toEqual([ { id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true }, { id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: false }, ]); }); - it('maps a photo provider with no fields to an empty fields array (the || [] fallback)', () => { + it('maps a photo provider with no fields to an empty fields array (the || [] fallback)', async () => { feedReads( [], [{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }], @@ -108,7 +108,7 @@ describe('AddonsService.list', () => { ); getPhotoProviderConfig.mockReturnValue({ baseUrl: 'http://x' }); - const res = svc().list(); + const res = await svc().list(); expect(res.addons).toEqual([ { id: 'immich', @@ -123,18 +123,18 @@ describe('AddonsService.list', () => { expect(getPhotoProviderConfig).toHaveBeenCalledWith('immich'); }); - it('coerces a disabled photo provider enabled flag to false', () => { + it('coerces a disabled photo provider enabled flag to false', async () => { feedReads( [], [{ id: 'synology', name: 'Synology', icon: 'image', enabled: 0, sort_order: 1 }], [], ); - const res = svc().list(); + const res = await svc().list(); expect((res.addons[0] as { enabled: boolean }).enabled).toBe(false); }); - it('groups multiple fields under their provider and keeps insertion order', () => { + it('groups multiple fields under their provider and keeps insertion order', async () => { feedReads( [], [{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }], @@ -169,7 +169,7 @@ describe('AddonsService.list', () => { ], ); - const res = svc().list(); + const res = await svc().list(); expect(providerFields(res.addons[0])).toEqual([ { key: 'url', @@ -198,7 +198,7 @@ describe('AddonsService.list', () => { ]); }); - it('falls back placeholder→"", hint→null, settings/payload keys→null when columns are missing/empty', () => { + it('falls back placeholder→"", hint→null, settings/payload keys→null when columns are missing/empty', async () => { feedReads( [], [{ id: 'p', name: 'P', icon: 'i', enabled: 1, sort_order: 0 }], @@ -216,7 +216,7 @@ describe('AddonsService.list', () => { ], ); - const res = svc().list(); + const res = await svc().list(); const field = providerFields(res.addons[0])[0]; expect(field).toMatchObject({ placeholder: '', @@ -226,7 +226,7 @@ describe('AddonsService.list', () => { }); }); - it('keeps fields belonging to other providers out of a provider with none of its own', () => { + it('keeps fields belonging to other providers out of a provider with none of its own', async () => { // A field exists, but for a DIFFERENT provider than the one returned — exercises // the `fieldsByProvider.get(p.id) || []` fallback while the map is non-empty. feedReads( @@ -245,23 +245,23 @@ describe('AddonsService.list', () => { ], ); - const res = svc().list(); + const res = await svc().list(); expect(providerFields(res.addons[0])).toEqual([]); }); - it('concatenates regular addons before the photo providers', () => { + it('concatenates regular addons before the photo providers', async () => { feedReads( [{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }], [{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }], [], ); - const res = svc().list(); + const res = await svc().list(); expect(res.addons.map((a) => (a as { id: string }).id)).toEqual(['atlas', 'immich']); expect((res.addons[1] as { type: string }).type).toBe('photo_provider'); }); - it('drops the photo providers while the journey addon is off, whatever their rows say', () => { + it('drops the photo providers while the journey addon is off, whatever their rows say', async () => { // The journey gate (.get) answers disabled, so the provider query is never // made and the .all chain shortens to addons, fields, collab. dbMock._stmt.get.mockReturnValueOnce({ enabled: 0 }); @@ -270,7 +270,7 @@ describe('AddonsService.list', () => { .mockReturnValueOnce([]) .mockReturnValueOnce([]); - const res = svc().list(); + const res = await svc().list(); expect(res.addons).toEqual([{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true }]); const providerReads = (dbMock.prepare.mock.calls as unknown[][]).filter((call) => String(call[0]).includes('FROM photo_providers')); expect(providerReads).toEqual([]); @@ -281,43 +281,43 @@ describe('AddonsService.list', () => { // services/adminService (finding `admin-1`). The polarity asymmetry is on // purpose: bag tracking is opt-in (=== 'true'), collab flags opt-out (!== 'false'). describe('AddonsService addon/feature flags', () => { - it('isAddonEnabled coerces the enabled column (1/0/missing row)', () => { + it('isAddonEnabled coerces the enabled column (1/0/missing row)', async () => { dbMock._stmt.get.mockReturnValueOnce({ enabled: 1 }); - expect(svc().isAddonEnabled('budget')).toBe(true); + expect(await svc().isAddonEnabled('budget')).toBe(true); dbMock._stmt.get.mockReturnValueOnce({ enabled: 0 }); - expect(svc().isAddonEnabled('budget')).toBe(false); + expect(await svc().isAddonEnabled('budget')).toBe(false); dbMock._stmt.get.mockReturnValueOnce(undefined); - expect(svc().isAddonEnabled('nope')).toBe(false); + expect(await svc().isAddonEnabled('nope')).toBe(false); }); - it('getBagTracking is opt-in: only the literal string true enables it', () => { + it('getBagTracking is opt-in: only the literal string true enables it', async () => { dbMock._stmt.get.mockReturnValueOnce({ value: 'true' }); - expect(svc().getBagTracking()).toEqual({ enabled: true }); + expect(await svc().getBagTracking()).toEqual({ enabled: true }); dbMock._stmt.get.mockReturnValueOnce({ value: 'false' }); - expect(svc().getBagTracking()).toEqual({ enabled: false }); + expect(await svc().getBagTracking()).toEqual({ enabled: false }); dbMock._stmt.get.mockReturnValueOnce(undefined); // absent row → OFF - expect(svc().getBagTracking()).toEqual({ enabled: false }); + expect(await svc().getBagTracking()).toEqual({ enabled: false }); }); - it('updateBagTracking persists true/false strings and echoes the flag (ADMIN-SVC-030)', () => { - expect(svc().updateBagTracking(true)).toEqual({ enabled: true }); + it('updateBagTracking persists true/false strings and echoes the flag (ADMIN-SVC-030)', async () => { + expect(await svc().updateBagTracking(true)).toEqual({ enabled: true }); expect(dbMock._stmt.run).toHaveBeenCalledWith('true'); - expect(svc().updateBagTracking(false)).toEqual({ enabled: false }); + expect(await svc().updateBagTracking(false)).toEqual({ enabled: false }); expect(dbMock._stmt.run).toHaveBeenCalledWith('false'); }); - it('getCollabFeatures is opt-out: absent rows default ON, only false disables', () => { + it('getCollabFeatures is opt-out: absent rows default ON, only false disables', async () => { dbMock._stmt.all.mockReturnValueOnce([ { key: 'collab_chat_enabled', value: 'false' }, { key: 'collab_polls_enabled', value: 'true' }, ]); - expect(svc().getCollabFeatures()).toEqual({ chat: false, notes: true, links: true, polls: true, whatsnext: true }); + expect(await svc().getCollabFeatures()).toEqual({ chat: false, notes: true, links: true, polls: true, whatsnext: true }); }); - it('updateCollabFeatures writes only the provided flags and reports changed (#1414, ADMIN-SVC-070)', () => { + it('updateCollabFeatures writes only the provided flags and reports changed (#1414, ADMIN-SVC-070)', async () => { // before-read: all default ON; after-read: chat flipped off → changed dbMock._stmt.all.mockReturnValueOnce([]).mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }]); - const first = svc().updateCollabFeatures({ chat: false }); + const first = await svc().updateCollabFeatures({ chat: false }); expect(first.changed).toBe(true); expect(first.features.chat).toBe(false); expect(dbMock._stmt.run).toHaveBeenCalledTimes(1); @@ -328,14 +328,14 @@ describe('AddonsService addon/feature flags', () => { dbMock._stmt.all .mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }]) .mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }]); - const second = svc().updateCollabFeatures({ chat: false }); + const second = await svc().updateCollabFeatures({ chat: false }); expect(second.changed).toBe(false); expect(dbMock._stmt.run).toHaveBeenCalledWith('collab_chat_enabled', 'false'); // undefined flags are not written dbMock._stmt.run.mockClear(); dbMock._stmt.all.mockReturnValueOnce([]).mockReturnValueOnce([]); - const third = svc().updateCollabFeatures({}); + const third = await svc().updateCollabFeatures({}); expect(third.changed).toBe(false); expect(dbMock._stmt.run).not.toHaveBeenCalled(); }); @@ -359,30 +359,30 @@ describe('AddonsService places flags', () => { ['getPlacesDetails', 'updatePlacesDetails', 'places_details_enabled'], ] as const; - it('ADDONS-SVC-080 an unset flag reads as OFF, and anything but the literal true does too', () => { + it('ADDONS-SVC-080 an unset flag reads as OFF, and anything but the literal true does too', async () => { for (const [getter, , key] of cases) { dbMock._stmt.get.mockReturnValueOnce(undefined); - expect(svc()[getter]()).toEqual({ enabled: false }); + expect(await svc()[getter]()).toEqual({ enabled: false }); expect(dbMock.prepare).toHaveBeenLastCalledWith('SELECT value FROM app_settings WHERE key = ?'); expect(dbMock._stmt.get).toHaveBeenLastCalledWith(key); dbMock._stmt.get.mockReturnValueOnce({ value: 'garbage' }); - expect(svc()[getter]()).toEqual({ enabled: false }); + expect(await svc()[getter]()).toEqual({ enabled: false }); } }); - it('ADDONS-SVC-081 a stored "true" reads as ON', () => { + it('ADDONS-SVC-081 a stored "true" reads as ON', async () => { for (const [getter] of cases) { dbMock._stmt.get.mockReturnValueOnce({ value: 'true' }); - expect(svc()[getter]()).toEqual({ enabled: true }); + expect(await svc()[getter]()).toEqual({ enabled: true }); } }); - it('ADDONS-SVC-082 the setters persist the literal string and echo the boolean back', () => { + it('ADDONS-SVC-082 the setters persist the literal string and echo the boolean back', async () => { for (const [, setter, key] of cases) { - expect(svc()[setter](true)).toEqual({ enabled: true }); + expect(await svc()[setter](true)).toEqual({ enabled: true }); expect(dbMock._stmt.run).toHaveBeenLastCalledWith(key, 'true'); - expect(svc()[setter](false)).toEqual({ enabled: false }); + expect(await svc()[setter](false)).toEqual({ enabled: false }); expect(dbMock._stmt.run).toHaveBeenLastCalledWith(key, 'false'); } }); @@ -403,25 +403,25 @@ describe('AddonsService places enrichment flag', () => { vi.clearAllMocks(); }); - it('ADDONS-SVC-083 an unset flag reads as ON', () => { + it('ADDONS-SVC-083 an unset flag reads as ON', async () => { dbMock._stmt.get.mockReturnValueOnce(undefined); - expect(svc().getPlacesEnrich()).toEqual({ enabled: true }); + expect(await svc().getPlacesEnrich()).toEqual({ enabled: true }); }); - it('ADDONS-SVC-084 only the literal "false" switches it off', () => { + it('ADDONS-SVC-084 only the literal "false" switches it off', async () => { dbMock._stmt.get.mockReturnValueOnce({ value: 'false' }); - expect(svc().getPlacesEnrich()).toEqual({ enabled: false }); + expect(await svc().getPlacesEnrich()).toEqual({ enabled: false }); for (const value of ['true', 'garbage', '']) { dbMock._stmt.get.mockReturnValueOnce({ value }); - expect(svc().getPlacesEnrich()).toEqual({ enabled: true }); + expect(await svc().getPlacesEnrich()).toEqual({ enabled: true }); } }); - it('ADDONS-SVC-085 the setter persists the literal string and echoes the boolean back', () => { - expect(svc().updatePlacesEnrich(false)).toEqual({ enabled: false }); + it('ADDONS-SVC-085 the setter persists the literal string and echoes the boolean back', async () => { + expect(await svc().updatePlacesEnrich(false)).toEqual({ enabled: false }); expect(dbMock._stmt.run).toHaveBeenLastCalledWith('places_enrich_enabled', 'false'); - expect(svc().updatePlacesEnrich(true)).toEqual({ enabled: true }); + expect(await svc().updatePlacesEnrich(true)).toEqual({ enabled: true }); expect(dbMock._stmt.run).toHaveBeenLastCalledWith('places_enrich_enabled', 'true'); }); }); @@ -437,27 +437,27 @@ describe('AddonsService transit provider', () => { vi.clearAllMocks(); }); - it('ADDONS-SVC-086 an unset provider reads as Transitous, with no key anywhere', () => { + it('ADDONS-SVC-086 an unset provider reads as Transitous, with no key anywhere', async () => { dbMock._stmt.get.mockReturnValue(undefined); - expect(svc().getTransitProvider()).toEqual({ provider: 'transitous', googleKeySource: null }); + expect(await svc().getTransitProvider()).toEqual({ provider: 'transitous', googleKeySource: null }); }); - it('ADDONS-SVC-087 only a known provider name is honoured', () => { + it('ADDONS-SVC-087 only a known provider name is honoured', async () => { dbMock._stmt.get.mockReturnValueOnce({ value: 'google' }).mockReturnValue(undefined); - expect(svc().getTransitProvider().provider).toBe('google'); + expect((await svc().getTransitProvider()).provider).toBe('google'); for (const value of ['someday-maps', '', 'GOOGLE']) { dbMock._stmt.get.mockReset(); dbMock._stmt.get.mockReturnValueOnce({ value }).mockReturnValue(undefined); - expect(svc().getTransitProvider().provider).toBe('transitous'); + expect((await svc().getTransitProvider()).provider).toBe('transitous'); } }); - it('ADDONS-SVC-088 the setter persists the name and echoes it back', () => { + it('ADDONS-SVC-088 the setter persists the name and echoes it back', async () => { dbMock._stmt.get.mockReturnValue(undefined); - expect(svc().updateTransitProvider('google').provider).toBe('google'); + expect((await svc().updateTransitProvider('google')).provider).toBe('google'); expect(dbMock._stmt.run).toHaveBeenLastCalledWith('transit_provider', 'google'); - expect(svc().updateTransitProvider('transitous').provider).toBe('transitous'); + expect((await svc().updateTransitProvider('transitous')).provider).toBe('transitous'); expect(dbMock._stmt.run).toHaveBeenLastCalledWith('transit_provider', 'transitous'); }); @@ -466,11 +466,11 @@ describe('AddonsService transit provider', () => { * the instance/user-row split is the part worth pinning: only 'user-row' * means "works for this admin, Transitous for everybody else". */ - it('ADDONS-SVC-089 reports where the Google key resolved from', () => { + it('ADDONS-SVC-089 reports where the Google key resolved from', async () => { // provider row, then the instance maps_api_key row. dbMock._stmt.get.mockReset(); dbMock._stmt.get.mockReturnValueOnce({ value: 'google' }).mockReturnValueOnce({ value: 'instance-key' }); - expect(svc().getTransitProvider(7).googleKeySource).toBe('instance'); + expect((await svc().getTransitProvider(7)).googleKeySource).toBe('instance'); // No instance row, but the caller's own users column has one. dbMock._stmt.get.mockReset(); @@ -478,12 +478,12 @@ describe('AddonsService transit provider', () => { .mockReturnValueOnce({ value: 'google' }) .mockReturnValueOnce(undefined) .mockReturnValueOnce({ maps_api_key: 'personal-key' }); - expect(svc().getTransitProvider(7).googleKeySource).toBe('user-row'); + expect((await svc().getTransitProvider(7)).googleKeySource).toBe('user-row'); // Nothing anywhere. dbMock._stmt.get.mockReset(); dbMock._stmt.get.mockReturnValue(undefined); - expect(svc().getTransitProvider(7).googleKeySource).toBeNull(); + expect((await svc().getTransitProvider(7)).googleKeySource).toBeNull(); }); }); @@ -500,24 +500,24 @@ describe('AddonsService place shadow flag', () => { vi.clearAllMocks(); }); - it('ADDONS-SVC-090 an unset flag reads as OFF, and so does every value but the literal "true"', () => { + it('ADDONS-SVC-090 an unset flag reads as OFF, and so does every value but the literal "true"', async () => { dbMock._stmt.get.mockReturnValueOnce(undefined); - expect(svc().getPlaceShadow()).toEqual({ enabled: false }); + expect(await svc().getPlaceShadow()).toEqual({ enabled: false }); expect(dbMock.prepare).toHaveBeenLastCalledWith('SELECT value FROM app_settings WHERE key = ?'); expect(dbMock._stmt.get).toHaveBeenLastCalledWith('place_shadow_enabled'); for (const value of ['false', 'TRUE', '1', '']) { dbMock._stmt.get.mockReturnValueOnce({ value }); - expect(svc().getPlaceShadow()).toEqual({ enabled: false }); + expect(await svc().getPlaceShadow()).toEqual({ enabled: false }); } }); - it('ADDONS-SVC-091 a stored "true" reads as ON', () => { + it('ADDONS-SVC-091 a stored "true" reads as ON', async () => { dbMock._stmt.get.mockReturnValueOnce({ value: 'true' }); - expect(svc().getPlaceShadow()).toEqual({ enabled: true }); + expect(await svc().getPlaceShadow()).toEqual({ enabled: true }); }); - it('ADDONS-SVC-092 the setter round-trips through the getter under its own key', () => { + it('ADDONS-SVC-092 the setter round-trips through the getter under its own key', async () => { // Keyed store instead of an echo assertion: the write has to produce the // exact string the read compares against, so a setter persisting '1' fails // here rather than silently reading back OFF in production. @@ -530,19 +530,19 @@ describe('AddonsService place shadow flag', () => { return value === undefined ? undefined : { value }; }); - expect(svc().updatePlaceShadow(true)).toEqual({ enabled: true }); + expect(await svc().updatePlaceShadow(true)).toEqual({ enabled: true }); expect(dbMock._stmt.run).toHaveBeenLastCalledWith('place_shadow_enabled', 'true'); - expect(svc().getPlaceShadow()).toEqual({ enabled: true }); + expect(await svc().getPlaceShadow()).toEqual({ enabled: true }); // a sibling switch must not ride along on the shared statement - expect(svc().getPlacesDetails()).toEqual({ enabled: false }); + expect(await svc().getPlacesDetails()).toEqual({ enabled: false }); - expect(svc().updatePlaceShadow(false)).toEqual({ enabled: false }); + expect(await svc().updatePlaceShadow(false)).toEqual({ enabled: false }); expect(dbMock._stmt.run).toHaveBeenLastCalledWith('place_shadow_enabled', 'false'); - expect(svc().getPlaceShadow()).toEqual({ enabled: false }); + expect(await svc().getPlaceShadow()).toEqual({ enabled: false }); expect([...stored.keys()]).toEqual(['place_shadow_enabled']); }); - it('ADDONS-SVC-093 answers the same as PlaceShadowService.enabled() for every stored value', () => { + it('ADDONS-SVC-093 answers the same as PlaceShadowService.enabled() for every stored value', async () => { const shadow = new PlaceShadowService(new DatabaseService(dbConn)); const rows: Array<[{ value: string } | undefined, boolean]> = [ [undefined, false], @@ -559,7 +559,7 @@ describe('AddonsService place shadow flag', () => { // so the key each one names has to be asserted too: the getter binds it, // the gate inlines it, and a divergence there would still look like // agreement on the value alone. - expect(svc().getPlaceShadow()).toEqual({ enabled: expected }); + expect(await svc().getPlaceShadow()).toEqual({ enabled: expected }); expect(dbMock._stmt.get).toHaveBeenLastCalledWith('place_shadow_enabled'); expect(shadow.enabled()).toBe(expected); diff --git a/server/tests/unit/nest/admin-default-user-settings.controller.test.ts b/server/tests/unit/nest/admin-default-user-settings.controller.test.ts index c4e17a243..1af27403b 100644 --- a/server/tests/unit/nest/admin-default-user-settings.controller.test.ts +++ b/server/tests/unit/nest/admin-default-user-settings.controller.test.ts @@ -44,11 +44,11 @@ describe('AdminDefaultUserSettingsController', () => { expect(controller().c.get()).toEqual({ theme: 'dark' }); }); - it('DEFAULTS-002 PUT writes, audits, and answers with the STORED defaults', () => { + it('DEFAULTS-002 PUT writes, audits, and answers with the STORED defaults', async () => { const { c, settings } = controller(); // Not the request body: the service normalises and drops unknown keys, and the // admin panel renders straight from this response. - expect(c.update(user, { theme: 'light' } as never, req)).toEqual({ theme: 'dark' }); + expect(await c.update(user, { theme: 'light' } as never, req)).toEqual({ theme: 'dark' }); expect(settings.setAdminUserDefaults).toHaveBeenCalledWith({ theme: 'light' }); expect(writeAudit).toHaveBeenCalledWith( expect.objectContaining({ userId: 1, action: 'admin.default_user_settings_update', details: { theme: 'light' } }), diff --git a/server/tests/unit/nest/admin-packing-templates.controller.test.ts b/server/tests/unit/nest/admin-packing-templates.controller.test.ts index 5910e654e..d0c3a62a8 100644 --- a/server/tests/unit/nest/admin-packing-templates.controller.test.ts +++ b/server/tests/unit/nest/admin-packing-templates.controller.test.ts @@ -73,18 +73,18 @@ describe('AdminPackingTemplatesController', () => { expect(thrown(() => c.createCategory('1', { name: '' }))).toEqual({ status: 400, body: { error: 'name required' } }); }); - it('PACKTPL-004 create audits with the new template id', () => { + it('PACKTPL-004 create audits with the new template id', async () => { const { c, packing } = controller(); - expect(c.create(user, { name: 'Beach' }, req)).toEqual({ template: { id: 9 } }); + expect(await c.create(user, { name: 'Beach' }, req)).toEqual({ template: { id: 9 } }); expect(packing.createPackingTemplate).toHaveBeenCalledWith('Beach', 1); expect(writeAudit).toHaveBeenCalledWith( expect.objectContaining({ action: 'admin.packing_template_create', resource: '9', details: { name: 'Beach' } }), ); }); - it('PACKTPL-005 delete audits the removed name and answers { success: true }', () => { + it('PACKTPL-005 delete audits the removed name and answers { success: true }', async () => { const { c } = controller(); - expect(c.remove(user, '1', req)).toEqual({ success: true }); + expect(await c.remove(user, '1', req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith( expect.objectContaining({ action: 'admin.packing_template_delete', resource: '1', details: { name: 'Beach' } }), ); diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index df5934b71..edcef987a 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -83,36 +83,36 @@ beforeEach(() => vi.clearAllMocks()); afterEach(() => { delete process.env.NODE_ENV; }); describe('AdminController users', () => { - it('lists, creates (201 + audit), maps an error', () => { + it('lists, creates (201 + audit), maps an error', async () => { expect(adminCtl(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listUsers()).toEqual({ users: [{ id: 1 }] }); expect(thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial)).createUser(user, {}, req))).toEqual({ status: 409, body: { error: 'Email taken' } }); const c = adminCtl(svc({ createUser: vi.fn().mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} }) } as Partial)); - expect(c.createUser(user, { email: 'a@b.c' }, req)).toEqual({ user: { id: 2 } }); + expect(await c.createUser(user, { email: 'a@b.c' }, req)).toEqual({ user: { id: 2 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.user_create' })); }); - it('update + delete audit and map errors', () => { - expect(adminCtl(svc({ updateUser: vi.fn().mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] }) } as Partial)).updateUser(user, '2', {}, req)).toEqual({ user: { id: 2 } }); + it('update + delete audit and map errors', async () => { + expect(await adminCtl(svc({ updateUser: vi.fn().mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] }) } as Partial)).updateUser(user, '2', {}, req)).toEqual({ user: { id: 2 } }); expect(thrown(() => adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ error: 'Cannot delete self', status: 400 }) } as Partial)).deleteUser(user, '1', req))).toEqual({ status: 400, body: { error: 'Cannot delete self' } }); - expect(adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ email: 'a@b.c' }) } as Partial)).deleteUser(user, '2', req)).toEqual({ success: true }); + expect(await adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ email: 'a@b.c' }) } as Partial)).deleteUser(user, '2', req)).toEqual({ success: true }); }); }); describe('AdminController permissions + oidc + misc', () => { - it('permissions: saves + audits', () => { + it('permissions: saves + audits', async () => { const c = adminCtl(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: { x: 1 }, skipped: [] }) } as Partial)); - expect(c.savePermissions(user, { permissions: { x: 1 } }, req)).toEqual({ success: true, permissions: { x: 1 } }); + expect(await c.savePermissions(user, { permissions: { x: 1 } }, req)).toEqual({ success: true, permissions: { x: 1 } }); }); - it('permissions: includes skipped when present', () => { + it('permissions: includes skipped when present', async () => { const c = adminCtl(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: {}, skipped: ['bad'] }) } as Partial)); - expect(c.savePermissions(user, { permissions: {} }, req)).toEqual({ success: true, permissions: {}, skipped: ['bad'] }); + expect(await c.savePermissions(user, { permissions: {} }, req)).toEqual({ success: true, permissions: {}, skipped: ['bad'] }); }); - it('save-demo-baseline maps error, else returns message', () => { + it('save-demo-baseline maps error, else returns message', async () => { expect(thrown(() => adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ error: 'not demo', status: 400 }) } as Partial)).saveDemoBaseline(user, req))).toEqual({ status: 400, body: { error: 'not demo' } }); - expect(adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial)).saveDemoBaseline(user, req)).toEqual({ success: true, message: 'saved' }); + expect(await adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial)).saveDemoBaseline(user, req)).toEqual({ success: true, message: 'saved' }); }); }); @@ -120,9 +120,9 @@ describe('AdminController permissions + oidc + misc', () => { // domains; their cases live in admin-feature-toggles.controller.test.ts and // admin-packing-templates.controller.test.ts. describe('AdminController invites', () => { - it('invites: create 201 + audit, delete maps error', () => { + it('invites: create 201 + audit, delete maps error', async () => { const c = adminCtl(svc(), undefined, undefined, {}, { createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) }); - expect(c.createInvite(user, {}, req)).toEqual({ invite: { id: 5 } }); + expect(await c.createInvite(user, {}, req)).toEqual({ invite: { id: 5 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.invite_create' })); expect(thrown(() => adminCtl(svc(), undefined, undefined, {}, { deleteInvite: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) }).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } }); }); @@ -165,10 +165,10 @@ describe('AdminController addons + sessions + jwt + defaults', () => { expect(writeAudit).not.toHaveBeenCalled(); }); - it('oauth-sessions revoke audits; rotate-jwt maps error', () => { - expect(adminCtl(svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockReturnValue({}) }).revokeOAuthSession(user, '3', req)).toEqual({ success: true }); + it('oauth-sessions revoke audits; rotate-jwt maps error', async () => { + expect(await adminCtl(svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockReturnValue({}) }).revokeOAuthSession(user, '3', req)).toEqual({ success: true }); expect(thrown(() => adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({ error: 'locked', status: 409 }) } as Partial)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } }); - expect(adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial)).rotateJwtSecret(user, req)).toEqual({ success: true }); + expect(await adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial)).rotateJwtSecret(user, req)).toEqual({ success: true }); }); }); @@ -183,7 +183,7 @@ describe('AdminController error envelope fallbacks', () => { describe('AdminController read-only getters', () => { it('return service values verbatim', async () => { - expect(adminCtl(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 }); + expect(await adminCtl(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 }); expect(adminCtl(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial)).stats()).toEqual({ users: 3 }); expect(adminCtl(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial)).permissions()).toEqual({ a: 1 }); expect(adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial)).auditLog({})).toEqual({ entries: [] }); @@ -208,8 +208,8 @@ describe('AdminController read-only getters', () => { describe('AdminController tokens + sessions', () => { - it('mcp token + oauth session deletes return success and map errors', () => { - expect(adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, '2', req)).toEqual({ success: true }); + it('mcp token + oauth session deletes return success and map errors', async () => { + expect(await adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, '2', req)).toEqual({ success: true }); expect(thrown(() => adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) }).deleteMcpToken(user, '9', req))).toEqual({ status: 404, body: { error: 'no token' } }); expect(thrown(() => adminCtl(svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) }).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } }); }); @@ -280,17 +280,17 @@ describe('AdminController feature toggles', () => { expect(c.getCollabFeatures()).toEqual({ chat: false }); }); - it('ADMIN-TOGGLE-002b places-enrich updates through the addons domain and is audited', () => { + it('ADMIN-TOGGLE-002b places-enrich updates through the addons domain and is audited', async () => { const c = adminCtl(svc()); - expect(c.updatePlacesEnrich(user, { enabled: false }, req)).toEqual({ enabled: false }); + expect(await c.updatePlacesEnrich(user, { enabled: false }, req)).toEqual({ enabled: false }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.places_enrich' })); }); - it('ADMIN-TOGGLE-002c transit-provider reads and writes through the addons domain and is audited (#1699)', () => { + it('ADMIN-TOGGLE-002c transit-provider reads and writes through the addons domain and is audited (#1699)', async () => { const addons = addonsStub(); const c = adminCtl(svc(), undefined, addons); expect(c.getTransitProvider(user)).toEqual({ provider: 'transitous', googleKeySource: null }); - expect(c.updateTransitProvider(user, { provider: 'google' }, req)).toEqual({ provider: 'google', googleKeySource: null }); + expect(await c.updateTransitProvider(user, { provider: 'google' }, req)).toEqual({ provider: 'google', googleKeySource: null }); // The acting admin's id drives key resolution — the panel warns about the // key THEY would search with. expect(addons.getTransitProvider).toHaveBeenCalledWith(user.id); @@ -300,17 +300,17 @@ describe('AdminController feature toggles', () => { ); }); - it('ADMIN-TOGGLE-004 place-shadow forwards the body value and audits the stored one', () => { + it('ADMIN-TOGGLE-004 place-shadow forwards the body value and audits the stored one', async () => { // The handler audits result.enabled. A service that declines the flip must // not leave behind an audit row claiming the shadow log was switched on. const updatePlaceShadow = vi.fn(() => ({ enabled: false })); const c = adminCtl(svc(), undefined, { ...addonsStub(), updatePlaceShadow } as unknown as AddonsService); - expect(c.updatePlaceShadow(user, { enabled: true }, req)).toEqual({ enabled: false }); + expect(await c.updatePlaceShadow(user, { enabled: true }, req)).toEqual({ enabled: false }); expect(updatePlaceShadow).toHaveBeenCalledWith(true); expect(writeAudit).toHaveBeenCalledWith({ userId: user.id, action: 'admin.place_shadow', ip: '1.2.3.4', details: { enabled: false } }); // Off is carried too: the table above only ever asks for true, so a handler // that hardcoded the argument would pass it. - c.updatePlaceShadow(user, { enabled: false }, req); + await c.updatePlaceShadow(user, { enabled: false }, req); expect(updatePlaceShadow).toHaveBeenLastCalledWith(false); }); @@ -322,10 +322,10 @@ describe('AdminController feature toggles', () => { expect(writeAudit).not.toHaveBeenCalled(); }); - it('ADMIN-TOGGLE-003 collab-features invalidates MCP sessions only when a flag flipped (#1414)', () => { + it('ADMIN-TOGGLE-003 collab-features invalidates MCP sessions only when a flag flipped (#1414)', async () => { const invalidateMcpSessions = vi.fn(); const c = adminCtl(svc({ invalidateMcpSessions } as Partial)); - expect(c.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true }); + expect(await c.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true }); expect(invalidateMcpSessions).toHaveBeenCalled(); const noopInvalidate = vi.fn(); @@ -334,7 +334,7 @@ describe('AdminController feature toggles', () => { undefined, { ...addonsStub(), updateCollabFeatures: vi.fn(() => ({ features: { chat: true }, changed: false })) } as unknown as AddonsService, ); - expect(noop.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true }); + expect(await noop.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true }); expect(noopInvalidate).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index 39c7dda7e..9cf8ea4f2 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -82,18 +82,28 @@ import { __clearVersionCacheForTests } from '../../../src/nest/admin/admin.helpe import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications'; import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; const dbs = new DatabaseService(testDb); const realtime = new RealtimeService(); -const permissions = new PermissionsService(dbs); + const webauthn = new WebauthnConfigService(dbs); -const userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime)); + // Positional and previously wrong: an AtlasService sat in the membership slot // and the mailer was missing entirely, so `auth` was built with its last four // collaborators shifted by one. Nothing failed, because none of the cases below // reach a path that uses them. -const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs)); -const svc = new AdminService( + +let permissions: PermissionsService; +let userCleanup: UserCleanupService; +let auth: AuthService; +let svc: AdminService; +beforeAll(async () => { + permissions = new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)); + userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime)); + auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs), await createTestUnitOfWork(dbs.connection)); + svc = new AdminService( dbs, new AddonsService(dbs), new PasskeyService(dbs, auth, webauthn), @@ -103,6 +113,7 @@ const svc = new AdminService( userCleanup, realtime, ); +}); // Legacy free-function names bound to the service, so the moved cases below read // exactly as they did before the fold. @@ -277,8 +288,8 @@ describe('Permissions', () => { expect(result.permissions.length).toBeGreaterThan(0); }); - it('ADMIN-SVC-020 — savePermissions persists a permission change', () => { - savePermissions({ trip_create: 'admin' }); + it('ADMIN-SVC-020 — savePermissions persists a permission change', async () => { + await savePermissions({ trip_create: 'admin' }); const result = getPermissions() as any; const perm = result.permissions.find((p: any) => p.key === 'trip_create'); expect(perm.level).toBe('admin'); @@ -494,8 +505,8 @@ describe('updateAddon', () => { expect(result.error).toBeDefined(); }); - it('ADMIN-SVC-069 — mcpAffected only fires on a real enabled-flip of an MCP-relevant addon (#1414)', () => { - updateAddon('packing', { enabled: true }); + it('ADMIN-SVC-069 — mcpAffected only fires on a real enabled-flip of an MCP-relevant addon (#1414)', async () => { + await updateAddon('packing', { enabled: true }); // no-op save (enabled already true) → sessions survive expect((updateAddon('packing', { enabled: true }) as any).mcpAffected).toBe(false); // config-only save → sessions survive diff --git a/server/tests/unit/nest/admin.version-notification.test.ts b/server/tests/unit/nest/admin.version-notification.test.ts index fc514e145..967d808e6 100644 --- a/server/tests/unit/nest/admin.version-notification.test.ts +++ b/server/tests/unit/nest/admin.version-notification.test.ts @@ -60,18 +60,28 @@ import { AdminService } from '../../../src/nest/admin/admin.service'; import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications'; import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; const dbs = new DatabaseService(testDb); const realtime = new RealtimeService(); -const permissions = new PermissionsService(dbs); + const webauthn = new WebauthnConfigService(dbs); -const userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime)); + // Positional and previously wrong: an AtlasService sat in the membership slot // and the mailer was missing entirely, so `auth` was built with its last four // collaborators shifted by one. Nothing failed, because the version-check path // below never reaches them. -const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs)); -const svc = new AdminService( + +let permissions: PermissionsService; +let userCleanup: UserCleanupService; +let auth: AuthService; +let svc: AdminService; +beforeAll(async () => { + permissions = new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)); + userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime)); + auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs), await createTestUnitOfWork(dbs.connection)); + svc = new AdminService( dbs, new AddonsService(dbs), new PasskeyService(dbs, auth, webauthn), @@ -81,6 +91,7 @@ const svc = new AdminService( userCleanup, realtime, ); +}); const checkAndNotifyVersion = () => svc.checkAndNotifyVersion(); // Helper: mock the GitHub releases/latest endpoint diff --git a/server/tests/unit/nest/assignments.controller.test.ts b/server/tests/unit/nest/assignments.controller.test.ts index 09d86a6fc..b555ae84d 100644 --- a/server/tests/unit/nest/assignments.controller.test.ts +++ b/server/tests/unit/nest/assignments.controller.test.ts @@ -32,9 +32,9 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route expect(thrown(() => new DayAssignmentsController(s).list(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Day not found' } }); }); - it('GET returns assignments (access-only, no permission gate)', () => { + it('GET returns assignments (access-only, no permission gate)', async () => { const s = svc({ canEdit: vi.fn().mockReturnValue(false), listDayAssignments: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); - expect(new DayAssignmentsController(s).list(user, '5', '3')).toEqual({ assignments: [{ id: 1 }] }); + expect(await new DayAssignmentsController(s).list(user, '5', '3')).toEqual({ assignments: [{ id: 1 }] }); }); describe('POST', () => { @@ -79,12 +79,12 @@ describe('AssignmentOpsController (parity with the per-assignment op routes)', ( expect(thrown(() => controller.endDay('5', '9', { end_day: false }))).toEqual({ status: 404, body: { error: 'Assignment not found' } }); }); - it('PUT /:id/move 404 assignment, 404 target day, else moves', () => { + it('PUT /:id/move 404 assignment, 404 target day, else moves', async () => { expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined) } as Partial)).move(user, '5', '9', { new_day_id: 4 }))).toEqual({ status: 404, body: { error: 'Assignment not found' } }); expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ day_id: 3 }), dayExists: vi.fn().mockReturnValue(false) } as Partial)).move(user, '5', '9', { new_day_id: 4 }))).toEqual({ status: 404, body: { error: 'Target day not found' } }); const moveAssignment = vi.fn().mockReturnValue({ assignment: { id: 9 }, oldDayId: 3 }); const broadcast = vi.fn(); const reconcile = vi.fn(); const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ day_id: 3 }), moveAssignment, broadcast, reconcile } as Partial); - expect(new AssignmentOpsController(s).move(user, '5', '9', { new_day_id: 4, order_index: 0 }, 'sock')).toEqual({ assignment: { id: 9 } }); + expect(await new AssignmentOpsController(s).move(user, '5', '9', { new_day_id: 4, order_index: 0 }, 'sock')).toEqual({ assignment: { id: 9 } }); expect(moveAssignment).toHaveBeenCalledWith('9', 4, 0); expect(broadcast).toHaveBeenCalledWith('5', 'assignment:moved', { assignment: { id: 9 }, oldDayId: 3, newDayId: 4 }, 'sock'); expect(reconcile).toHaveBeenCalledWith('5', 'sock'); @@ -97,24 +97,24 @@ describe('AssignmentOpsController (parity with the per-assignment op routes)', ( expect(new AssignmentOpsController(s).participants(user, '5', '9')).toEqual({ participants: [{ user_id: 2 }] }); }); - it('PUT /:id/time 404 missing, else updates', () => { + it('PUT /:id/time 404 missing, else updates', async () => { expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined) } as Partial)).time(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Assignment not found' } }); const updateTime = vi.fn().mockReturnValue({ assignment: { id: 9 }, reordered: null, vias: null }); const broadcast = vi.fn(); const reconcile = vi.fn(); const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), updateTime, broadcast, reconcile } as Partial); - expect(new AssignmentOpsController(s).time(user, '5', '9', { place_time: '10:00' }, 'sock')).toEqual({ assignment: { id: 9 } }); + expect(await new AssignmentOpsController(s).time(user, '5', '9', { place_time: '10:00' }, 'sock')).toEqual({ assignment: { id: 9 } }); expect(updateTime).toHaveBeenCalledWith('9', '10:00', undefined); // Nothing moved, so the row is all collaborators hear about. expect(broadcast.mock.calls).toEqual([['5', 'assignment:updated', { assignment: { id: 9 } }, 'sock']]); expect(reconcile).toHaveBeenCalledWith('5', 'sock'); }); - it('PUT /:id/time sends the whole day and the re-pinned vias to every socket when a start moved stops', () => { + it('PUT /:id/time sends the whole day and the re-pinned vias to every socket when a start moved stops', async () => { const reordered = { dayId: 3, orderedIds: [7, 9, 8] }; const vias = { dayId: 3, vias: [{ id: 40, day_id: 3, after_order_index: 1, sequence: 0, lat: 1, lng: 2 }] }; const updateTime = vi.fn().mockReturnValue({ assignment: { id: 9 }, reordered, vias }); const broadcast = vi.fn(); const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), updateTime, broadcast } as Partial); - expect(new AssignmentOpsController(s).time(user, '5', '9', { place_time: '10:00' }, 'sock')).toEqual({ assignment: { id: 9 } }); + expect(await new AssignmentOpsController(s).time(user, '5', '9', { place_time: '10:00' }, 'sock')).toEqual({ assignment: { id: 9 } }); expect(broadcast.mock.calls).toEqual([ ['5', 'assignment:updated', { assignment: { id: 9 } }, 'sock'], diff --git a/server/tests/unit/nest/assignments.service.test.ts b/server/tests/unit/nest/assignments.service.test.ts index d7c381081..6cac1302e 100644 --- a/server/tests/unit/nest/assignments.service.test.ts +++ b/server/tests/unit/nest/assignments.service.test.ts @@ -55,18 +55,23 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; const dbs = new DatabaseService(testDb); const realtime = new RealtimeService(); -const svc = new AssignmentsService( +let svc: AssignmentsService; +beforeAll(async () => { + svc = new AssignmentsService( dbs, - new PermissionsService(dbs), + new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), realtime, new QueryHelpersService(dbs), // Real collaborator rather than a stub: reconcile() runs after every mutation // and needs the same connection to see the rows these cases write. new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)), ); +}); /** * canEdit only reads trip.user_id, so the case below hands it just that field. @@ -117,12 +122,12 @@ describe('verifyTripAccess', () => { }); describe('canEdit', () => { - it('ASG-SVC-003: owner and member pass day_edit (default trip_member)', () => { + it('ASG-SVC-003: owner and member pass day_edit (default trip_member)', async () => { const { user, trip } = fixture(); const { user: member } = createUser(testDb); addTripMember(testDb, trip.id, member.id); - expect(svc.canEdit(tripOwnedBy(trip.user_id), user)).toBe(true); - expect(svc.canEdit(tripOwnedBy(trip.user_id), member)).toBe(true); + expect(await svc.canEdit(tripOwnedBy(trip.user_id), user)).toBe(true); + expect(await svc.canEdit(tripOwnedBy(trip.user_id), member)).toBe(true); }); }); @@ -159,21 +164,21 @@ describe('dayExists / placeExists / assignmentExistsInDay', () => { // ── createAssignment ────────────────────────────────────────────────────────── describe('createAssignment', () => { - it('ASG-SVC-007: first assignment gets order_index 0, then MAX+1 appends', () => { + it('ASG-SVC-007: first assignment gets order_index 0, then MAX+1 appends', async () => { const { day, place } = fixture(); const first = svc.createAssignment(day.id, place.id, null); const second = svc.createAssignment(day.id, place.id, null); - expect(first!.order_index).toBe(0); - expect(second!.order_index).toBe(1); + expect((await first!).order_index).toBe(0); + expect((await second!).order_index).toBe(1); }); - it('ASG-SVC-008: empty-string notes coerce to null (`notes || null`)', () => { + it('ASG-SVC-008: empty-string notes coerce to null (`notes || null`)', async () => { const { day, place } = fixture(); const a = svc.createAssignment(day.id, place.id, ''); - expect(a!.notes).toBeNull(); + expect((await a!).notes).toBeNull(); }); - it('ASG-SVC-009: returns the re-selected nested shape with place, category and compact tags', () => { + it('ASG-SVC-009: returns the re-selected nested shape with place, category and compact tags', async () => { const { user, trip, day, place } = fixture(); const tag = createTag(testDb, user.id, { name: 'museum' }); testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id); @@ -190,7 +195,7 @@ describe('createAssignment', () => { }); // The same compact tag projection as listDayAssignments — id/name/color/ // created_at only, no user_id — so both read paths share one wire shape. - expect(a!.place.tags).toEqual([{ id: tag.id, name: 'museum', color: '#10b981', created_at: (tag as { created_at?: string }).created_at }]); + expect((await a!).place.tags).toEqual([{ id: tag.id, name: 'museum', color: '#10b981', created_at: (tag as { created_at?: string }).created_at }]); void trip; }); }); @@ -198,7 +203,7 @@ describe('createAssignment', () => { // ── listDayAssignments ──────────────────────────────────────────────────────── describe('listDayAssignments', () => { - it('ASG-SVC-010: orders by order_index and formats with compact tags + participants', () => { + it('ASG-SVC-010: orders by order_index and formats with compact tags + participants', async () => { const { user, trip, day, place } = fixture(); const second = createPlace(testDb, trip.id, { name: 'Orsay' }); const tag = createTag(testDb, user.id, { name: 'art' }); @@ -207,7 +212,7 @@ describe('listDayAssignments', () => { const a2 = createDayAssignment(testDb, day.id, second.id, { order_index: 0 }); svc.setParticipants(a1.id, [user.id], trip.id); - const list = svc.listDayAssignments(day.id); + const list = await svc.listDayAssignments(day.id); expect(list.map(a => a.id)).toEqual([a2.id, a1.id]); // Compact tag projection (id/name/color/created_at only — no user_id key). expect(list[1].place.tags).toEqual([{ id: tag.id, name: 'art', color: '#10b981', created_at: (tag as { created_at?: string }).created_at }]); @@ -216,9 +221,9 @@ describe('listDayAssignments', () => { expect(list[0].place.tags).toEqual([]); }); - it('ASG-SVC-011: returns [] for an empty day', () => { + it('ASG-SVC-011: returns [] for an empty day', async () => { const { day } = fixture(); - expect(svc.listDayAssignments(day.id)).toEqual([]); + expect(await svc.listDayAssignments(day.id)).toEqual([]); }); }); @@ -261,22 +266,22 @@ describe('getAssignmentForTrip', () => { }); describe('moveAssignment', () => { - it('ASG-SVC-015: moves to the new day, re-selects the nested shape, derives oldDayId from the row', () => { + it('ASG-SVC-015: moves to the new day, re-selects the nested shape, derives oldDayId from the row', async () => { const { trip, day, place } = fixture(); const target = createDay(testDb, trip.id); const a = createDayAssignment(testDb, day.id, place.id); - const result = svc.moveAssignment(a.id, target.id, 2); + const result = await svc.moveAssignment(a.id, target.id, 2); expect(result.assignment).toMatchObject({ id: a.id, day_id: target.id, order_index: 2 }); expect(result.oldDayId).toBe(day.id); }); - it('ASG-SVC-016: nullish orderIndex coerces to 0; explicit 0 stays 0', () => { + it('ASG-SVC-016: nullish orderIndex coerces to 0; explicit 0 stays 0', async () => { const { trip, day, place } = fixture(); const target = createDay(testDb, trip.id); const a = createDayAssignment(testDb, day.id, place.id, { order_index: 7 }); - expect(svc.moveAssignment(a.id, target.id, undefined).assignment!.order_index).toBe(0); - expect(svc.moveAssignment(a.id, day.id, null).assignment!.order_index).toBe(0); - expect(svc.moveAssignment(a.id, target.id, 0).assignment!.order_index).toBe(0); + expect((await (await svc.moveAssignment(a.id, target.id, undefined)).assignment!).order_index).toBe(0); + expect((await (await svc.moveAssignment(a.id, day.id, null)).assignment!).order_index).toBe(0); + expect((await (await svc.moveAssignment(a.id, target.id, 0)).assignment!).order_index).toBe(0); }); }); @@ -375,32 +380,32 @@ describe('updateTime', () => { const viaAnchors = (dayId: number) => testDb.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ? ORDER BY id').all(dayId); - it('ASG-SVC-021: persists both times and re-selects the nested shape', () => { + it('ASG-SVC-021: persists both times and re-selects the nested shape', async () => { const { day, place } = fixture(); const a = createDayAssignment(testDb, day.id, place.id); - const { assignment } = svc.updateTime(a.id, '09:00', '11:30'); + const { assignment } = await svc.updateTime(a.id, '09:00', '11:30'); expect(assignment).toMatchObject({ id: a.id, assignment_time: '09:00', assignment_end_time: '11:30' }); }); - it('ASG-SVC-022: a start sorts the timed stops and leaves every untimed stop behind the one it followed', () => { + it('ASG-SVC-022: a start sorts the timed stops and leaves every untimed stop behind the one it followed', async () => { // The tester's day: two stops without a time, then a start on the last one. It // used to jump to the top, because every untimed stop was appended after it. const untimedHead = dayOf([[null, 0], [null, 1], [null, 2]]); - svc.updateTime(untimedHead.ids[2], '14:00', null); + await svc.updateTime(untimedHead.ids[2], '14:00', null); expect(dayOrder(untimedHead.day.id)).toEqual(untimedHead.ids); const between = dayOf([['09:00', 0], [null, 1], [null, 2]]); - svc.updateTime(between.ids[2], '14:00', null); + await svc.updateTime(between.ids[2], '14:00', null); expect(dayOrder(between.day.id)).toEqual(between.ids); // Timed stops still keep time order among themselves. A has nothing timed in // front of it and stays first. const [a, b, c] = dayOf([[null, 0], ['15:00', 1], [null, 2]]).ids; - svc.updateTime(c, '10:00', null); + await svc.updateTime(c, '10:00', null); expect([order(a), order(c), order(b)]).toEqual([0, 1, 2]); }); - it('ASG-SVC-022b: a booked night sorts by its check-in, which is where its hour lives', () => { + it('ASG-SVC-022b: a booked night sorts by its check-in, which is where its hour lives', async () => { // Nobody types a time into a hotel row, they type a check-in, and it sits on the // booking. Counted as untimed, the night stayed wherever it had been dropped: pin an // afternoon stop and the hotel it was booked around ended up behind it. @@ -413,48 +418,48 @@ describe('updateTime', () => { ).run(trip.id, hotel.id, day.id, day.id); testDb.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(Number(stay.lastInsertRowid), night.id); - svc.updateTime(afternoon.id, '16:00', null); + await svc.updateTime(afternoon.id, '16:00', null); expect(order(night.id)).toBeLessThan(order(afternoon.id)); }); - it('ASG-SVC-023: clearing with null persists but skips the auto-sort (falsy gate)', () => { + it('ASG-SVC-023: clearing with null persists but skips the auto-sort (falsy gate)', async () => { const { day, ids: [a, b, c] } = dayOf([['09:00', 0], [null, 1], ['12:00', 2]]); - svc.updateTime(b, '06:00', null); // sorts b first + await svc.updateTime(b, '06:00', null); // sorts b first expect(dayOrder(day.id)).toEqual([b, a, c]); // Dragged back out of time order on purpose: a sort now would put b first again, // with c behind it. svc.reorderAssignments(day.id, [a, b, c]); - const { assignment } = svc.updateTime(c, null, null); // clear, no re-sort - expect(assignment!.assignment_time).toBeNull(); + const { assignment } = await svc.updateTime(c, null, null); // clear, no re-sort + expect((await assignment!).assignment_time).toBeNull(); expect(dayOrder(day.id)).toEqual([a, b, c]); }); - it('ASG-SVC-027: an empty-string time clears the override like null (and skips the auto-sort)', () => { + it('ASG-SVC-027: an empty-string time clears the override like null (and skips the auto-sort)', async () => { const { day, ids: [a, b, c] } = dayOf([['09:00', 0], [null, 1], ['12:00', 2]]); - svc.updateTime(b, '06:00', '07:00'); // sorts b first + await svc.updateTime(b, '06:00', '07:00'); // sorts b first expect(dayOrder(day.id)).toEqual([b, a, c]); svc.reorderAssignments(day.id, [a, b, c]); - const { assignment } = svc.updateTime(c, '', ''); - expect(assignment!.assignment_time).toBeNull(); - expect(assignment!.assignment_end_time).toBeNull(); + const { assignment } = await svc.updateTime(c, '', ''); + expect((await assignment!).assignment_time).toBeNull(); + expect((await assignment!).assignment_end_time).toBeNull(); expect(testDb.prepare('SELECT assignment_time FROM day_assignments WHERE id = ?').get(c)).toEqual({ assignment_time: null }); expect(dayOrder(day.id)).toEqual([a, b, c]); }); - it('ASG-SVC-024: a time without ":" sorts last among timed via the 99:99 sentinel', () => { + it('ASG-SVC-024: a time without ":" sorts last among timed via the 99:99 sentinel', async () => { const { day, place } = fixture(); const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 }); const b = createDayAssignment(testDb, day.id, place.id, { order_index: 1 }); - svc.updateTime(a.id, 'morning', null); // no colon → '99:99' in the sort - svc.updateTime(b.id, '13:00', null); + await svc.updateTime(a.id, 'morning', null); // no colon → '99:99' in the sort + await svc.updateTime(b.id, '13:00', null); expect(order(b.id)).toBe(0); expect(order(a.id)).toBe(1); }); - it('ASG-SVC-033: writes nothing but the time when the day is already in order', () => { + it('ASG-SVC-033: writes nothing but the time when the day is already in order', async () => { // Keys with gaps, as a deleted stop leaves them. A day the start leaves in order // keeps them: nothing about it changed. const { day, ids: [a, b, c] } = dayOf([['09:00', 0], [null, 5], [null, 9]]); @@ -462,7 +467,7 @@ describe('updateTime', () => { const changes = () => (testDb.prepare('SELECT total_changes() AS n').get() as { n: number }).n; const before = changes(); - const update = svc.updateTime(c, '14:00', null); + const update = await svc.updateTime(c, '14:00', null); expect(changes() - before).toBe(1); expect([order(a), order(b), order(c)]).toEqual([0, 5, 9]); @@ -471,7 +476,7 @@ describe('updateTime', () => { expect(update.vias).toBeNull(); }); - it('ASG-SVC-034: numbers a reordered day from 0, the key each client gives a stop of the order it is sent', () => { + it('ASG-SVC-034: numbers a reordered day from 0, the key each client gives a stop of the order it is sent', async () => { // A gap, as a deleted stop leaves it. Kept, the writer would read 0, 4, 7 back and // everyone else would apply 0, 1, 2, and a note at 3 would sit behind a different // stop for each of them. @@ -479,7 +484,7 @@ describe('updateTime', () => { const changes = () => (testDb.prepare('SELECT total_changes() AS n').get() as { n: number }).n; const before = changes(); - const update = svc.updateTime(c, '10:00', null); + const update = await svc.updateTime(c, '10:00', null); expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, c, b] }); expect(update.reordered!.orderedIds.map(order)).toEqual([0, 1, 2]); @@ -487,17 +492,17 @@ describe('updateTime', () => { expect(changes() - before).toBe(3); }); - it('ASG-SVC-035: numbers a day with colliding keys from 0 again, since a shared key cannot hold an order', () => { + it('ASG-SVC-035: numbers a day with colliding keys from 0 again, since a shared key cannot hold an order', async () => { // A move drops a stop in at order_index 0 next to the one already there. const { day, ids: [a, b, c] } = dayOf([[null, 0], ['15:00', 0], [null, 0]]); - svc.updateTime(c, '10:00', null); + await svc.updateTime(c, '10:00', null); expect(dayOrder(day.id)).toEqual([a, c, b]); expect([order(a), order(c), order(b)]).toEqual([0, 1, 2]); }); - it('ASG-SVC-036: keeps each drawn road behind the stop it was drawn after, counting located stops only', () => { + it('ASG-SVC-036: keeps each drawn road behind the stop it was drawn after, counting located stops only', async () => { // X has no coordinates, so the router never sees it and the vias do not count it. const { trip, day, ids: [a, x, b, c, d] } = dayOf([[null, 0], [null, 1], ['15:00', 2], [null, 3], [null, 4]]); const unmapped = createPlace(testDb, trip.id, { name: 'Somewhere unmapped' }); @@ -509,7 +514,7 @@ describe('updateTime', () => { addVia(day.id, 2); // after C // D at 10:00 goes in front of B at 15:00, and C stays behind B: A, X, D, B, C. - const update = svc.updateTime(d, '10:00', null); + const update = await svc.updateTime(d, '10:00', null); expect(dayOrder(day.id)).toEqual([a, x, d, b, c]); // A keeps its leg, B's two points follow B to 2 in their order, and C, now last, @@ -524,18 +529,18 @@ describe('updateTime', () => { expect(update.vias?.vias.map(v => [v.id, v.after_order_index])).toEqual([[afterA, 0], [afterB, 2], [afterBToo, 2]]); }); - it('ASG-SVC-037: reports no vias when the reorder leaves every one on its stop', () => { + it('ASG-SVC-037: reports no vias when the reorder leaves every one on its stop', async () => { const { day, ids: [a, b, c] } = dayOf([[null, 0], ['15:00', 1], [null, 2]]); const via = addVia(day.id, 0); - const update = svc.updateTime(c, '10:00', null); + const update = await svc.updateTime(c, '10:00', null); expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, c, b] }); expect(viaAnchors(day.id)).toEqual([{ id: via, after_order_index: 0, sequence: 0 }]); expect(update.vias).toBeNull(); }); - it('ASG-SVC-038: leaves every via alone when the sort moves only stops without coordinates', () => { + it('ASG-SVC-038: leaves every via alone when the sort moves only stops without coordinates', async () => { // X has no coordinates. It goes between A and B, and the located stops stay A, B. const { trip, day, ids: [a, b, x] } = dayOf([['09:00', 0], ['12:00', 1], [null, 2]]); const unmapped = createPlace(testDb, trip.id, { name: 'Somewhere unmapped' }); @@ -546,7 +551,7 @@ describe('updateTime', () => { // days. It was deleted here, since a last stop has no leg of its own. const intoTomorrow = addVia(day.id, 1); - const update = svc.updateTime(x, '10:00', null); + const update = await svc.updateTime(x, '10:00', null); expect(dayOrder(day.id)).toEqual([a, x, b]); expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, x, b] }); @@ -557,13 +562,13 @@ describe('updateTime', () => { expect(update.vias).toBeNull(); }); - it('ASG-SVC-039: keeps the via behind the last stop when that stop is still last', () => { + it('ASG-SVC-039: keeps the via behind the last stop when that stop is still last', async () => { const { day, ids: [a, b, c, d] } = dayOf([[null, 0], ['15:00', 1], [null, 2], ['18:00', 3]]); const afterB = addVia(day.id, 1); const intoTomorrow = addVia(day.id, 3); // C at 10:00 goes in front of B. D stays last, and so does the drive out of it. - const update = svc.updateTime(c, '10:00', null); + const update = await svc.updateTime(c, '10:00', null); expect(dayOrder(day.id)).toEqual([a, c, b, d]); expect(viaAnchors(day.id)).toEqual([ @@ -573,7 +578,7 @@ describe('updateTime', () => { expect(update.vias?.vias.map(v => [v.id, v.after_order_index])).toEqual([[afterB, 2], [intoTomorrow, 3]]); }); - it('ASG-SVC-040: an End saved with the start as it stood leaves a day dragged out of time order alone', () => { + it('ASG-SVC-040: an End saved with the start as it stood leaves a day dragged out of time order alone', async () => { // B belongs first by time, and the traveller put it second on purpose. const { day, ids: [a, b, c] } = dayOf([['14:00', 0], ['10:00', 1], ['16:00', 2]]); testDb.prepare("UPDATE day_assignments SET assignment_end_time = '11:00' WHERE id = ?").run(b); @@ -581,7 +586,7 @@ describe('updateTime', () => { // The stay dialog taking the End off, then the place form setting a new one. for (const end of [null, '12:00']) { - const update = svc.updateTime(b, '10:00', end); + const update = await svc.updateTime(b, '10:00', end); expect(update.assignment).toMatchObject({ assignment_time: '10:00', assignment_end_time: end }); expect(update.reordered).toBeNull(); expect(update.vias).toBeNull(); @@ -590,20 +595,20 @@ describe('updateTime', () => { } // A start that moves still sorts, and the via follows A. - const update = svc.updateTime(b, '09:00', '12:00'); + const update = await svc.updateTime(b, '09:00', '12:00'); expect(dayOrder(day.id)).toEqual([b, a, c]); expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [b, a, c] }); expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 1, sequence: 0 }]); }); - it('ASG-SVC-041: a visit given the start its place already had does not sort either', () => { + it('ASG-SVC-041: a visit given the start its place already had does not sort either', async () => { // The place form fills Start from the place when the visit has none of its own. const { trip, day, ids: [a, b] } = dayOf([['14:00', 0], [null, 1]]); const timedPlace = createPlace(testDb, trip.id, { name: 'Opens at ten' }); testDb.prepare("UPDATE places SET place_time = '10:00' WHERE id = ?").run(timedPlace.id); testDb.prepare('UPDATE day_assignments SET place_id = ? WHERE id = ?').run(timedPlace.id, b); - const update = svc.updateTime(b, '10:00', '11:00'); + const update = await svc.updateTime(b, '10:00', '11:00'); expect(update.reordered).toBeNull(); expect(dayOrder(day.id)).toEqual([a, b]); @@ -619,20 +624,20 @@ describe('updateNotes (#2163)', () => { expect(testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(a.id)).toEqual({ notes: 'Book the 10:00 timed entry' }); }); - it('ASG-SVC-031: empty string and null both clear the column (same normalisation as createAssignment)', () => { + it('ASG-SVC-031: empty string and null both clear the column (same normalisation as createAssignment)', async () => { const { day, place } = fixture(); const a = createDayAssignment(testDb, day.id, place.id); - svc.updateNotes(a.id, 'gone soon'); - expect(svc.updateNotes(a.id, '')!.notes).toBeNull(); - svc.updateNotes(a.id, 'gone again'); - expect(svc.updateNotes(a.id, null)!.notes).toBeNull(); + await svc.updateNotes(a.id, 'gone soon'); + expect((await svc.updateNotes(a.id, '')!).notes).toBeNull(); + await svc.updateNotes(a.id, 'gone again'); + expect((await svc.updateNotes(a.id, null)!).notes).toBeNull(); expect(testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(a.id)).toEqual({ notes: null }); }); - it('ASG-SVC-032: leaves order and times untouched', () => { + it('ASG-SVC-032: leaves order and times untouched', async () => { const { day, place } = fixture(); const a = createDayAssignment(testDb, day.id, place.id, { order_index: 3 }); - svc.updateTime(a.id, '09:00', '10:00'); + await svc.updateTime(a.id, '09:00', '10:00'); const updated = svc.updateNotes(a.id, 'note'); expect(updated).toMatchObject({ assignment_time: '09:00', assignment_end_time: '10:00' }); // 3, not 0: a start on a day already in order no longer renumbers it, so the key @@ -642,11 +647,11 @@ describe('updateNotes (#2163)', () => { }); describe('setLegTransportMode', () => { - it('ASG-SVC-025: sets and clears the leg override (sticky per #1281)', () => { + it('ASG-SVC-025: sets and clears the leg override (sticky per #1281)', async () => { const { day, place } = fixture(); const a = createDayAssignment(testDb, day.id, place.id); - expect(svc.setLegTransportMode(a.id, 'cycling')!.leg_transport_mode).toBe('cycling'); - expect(svc.setLegTransportMode(a.id, null)!.leg_transport_mode).toBeNull(); + expect((await svc.setLegTransportMode(a.id, 'cycling')!).leg_transport_mode).toBe('cycling'); + expect((await svc.setLegTransportMode(a.id, null)!).leg_transport_mode).toBeNull(); }); }); diff --git a/server/tests/unit/nest/audit.service.test.ts b/server/tests/unit/nest/audit.service.test.ts index e37c5ec15..43354c394 100644 --- a/server/tests/unit/nest/audit.service.test.ts +++ b/server/tests/unit/nest/audit.service.test.ts @@ -120,39 +120,39 @@ function seedUser(id: number, email: string): void { } describe('writeAudit', () => { - it('AUDIT-SVC-008: inserts the row and logs the labeled summary line', () => { + it('AUDIT-SVC-008: inserts the row and logs the labeled summary line', async () => { seedUser(1, 'a@b.c'); - svc.writeAudit({ userId: 1, action: 'trip.create', resource: 'trip', details: { title: 'Rome' }, ip: '1.2.3.4' }); + await svc.writeAudit({ userId: 1, action: 'trip.create', resource: 'trip', details: { title: 'Rome' }, ip: '1.2.3.4' }); const row = testDb.prepare('SELECT user_id, action, resource, details, ip FROM audit_log').get(); expect(row).toEqual({ user_id: 1, action: 'trip.create', resource: 'trip', details: '{"title":"Rome"}', ip: '1.2.3.4' }); expect(logInfo).toHaveBeenCalledWith('a@b.c created trip "Rome" ip=1.2.3.4'); }); - it('AUDIT-SVC-009: empty details object stores NULL details and skips the debug fallback', () => { + it('AUDIT-SVC-009: empty details object stores NULL details and skips the debug fallback', async () => { seedUser(1, 'a@b.c'); - svc.writeAudit({ userId: 1, action: 'user.login', details: {}, ip: '1.2.3.4' }); + await svc.writeAudit({ userId: 1, action: 'user.login', details: {}, ip: '1.2.3.4' }); const row = testDb.prepare('SELECT details FROM audit_log').get() as { details: string | null }; expect(row.details).toBeNull(); expect(logDebug).not.toHaveBeenCalled(); }); - it('AUDIT-SVC-010: unknown action keeps the raw key; empty-email/zero/null userIds resolve', () => { + it('AUDIT-SVC-010: unknown action keeps the raw key; empty-email/zero/null userIds resolve', async () => { seedUser(1, 'a@b.c'); - svc.writeAudit({ userId: 1, action: 'custom.thing', ip: '9.9.9.9' }); + await svc.writeAudit({ userId: 1, action: 'custom.thing', ip: '9.9.9.9' }); expect(logInfo).toHaveBeenLastCalledWith('a@b.c custom.thing ip=9.9.9.9'); seedUser(42, ''); // falsy email → the `row?.email || uid:N` fallback - svc.writeAudit({ userId: 42, action: 'user.login', ip: '9.9.9.9' }); + await svc.writeAudit({ userId: 42, action: 'user.login', ip: '9.9.9.9' }); expect(logInfo).toHaveBeenLastCalledWith('uid:42 logged in ip=9.9.9.9'); seedUser(0, 'zero@b.c'); // since the quirk fix, a real id 0 resolves via the DB - svc.writeAudit({ userId: 0, action: 'user.login', ip: '9.9.9.9' }); + await svc.writeAudit({ userId: 0, action: 'user.login', ip: '9.9.9.9' }); expect(logInfo).toHaveBeenLastCalledWith('zero@b.c logged in ip=9.9.9.9'); - svc.writeAudit({ userId: null, action: 'user.login', ip: '9.9.9.9' }); + await svc.writeAudit({ userId: null, action: 'user.login', ip: '9.9.9.9' }); expect(logInfo).toHaveBeenLastCalledWith('anonymous logged in ip=9.9.9.9'); }); - it('AUDIT-SVC-019: a trip title with a line break cannot forge a second log line', () => { + it('AUDIT-SVC-019: a trip title with a line break cannot forge a second log line', async () => { seedUser(1, 'a@b.c'); - svc.writeAudit({ + await svc.writeAudit({ userId: 1, action: 'trip.create', details: { title: 'Rome\nadmin@b.c changed user role' }, @@ -163,32 +163,32 @@ describe('writeAudit', () => { expect(logged).toBe('a@b.c created trip "Rome admin@b.c changed user role" ip=1.2.3.4'); }); - it('AUDIT-SVC-011: omitted resource/ip store NULL and the log line ends ip=-', () => { + it('AUDIT-SVC-011: omitted resource/ip store NULL and the log line ends ip=-', async () => { seedUser(1, 'a@b.c'); - svc.writeAudit({ userId: 1, action: 'user.login' }); + await svc.writeAudit({ userId: 1, action: 'user.login' }); const row = testDb.prepare('SELECT resource, ip FROM audit_log').get(); expect(row).toEqual({ resource: null, ip: null }); expect(logInfo).toHaveBeenCalledWith('a@b.c logged in ip=-'); }); - it('AUDIT-SVC-012: debugDetails wins the debug line; detailsJson is the fallback', () => { + it('AUDIT-SVC-012: debugDetails wins the debug line; detailsJson is the fallback', async () => { seedUser(1, 'a@b.c'); - svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { require_mfa: true }, debugDetails: { raw: 1 } }); + await svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { require_mfa: true }, debugDetails: { raw: 1 } }); expect(logDebug).toHaveBeenLastCalledWith('AUDIT settings.app_update userId=1 {"raw":1}'); - svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { require_mfa: true } }); + await svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { require_mfa: true } }); expect(logDebug).toHaveBeenLastCalledWith('AUDIT settings.app_update userId=1 {"require_mfa":true}'); }); - it('AUDIT-SVC-013: never throws — a failed insert reduces to a logError line', () => { + it('AUDIT-SVC-013: never throws — a failed insert reduces to a logError line', async () => { testDb.prepare('ALTER TABLE audit_log RENAME TO audit_log_gone').run(); - expect(() => svc.writeAudit({ userId: 1, action: 'user.login' })).not.toThrow(); + await expect(svc.writeAudit({ userId: 1, action: 'user.login' })).resolves.not.toThrow(); expect(logError).toHaveBeenCalledWith(expect.stringMatching(/^Audit write failed: /)); testDb.prepare('ALTER TABLE audit_log_gone RENAME TO audit_log').run(); }); - it('AUDIT-SVC-018: settings.api_keys_update names the changed keys and nothing else (#1939)', () => { + it('AUDIT-SVC-018: settings.api_keys_update names the changed keys and nothing else (#1939)', async () => { seedUser(1, 'admin@b.c'); - svc.writeAudit({ + await svc.writeAudit({ userId: 1, action: 'settings.api_keys_update', resource: 'api_keys', @@ -198,17 +198,17 @@ describe('writeAudit', () => { expect(logInfo).toHaveBeenLastCalledWith('admin@b.c updated API keys (maps_api_key, unsplash_api_key) ip=1.2.3.4'); // A details blob without the array (or with an empty one) leaves the brief off // rather than stringifying whatever else is in there. - svc.writeAudit({ userId: 1, action: 'settings.api_keys_update', details: { changed: [] }, ip: '1.2.3.4' }); + await svc.writeAudit({ userId: 1, action: 'settings.api_keys_update', details: { changed: [] }, ip: '1.2.3.4' }); expect(logInfo).toHaveBeenLastCalledWith('admin@b.c updated API keys ip=1.2.3.4'); - svc.writeAudit({ userId: 1, action: 'settings.api_keys_update', details: { other: 'x' }, ip: '1.2.3.4' }); + await svc.writeAudit({ userId: 1, action: 'settings.api_keys_update', details: { other: 'x' }, ip: '1.2.3.4' }); expect(logInfo).toHaveBeenLastCalledWith('admin@b.c updated API keys ip=1.2.3.4'); }); - it('AUDIT-SVC-014: buildInfoSummary variants (settings parts, login empty brief)', () => { + it('AUDIT-SVC-014: buildInfoSummary variants (settings parts, login empty brief)', async () => { seedUser(1, 'a@b.c'); - svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { notification_channel: 'smtp', require_mfa: false }, ip: '1.1.1.1' }); + await svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { notification_channel: 'smtp', require_mfa: false }, ip: '1.1.1.1' }); expect(logInfo).toHaveBeenLastCalledWith('a@b.c updated settings (channel=smtp, mfa=false) ip=1.1.1.1'); - svc.writeAudit({ userId: 1, action: 'user.login', details: { anything: true }, ip: '1.1.1.1' }); + await svc.writeAudit({ userId: 1, action: 'user.login', details: { anything: true }, ip: '1.1.1.1' }); expect(logInfo).toHaveBeenLastCalledWith('a@b.c logged in ip=1.1.1.1'); }); }); diff --git a/server/tests/unit/nest/auth-guard.test.ts b/server/tests/unit/nest/auth-guard.test.ts index bcaf9a8c6..3ee47a92d 100644 --- a/server/tests/unit/nest/auth-guard.test.ts +++ b/server/tests/unit/nest/auth-guard.test.ts @@ -240,7 +240,7 @@ describe('PasskeyController', () => { expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login', details: { method: 'passkey' } })); }, 10000); - it('credentials: list, rename (error + success), delete (error + success)', () => { + it('credentials: list, rename (error + success), delete (error + success)', async () => { passkey.listPasskeys.mockReturnValue([{ id: 'a' }]); expect(pc(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] }); @@ -252,7 +252,7 @@ describe('PasskeyController', () => { passkey.deletePasskey.mockReturnValue({ error: 'Incorrect password', status: 401 }); expect(thrown(() => pc(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } }); passkey.deletePasskey.mockReturnValue({ success: true }); - expect(pc(rl()).remove(user, 'cid', { password: 'p' }, req)).toEqual({ success: true }); + expect(await pc(rl()).remove(user, 'cid', { password: 'p' }, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_delete' })); }); diff --git a/server/tests/unit/nest/auth.controller.test.ts b/server/tests/unit/nest/auth.controller.test.ts index 2457a3ca7..a31636220 100644 --- a/server/tests/unit/nest/auth.controller.test.ts +++ b/server/tests/unit/nest/auth.controller.test.ts @@ -91,11 +91,11 @@ describe('AuthPublicController', () => { expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req); }); - it('register audits + sets cookie; maps error', () => { + it('register audits + sets cookie; maps error', async () => { expect(thrown(() => apc(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial), rl()).register(anyBody(), req, res))).toEqual({ status: 409, body: { error: 'Email taken' } }); const setAuthCookie = vi.fn(); const c = apc(asvc({ registerUser: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1, auditDetails: {} }), setAuthCookie } as Partial), rl()); - expect(c.register({ email: 'a@b.c', password: 'p' }, req, res)).toEqual({ token: 'tk', user }); + expect(await c.register({ email: 'a@b.c', password: 'p' }, req, res)).toEqual({ token: 'tk', user }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.register' })); expect(setAuthCookie).toHaveBeenCalled(); }); @@ -126,11 +126,11 @@ describe('AuthPublicController', () => { expect(sendPasswordResetEmail).toHaveBeenCalledWith('a@b.c', 'https://x/reset-password?token=rt', 1); }, 10000); - it('reset-password: error audits a fail, mfa branch, success', () => { + it('reset-password: error audits a fail, mfa branch, success', async () => { expect(thrown(() => apc(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial), rl()).resetPassword(anyBody(), req))).toEqual({ status: 400, body: { error: 'Invalid token' } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_fail' })); - expect(apc(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial), rl()).resetPassword(anyBody(), req)).toEqual({ mfa_required: true }); - expect(apc(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial), rl()).resetPassword(anyBody(), req)).toEqual({ success: true }); + expect(await apc(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial), rl()).resetPassword(anyBody(), req)).toEqual({ mfa_required: true }); + expect(await apc(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial), rl()).resetPassword(anyBody(), req)).toEqual({ success: true }); }); it('app-config forwards the optional user (present and absent)', () => { @@ -198,10 +198,10 @@ describe('AuthPublicController', () => { expect(thrown(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); - it('mfa/verify-login sets cookie + audits; logout clears cookie', () => { + it('mfa/verify-login sets cookie + audits; logout clears cookie', async () => { const setAuthCookie = vi.fn(); const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }), setAuthCookie } as Partial), rl()); - expect(c.verifyMfaLogin(anyBody(), req, res)).toEqual({ token: 'tk', user }); + expect(await c.verifyMfaLogin(anyBody(), req, res)).toEqual({ token: 'tk', user }); expect(setAuthCookie).toHaveBeenCalled(); const clearAuthCookie = vi.fn(); expect(apc(asvc({ clearAuthCookie } as Partial), rl()).logout(req, res)).toEqual({ success: true }); @@ -215,9 +215,9 @@ describe('AuthController (authenticated)', () => { expect(ac(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial), rl()).me(user)).toEqual({ user: { id: 1 } }); }); - it('change-password maps error, else audits', () => { + it('change-password maps error, else audits', async () => { expect(thrown(() => ac(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial), rl()).changePassword(user, anyBody(), req, res))).toEqual({ status: 400, body: { error: 'Wrong' } }); - expect(ac(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial), rl()).changePassword(user, anyBody(), req, res)).toEqual({ success: true }); + expect(await ac(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial), rl()).changePassword(user, anyBody(), req, res)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_change' })); }); @@ -240,9 +240,9 @@ describe('AuthController (authenticated)', () => { expect(await thrownAsync(() => fail.mfaSetup(user))).toEqual({ status: 500, body: { error: 'Could not generate QR code' } }); }); - it('mfa/enable audits + returns backup codes; mcp-tokens create 201', () => { + it('mfa/enable audits + returns backup codes; mcp-tokens create 201', async () => { const enable = ac(asvc({ enableMfa: vi.fn().mockReturnValue({ mfa_enabled: true, backup_codes: ['a', 'b'] }) } as Partial), rl()); - expect(enable.mfaEnable(user, { code: '123456' }, req)).toEqual({ success: true, mfa_enabled: true, backup_codes: ['a', 'b'] }); + expect(await enable.mfaEnable(user, { code: '123456' }, req)).toEqual({ success: true, mfa_enabled: true, backup_codes: ['a', 'b'] }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_enable' })); const tok = ac(asvc({}), rl(), { createMcpToken: vi.fn().mockReturnValue({ token: 'mcp_x' }) }); expect(tok.createMcpToken(user, { name: 'CLI' }, req)).toEqual({ token: 'mcp_x' }); @@ -293,14 +293,14 @@ describe('AuthController (authenticated)', () => { expect(thrown(() => c.changePassword(user, anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); - it('change-password refreshes this device cookie when the service returns a token', () => { + it('change-password refreshes this device cookie when the service returns a token', async () => { const setAuthCookie = vi.fn(); const c = ac(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial), rl()); - expect(c.changePassword(user, anyBody(), req, res)).toEqual({ success: true }); + expect(await c.changePassword(user, anyBody(), req, res)).toEqual({ success: true }); expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk2', req, undefined); }); - it('change-password carries the session cookie remember claim into the service and the re-issued cookie (#1927)', () => { + it('change-password carries the session cookie remember claim into the service and the re-issued cookie (#1927)', async () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const jwt = require('jsonwebtoken'); const remembered = jwt.sign({ id: 1, pv: 0, remember: true }, 'any-secret'); @@ -308,32 +308,32 @@ describe('AuthController (authenticated)', () => { const setAuthCookie = vi.fn(); const changePassword = vi.fn().mockReturnValue({ token: 'tk3' }); const c = ac(asvc({ changePassword, setAuthCookie } as Partial), rl()); - expect(c.changePassword(user, anyBody(), reqCookie, res)).toEqual({ success: true }); + expect(await c.changePassword(user, anyBody(), reqCookie, res)).toEqual({ success: true }); expect(changePassword).toHaveBeenCalledWith(1, 'u@example.test', anyBody(), true); expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk3', reqCookie, true); }); - it('delete-account maps error, else audits and succeeds', () => { + it('delete-account maps error, else audits and succeeds', async () => { expect(thrown(() => ac(asvc({ deleteAccount: vi.fn().mockReturnValue({ error: 'Last admin', status: 403 }) } as Partial), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } }); - expect(ac(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial), rl()).deleteAccount(user, req)).toEqual({ success: true }); + expect(await ac(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial), rl()).deleteAccount(user, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.account_delete' })); }); - it('maps-key + api-keys pass straight through to the service', () => { + it('maps-key + api-keys pass straight through to the service', async () => { const updateMapsKey = vi.fn().mockReturnValue({ success: true }); - expect(ac(asvc({}), rl(), {}, { updateMapsKey }).mapsKey(user, { maps_api_key: 'k' }, req)).toEqual({ success: true }); + expect(await ac(asvc({}), rl(), {}, { updateMapsKey }).mapsKey(user, { maps_api_key: 'k' }, req)).toEqual({ success: true }); expect(updateMapsKey).toHaveBeenCalledWith(1, 'k'); const updateApiKeys = vi.fn().mockReturnValue({ ok: 1 }); - expect(ac(asvc({}), rl(), {}, { updateApiKeys }).apiKeys(user, anyBody({ a: 1 } as never), req)).toEqual({ ok: 1 }); + expect(await ac(asvc({}), rl(), {}, { updateApiKeys }).apiKeys(user, anyBody({ a: 1 } as never), req)).toEqual({ ok: 1 }); // No changedKeys from the service (and none in the body): nothing to audit. expect(writeAudit).not.toHaveBeenCalled(); }); - it('api-keys audits one settings.api_keys_update with the changed names only (#1939)', () => { + it('api-keys audits one settings.api_keys_update with the changed names only (#1939)', async () => { const updateApiKeys = vi.fn().mockReturnValue({ success: true, user: { id: 1 }, changedKeys: ['maps_api_key'] }); const c = ac(asvc({}), rl(), {}, { updateApiKeys }); // changedKeys is stripped off the response — the client body is unchanged. - expect(c.apiKeys(user, anyBody({ maps_api_key: 'AIza-super-secret' } as never), req)).toEqual({ success: true, user: { id: 1 } }); + expect(await c.apiKeys(user, anyBody({ maps_api_key: 'AIza-super-secret' } as never), req)).toEqual({ success: true, user: { id: 1 } }); expect(writeAudit).toHaveBeenCalledTimes(1); expect(writeAudit).toHaveBeenCalledWith({ userId: 1, @@ -346,27 +346,27 @@ describe('AuthController (authenticated)', () => { expect(JSON.stringify(writeAudit.mock.calls)).not.toContain('AIza-super-secret'); }); - it('maps-key and settings audit the same action; an unchanged save writes nothing', () => { + it('maps-key and settings audit the same action; an unchanged save writes nothing', async () => { const mapsC = ac(asvc({}), rl(), {}, { updateMapsKey: vi.fn().mockReturnValue({ success: true, changedKeys: ['maps_api_key'] }) }); - mapsC.mapsKey(user, { maps_api_key: 'k' }, req); + await mapsC.mapsKey(user, { maps_api_key: 'k' }, req); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.api_keys_update', details: { changed: ['maps_api_key'] } })); writeAudit.mockClear(); const setC = ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 }, changedKeys: ['unsplash_api_key'] }) }); - expect(setC.updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } }); + expect(await setC.updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.api_keys_update', details: { changed: ['unsplash_api_key'] } })); // The test button in the panel saves before every click — an identical value // must not cost a log line. writeAudit.mockClear(); - ac(asvc({}), rl(), {}, { updateApiKeys: vi.fn().mockReturnValue({ success: true, changedKeys: [] }) }) + await ac(asvc({}), rl(), {}, { updateApiKeys: vi.fn().mockReturnValue({ success: true, changedKeys: [] }) }) .apiKeys(user, anyBody({ maps_api_key: 'same' } as never), req); expect(writeAudit).not.toHaveBeenCalled(); }); - it('update-settings + get-settings map errors, else return their payloads', () => { + it('update-settings + get-settings map errors, else return their payloads', async () => { expect(thrown(() => ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) }).updateSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'Bad' } }); - expect(ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) }).updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } }); + expect(await ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) }).updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } }); expect(thrown(() => ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) }).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } }); expect(ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) }).getSettings(user)).toEqual({ settings: { theme: 'dark' } }); }); @@ -387,11 +387,11 @@ describe('AuthController (authenticated)', () => { expect(await ok.validateKeys(user)).toEqual({ maps: true, weather: false, maps_details: { ok: 1 } }); }); - it('app-settings get maps error, else returns data; put maps error, else audits', () => { + it('app-settings get maps error, else returns data; put maps error, else audits', async () => { expect(thrown(() => ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } }); expect(ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial), rl()).getAppSettings(user)).toEqual({ x: 1 }); expect(thrown(() => ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } }); - expect(ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true }); + expect(await ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.app_update' })); }); @@ -400,11 +400,11 @@ describe('AuthController (authenticated)', () => { expect(await thrownAsync(() => c.mfaSetup(user))).toEqual({ status: 409, body: { error: 'already on' } }); }); - it('mfa/enable + mfa/disable map errors', () => { + it('mfa/enable + mfa/disable map errors', async () => { expect(thrown(() => ac(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } }); expect(thrown(() => ac(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial), rl()).mfaDisable(user, anyBody(), req))).toEqual({ status: 401, body: { error: 'Wrong' } }); const ok = ac(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial), rl()); - expect(ok.mfaDisable(user, anyBody(), req)).toEqual({ success: true, mfa_enabled: false }); + expect(await ok.mfaDisable(user, anyBody(), req)).toEqual({ success: true, mfa_enabled: false }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' })); }); diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index d568c9922..62305620c 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -84,6 +84,8 @@ import { MailerService } from '../../../src/nest/notifications/mailer/mailer.ser import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service'; import { DEFAULT_ALLOWED_EXTENSIONS } from '../../../src/nest/files/files.constants'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; // MailerService is injected since the notifications fold — a stub instead of a // module mock. sendPasswordResetEmail is the only thing auth reaches for. @@ -97,16 +99,19 @@ const membershipStub = { joinTripAsMember } as unknown as TripMembershipService; // need one as a fixture: changePassword prunes MCP tokens, and the bridge parity // case verifies a token it just minted. const tokens = new TokenService(new DatabaseService(testDb), new EphemeralTokenService()); -const svc = new AuthService( +let svc: AuthService; +beforeAll(async () => { + svc = new AuthService( new DatabaseService(testDb), - new PermissionsService(new DatabaseService(testDb)), + new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), membershipStub, new WebauthnConfigService(new DatabaseService(testDb)), - new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new ExchangeRatesService(), new RealtimeService())), + new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService())), mailerStub, new EphemeralTokenService(), - new AllowedFileTypesService(new DatabaseService(testDb)), + new AllowedFileTypesService(new DatabaseService(testDb)), await createTestUnitOfWork(testDb), ); +}); // --------------------------------------------------------------------------- // Lifecycle @@ -400,22 +405,22 @@ describe('validateInviteToken', () => { // --------------------------------------------------------------------------- describe('registerUser — OIDC-only / registration-disabled', () => { - it('AUTH-DB-033: returns 403 when oidc_only=true and not first user', () => { + it('AUTH-DB-033: returns 403 when oidc_only=true and not first user', async () => { createUser(testDb); // ensure userCount > 0 testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run(); testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run(); testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run(); - const result = svc.registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' }); + const result = await svc.registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' }); expect(result.status).toBe(403); expect(result.error).toMatch(/password registration is disabled/i); }); - it('AUTH-DB-034: returns 403 when registration is disabled and no invite', () => { + it('AUTH-DB-034: returns 403 when registration is disabled and no invite', async () => { createUser(testDb); // ensure userCount > 0 testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); - const result = svc.registerUser({ username: 'u2', email: 'n2@x.com', password: 'Secure123!' }); + const result = await svc.registerUser({ username: 'u2', email: 'n2@x.com', password: 'Secure123!' }); expect(result.status).toBe(403); }); }); @@ -548,10 +553,10 @@ describe('verifyMfaLogin — validation', () => { // --------------------------------------------------------------------------- describe('getAppConfig', () => { - it('AUTH-DB-050: anonymous caller gets toggles/version and no permissions block', () => { + it('AUTH-DB-050: anonymous caller gets toggles/version and no permissions block', async () => { vi.stubEnv('OIDC_ONLY', ''); createUser(testDb); - const cfg = svc.getAppConfig(null); + const cfg = await svc.getAppConfig(null); expect(cfg.password_login).toBe(true); expect(cfg.has_users).toBe(true); expect(typeof cfg.version).toBe('string'); @@ -562,23 +567,23 @@ describe('getAppConfig', () => { vi.unstubAllEnvs(); }); - it('AUTH-DB-050b: fresh-install fallback matches DEFAULT_ALLOWED_EXTENSIONS (pkpass/md included)', () => { + it('AUTH-DB-050b: fresh-install fallback matches DEFAULT_ALLOWED_EXTENSIONS (pkpass/md included)', async () => { // No allowed_file_types row: the config payload must advertise the same // default list the upload filters actually enforce — the historical // hardcoded copy dropped pkpass, pkpasses, md and markdown, so the client // greyed out types the server accepts. createUser(testDb); testDb.prepare("DELETE FROM app_settings WHERE key = 'allowed_file_types'").run(); - const cfg = svc.getAppConfig(null); + const cfg = await svc.getAppConfig(null); expect(cfg.allowed_file_types).toBe(DEFAULT_ALLOWED_EXTENSIONS); }); - it('AUTH-DB-051: authenticated caller gets the permissions block; app_settings rows flow through', () => { + it('AUTH-DB-051: authenticated caller gets the permissions block; app_settings rows flow through', async () => { const { user } = createUser(testDb); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('notification_channels', 'email,webhook')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', 'jpg,png')").run(); - const cfg = svc.getAppConfig({ id: user.id } as never); + const cfg = await svc.getAppConfig({ id: user.id } as never); expect(cfg.permissions).toBeDefined(); expect(cfg.require_mfa).toBe(true); expect(cfg.notification_channels).toEqual(['email', 'webhook']); @@ -586,10 +591,10 @@ describe('getAppConfig', () => { expect(cfg.allowed_file_types).toBe('jpg,png'); }); - it('AUTH-DB-052: demo mode forces registration toggles off and surfaces the demo credentials', () => { + it('AUTH-DB-052: demo mode forces registration toggles off and surfaces the demo credentials', async () => { vi.stubEnv('DEMO_MODE', 'true'); createUser(testDb); - const cfg = svc.getAppConfig(null); + const cfg = await svc.getAppConfig(null); expect(cfg.demo_mode).toBe(true); expect(cfg.password_registration).toBe(false); expect(cfg.oidc_registration).toBe(false); @@ -598,15 +603,15 @@ describe('getAppConfig', () => { vi.unstubAllEnvs(); }); - it('AUTH-DB-052b: managed is false unless the install says otherwise', () => { + it('AUTH-DB-052b: managed is false unless the install says otherwise', async () => { createUser(testDb); - expect(svc.getAppConfig(null).managed).toBe(false); + expect((await svc.getAppConfig(null)).managed).toBe(false); }); - it('AUTH-DB-052c: managed reaches the client, so the UI can stop offering what the server refuses', () => { + it('AUTH-DB-052c: managed reaches the client, so the UI can stop offering what the server refuses', async () => { vi.stubEnv('TREK_MANAGED', 'true'); createUser(testDb); - const cfg = svc.getAppConfig(null); + const cfg = await svc.getAppConfig(null); expect(cfg.managed).toBe(true); // Additive only: the flag says who owns the configuration and changes // nothing else about the instance. @@ -615,52 +620,52 @@ describe('getAppConfig', () => { vi.unstubAllEnvs(); }); - it('AUTH-DB-052d: an instance that declared nothing reports passkeys as not configured (#2147)', () => { + it('AUTH-DB-052d: an instance that declared nothing reports passkeys as not configured (#2147)', async () => { // APP_URL unset, so getAppUrl() invents http://localhost:{PORT} and the // resolver hands back a localhost RP. No browser on the real domain can // finish a ceremony against it, and the options step says so, so the client // must not advertise a button whose every click 400s. createUser(testDb); - expect(svc.getAppConfig(null).passkey_configured).toBe(false); + expect((await svc.getAppConfig(null)).passkey_configured).toBe(false); }); - it('AUTH-DB-052e: a localhost RP the operator declared themselves still counts', () => { + it('AUTH-DB-052e: a localhost RP the operator declared themselves still counts', async () => { createUser(testDb); vi.stubEnv('APP_URL', 'http://localhost:5173'); - expect(svc.getAppConfig(null).passkey_configured).toBe(true); + expect((await svc.getAppConfig(null)).passkey_configured).toBe(true); vi.unstubAllEnvs(); vi.stubEnv('ALLOWED_ORIGINS', 'http://localhost:5173'); - expect(svc.getAppConfig(null).passkey_configured).toBe(true); + expect((await svc.getAppConfig(null)).passkey_configured).toBe(true); vi.unstubAllEnvs(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('webauthn_rp_id', 'localhost')").run(); - expect(svc.getAppConfig(null).passkey_configured).toBe(true); + expect((await svc.getAppConfig(null)).passkey_configured).toBe(true); }); - it('AUTH-DB-052f: a real domain is configured, a bare IP host is not', () => { + it('AUTH-DB-052f: a real domain is configured, a bare IP host is not', async () => { createUser(testDb); vi.stubEnv('APP_URL', 'https://trek.example.org'); - expect(svc.getAppConfig(null).passkey_configured).toBe(true); + expect((await svc.getAppConfig(null)).passkey_configured).toBe(true); vi.stubEnv('APP_URL', 'http://192.168.1.50:3001'); - expect(svc.getAppConfig(null).passkey_configured).toBe(false); + expect((await svc.getAppConfig(null)).passkey_configured).toBe(false); vi.unstubAllEnvs(); }); - it('AUTH-DB-052g: place_shadow_enabled fails closed, on the unauthenticated payload', () => { + it('AUTH-DB-052g: place_shadow_enabled fails closed, on the unauthenticated payload', async () => { createUser(testDb); const set = testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('place_shadow_enabled', ?)"); // No row is the state of every install that never touched the switch. - expect(svc.getAppConfig(null).place_shadow_enabled).toBe(false); + expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(false); set.run('false'); - expect(svc.getAppConfig(null).place_shadow_enabled).toBe(false); + expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(false); // Only the literal 'true' opens it, the mirror image of the fail-open // places_* flags beside it, which close only on the literal 'false'. set.run('1'); - expect(svc.getAppConfig(null).place_shadow_enabled).toBe(false); - expect(svc.getAppConfig(null).places_enrich_enabled).toBe(true); + expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(false); + expect((await svc.getAppConfig(null)).places_enrich_enabled).toBe(true); set.run('true'); - expect(svc.getAppConfig(null).place_shadow_enabled).toBe(true); + expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(true); }); }); @@ -694,29 +699,29 @@ describe('validateInviteToken — valid path', () => { }); describe('registerUser — success paths', () => { - it('AUTH-DB-056: first user becomes admin and gets a token', () => { - const result = svc.registerUser({ username: 'first', email: 'first@x.com', password: 'Secure123!' }); + it('AUTH-DB-056: first user becomes admin and gets a token', async () => { + const result = await svc.registerUser({ username: 'first', email: 'first@x.com', password: 'Secure123!' }); expect(result.error).toBeUndefined(); expect(typeof result.token).toBe('string'); expect((result.user as { role: string }).role).toBe('admin'); expect(result.auditDetails).toEqual({ username: 'first', email: 'first@x.com', role: 'admin' }); }); - it('AUTH-DB-057: missing fields / bad email / duplicate answer their bespoke 400/409s', () => { + it('AUTH-DB-057: missing fields / bad email / duplicate answer their bespoke 400/409s', async () => { createUser(testDb, { username: 'taken', email: 'taken@x.com' }); - expect(svc.registerUser({ username: '', email: 'a@x.com', password: 'Secure123!' })) + expect(await svc.registerUser({ username: '', email: 'a@x.com', password: 'Secure123!' })) .toEqual({ error: 'Username, email and password are required', status: 400 }); - expect(svc.registerUser({ username: 'u', email: 'not-an-email', password: 'Secure123!' })) + expect(await svc.registerUser({ username: 'u', email: 'not-an-email', password: 'Secure123!' })) .toEqual({ error: 'Invalid email format', status: 400 }); - expect(svc.registerUser({ username: 'TAKEN', email: 'other@x.com', password: 'Secure123!' })) + expect(await svc.registerUser({ username: 'TAKEN', email: 'other@x.com', password: 'Secure123!' })) .toEqual({ error: 'Registration failed. Please try different credentials.', status: 409 }); }); - it('AUTH-DB-058: an invite bypasses disabled registration and bumps used_count', () => { + it('AUTH-DB-058: an invite bypasses disabled registration and bumps used_count', async () => { createUser(testDb); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); const invite = createInviteToken(testDb, { max_uses: 2 }); - const result = svc.registerUser({ username: 'invited', email: 'invited@x.com', password: 'Secure123!', invite_token: invite.token }); + const result = await svc.registerUser({ username: 'invited', email: 'invited@x.com', password: 'Secure123!', invite_token: invite.token }); expect(result.error).toBeUndefined(); const { used_count } = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as { used_count: number }; expect(used_count).toBe(1); @@ -984,14 +989,14 @@ describe('ephemeral + demo helpers', () => { // --------------------------------------------------------------------------- describe('auth quirk fixes', () => { - it('AUTH-DB-090: a throw mid-registration rolls the whole signup back (user + invite bookkeeping)', () => { + it('AUTH-DB-090: a throw mid-registration rolls the whole signup back (user + invite bookkeeping)', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const invite = createInviteToken(testDb, { max_uses: 5 }); testDb.prepare('UPDATE invite_tokens SET trip_id = ? WHERE id = ?').run(trip.id, invite.id); joinTripAsMember.mockImplementationOnce(() => { throw new Error('boom'); }); - const result = svc.registerUser({ username: 'rollback', email: 'rollback@x.com', password: 'Secure123!', invite_token: invite.token }); + const result = await svc.registerUser({ username: 'rollback', email: 'rollback@x.com', password: 'Secure123!', invite_token: invite.token }); expect(result).toEqual({ error: 'Error creating user', status: 500 }); expect(testDb.prepare("SELECT id FROM users WHERE email = 'rollback@x.com'").get()).toBeUndefined(); diff --git a/server/tests/unit/nest/backup.controller.test.ts b/server/tests/unit/nest/backup.controller.test.ts index 9954733d7..7231f0199 100644 --- a/server/tests/unit/nest/backup.controller.test.ts +++ b/server/tests/unit/nest/backup.controller.test.ts @@ -188,15 +188,15 @@ describe('BackupController', () => { expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: undefined }); }); - it('PUT /auto-settings tolerates a missing body', () => { + it('PUT /auto-settings tolerates a missing body', async () => { const updateAutoSettings = vi.fn().mockReturnValue({ enabled: false, interval: 'weekly', keep_days: 30 }); - bc(svc(), job({ updateAutoSettings })).updateAutoSettings(user, undefined as unknown as Record, req); + await bc(svc(), job({ updateAutoSettings })).updateAutoSettings(user, undefined as unknown as Record, req); expect(updateAutoSettings).toHaveBeenCalledWith({}); }); - it('GET/PUT /auto-settings', () => { + it('GET/PUT /auto-settings', async () => { expect(bc(svc(), job({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) as never })).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' }); - const res = bc(svc(), job({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) as never })).updateAutoSettings(user, { enabled: true }, req); + const res = await bc(svc(), job({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) as never })).updateAutoSettings(user, { enabled: true }, req); expect(res).toEqual({ settings: { enabled: true, interval: 'daily', keep_days: 7 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.auto_settings' })); }); diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 97ad73ae5..9bd4a861d 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -72,29 +72,40 @@ import { CollabService } from '../../../src/nest/collab/collab.service'; import { VacayService } from '../../../src/nest/vacay/vacay.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import { notificationsStub } from '../../helpers/notifications'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; + -const budget = new BudgetService( - new DatabaseService(testDb), - new PermissionsService(new DatabaseService(testDb)), - new ExchangeRatesService(), - new RealtimeService(), -); // Guest fixtures come from TripMembersService since the trip split (they were on // TripsService before, and on the deleted services/tripService before that); // deleteGuest routes through the SAME BudgetService domain SQL // (removeUserFromBudgetItems) under test. const dbs = () => new DatabaseService(testDb); -const membersSvc = new TripMembersService( + + +let budget: BudgetService; +let membersSvc: TripMembersService; +let createGuest: typeof membersSvc.createGuest; +let deleteGuest: typeof membersSvc.deleteGuest; +beforeAll(async () => { + budget = new BudgetService( + new DatabaseService(testDb), + new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), + new ExchangeRatesService(), + new RealtimeService(), +); + membersSvc = new TripMembersService( dbs(), budget, new UserCleanupService(dbs(), budget), - new PermissionsService(dbs()), + new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), ); -const createGuest = membersSvc.createGuest.bind(membersSvc); -const deleteGuest = membersSvc.deleteGuest.bind(membersSvc); + createGuest = membersSvc.createGuest.bind(membersSvc); + deleteGuest = membersSvc.deleteGuest.bind(membersSvc); +}); beforeAll(() => { createTables(testDb); diff --git a/server/tests/unit/nest/budget.service.test.ts b/server/tests/unit/nest/budget.service.test.ts index f7317cc7b..e0bc89146 100644 --- a/server/tests/unit/nest/budget.service.test.ts +++ b/server/tests/unit/nest/budget.service.test.ts @@ -56,15 +56,15 @@ describe('BudgetService', () => { expect(canAccessTrip).toHaveBeenCalledWith('5', 2); }); - it('canEdit forwards the ownership flag when the user owns the trip', () => { + it('canEdit forwards the ownership flag when the user owns the trip', async () => { checkPermission.mockReturnValue(true); - expect(svc().canEdit({ user_id: 1 } as never, { id: 1, role: 'user' } as never)).toBe(true); + expect(await svc().canEdit({ user_id: 1 } as never, { id: 1, role: 'user' } as never)).toBe(true); expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 1, 1, false); }); - it('canEdit marks the user as a guest when they do not own the trip', () => { + it('canEdit marks the user as a guest when they do not own the trip', async () => { checkPermission.mockReturnValue(false); - expect(svc().canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never)).toBe(false); + expect(await svc().canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never)).toBe(false); expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 2, 1, true); }); diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index 8a00d2b8a..311397dd6 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -57,15 +57,22 @@ import { CalendarModule } from '../../../src/nest/calendar/calendar.module'; import { expectRegisteredProvider } from '../../helpers/module-providers'; import { notificationsStub } from '../../helpers/notifications'; import { accommodationsOver } from '../../helpers/accommodations-service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; const dbs = () => new DatabaseService(testDb); -const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService()); + // Named `svc` so the moved cases below read exactly as they did on TripsService. -const svc = new CalendarService( +let budgetSvc: BudgetService; +let svc: CalendarService; +beforeAll(async () => { + budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService()); + svc = new CalendarService( dbs(), - new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsOver(dbs())), + new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection)), ); +}); beforeAll(() => { createTables(testDb); diff --git a/server/tests/unit/nest/categories.controller.test.ts b/server/tests/unit/nest/categories.controller.test.ts index 60012ce85..c35801f8b 100644 --- a/server/tests/unit/nest/categories.controller.test.ts +++ b/server/tests/unit/nest/categories.controller.test.ts @@ -13,9 +13,9 @@ function makeController(svc: Partial) { const cat: Category = { id: 1, name: 'Food', color: '#fff', icon: '🍔' }; -function thrown(fn: () => unknown): { status: number; body: unknown } { +async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> { try { - fn(); + await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; @@ -25,9 +25,9 @@ function thrown(fn: () => unknown): { status: number; body: unknown } { } describe('CategoriesController (parity with the legacy /api/categories route)', () => { - it('GET / returns the category list wrapped in { categories }', () => { + it('GET / returns the category list wrapped in { categories }', async () => { const list = vi.fn().mockReturnValue([cat]); - expect(makeController({ list }).list()).toEqual({ categories: [cat] }); + expect(await makeController({ list }).list()).toEqual({ categories: [cat] }); }); describe('POST /', () => { @@ -35,45 +35,45 @@ describe('CategoriesController (parity with the legacy /api/categories route)', // requires it, and the global ZodValidationPipe rejects it by metatype. The // contract itself is covered by shared/src/category/category.schema.spec.ts. - it('creates and returns { category }', () => { + it('creates and returns { category }', async () => { const create = vi.fn().mockReturnValue(cat); - expect(makeController({ create }).create(admin, { name: 'Food', color: '#fff', icon: '🍔' })).toEqual({ category: cat }); + expect(await makeController({ create }).create(admin, { name: 'Food', color: '#fff', icon: '🍔' })).toEqual({ category: cat }); expect(create).toHaveBeenCalledWith(1, 'Food', '#fff', '🍔'); }); }); describe('PUT /:id', () => { - it('404 when the category does not exist', () => { + it('404 when the category does not exist', async () => { const getById = vi.fn().mockReturnValue(undefined); const update = vi.fn(); - expect(thrown(() => makeController({ getById, update }).update('9', { name: 'X' }))).toEqual({ + expect(await thrown(() => makeController({ getById, update }).update('9', { name: 'X' }))).toEqual({ status: 404, body: { error: 'Category not found' }, }); expect(update).not.toHaveBeenCalled(); }); - it('updates and returns { category }', () => { + it('updates and returns { category }', async () => { const getById = vi.fn().mockReturnValue(cat); const update = vi.fn().mockReturnValue({ ...cat, name: 'Drinks' }); - expect(makeController({ getById, update }).update('1', { name: 'Drinks' })).toEqual({ category: { ...cat, name: 'Drinks' } }); + expect(await makeController({ getById, update }).update('1', { name: 'Drinks' })).toEqual({ category: { ...cat, name: 'Drinks' } }); expect(update).toHaveBeenCalledWith('1', 'Drinks', undefined, undefined); }); }); describe('DELETE /:id', () => { - it('404 when the category does not exist', () => { + it('404 when the category does not exist', async () => { const getById = vi.fn().mockReturnValue(undefined); const remove = vi.fn(); - expect(thrown(() => makeController({ getById, remove }).remove('9'))).toEqual({ + expect(await thrown(() => makeController({ getById, remove }).remove('9'))).toEqual({ status: 404, body: { error: 'Category not found' }, }); expect(remove).not.toHaveBeenCalled(); }); - it('deletes and returns { success: true }', () => { + it('deletes and returns { success: true }', async () => { const getById = vi.fn().mockReturnValue(cat); const remove = vi.fn(); - expect(makeController({ getById, remove }).remove('1')).toEqual({ success: true }); + expect(await makeController({ getById, remove }).remove('1')).toEqual({ success: true }); expect(remove).toHaveBeenCalledWith('1'); }); }); diff --git a/server/tests/unit/nest/categories.service.test.ts b/server/tests/unit/nest/categories.service.test.ts index f23084ffe..5d2cbce76 100644 --- a/server/tests/unit/nest/categories.service.test.ts +++ b/server/tests/unit/nest/categories.service.test.ts @@ -58,19 +58,19 @@ afterAll(() => { // ── list ────────────────────────────────────────────────────────────────────── describe('list', () => { - it('CAT-SVC-001 — returns an array (seeded defaults are present after migrations)', () => { + it('CAT-SVC-001 — returns an array (seeded defaults are present after migrations)', async () => { // Migrations seed default categories, so the list is never empty in a fully initialized DB - const cats = svc.list(); + const cats = await svc.list(); expect(Array.isArray(cats)).toBe(true); expect(cats.length).toBeGreaterThan(0); }); - it('CAT-SVC-002 — results are ordered by name ascending (custom categories sort correctly)', () => { + it('CAT-SVC-002 — results are ordered by name ascending (custom categories sort correctly)', async () => { const { user } = createUser(testDb); - svc.create(user.id, 'Zoo'); - svc.create(user.id, 'Aquarium'); + await svc.create(user.id, 'Zoo'); + await svc.create(user.id, 'Aquarium'); // Migrations seed default categories; verify ordering by checking our custom ones appear in sorted order - const names = svc.list().map((c) => c.name); + const names = (await svc.list()).map((c) => c.name); const aquariumIdx = names.indexOf('Aquarium'); const zooIdx = names.indexOf('Zoo'); expect(aquariumIdx).toBeGreaterThanOrEqual(0); @@ -78,43 +78,43 @@ describe('list', () => { expect(aquariumIdx).toBeLessThan(zooIdx); }); - it('CAT-SVC-003 — returns categories from all users (including seeded defaults)', () => { + it('CAT-SVC-003 — returns categories from all users (including seeded defaults)', async () => { const { user: a } = createUser(testDb); const { user: b } = createUser(testDb); - const before = svc.list().length; - svc.create(a.id, 'Cat-A'); - svc.create(b.id, 'Cat-B'); - expect(svc.list()).toHaveLength(before + 2); + const before = (await svc.list()).length; + await svc.create(a.id, 'Cat-A'); + await svc.create(b.id, 'Cat-B'); + expect(await svc.list()).toHaveLength(before + 2); }); }); // ── create ──────────────────────────────────────────────────────────────────── describe('create', () => { - it('CAT-SVC-004 — creates a category with name, color, and icon', () => { + it('CAT-SVC-004 — creates a category with name, color, and icon', async () => { const { user } = createUser(testDb); - const cat = svc.create(user.id, 'Restaurant', '#ff5500', '🍽️'); + const cat = await svc.create(user.id, 'Restaurant', '#ff5500', '🍽️'); expect(cat.name).toBe('Restaurant'); expect(cat.color).toBe('#ff5500'); expect(cat.icon).toBe('🍽️'); expect(cat.user_id).toBe(user.id); }); - it('CAT-SVC-005 — defaults color to #6366f1 when not provided', () => { + it('CAT-SVC-005 — defaults color to #6366f1 when not provided', async () => { const { user } = createUser(testDb); - const cat = svc.create(user.id, 'Default Color'); + const cat = await svc.create(user.id, 'Default Color'); expect(cat.color).toBe('#6366f1'); }); - it('CAT-SVC-006 — defaults icon to 📍 when not provided', () => { + it('CAT-SVC-006 — defaults icon to 📍 when not provided', async () => { const { user } = createUser(testDb); - const cat = svc.create(user.id, 'Default Icon'); + const cat = await svc.create(user.id, 'Default Icon'); expect(cat.icon).toBe('📍'); }); - it('CAT-SVC-007 — returns the inserted row with an id', () => { + it('CAT-SVC-007 — returns the inserted row with an id', async () => { const { user } = createUser(testDb); - const cat = svc.create(user.id, 'WithId'); + const cat = await svc.create(user.id, 'WithId'); expect(typeof cat.id).toBe('number'); expect(cat.id).toBeGreaterThan(0); }); @@ -123,22 +123,22 @@ describe('create', () => { // ── getById ─────────────────────────────────────────────────────────────────── describe('getById', () => { - it('CAT-SVC-008 — returns category for a valid id', () => { + it('CAT-SVC-008 — returns category for a valid id', async () => { const { user } = createUser(testDb); - const created = svc.create(user.id, 'Find Me'); - const found = svc.getById(created.id); + const created = await svc.create(user.id, 'Find Me'); + const found = await svc.getById(created.id); expect(found).toBeDefined(); expect(found?.name).toBe('Find Me'); }); - it('CAT-SVC-009 — returns undefined for non-existent id', () => { - expect(svc.getById(99999)).toBeUndefined(); + it('CAT-SVC-009 — returns undefined for non-existent id', async () => { + expect(await svc.getById(99999)).toBeUndefined(); }); - it('CAT-SVC-010 — accepts string id (coerced by SQLite)', () => { + it('CAT-SVC-010 — accepts string id (coerced by SQLite)', async () => { const { user } = createUser(testDb); - const created = svc.create(user.id, 'StringId'); - const found = svc.getById(String(created.id)); + const created = await svc.create(user.id, 'StringId'); + const found = await svc.getById(String(created.id)); expect(found).toBeDefined(); expect(found?.id).toBe(created.id); }); @@ -147,27 +147,27 @@ describe('getById', () => { // ── update ──────────────────────────────────────────────────────────────────── describe('update', () => { - it('CAT-SVC-011 — updates name, color, and icon', () => { + it('CAT-SVC-011 — updates name, color, and icon', async () => { const { user } = createUser(testDb); - const cat = svc.create(user.id, 'Old', '#aaaaaa', '❓'); - const updated = svc.update(cat.id, 'New', '#bbbbbb', '✅'); + const cat = await svc.create(user.id, 'Old', '#aaaaaa', '❓'); + const updated = await svc.update(cat.id, 'New', '#bbbbbb', '✅'); expect(updated.name).toBe('New'); expect(updated.color).toBe('#bbbbbb'); expect(updated.icon).toBe('✅'); }); - it('CAT-SVC-012 — COALESCE: omitting name preserves existing name', () => { + it('CAT-SVC-012 — COALESCE: omitting name preserves existing name', async () => { const { user } = createUser(testDb); - const cat = svc.create(user.id, 'KeepName', '#aaaaaa', '⭐'); - const updated = svc.update(cat.id, undefined, '#cccccc', '🔥'); + const cat = await svc.create(user.id, 'KeepName', '#aaaaaa', '⭐'); + const updated = await svc.update(cat.id, undefined, '#cccccc', '🔥'); expect(updated.name).toBe('KeepName'); expect(updated.color).toBe('#cccccc'); }); - it('CAT-SVC-013 — COALESCE: omitting color preserves existing color', () => { + it('CAT-SVC-013 — COALESCE: omitting color preserves existing color', async () => { const { user } = createUser(testDb); - const cat = svc.create(user.id, 'KeepColor', '#dddddd', '⭐'); - const updated = svc.update(cat.id, 'NewName', undefined, '🌟'); + const cat = await svc.create(user.id, 'KeepColor', '#dddddd', '⭐'); + const updated = await svc.update(cat.id, 'NewName', undefined, '🌟'); expect(updated.name).toBe('NewName'); expect(updated.color).toBe('#dddddd'); }); @@ -176,14 +176,14 @@ describe('update', () => { // ── remove ──────────────────────────────────────────────────────────────────── describe('remove', () => { - it('CAT-SVC-014 — deletes the category from the database', () => { + it('CAT-SVC-014 — deletes the category from the database', async () => { const { user } = createUser(testDb); - const cat = svc.create(user.id, 'ToDelete'); - svc.remove(cat.id); - expect(svc.getById(cat.id)).toBeUndefined(); + const cat = await svc.create(user.id, 'ToDelete'); + await svc.remove(cat.id); + expect(await svc.getById(cat.id)).toBeUndefined(); }); - it('CAT-SVC-015 — deleting a non-existent category does not throw', () => { - expect(() => svc.remove(99999)).not.toThrow(); + it('CAT-SVC-015 — deleting a non-existent category does not throw', async () => { + await expect(svc.remove(99999)).resolves.not.toThrow(); }); }); diff --git a/server/tests/unit/nest/channel-override.test.ts b/server/tests/unit/nest/channel-override.test.ts index c4aad493d..a6b6032ff 100644 --- a/server/tests/unit/nest/channel-override.test.ts +++ b/server/tests/unit/nest/channel-override.test.ts @@ -253,7 +253,7 @@ describe('the plugin channel source reaches the outside-container instance', () setNotificationChannels(testDb, 'none'); const delivered: Array<{ userId: number; title: string }> = []; - const runtime = createPluginRuntime(new DatabaseService(testDb)); + const runtime = await createPluginRuntime(new DatabaseService(testDb)); // Stand in for a booted supervisor: one plugin providing the hook, and an // invokeHook that records instead of forking a child. Object.defineProperty(runtime, 'supervisor', { diff --git a/server/tests/unit/nest/collab.service.test.ts b/server/tests/unit/nest/collab.service.test.ts index a3f6a7107..ab235941b 100644 --- a/server/tests/unit/nest/collab.service.test.ts +++ b/server/tests/unit/nest/collab.service.test.ts @@ -87,13 +87,18 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { notificationsStub } from '../../helpers/notifications'; import { makeStorageFixture } from '../../helpers/storage-fixture'; import { RateLimitService } from '../../../src/nest/common/rate-limit.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; const collabFx = makeStorageFixture('files/'); const rateLimit = new RateLimitService(); -const svc = new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), notificationsStub(), collabFx.storage, rateLimit); +let svc: CollabService; +beforeAll(async () => { + svc = new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(), collabFx.storage, rateLimit); +}); /** A CollabService with its own preview cache and budget, for the tests that fill either. */ -const freshSvc = () => new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService()); +const freshSvc = async () => new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService()); beforeAll(() => { createTables(testDb); @@ -527,7 +532,7 @@ describe('linkPreview hardening', () => { it('COLLAB-SVC-039: asks the SSRF guard to refuse internal targets even where the instance allows them', async () => { stubFetch({ ok: true, text: async () => '' }); - await freshSvc().linkPreview('https://example.com/guard-args'); + await (await freshSvc()).linkPreview('https://example.com/guard-args'); // The second argument is what keeps ALLOW_INTERNAL_NETWORK from widening a // route whose URL comes from whoever is typing in the chat. expect(mockCheckSsrf).toHaveBeenCalledWith('https://example.com/guard-args', true); @@ -538,7 +543,7 @@ describe('linkPreview hardening', () => { mockCreatePinnedDispatcher.mockReturnValueOnce(dispatcher); const fetchMock = stubFetch({ ok: true, text: async () => '' }); - await freshSvc().linkPreview('https://example.com/init'); + await (await freshSvc()).linkPreview('https://example.com/init'); const init = fetchMock.mock.calls[0][1] as Record; // Without redirect:'error' a public URL could 302 onto an internal one, and @@ -556,7 +561,7 @@ describe('linkPreview hardening', () => { // "loopback". Relaying that verbatim turns the route into a probe for the // internal DNS of the server, one guessed hostname at a time. mockCheckSsrf.mockResolvedValue({ allowed: false, isPrivate: true, resolvedIp: '10.0.0.5', error: 'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.' }); - const result = await freshSvc().linkPreview('http://nas.internal/'); + const result = await (await freshSvc()).linkPreview('http://nas.internal/'); expect(result.error).toBe('URL not allowed'); expect(JSON.stringify(result)).not.toContain('ALLOW_INTERNAL_NETWORK'); expect(JSON.stringify(result)).not.toContain('private'); @@ -571,7 +576,7 @@ describe('linkPreview hardening', () => { // whole server, on one request, from any trip member. stubFetch({ ok: true, text: async () => ' { body: { getReader: () => ({ read: async () => ({ done: true }), cancel }), cancel }, text: async () => 'Nicht gelesen', }); - const result = await freshSvc().linkPreview('https://example.com/video'); + const result = await (await freshSvc()).linkPreview('https://example.com/video'); expect(result.title).toBeNull(); // An unread body keeps its socket reserved until the collector runs. expect(cancel).toHaveBeenCalled(); @@ -602,14 +607,14 @@ describe('linkPreview hardening', () => { ['http://cdn.example/ok.png', 'http://cdn.example/ok.png'], ] as const) { stubFetch({ ok: true, text: async () => `` }); - const result = await freshSvc().linkPreview(`https://example.com/img-${encodeURIComponent(image)}`); + const result = await (await freshSvc()).linkPreview(`https://example.com/img-${encodeURIComponent(image)}`); expect(result.image).toBe(expected); } }); it('COLLAB-SVC-045: a preview already fetched is served again without a second request', async () => { const fetchMock = stubFetch({ ok: true, text: async () => 'Einmal geholt' }); - const service = freshSvc(); + const service = await freshSvc(); const first = await service.linkPreview('https://example.com/cached', 7); const second = await service.linkPreview('https://example.com/cached', 7); expect(first.title).toBe('Einmal geholt'); @@ -619,7 +624,7 @@ describe('linkPreview hardening', () => { it('COLLAB-SVC-046: the budget runs out per user, and cached previews do not charge it', async () => { const fetchMock = stubFetch({ ok: true, text: async () => 'T' }); - const service = freshSvc(); + const service = await freshSvc(); // 60 distinct URLs is the whole allowance for a minute. for (let i = 0; i < 60; i++) { @@ -649,7 +654,7 @@ describe('linkPreview hardening', () => { }); vi.stubGlobal('fetch', fetchMock); - const service = freshSvc(); + const service = await freshSvc(); const all = Promise.all(Array.from({ length: 20 }, () => service.linkPreview('https://example.com/same', 9))); release!(); const results = await all; @@ -667,7 +672,7 @@ describe('linkPreview hardening', () => { ]) { const cancel = vi.fn().mockResolvedValue(undefined); stubFetch({ ...response, body: { getReader: () => ({ read: async () => ({ done: true }), cancel }), cancel }, text: async () => '' }); - const result = await freshSvc().linkPreview(`https://example.com/drop-${String(response.ok)}`); + const result = await (await freshSvc()).linkPreview(`https://example.com/drop-${String(response.ok)}`); expect(result.title).toBeNull(); expect(cancel).toHaveBeenCalled(); } @@ -681,10 +686,10 @@ describe('linkPreview hardening', () => { // ── Post-migration hardening (transactions, scoping, guards) ────────────────── describe('hardening', () => { - it('COLLAB-SVC-034: votePoll switch is atomic — prior vote survives a failed INSERT', () => { + it('COLLAB-SVC-034: votePoll switch is atomic — prior vote survives a failed INSERT', async () => { const { user1, trip } = setup(); const dbs = new DatabaseService(testDb); - const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService()); + const failing = new CollabService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService()); const poll = failing.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); failing.votePoll(trip.id, poll!.id, user1.id, 0); @@ -705,7 +710,7 @@ describe('hardening', () => { it('COLLAB-SVC-035: deleteNote is atomic — trip_files rows survive a failed note DELETE', async () => { const { user1, trip } = setup(); const dbs = new DatabaseService(testDb); - const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService()); + const failing = new CollabService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService()); const note = failing.createNote(trip.id, user1.id, { title: 'With file' }); testDb.prepare('INSERT INTO trip_files (trip_id, note_id, filename, original_name) VALUES (?, ?, ?, ?)') .run(trip.id, note.id, 'files/a.pdf', 'a.pdf'); @@ -726,7 +731,7 @@ describe('hardening', () => { const { user1, trip } = setup(); const dbs = new DatabaseService(testDb); const failingStorage = { delete: vi.fn().mockRejectedValue(new Error('EACCES')) }; - const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService(), notificationsStub(), failingStorage as unknown as import('../../../src/nest/storage/storage.service').StorageService, new RateLimitService()); + const failing = new CollabService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new RealtimeService(), notificationsStub(), failingStorage as unknown as import('../../../src/nest/storage/storage.service').StorageService, new RateLimitService()); const note = failing.createNote(trip.id, user1.id, { title: 'Sticky file' }); testDb.prepare('INSERT INTO trip_files (trip_id, note_id, filename, original_name) VALUES (?, ?, ?, ?)') .run(trip.id, note.id, 'stuck.pdf', 'stuck.pdf'); diff --git a/server/tests/unit/nest/collections.service.test.ts b/server/tests/unit/nest/collections.service.test.ts index 48fd050bf..4b1052118 100644 --- a/server/tests/unit/nest/collections.service.test.ts +++ b/server/tests/unit/nest/collections.service.test.ts @@ -55,9 +55,14 @@ import { CollectionsService } from '../../../src/nest/collections/collections.se import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { makeStorageFixture } from '../../helpers/storage-fixture'; import { notificationsStub } from '../../helpers/notifications'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; const storageFx = makeStorageFixture(''); -const svc = new CollectionsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), notificationsStub(notifSend), storageFx.storage); +let svc: CollectionsService; +beforeAll(async () => { + svc = new CollectionsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(notifSend), storageFx.storage); +}); // The real cache: these cases assert what removeIfUnreferenced actually does // about collection_places (#1081), so a stub would assert nothing. const photoCache = new PlacePhotoCacheService(new DatabaseService(testDb), makeStorageFixture('photos/google/').storage); @@ -342,7 +347,7 @@ describe('status + updatePlace move', () => { // ── copy to trip ───────────────────────────────────────────────────────────── describe('copyToTrip', () => { - it('COLLECTIONS-SVC-020: reduced INSERT (itinerary defaults), skips dups, copies tags', () => { + it('COLLECTIONS-SVC-020: reduced INSERT (itinerary defaults), skips dups, copies tags', async () => { const u = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, u.id); @@ -354,7 +359,7 @@ describe('copyToTrip', () => { createPlace(testDb, trip.id, { name: 'Pantheon' }); const p2 = svc.savePlace(u.id, { collection_id: col.id, name: 'Pantheon' }).place!; - const res = svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p1.id, p2.id] }); + const res = await svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p1.id, p2.id] }); expect(res.copied).toBe(1); expect(res.skipped.map((s) => s.name)).toEqual(['Pantheon']); @@ -365,7 +370,7 @@ describe('copyToTrip', () => { expect(tagLink).toEqual({ n: 1 }); }); - it('COLLECTIONS-SVC-021: rejects place_ids from a collection the user cannot see', () => { + it('COLLECTIONS-SVC-021: rejects place_ids from a collection the user cannot see', async () => { const owner = createUser(testDb).user; const stranger = createUser(testDb).user; createCategory(testDb); @@ -373,20 +378,20 @@ describe('copyToTrip', () => { const p = svc.savePlace(owner.id, { collection_id: hidden.id, name: 'Secret' }).place!; const trip = createTrip(testDb, stranger.id); - expect(() => svc.copyToTrip(stranger.id, { trip_id: trip.id, place_ids: [p.id] })).toThrow(); + await expect(svc.copyToTrip(stranger.id, { trip_id: trip.id, place_ids: [p.id] })).rejects.toThrow(); }); - it('COLLECTIONS-SVC-022: rejects a trip the user cannot edit (403/404)', () => { + it('COLLECTIONS-SVC-022: rejects a trip the user cannot edit (403/404)', async () => { const u = createUser(testDb).user; const owner2 = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, owner2.id); // u has no access const col = svc.createCollection(u.id, { name: 'C' }); const p = svc.savePlace(u.id, { collection_id: col.id, name: 'X' }).place!; - expect(() => svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p.id] })).toThrow(); + await expect(svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p.id] })).rejects.toThrow(); }); - it('COLLECTIONS-SVC-023: a trip MEMBER can copy (place_edit allowed)', () => { + it('COLLECTIONS-SVC-023: a trip MEMBER can copy (place_edit allowed)', async () => { const owner2 = createUser(testDb).user; const member = createUser(testDb).user; createCategory(testDb); @@ -394,7 +399,7 @@ describe('copyToTrip', () => { addTripMember(testDb, trip.id, member.id); const col = svc.createCollection(member.id, { name: 'C' }); const p = svc.savePlace(member.id, { collection_id: col.id, name: 'Forum' }).place!; - const res = svc.copyToTrip(member.id, { trip_id: trip.id, place_ids: [p.id] }); + const res = await svc.copyToTrip(member.id, { trip_id: trip.id, place_ids: [p.id] }); expect(res.copied).toBe(1); }); }); @@ -792,7 +797,7 @@ describe('collaborative ratings (#1435)', () => { expect(votes.find(v => v.user_id === tripOnly.id)).toBeUndefined(); }); - it('COLLECTIONS-SVC-074: copying a saved place into a trip carries its ratings along', () => { + it('COLLECTIONS-SVC-074: copying a saved place into a trip carries its ratings along', async () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; const col = svc.createCollection(owner.id, { name: 'Copyable' }); @@ -803,7 +808,7 @@ describe('collaborative ratings (#1435)', () => { const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); // member is on the trip, so their vote carries - const res = svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] }); + const res = await svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] }); expect(res.copied).toBe(1); const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as { id: number }; @@ -832,7 +837,7 @@ describe('collaborative ratings (#1435)', () => { expect(stolen).toHaveLength(0); // no access to the source trip → nothing copied }); - it('COLLECTIONS-SVC-076: copyToTrip carries only votes from members of the target trip', () => { + it('COLLECTIONS-SVC-076: copyToTrip carries only votes from members of the target trip', async () => { const owner = createUser(testDb).user; const inTrip = createUser(testDb).user; // collection member AND trip member const notInTrip = createUser(testDb).user; // collection member only @@ -847,7 +852,7 @@ describe('collaborative ratings (#1435)', () => { const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, inTrip.id); - svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] }); + await svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] }); const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as { id: number }; const ids = (testDb.prepare('SELECT user_id FROM place_ratings WHERE place_id = ?').all(newPlace.id) as { user_id: number }[]) diff --git a/server/tests/unit/nest/dawarich-suggestions.service.test.ts b/server/tests/unit/nest/dawarich-suggestions.service.test.ts index 5999e467e..7fc4344a9 100644 --- a/server/tests/unit/nest/dawarich-suggestions.service.test.ts +++ b/server/tests/unit/nest/dawarich-suggestions.service.test.ts @@ -67,6 +67,8 @@ import type { PlacesService } from '../../../src/nest/places/places.service'; import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; // ── Collaborator stubs ─────────────────────────────────────────────────────── // @@ -114,7 +116,9 @@ const permissionsStub = { checkPermission: vi.fn() }; const journeyStub = { canEdit: vi.fn(), createEntry: vi.fn() }; const dbs = new DatabaseService(testDb); -const svc = new DawarichSuggestionsService( +let svc: DawarichSuggestionsService; +beforeAll(async () => { + svc = new DawarichSuggestionsService( dbs, dawarichStub as unknown as DawarichService, clientStub as unknown as DawarichClient, @@ -122,8 +126,9 @@ const svc = new DawarichSuggestionsService( placesStub as unknown as PlacesService, assignmentsStub as unknown as AssignmentsService, permissionsStub as unknown as PermissionsService, - journeyStub as unknown as JourneyDomainService, + journeyStub as unknown as JourneyDomainService, await createTestUnitOfWork(dbs.connection), ); +}); const CREATED_ENTRY_ID = 7702; @@ -382,11 +387,11 @@ describe('DawarichSuggestionsService — the review list', () => { expect(svc.getOne(stranger.id, theirs)?.name).toBe('Their Kitchen'); }); - it('DAWARICH-SUG-059: the flags the panel warns with are read off the row, never acted on', () => { + it('DAWARICH-SUG-059: the flags the panel warns with are read off the row, never acted on', async () => { const { user } = createUser(testDb); const wish = seedBucketItem(user.id); const id = seedSuggestion({ userId: user.id }); - svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish }); + await svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish }); // Everything a later sync is allowed to do to a row somebody already // accepted: the detector re-ran and the hash moved, it upgraded the stay @@ -448,7 +453,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { expect(rowOf(id).state).toBe('new'); }); - it('DAWARICH-SUG-007: a stay on a trip the caller is a member of becomes a place pinned to the day', () => { + it('DAWARICH-SUG-007: a stay on a trip the caller is a member of becomes a place pinned to the day', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); @@ -456,7 +461,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { const day = createDay(testDb, trip.id, { date: '2026-09-01' }); const id = seedSuggestion({ userId: member.id, tripId: trip.id, name: 'Cafe Central' }); - const result = svc.accept(member.id, id, { + const result = await svc.accept(member.id, id, { target: 'place', tripId: trip.id, dayId: day.id, @@ -500,7 +505,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { expect(placesStub.create).not.toHaveBeenCalled(); }); - it('DAWARICH-SUG-043: every field the review step lets someone correct beats the recorded one', () => { + it('DAWARICH-SUG-043: every field the review step lets someone correct beats the recorded one', async () => { // The review step exists so a detector's guess can be fixed rather than // swallowed; a correction that silently lost to the recording would make // the whole form decorative. @@ -508,7 +513,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { const trip = createTrip(testDb, user.id); const id = seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Unnamed stay' }); - svc.accept(user.id, id, { + await svc.accept(user.id, id, { target: 'place', tripId: trip.id, name: ' Cafe Central ', @@ -534,7 +539,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { ); }); - it('DAWARICH-SUG-044: a stay with no position and no measurable length passes neither on', () => { + it('DAWARICH-SUG-044: a stay with no position and no measurable length passes neither on', async () => { // `undefined` rather than null or zero: PlacesService applies its own // defaults for a field nobody supplied, and a place pinned at 0,0 off the // coast of Africa with a duration of "0 min" is worse than one with none. @@ -542,7 +547,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { const trip = createTrip(testDb, user.id); const id = seedSuggestion({ userId: user.id, tripId: trip.id, lat: null, lng: null, durationMinutes: 0 }); - svc.accept(user.id, id, { target: 'place', tripId: trip.id }); + await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); const body = placesStub.create.mock.calls[0][1] as Record; expect(body.lat).toBeUndefined(); @@ -550,18 +555,18 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { expect(body.duration_minutes).toBeUndefined(); }); - it('DAWARICH-SUG-045: with no trip in the body, the one the sync filed the stay under is used', () => { + it('DAWARICH-SUG-045: with no trip in the body, the one the sync filed the stay under is used', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const id = seedSuggestion({ userId: user.id, tripId: trip.id }); - const result = svc.accept(user.id, id, { target: 'place' }); + const result = await svc.accept(user.id, id, { target: 'place' }); expect(placesStub.create).toHaveBeenCalledWith(String(trip.id), expect.anything()); expect(result.createdPlaceId).not.toBeNull(); }); - it('DAWARICH-SUG-046: a timestamp with no clock time in it yields no time rather than a wrong one', () => { + it('DAWARICH-SUG-046: a timestamp with no clock time in it yields no time rather than a wrong one', async () => { // Dawarich normally sends a full ISO instant, but a visit imported from a // GPX or an older schema can arrive as a bare date. Slicing characters 11 // to 16 out of that would put the last five characters of the date into a @@ -575,7 +580,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { endedAt: '2026-09-01', }); - svc.accept(user.id, id, { target: 'place', tripId: trip.id }); + await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); expect(placesStub.create).toHaveBeenCalledWith( String(trip.id), @@ -614,11 +619,11 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { expect(rowOf(id).state).toBe('new'); }); - it('DAWARICH-SUG-011: a permitted journey gets a dated entry, with the socket id passed through', () => { + it('DAWARICH-SUG-011: a permitted journey gets a dated entry, with the socket id passed through', async () => { const { user } = createUser(testDb); const id = seedSuggestion({ userId: user.id, name: 'Cafe Central', localDate: '2026-09-01' }); - const result = svc.accept(user.id, id, { target: 'journal', journalId: 88 }, 'socket-7'); + const result = await svc.accept(user.id, id, { target: 'journal', journalId: 88 }, 'socket-7'); expect(journeyStub.createEntry).toHaveBeenCalledWith( 88, @@ -654,11 +659,11 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { expect(journeyStub.canEdit).not.toHaveBeenCalled(); }); - it('DAWARICH-SUG-047: a corrected entry carries the corrections, not the recording', () => { + it('DAWARICH-SUG-047: a corrected entry carries the corrections, not the recording', async () => { const { user } = createUser(testDb); const id = seedSuggestion({ userId: user.id, name: 'Cafe Central', localDate: '2026-09-01' }); - svc.accept(user.id, id, { + await svc.accept(user.id, id, { target: 'journal', journalId: 88, name: 'Dinner at the Naschmarkt', @@ -685,7 +690,7 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { ); }); - it('DAWARICH-SUG-048: a stay with no position and no clock time writes an entry without either', () => { + it('DAWARICH-SUG-048: a stay with no position and no clock time writes an entry without either', async () => { // The journey domain treats a missing field and an empty one differently // (a location_lat of null would put a pin on the map at the equator), so // everything unknown has to arrive as undefined rather than as null. @@ -698,7 +703,7 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { localDate: '2026-09-03', }); - svc.accept(user.id, id, { target: 'journal', journalId: 88 }); + await svc.accept(user.id, id, { target: 'journal', journalId: 88 }); const body = journeyStub.createEntry.mock.calls[0][2] as Record; expect(body.entry_date).toBe('2026-09-03'); @@ -739,12 +744,12 @@ describe('DawarichSuggestionsService — ticking off a wish', () => { expect(rowOf(id).state).toBe('new'); }); - it("DAWARICH-SUG-015: the caller's own wish is ticked with the stay's arrival and marked as imported", () => { + it("DAWARICH-SUG-015: the caller's own wish is ticked with the stay's arrival and marked as imported", async () => { const { user } = createUser(testDb); const wish = seedBucketItem(user.id, { name: 'Hallstatt' }); const id = seedSuggestion({ userId: user.id, startedAt: '2026-09-01T09:30:00Z' }); - const result = svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish }); + const result = await svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish }); const item = bucketOf(wish); expect(item.visited_at).toBe('2026-09-01T09:30:00Z'); @@ -754,12 +759,12 @@ describe('DawarichSuggestionsService — ticking off a wish', () => { expect(rowOf(id).accepted_bucket_list_item_id).toBe(wish); }); - it('DAWARICH-SUG-016: with no wish named in the body, the matched one is used', () => { + it('DAWARICH-SUG-016: with no wish named in the body, the matched one is used', async () => { const { user } = createUser(testDb); const wish = seedBucketItem(user.id, { name: 'Hallstatt' }); const id = seedSuggestion({ userId: user.id, matchedBucketListItemId: wish }); - const result = svc.accept(user.id, id, { target: 'bucket_list' }); + const result = await svc.accept(user.id, id, { target: 'bucket_list' }); expect(result.bucketListItemId).toBe(wish); expect(bucketOf(wish).visited_source).toBe('dawarich'); @@ -779,12 +784,12 @@ describe('DawarichSuggestionsService — ticking off a wish', () => { // ── accept twice / state ───────────────────────────────────────────────────── describe('DawarichSuggestionsService — what an acceptance closes off', () => { - it('DAWARICH-SUG-018: accepting an already accepted suggestion is a 409, not a second place', () => { + it('DAWARICH-SUG-018: accepting an already accepted suggestion is a 409, not a second place', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const id = seedSuggestion({ userId: user.id, tripId: trip.id }); - const first = svc.accept(user.id, id, { target: 'place', tripId: trip.id }); + const first = await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); expect(placesStub.create).toHaveBeenCalledTimes(1); const err = refusalFrom(() => svc.accept(user.id, id, { target: 'place', tripId: trip.id })); @@ -797,11 +802,11 @@ describe('DawarichSuggestionsService — what an acceptance closes off', () => { expect(testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id)).toEqual({ n: 1 }); }); - it('DAWARICH-SUG-019: setState cannot pull an accepted suggestion back into review', () => { + it('DAWARICH-SUG-019: setState cannot pull an accepted suggestion back into review', async () => { const { user } = createUser(testDb); const wish = seedBucketItem(user.id); const id = seedSuggestion({ userId: user.id }); - svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish }); + await svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish }); const back = svc.setState(user.id, id, 'new'); @@ -1259,12 +1264,12 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { }); // ── What an acceptance tells the rest of the trip ────────────────────────── - it('DAWARICH-SUG-031: accepting as a place broadcasts it like any other place, socket id and all', () => { + it('DAWARICH-SUG-031: accepting as a place broadcasts it like any other place, socket id and all', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const id = seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Museum Ludwig' }); - const result = svc.accept(user.id, id, { target: 'place', tripId: trip.id }, 'socket-7'); + const result = await svc.accept(user.id, id, { target: 'place', tripId: trip.id }, 'socket-7'); expect(placesStub.broadcast).toHaveBeenCalledWith( String(trip.id), @@ -1276,7 +1281,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { expect(placesStub.onCreated).toHaveBeenCalledWith(String(trip.id), result.createdPlaceId); }); - it('DAWARICH-SUG-060: the broadcast carries the re-read row, so everyone on the trip sees the dawarich mark', () => { + it('DAWARICH-SUG-060: the broadcast carries the re-read row, so everyone on the trip sees the dawarich mark', async () => { // `source` is stamped after PlacesService.create returns, so the object // that call handed back does not have it. Broadcasting that one would leave // every other client showing the place without its provenance until the @@ -1285,14 +1290,14 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { const trip = createTrip(testDb, user.id); const id = seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Museum Ludwig' }); - const result = svc.accept(user.id, id, { target: 'place', tripId: trip.id }); + const result = await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); const payload = placesStub.broadcast.mock.calls[0][2] as { place: { id: number; source: string | null } }; expect(payload.place.id).toBe(result.createdPlaceId); expect(payload.place.source).toBe('dawarich'); }); - it('DAWARICH-SUG-061: a re-read that comes back empty still broadcasts the place that was created', () => { + it('DAWARICH-SUG-061: a re-read that comes back empty still broadcasts the place that was created', async () => { // The re-read is an improvement on the payload, not a precondition for it: // if it finds nothing the acceptance has still happened, and a broadcast // the trip never receives is a place that needs a page reload to appear. @@ -1301,7 +1306,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { const id = seedSuggestion({ userId: user.id, tripId: trip.id }); dbMock.getPlaceWithTags.mockReturnValue(null); - const result = svc.accept(user.id, id, { target: 'place', tripId: trip.id }, 'socket-3'); + const result = await svc.accept(user.id, id, { target: 'place', tripId: trip.id }, 'socket-3'); expect(placesStub.broadcast).toHaveBeenCalledWith( String(trip.id), @@ -1311,7 +1316,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { ); }); - it('DAWARICH-SUG-039: a member refused place_edit is refused here too', () => { + it('DAWARICH-SUG-039: a member refused place_edit is refused here too', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); @@ -1319,13 +1324,13 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { const id = seedSuggestion({ userId: member.id, tripId: trip.id }); permissionsStub.checkPermission.mockImplementation((action: string) => action !== 'place_edit'); - expect(() => svc.accept(member.id, id, { target: 'place', tripId: trip.id })).toThrow(AcceptError); + await expect(svc.accept(member.id, id, { target: 'place', tripId: trip.id })).rejects.toThrow(AcceptError); // Nothing written, and the stay is still waiting rather than marked handled. expect(placesStub.create).not.toHaveBeenCalled(); expect(rowOf(id).state).toBe('new'); }); - it('DAWARICH-SUG-040: pinning to a day asks day_edit as well, and writes nothing without it', () => { + it('DAWARICH-SUG-040: pinning to a day asks day_edit as well, and writes nothing without it', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); @@ -1334,29 +1339,29 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { const id = seedSuggestion({ userId: member.id, tripId: trip.id }); permissionsStub.checkPermission.mockImplementation((action: string) => action !== 'day_edit'); - expect(() => svc.accept(member.id, id, { target: 'place', tripId: trip.id, dayId: day.id })).toThrow(AcceptError); + await expect(svc.accept(member.id, id, { target: 'place', tripId: trip.id, dayId: day.id })).rejects.toThrow(AcceptError); expect(placesStub.create).not.toHaveBeenCalled(); expect(rowOf(id).state).toBe('new'); }); - it('DAWARICH-SUG-041: the permission is asked about the trip owner, not about the caller', () => { + it('DAWARICH-SUG-041: the permission is asked about the trip owner, not about the caller', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); const id = seedSuggestion({ userId: member.id, tripId: trip.id }); - svc.accept(member.id, id, { target: 'place', tripId: trip.id }); + await svc.accept(member.id, id, { target: 'place', tripId: trip.id }); expect(permissionsStub.checkPermission).toHaveBeenCalledWith('place_edit', 'user', owner.id, member.id, true); }); - it('DAWARICH-SUG-038: a place made from a recording says where it came from', () => { + it('DAWARICH-SUG-038: a place made from a recording says where it came from', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const id = seedSuggestion({ userId: user.id, tripId: trip.id }); - const result = svc.accept(user.id, id, { target: 'place', tripId: trip.id }); + const result = await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); const place = testDb .prepare('SELECT source FROM places WHERE id = ?') @@ -1364,14 +1369,14 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { expect(place.source).toBe('dawarich'); }); - it('DAWARICH-SUG-032: a stay accepted onto a day broadcasts the assignment too', () => { + it('DAWARICH-SUG-032: a stay accepted onto a day broadcasts the assignment too', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); const id = seedSuggestion({ userId: user.id, tripId: trip.id }); assignmentsStub.createAssignment.mockReturnValue({ id: 4242, day_id: day.id }); - svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: day.id }, 'socket-9'); + await svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: day.id }, 'socket-9'); expect(assignmentsStub.broadcast).toHaveBeenCalledWith( String(trip.id), @@ -1381,14 +1386,14 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { ); }); - it('DAWARICH-SUG-033: an assignment the domain refuses to create is not broadcast as one', () => { + it('DAWARICH-SUG-033: an assignment the domain refuses to create is not broadcast as one', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); const id = seedSuggestion({ userId: user.id, tripId: trip.id }); assignmentsStub.createAssignment.mockReturnValue(undefined); - svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: day.id }); + await svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: day.id }); expect(assignmentsStub.broadcast).not.toHaveBeenCalled(); }); @@ -1401,11 +1406,11 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { // points at a place that is gone and accepting it again is refused as a // duplicate. - it('DAWARICH-SUG-034: deleting the place an acceptance created puts the stay back into review', () => { + it('DAWARICH-SUG-034: deleting the place an acceptance created puts the stay back into review', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const id = seedSuggestion({ userId: user.id, tripId: trip.id }); - const result = svc.accept(user.id, id, { target: 'place', tripId: trip.id }); + const result = await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); expect(rowOf(id).state).toBe('accepted'); testDb.prepare('DELETE FROM places WHERE id = ?').run(result.createdPlaceId); @@ -1415,21 +1420,21 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { expect(listed.target).toBeNull(); expect(listed.acceptedPlaceId).toBeNull(); // And it can be accepted again rather than answering 409 forever. - expect(() => svc.accept(user.id, id, { target: 'place', tripId: trip.id })).not.toThrow(); + await expect(svc.accept(user.id, id, { target: 'place', tripId: trip.id })).resolves.not.toThrow(); }); - it('DAWARICH-SUG-035: an acceptance whose place still exists is left alone', () => { + it('DAWARICH-SUG-035: an acceptance whose place still exists is left alone', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const id = seedSuggestion({ userId: user.id, tripId: trip.id }); - svc.accept(user.id, id, { target: 'place', tripId: trip.id }); + await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); const listed = svc.list(user.id, {}).suggestions.find((s) => s.id === id)!; expect(listed.state).toBe('accepted'); expect(listed.acceptedPlaceId).not.toBeNull(); }); - it('DAWARICH-SUG-036: a journal acceptance reopens once its entry is gone', () => { + it('DAWARICH-SUG-036: a journal acceptance reopens once its entry is gone', async () => { const { user } = createUser(testDb); const id = seedSuggestion({ userId: user.id, tripId: null }); const journeyId = Number( @@ -1448,7 +1453,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { ); journeyStub.createEntry.mockReturnValue({ id: entryId }); - svc.accept(user.id, id, { target: 'journal', journalId: journeyId }); + await svc.accept(user.id, id, { target: 'journal', journalId: journeyId }); expect(svc.list(user.id, {}).suggestions.find((s) => s.id === id)!.state).toBe('accepted'); testDb.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId); @@ -1456,12 +1461,12 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { expect(svc.list(user.id, {}).suggestions.find((s) => s.id === id)!.state).toBe('new'); }); - it('DAWARICH-SUG-037: reopening is scoped to the caller — another user\'s orphan stays put', () => { + it('DAWARICH-SUG-037: reopening is scoped to the caller — another user\'s orphan stays put', async () => { const { user: mine } = createUser(testDb); const { user: theirs } = createUser(testDb); const trip = createTrip(testDb, theirs.id); const id = seedSuggestion({ userId: theirs.id, tripId: trip.id }); - const result = svc.accept(theirs.id, id, { target: 'place', tripId: trip.id }); + const result = await svc.accept(theirs.id, id, { target: 'place', tripId: trip.id }); testDb.prepare('DELETE FROM places WHERE id = ?').run(result.createdPlaceId); svc.list(mine.id, {}); diff --git a/server/tests/unit/nest/dawarich-sync.service.test.ts b/server/tests/unit/nest/dawarich-sync.service.test.ts index ae31c8ca0..466b6dce3 100644 --- a/server/tests/unit/nest/dawarich-sync.service.test.ts +++ b/server/tests/unit/nest/dawarich-sync.service.test.ts @@ -952,10 +952,10 @@ describe('DawarichSyncService — gates', () => { expect(listVisits).not.toHaveBeenCalled(); }); - it('DAWARICH-SYNC-052: syncGloballyEnabled follows the addon row', () => { - expect(svc.syncGloballyEnabled()).toBe(true); + it('DAWARICH-SYNC-052: syncGloballyEnabled follows the addon row', async () => { + expect(await svc.syncGloballyEnabled()).toBe(true); setAddonEnabled(testDb, 'dawarich', false); - expect(svc.syncGloballyEnabled()).toBe(false); + expect(await svc.syncGloballyEnabled()).toBe(false); }); }); diff --git a/server/tests/unit/nest/dawarich.controller.test.ts b/server/tests/unit/nest/dawarich.controller.test.ts index 83fa1d32e..4f27186cb 100644 --- a/server/tests/unit/nest/dawarich.controller.test.ts +++ b/server/tests/unit/nest/dawarich.controller.test.ts @@ -168,12 +168,12 @@ describe('DawarichController connection routes', () => { .toEqual({ success: true, warning: 'resolves to a private IP' }); }); - it('DAWARICH-CTRL-016: DELETE settings disconnects, forgets the track and answers 200 { success: true }', () => { + it('DAWARICH-CTRL-016: DELETE settings disconnects, forgets the track and answers 200 { success: true }', async () => { const disconnect = vi.fn(); const forget = vi.fn(); const c = makeController({ dawarich: { disconnect }, tracks: { forget } }); - expect(c.disconnect(user, makeReq({}, '198.51.100.4'))).toEqual({ success: true }); + expect(await c.disconnect(user, makeReq({}, '198.51.100.4'))).toEqual({ success: true }); expect(disconnect).toHaveBeenCalledWith(7, '198.51.100.4'); expect(forget).toHaveBeenCalledWith(7); }); @@ -310,22 +310,22 @@ describe('DawarichController suggestion routes', () => { expect(r).toEqual({ status: 404, body: { error: 'Suggestion not found' } })); }); - it('DAWARICH-CTRL-042: POST accept forwards X-Socket-Id, so the broadcast does not echo into the originating tab', () => { + it('DAWARICH-CTRL-042: POST accept forwards X-Socket-Id, so the broadcast does not echo into the originating tab', async () => { const accept = vi.fn().mockReturnValue({ createdPlaceId: 5 }); const body = { target: 'place' as const, tripId: 2 }; - makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', body, makeReq({ 'x-socket-id': 'sock-abc' })); + await makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', body, makeReq({ 'x-socket-id': 'sock-abc' })); expect(accept).toHaveBeenCalledWith(7, 9, body, 'sock-abc'); }); - it('DAWARICH-CTRL-043: no X-Socket-Id means undefined, not an empty string a broadcaster would compare against', () => { + it('DAWARICH-CTRL-043: no X-Socket-Id means undefined, not an empty string a broadcaster would compare against', async () => { const accept = vi.fn().mockReturnValue({}); - makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', { target: 'journal' }, makeReq()); + await makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', { target: 'journal' }, makeReq()); expect(accept.mock.calls[0][3]).toBeUndefined(); }); - it('DAWARICH-CTRL-044: a blank X-Socket-Id counts as absent', () => { + it('DAWARICH-CTRL-044: a blank X-Socket-Id counts as absent', async () => { const accept = vi.fn().mockReturnValue({}); - makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', { target: 'journal' }, makeReq({ 'x-socket-id': '' })); + await makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', { target: 'journal' }, makeReq({ 'x-socket-id': '' })); expect(accept.mock.calls[0][3]).toBeUndefined(); }); diff --git a/server/tests/unit/nest/dawarich.service.test.ts b/server/tests/unit/nest/dawarich.service.test.ts index e048ae3f7..56711658e 100644 --- a/server/tests/unit/nest/dawarich.service.test.ts +++ b/server/tests/unit/nest/dawarich.service.test.ts @@ -845,10 +845,10 @@ describe('DawarichService saveSettings', () => { // --------------------------------------------------------------------------- describe('DawarichService disconnect', () => { - it('DAWARICH-SVC-080: forgets the credential and says so in the audit log', () => { + it('DAWARICH-SVC-080: forgets the credential and says so in the audit log', async () => { connect(USER); - svc.disconnect(USER, '198.51.100.7'); + await svc.disconnect(USER, '198.51.100.7'); expect(row()).toBeUndefined(); expect(svc.getConnection(USER).connected).toBe(false); @@ -860,19 +860,19 @@ describe('DawarichService disconnect', () => { }); }); - it('DAWARICH-SVC-081: disconnecting something that was never connected is a no-op that still leaves a trail', () => { - svc.disconnect(USER, null); + it('DAWARICH-SVC-081: disconnecting something that was never connected is a no-op that still leaves a trail', async () => { + await svc.disconnect(USER, null); expect(row()).toBeUndefined(); expect(audit.writeAudit).toHaveBeenCalledWith(expect.objectContaining({ ip: null })); }); - it('DAWARICH-SVC-082: only the calling user loses their connection', () => { + it('DAWARICH-SVC-082: only the calling user loses their connection', async () => { const other = createUser(testDb).user.id; connect(USER); connect(other); - svc.disconnect(USER, null); + await svc.disconnect(USER, null); expect(row(other)).toBeDefined(); }); diff --git a/server/tests/unit/nest/day-notes.service.test.ts b/server/tests/unit/nest/day-notes.service.test.ts index 48d6458bf..959c28490 100644 --- a/server/tests/unit/nest/day-notes.service.test.ts +++ b/server/tests/unit/nest/day-notes.service.test.ts @@ -51,8 +51,13 @@ import { PermissionsService } from '../../../src/nest/permissions/permissions.se import { DayNotesService } from '../../../src/nest/day-notes/day-notes.service'; import type { DayNote } from '../../../src/types'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; -const svc = new DayNotesService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); +let svc: DayNotesService; +beforeAll(async () => { + svc = new DayNotesService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService()); +}); beforeAll(() => { createTables(testDb); @@ -281,21 +286,21 @@ describe('remove', () => { * an HTTP guard, and it is tested directly here for the same reason. */ describe('DayNotesService.canEdit', () => { - it('DAYNOTE-SVC-090 asks for day_edit and flags a non-owner as shared', () => { + it('DAYNOTE-SVC-090 asks for day_edit and flags a non-owner as shared', async () => { const checkPermission = vi.fn(() => true); const permissions = { checkPermission } as unknown as PermissionsService; const withStub = new DayNotesService(new DatabaseService(testDb), permissions, new RealtimeService()); const trip = { id: 1, user_id: 1 } as never; - expect(withStub.canEdit(trip, { id: 1, role: 'user' } as never)).toBe(true); + expect(await withStub.canEdit(trip, { id: 1, role: 'user' } as never)).toBe(true); expect(checkPermission).toHaveBeenLastCalledWith('day_edit', 'user', 1, 1, false); - withStub.canEdit(trip, { id: 2, role: 'user' } as never); + await withStub.canEdit(trip, { id: 2, role: 'user' } as never); // The shared flag is what the guard has to reproduce; getting it wrong would give a // member the owner's rights on somebody else's trip. expect(checkPermission).toHaveBeenLastCalledWith('day_edit', 'user', 1, 2, true); checkPermission.mockReturnValue(false); - expect(withStub.canEdit(trip, { id: 2, role: 'user' } as never)).toBe(false); + expect(await withStub.canEdit(trip, { id: 2, role: 'user' } as never)).toBe(false); }); }); diff --git a/server/tests/unit/nest/days.controller.test.ts b/server/tests/unit/nest/days.controller.test.ts index 50008a8c9..7c04692c2 100644 --- a/server/tests/unit/nest/days.controller.test.ts +++ b/server/tests/unit/nest/days.controller.test.ts @@ -83,17 +83,17 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)' }); }); - it('rethrows a non-DayReorderError unchanged', () => { + it('rethrows a non-DayReorderError unchanged', async () => { const boom = new Error('db is down'); const reorder = vi.fn(() => { throw boom; }); const svc = daysSvc({ reorder } as Partial); - expect(() => new DaysController(svc).reorder(user, '5', { orderedIds: [1, 2] })).toThrow(boom); + await expect(new DaysController(svc).reorder(user, '5', { orderedIds: [1, 2] })).rejects.toThrow(boom); }); - it('reorders and broadcasts day:reordered', () => { + it('reorders and broadcasts day:reordered', async () => { const reorder = vi.fn(); const broadcast = vi.fn(); const svc = daysSvc({ reorder, broadcast } as Partial); - expect(new DaysController(svc).reorder(user, '5', { orderedIds: [2, 1] }, 'sock')).toEqual({ success: true }); + expect(await new DaysController(svc).reorder(user, '5', { orderedIds: [2, 1] }, 'sock')).toEqual({ success: true }); expect(reorder).toHaveBeenCalledWith('5', [2, 1]); expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { orderedIds: [2, 1] }, 'sock'); }); diff --git a/server/tests/unit/nest/days.service.test.ts b/server/tests/unit/nest/days.service.test.ts index 466f65e8f..94a184a2f 100644 --- a/server/tests/unit/nest/days.service.test.ts +++ b/server/tests/unit/nest/days.service.test.ts @@ -64,9 +64,17 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import { makeAccommodationsService } from '../../helpers/accommodations-service'; import type { Day } from '../../../src/types'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; -const svc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), new QueryHelpersService(new DatabaseService(testDb))); -const accommodations = makeAccommodationsService(testDb); +let svc: DaysService; +beforeAll(async () => { + svc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), new QueryHelpersService(new DatabaseService(testDb))); +}); +let accommodations: Awaited>; +beforeAll(async () => { + accommodations = await makeAccommodationsService(testDb); +}); beforeAll(() => { createTables(testDb); @@ -103,28 +111,28 @@ describe('verifyTripAccess', () => { // ── getAssignmentsForDay ────────────────────────────────────────────────────── describe('getAssignmentsForDay', () => { - it('DAY-SVC-003 — returns empty array when day has no assignments', () => { + it('DAY-SVC-003 — returns empty array when day has no assignments', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; - expect(svc.getAssignmentsForDay(day.id)).toEqual([]); + expect(await svc.getAssignmentsForDay(day.id)).toEqual([]); }); - it('DAY-SVC-004 — returns assignments with nested place object', () => { + it('DAY-SVC-004 — returns assignments with nested place object', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower', lat: 48.8, lng: 2.3 }) as any; createDayAssignment(testDb, day.id, place.id, { order_index: 0 }); - const assignments = svc.getAssignmentsForDay(day.id) as any[]; + const assignments = (await svc.getAssignmentsForDay(day.id)) as any[]; expect(assignments).toHaveLength(1); expect(assignments[0].place).toBeDefined(); expect(assignments[0].place.name).toBe('Eiffel Tower'); expect(assignments[0].place.lat).toBe(48.8); }); - it('DAY-SVC-029 — a road-trip stop keeps its kind on both loaders', () => { + it('DAY-SVC-029 — a road-trip stop keeps its kind on both loaders', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; @@ -135,25 +143,25 @@ describe('getAssignmentsForDay', () => { // Both queries build the same place shape by hand, and only one of them used to // fetch the column — so the rail drew a petrol station as an ordinary numbered stop // while the database had known it was fuel all along. - const single = svc.getAssignmentsForDay(day.id) as any[]; + const single = (await svc.getAssignmentsForDay(day.id)) as any[]; expect(single[0].place.stop_type).toBe('fuel'); - const listed = svc.list(trip.id) as any; + const listed = (await svc.list(trip.id)) as any; expect(listed.days[0].assignments[0].place.stop_type).toBe('fuel'); }); - it('DAY-SVC-005 — assignment includes tags array (empty when place has none)', () => { + it('DAY-SVC-005 — assignment includes tags array (empty when place has none)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'No Tags' }) as any; createDayAssignment(testDb, day.id, place.id); - const assignments = svc.getAssignmentsForDay(day.id) as any[]; + const assignments = (await svc.getAssignmentsForDay(day.id)) as any[]; expect(Array.isArray(assignments[0].place.tags)).toBe(true); }); - it('DAY-SVC-006 — assignments are ordered by order_index ASC', () => { + it('DAY-SVC-006 — assignments are ordered by order_index ASC', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; @@ -162,7 +170,7 @@ describe('getAssignmentsForDay', () => { createDayAssignment(testDb, day.id, p1.id, { order_index: 2 }); createDayAssignment(testDb, day.id, p2.id, { order_index: 1 }); - const assignments = svc.getAssignmentsForDay(day.id) as any[]; + const assignments = (await svc.getAssignmentsForDay(day.id)) as any[]; expect(assignments[0].place.name).toBe('First'); expect(assignments[1].place.name).toBe('Second'); }); @@ -171,18 +179,18 @@ describe('getAssignmentsForDay', () => { // ── list ────────────────────────────────────────────────────────────────────── describe('list', () => { - it('DAY-SVC-007 — returns { days: [] } for trip with no days', () => { + it('DAY-SVC-007 — returns { days: [] } for trip with no days', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const result = svc.list(trip.id) as any; + const result = (await svc.list(trip.id)) as any; expect(result.days).toEqual([]); }); - it('DAY-SVC-008 — returns days with assignments nested', () => { + it('DAY-SVC-008 — returns days with assignments nested', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); createDay(testDb, trip.id); - const result = svc.list(trip.id) as any; + const result = (await svc.list(trip.id)) as any; expect(result.days).toHaveLength(1); expect(Array.isArray(result.days[0].assignments)).toBe(true); }); @@ -229,20 +237,20 @@ describe('getDay', () => { }); describe('update', () => { - it('DAY-SVC-013 — updates notes and returns updated day with assignments', () => { + it('DAY-SVC-013 — updates notes and returns updated day with assignments', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; - const updated = svc.update(day.id, day, { notes: 'Updated notes' }) as any; + const updated = (await svc.update(day.id, day, { notes: 'Updated notes' })) as any; expect(updated.notes).toBe('Updated notes'); expect(Array.isArray(updated.assignments)).toBe(true); }); - it('DAY-SVC-014 — updates title', () => { + it('DAY-SVC-014 — updates title', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; - const updated = svc.update(day.id, day, { title: 'Day 1 - City Tour' }) as any; + const updated = (await svc.update(day.id, day, { title: 'Day 1 - City Tour' })) as any; expect(updated.title).toBe('Day 1 - City Tour'); }); }); @@ -282,11 +290,11 @@ describe('DaysService — the surface the deleted bridge exposed', () => { expect(bridgeGetDay(99999, trip.id)).toBeUndefined(); }); - it('DAY-SVC-028 — listDays delegates to DaysService.list', () => { + it('DAY-SVC-028 — listDays delegates to DaysService.list', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); createDay(testDb, trip.id); - const result = bridgeListDays(trip.id); + const result = await bridgeListDays(trip.id); expect(result.days).toHaveLength(1); expect(Array.isArray(result.days[0].assignments)).toBe(true); }); @@ -341,19 +349,19 @@ describe('DaysService — the surface the deleted bridge exposed', () => { // ── post-port defect fixes ──────────────────────────────────────────────────── describe('quirk fixes', () => { - it('DAY-SVC-033 — update preserves the omitted column (title-only keeps notes, notes-only keeps title)', () => { + it('DAY-SVC-033 — update preserves the omitted column (title-only keeps notes, notes-only keeps title)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); // Typed as the domain Day, not as the factory's row shape: the variable is // rebound to what update() returns, and only Day carries `notes`. let day: Day = createDay(testDb, trip.id); - day = svc.update(day.id, day, { notes: 'Walking day' }); - day = svc.update(day.id, day, { title: 'Arrival' }); + day = await svc.update(day.id, day, { notes: 'Walking day' }); + day = await svc.update(day.id, day, { title: 'Arrival' }); expect(day).toMatchObject({ title: 'Arrival', notes: 'Walking day' }); - day = svc.update(day.id, day, { notes: 'Museum day' }); + day = await svc.update(day.id, day, { notes: 'Museum day' }); expect(day).toMatchObject({ title: 'Arrival', notes: 'Museum day' }); // A present key still clears via the legacy falsy coercion. - day = svc.update(day.id, day, { notes: '' }); + day = await svc.update(day.id, day, { notes: '' }); expect(day.notes).toBeNull(); expect(day.title).toBe('Arrival'); }); @@ -363,23 +371,23 @@ describe('quirk fixes', () => { // that is `undefined`, calling it throws, and `.toThrow()` was satisfied by the // TypeError rather than by the rollback. The invariant went unchecked for the // whole time the case reported green. - it('DAY-SVC-034 — createAccommodation is atomic: a failed reservation insert leaves no orphan stay', () => { + it('DAY-SVC-034 — createAccommodation is atomic: a failed reservation insert leaves no orphan stay', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel' }); testDb.exec("CREATE TRIGGER boom BEFORE INSERT ON reservations BEGIN SELECT RAISE(ABORT, 'boom'); END"); try { - expect(() => accommodations.createAccommodation(trip.id, { + await expect(accommodations.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, - })).toThrow(); + })).rejects.toThrow(); expect(testDb.prepare('SELECT COUNT(*) as n FROM day_accommodations WHERE trip_id = ?').get(trip.id)).toMatchObject({ n: 0 }); } finally { testDb.exec('DROP TRIGGER boom'); } }); - it('DAY-SVC-036 — getAssignmentsForDay returns full tag rows from the batched load', () => { + it('DAY-SVC-036 — getAssignmentsForDay returns full tag rows from the batched load', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); @@ -388,7 +396,7 @@ describe('quirk fixes', () => { const tagId = Number(testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'Food', '#ff0000').lastInsertRowid); testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tagId); - const assignments = svc.getAssignmentsForDay(day.id); + const assignments = await svc.getAssignmentsForDay(day.id); expect(assignments[0].place.tags).toHaveLength(1); expect(assignments[0].place.tags[0]).toMatchObject({ id: tagId, name: 'Food', color: '#ff0000' }); }); @@ -624,7 +632,7 @@ describe('reorder', () => { expect(orderedDays(trip.id).map(d => d.day_number)).toEqual([1, 2]); }); - it('DAY-SVC-047 — renumbers a dateless trip without inventing dates or touching bookings', () => { + it('DAY-SVC-047 — renumbers a dateless trip without inventing dates or touching bookings', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const d1 = createDay(testDb, trip.id); @@ -634,7 +642,7 @@ describe('reorder', () => { 'INSERT INTO reservations (trip_id, day_id, title, reservation_time) VALUES (?, ?, ?, ?)' ).run(trip.id, d2.id, 'Dinner', '2026-02-02T19:00').lastInsertRowid); - const result = svc.reorder(trip.id, [d3.id, d1.id, d2.id]); + const result = await svc.reorder(trip.id, [d3.id, d1.id, d2.id]); expect(result.days.map(d => d.id)).toEqual([d3.id, d1.id, d2.id]); expect(orderedDays(trip.id).map(d => d.date)).toEqual([null, null, null]); @@ -644,13 +652,13 @@ describe('reorder', () => { .toMatchObject({ reservation_time: '2026-02-02T19:00' }); }); - it('DAY-SVC-048 — pins the known dates to the leading slots and nulls the slots beyond them', () => { + it('DAY-SVC-048 — pins the known dates to the leading slots and nulls the slots beyond them', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const dated = createDay(testDb, trip.id, { date: '2026-03-01' }); const dateless = createDay(testDb, trip.id); - svc.reorder(trip.id, [dateless.id, dated.id]); + await svc.reorder(trip.id, [dateless.id, dated.id]); // The slot beyond the known dates must resolve to null: better-sqlite3 // refuses to bind the `undefined` a bare index lookup would hand it. @@ -660,7 +668,7 @@ describe('reorder', () => { ]); }); - it('DAY-SVC-049 — allows a move that keeps a stay ordered and rolls back one that inverts it', () => { + it('DAY-SVC-049 — allows a move that keeps a stay ordered and rolls back one that inverts it', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const d1 = createDay(testDb, trip.id, { date: '2026-03-01' }); @@ -671,7 +679,7 @@ describe('reorder', () => { // Stretching the stay over a third slot is legal — the guard only rejects // an end that lands before its start. - svc.reorder(trip.id, [d1.id, d3.id, d2.id]); + await svc.reorder(trip.id, [d1.id, d3.id, d2.id]); expect(orderedDays(trip.id).map(d => d.id)).toEqual([d1.id, d3.id, d2.id]); expect(() => svc.reorder(trip.id, [d2.id, d3.id, d1.id])).toThrow(DayReorderError); @@ -708,24 +716,24 @@ describe('insert', () => { // ── day shaping ─────────────────────────────────────────────────────────────── describe('setDefaultTransportMode', () => { - it('DAY-SVC-051 — sets and clears the whole-day mode without disturbing notes or title', () => { + it('DAY-SVC-051 — sets and clears the whole-day mode without disturbing notes or title', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { title: 'Arrival' }); - svc.update(day.id, day as never, { notes: 'Keep me' }); + await svc.update(day.id, day as never, { notes: 'Keep me' }); - const set = svc.setDefaultTransportMode(day.id, 'walk'); + const set = await svc.setDefaultTransportMode(day.id, 'walk'); expect(set).toMatchObject({ default_transport_mode: 'walk', title: 'Arrival', notes: 'Keep me' }); // Its own endpoint exists precisely so clearing the mode cannot wipe the // day's text the way a general update would. - const cleared = svc.setDefaultTransportMode(day.id, null); + const cleared = await svc.setDefaultTransportMode(day.id, null); expect(cleared).toMatchObject({ default_transport_mode: null, title: 'Arrival', notes: 'Keep me' }); }); }); describe('day shaping', () => { - it('DAY-SVC-052 — getAssignmentsForDay reports a null category for an uncategorised place', () => { + it('DAY-SVC-052 — getAssignmentsForDay reports a null category for an uncategorised place', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); @@ -735,10 +743,10 @@ describe('day shaping', () => { // The LEFT JOIN yields category_name/color/icon as NULL, which must collapse // to a null category instead of an object of nulls the client would render. - expect(svc.getAssignmentsForDay(day.id)[0].place.category).toBeNull(); + expect((await svc.getAssignmentsForDay(day.id))[0].place.category).toBeNull(); }); - it('DAY-SVC-053 — list groups assignments, tags, participants and notes onto their own day', () => { + it('DAY-SVC-053 — list groups assignments, tags, participants and notes onto their own day', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const busy = createDay(testDb, trip.id); @@ -753,7 +761,7 @@ describe('day shaping', () => { createDayNote(testDb, busy.id, trip.id, { text: 'Breakfast', sort_order: 1 }); createDayNote(testDb, busy.id, trip.id, { text: 'Dinner', sort_order: 2 }); - const { days } = svc.list(trip.id); + const { days } = await svc.list(trip.id); expect(days[0].assignments.map(a => a.place.name)).toEqual(['Louvre', 'Pont Neuf']); expect(days[0].assignments[0].place.tags.map(t => t.name)).toEqual(['Museum']); @@ -767,7 +775,7 @@ describe('day shaping', () => { expect(days[1]).toMatchObject({ id: empty.id, assignments: [], notes_items: [] }); }); - it('DAY-SVC-054 — create keeps an explicit date and notes, update clears a title sent as null', () => { + it('DAY-SVC-054 — create keeps an explicit date and notes, update clears a title sent as null', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -776,8 +784,8 @@ describe('day shaping', () => { // The MCP update_day tool clears a title by sending null, which must reach // the column instead of being treated as "key absent, keep the old title". - const titled = svc.update(day.id, day as never, { title: 'Crossing' }); - expect(svc.update(day.id, titled as never, { title: null })).toMatchObject({ title: null, notes: 'Ferry to the island' }); + const titled = await svc.update(day.id, day as never, { title: 'Crossing' }); + expect(await svc.update(day.id, titled as never, { title: null })).toMatchObject({ title: null, notes: 'Ferry to the island' }); }); }); @@ -788,21 +796,21 @@ describe('day shaping', () => { * an HTTP guard, and it is tested directly here for the same reason. */ describe('DaysService.canEdit', () => { - it('DAY-SVC-090 asks for day_edit and flags a non-owner as shared', () => { + it('DAY-SVC-090 asks for day_edit and flags a non-owner as shared', async () => { const checkPermission = vi.fn(() => true); const permissions = { checkPermission } as unknown as PermissionsService; const withStub = new DaysService(new DatabaseService(testDb), permissions, new RealtimeService(), new QueryHelpersService(new DatabaseService(testDb))); const trip = { id: 1, user_id: 1 } as never; - expect(withStub.canEdit(trip, { id: 1, role: 'user' } as never)).toBe(true); + expect(await withStub.canEdit(trip, { id: 1, role: 'user' } as never)).toBe(true); expect(checkPermission).toHaveBeenLastCalledWith('day_edit', 'user', 1, 1, false); - withStub.canEdit(trip, { id: 2, role: 'user' } as never); + await withStub.canEdit(trip, { id: 2, role: 'user' } as never); // The shared flag is what the guard has to reproduce; getting it wrong would give a // member the owner's rights on somebody else's trip. expect(checkPermission).toHaveBeenLastCalledWith('day_edit', 'user', 1, 2, true); checkPermission.mockReturnValue(false); - expect(withStub.canEdit(trip, { id: 2, role: 'user' } as never)).toBe(false); + expect(await withStub.canEdit(trip, { id: 2, role: 'user' } as never)).toBe(false); }); }); diff --git a/server/tests/unit/nest/discovery.controller.test.ts b/server/tests/unit/nest/discovery.controller.test.ts index 0a08b55e1..46b02379d 100644 --- a/server/tests/unit/nest/discovery.controller.test.ts +++ b/server/tests/unit/nest/discovery.controller.test.ts @@ -150,16 +150,16 @@ describe('DiscoveryController', () => { expect(body.userinfo_endpoint).toBe('https://trek.example.test/oauth/userinfo'); }); - it('DISC-031: flat oauth-protected-resource 404s empty when MCP is disabled', () => { + it('DISC-031: flat oauth-protected-resource 404s empty when MCP is disabled', async () => { const res = makeRes(); - controller(false).protectedResource(res as never); + await controller(false).protectedResource(res as never); expect(res.statusCode).toBe(404); expect(res.ended).toBe(true); }); - it('DISC-032: flat oauth-protected-resource serves the PRM document when enabled', () => { + it('DISC-032: flat oauth-protected-resource serves the PRM document when enabled', async () => { const res = makeRes(); - controller().protectedResource(res as never); + await controller().protectedResource(res as never); expect(res.body).toEqual({ resource: 'https://trek.example.test/mcp', authorization_servers: ['https://trek.example.test'], diff --git a/server/tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts b/server/tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts index 6043b0b99..42b30bdb6 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts @@ -141,47 +141,47 @@ function settle() { } describe('an unknown token', () => { - it('answers as if it were known, so nobody can enumerate which tokens exist', () => { - expect(controller.nudge('tok-guessed', makeReq())).toEqual({ received: true }); + it('answers as if it were known, so nobody can enumerate which tokens exist', async () => { + expect(await controller.nudge('tok-guessed', makeReq())).toEqual({ received: true }); }); - it('schedules no run', () => { - controller.nudge('tok-guessed', makeReq()); + it('schedules no run', async () => { + await controller.nudge('tok-guessed', makeReq()); expect(sync.syncLink).not.toHaveBeenCalled(); }); }); describe('a binding whose sync is switched off', () => { - it('is left alone, and says nothing about it', () => { + it('is left alone, and says nothing about it', async () => { config.getLinkByToken.mockReturnValue(link({ sync_enabled: 0 })); - expect(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }))).toEqual({ received: true }); + expect(await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }))).toEqual({ received: true }); expect(sync.syncLink).not.toHaveBeenCalled(); }); }); describe('a shared-secret header', () => { - it('triggers the run when it matches', () => { - controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); + it('triggers the run when it matches', async () => { + await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); settle(); expect(sync.syncLink).toHaveBeenCalledTimes(1); settle(); expect(sync.syncLink.mock.calls[0][0]).toMatchObject({ id: 4 }); }); - it('triggers nothing when it is wrong, and the answer looks identical', () => { + it('triggers nothing when it is wrong, and the answer looks identical', async () => { const wrong = `${SECRET.slice(0, -1)}X`; - expect(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': wrong }))).toEqual({ received: true }); + expect(await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': wrong }))).toEqual({ received: true }); expect(sync.syncLink).not.toHaveBeenCalled(); }); - it('triggers nothing when the header is missing altogether', () => { - controller.nudge('tok-live', makeReq()); + it('triggers nothing when the header is missing altogether', async () => { + await controller.nudge('tok-live', makeReq()); expect(sync.syncLink).not.toHaveBeenCalled(); }); - it('is not demanded from a binding that carries no secret: the token alone authenticates there', () => { + it('is not demanded from a binding that carries no secret: the token alone authenticates there', async () => { config.webhookSecret.mockReturnValue(''); - controller.nudge('tok-live', makeReq()); + await controller.nudge('tok-live', makeReq()); settle(); expect(sync.syncLink).toHaveBeenCalledTimes(1); }); @@ -205,22 +205,22 @@ describe('a binding pasted into the store by hand', () => { config.getLink.mockImplementation((id: number) => (id === 4 ? pasted() : undefined)); }); - it('runs on the token alone, with no secret in the call', () => { - controller.nudge('tok-live', makeReq()); + it('runs on the token alone, with no secret in the call', async () => { + await controller.nudge('tok-live', makeReq()); settle(); expect(sync.syncLink).toHaveBeenCalledTimes(1); expect(config.webhookSecret).not.toHaveBeenCalled(); }); - it('runs on a Papra call signed with a secret TREK has never seen', () => { + it('runs on a Papra call signed with a secret TREK has never seen', async () => { const payload = JSON.stringify({ event: 'document.created', documentId: 'doc_1' }); - controller.nudge('tok-live', papraReq(payload, sign(payload, 'papras-own-signing-secret'))); + await controller.nudge('tok-live', papraReq(payload, sign(payload, 'papras-own-signing-secret'))); settle(); expect(sync.syncLink).toHaveBeenCalledTimes(1); }); - it('still needs the right token', () => { - controller.nudge('tok-guessed', makeReq()); + it('still needs the right token', async () => { + await controller.nudge('tok-guessed', makeReq()); settle(); expect(sync.syncLink).not.toHaveBeenCalled(); }); @@ -229,33 +229,33 @@ describe('a binding pasted into the store by hand', () => { describe('a Papra standard-webhooks signature', () => { const payload = JSON.stringify({ event: 'document.created', documentId: 'doc_1' }); - it('triggers the run when it covers the bytes that arrived', () => { - controller.nudge('tok-live', papraReq(payload, sign(payload))); + it('triggers the run when it covers the bytes that arrived', async () => { + await controller.nudge('tok-live', papraReq(payload, sign(payload))); settle(); expect(sync.syncLink).toHaveBeenCalledTimes(1); }); - it('triggers nothing when the payload was altered after it was signed', () => { + it('triggers nothing when the payload was altered after it was signed', async () => { const tampered = JSON.stringify({ event: 'document.created', documentId: 'doc_999' }); - expect(controller.nudge('tok-live', papraReq(payload, sign(payload), tampered))).toEqual({ received: true }); + expect(await controller.nudge('tok-live', papraReq(payload, sign(payload), tampered))).toEqual({ received: true }); expect(sync.syncLink).not.toHaveBeenCalled(); }); - it('triggers nothing when the signature was made with another secret', () => { - controller.nudge('tok-live', papraReq(payload, sign(payload, 'someone-elses-secret-value'))); + it('triggers nothing when the signature was made with another secret', async () => { + await controller.nudge('tok-live', papraReq(payload, sign(payload, 'someone-elses-secret-value'))); expect(sync.syncLink).not.toHaveBeenCalled(); }); - it('triggers nothing when the id or timestamp does not match what was signed', () => { + it('triggers nothing when the id or timestamp does not match what was signed', async () => { const req = makeReq( { 'webhook-id': 'msg_other', 'webhook-timestamp': WEBHOOK_TS, 'webhook-signature': `v1,${sign(payload)}` }, { rawBody: Buffer.from(payload, 'utf8') }, ); - controller.nudge('tok-live', req); + await controller.nudge('tok-live', req); expect(sync.syncLink).not.toHaveBeenCalled(); }); - it('accepts a header carrying several signatures, as a key rotation sends', () => { + it('accepts a header carrying several signatures, as a key rotation sends', async () => { const req = makeReq( { 'webhook-id': WEBHOOK_ID, @@ -264,28 +264,28 @@ describe('a Papra standard-webhooks signature', () => { }, { rawBody: Buffer.from(payload, 'utf8') }, ); - controller.nudge('tok-live', req); + await controller.nudge('tok-live', req); settle(); expect(sync.syncLink).toHaveBeenCalledTimes(1); }); - it('verifies against the parsed body when a mount left no raw bytes behind', () => { + it('verifies against the parsed body when a mount left no raw bytes behind', async () => { const body = { event: 'document.created', documentId: 'doc_1' }; const req = makeReq( { 'webhook-id': WEBHOOK_ID, 'webhook-timestamp': WEBHOOK_TS, 'webhook-signature': `v1,${sign(JSON.stringify(body))}` }, { body }, ); - controller.nudge('tok-live', req); + await controller.nudge('tok-live', req); settle(); expect(sync.syncLink).toHaveBeenCalledTimes(1); }); - it('is ignored when the timestamp header is missing, rather than verified without it', () => { + it('is ignored when the timestamp header is missing, rather than verified without it', async () => { const req = makeReq( { 'webhook-id': WEBHOOK_ID, 'webhook-signature': `v1,${sign(payload)}` }, { rawBody: Buffer.from(payload, 'utf8') }, ); - controller.nudge('tok-live', req); + await controller.nudge('tok-live', req); expect(sync.syncLink).not.toHaveBeenCalled(); }); }); @@ -294,16 +294,14 @@ describe('a credential of the wrong length', () => { // crypto.timingSafeEqual throws on buffers of unequal length, so a one-byte // header would turn into a 500 that tells a prober the secret is longer than // what they sent. - it('is rejected rather than thrown over, whether it arrives as a header or as a signature', () => { - expect(() => controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': 'a' }))).not.toThrow(); - expect(() => - controller.nudge('tok-live', papraReq('{}', 'short')), - ).not.toThrow(); + it('is rejected rather than thrown over, whether it arrives as a header or as a signature', async () => { + await expect(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': 'a' }))).resolves.not.toThrow(); + await expect(controller.nudge('tok-live', papraReq('{}', 'short'))).resolves.not.toThrow(); expect(sync.syncLink).not.toHaveBeenCalled(); }); - it('is rejected the same way when it is longer than the stored secret', () => { - expect(() => controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': `${SECRET}extra` }))).not.toThrow(); + it('is rejected the same way when it is longer than the stored secret', async () => { + await expect(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': `${SECRET}extra` }))).resolves.not.toThrow(); expect(sync.syncLink).not.toHaveBeenCalled(); }); }); @@ -318,62 +316,62 @@ describe('a credential of the wrong length', () => { * before most of the changes landed. */ describe('a burst of nudges', () => { - it('runs once for twenty calls rather than twenty times', () => { + it('runs once for twenty calls rather than twenty times', async () => { for (let i = 0; i < 20; i += 1) { - controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); + await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); } settle(); expect(sync.syncLink).toHaveBeenCalledTimes(1); }); - it('answers every one of them immediately, before any run happens', () => { + it('answers every one of them immediately, before any run happens', async () => { const answers = []; for (let i = 0; i < 5; i += 1) { - answers.push(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }))); + answers.push(await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }))); } // Paperless gives the call five seconds before it counts it as failed. expect(answers).toEqual(Array.from({ length: 5 }, () => ({ received: true }))); expect(sync.syncLink).not.toHaveBeenCalled(); }); - it('runs again for a burst that arrives after the window closed', () => { - controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); + it('runs again for a burst that arrives after the window closed', async () => { + await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); settle(); - controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); + await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); settle(); expect(sync.syncLink).toHaveBeenCalledTimes(2); }); - it('keeps one window per binding, so a busy trip cannot starve a quiet one', () => { + it('keeps one window per binding, so a busy trip cannot starve a quiet one', async () => { const other = link({ id: 9, webhook_token: 'tok-other' }); config.getLinkByToken.mockImplementation((t: string) => t === 'tok-live' ? link() : t === 'tok-other' ? other : undefined); config.getLink.mockImplementation((id: number) => (id === 4 ? link() : id === 9 ? other : undefined)); - controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); - controller.nudge('tok-other', makeReq({ 'x-trek-docsync-secret': SECRET })); + await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); + await controller.nudge('tok-other', makeReq({ 'x-trek-docsync-secret': SECRET })); settle(); expect(sync.syncLink).toHaveBeenCalledTimes(2); expect(sync.syncLink.mock.calls.map((c) => c[0].id).sort()).toEqual([4, 9]); }); - it('drops the run when the binding is switched off inside the window', () => { - controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); + it('drops the run when the binding is switched off inside the window', async () => { + await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); config.getLink.mockReturnValue(link({ sync_enabled: 0 })); settle(); expect(sync.syncLink).not.toHaveBeenCalled(); }); - it('drops the run when the binding is deleted inside the window', () => { - controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); + it('drops the run when the binding is deleted inside the window', async () => { + await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); config.getLink.mockReturnValue(undefined); settle(); expect(sync.syncLink).not.toHaveBeenCalled(); }); - it('forgets its pending timers when the module goes down', () => { - controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); + it('forgets its pending timers when the module goes down', async () => { + await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); controller.onModuleDestroy(); settle(); expect(sync.syncLink).not.toHaveBeenCalled(); @@ -389,30 +387,30 @@ describe('a burst of nudges', () => { * carried on driving full runs. */ describe('the admin switches', () => { - it('does nothing while the addon or the provider of the binding is switched off', () => { + it('does nothing while the addon or the provider of the binding is switched off', async () => { sync.isSwitchedOff.mockReturnValue(true); - expect(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }))).toEqual({ received: true }); + expect(await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }))).toEqual({ received: true }); settle(); expect(sync.isSwitchedOff.mock.calls[0][0]).toMatchObject({ id: 4, provider_id: 'papra' }); expect(sync.syncLink).not.toHaveBeenCalled(); }); - it('does nothing while the kill switch is set', () => { + it('does nothing while the kill switch is set', async () => { settings.set('docsync_sync_enabled', 'false'); - controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); + await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); settle(); expect(sync.syncLink).not.toHaveBeenCalled(); }); - it('drops a scheduled run when the provider goes off inside the window', () => { - controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); + it('drops a scheduled run when the provider goes off inside the window', async () => { + await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); sync.isSwitchedOff.mockReturnValue(true); settle(); expect(sync.syncLink).not.toHaveBeenCalled(); }); - it('runs normally while both are on', () => { - controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); + it('runs normally while both are on', async () => { + await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); settle(); expect(sync.syncLink).toHaveBeenCalledTimes(1); }); @@ -429,7 +427,7 @@ describe('the admin switches', () => { describe('a nudge that arrives mid-run', () => { it('asks again once when the run was busy', async () => { sync.syncLink.mockResolvedValueOnce({ state: 'busy', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }); - controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); + await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); settle(); await Promise.resolve(); settle(); @@ -438,7 +436,7 @@ describe('a nudge that arrives mid-run', () => { it('gives up after that one retry rather than chasing itself', async () => { sync.syncLink.mockResolvedValue({ state: 'busy', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }); - controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); + await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); for (let i = 0; i < 5; i += 1) { settle(); await Promise.resolve(); } expect(sync.syncLink).toHaveBeenCalledTimes(2); }); @@ -447,7 +445,7 @@ describe('a nudge that arrives mid-run', () => { // Set explicitly: clearAllMocks drops the calls but keeps the // implementation the previous case installed. sync.syncLink.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }); - controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); + await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })); settle(); await Promise.resolve(); settle(); diff --git a/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts index db5196f11..efca6c23f 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts @@ -431,8 +431,8 @@ describe('a secret the adapter earned itself', () => { }); describe('reading the trip state', () => { - it('asks the reconciler with a number, because the path hands the handler a string', () => { - controller.status(String(tripId)); + it('asks the reconciler with a number, because the path hands the handler a string', async () => { + await controller.status(String(tripId)); expect(sync.status).toHaveBeenCalledWith(tripId); }); }); diff --git a/server/tests/unit/nest/doc-sync/doc-sync.mcp.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.mcp.test.ts index 1430d7bee..983bc9b54 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.mcp.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.mcp.test.ts @@ -167,7 +167,7 @@ describe('DocSyncMcp access', () => { const { mcp, sync, config } = makeMcp({ access: false, links: [link(1)] }); await mcp.syncNow({ tripId: 3 }, ctx); mcp.listIssues({ tripId: 3 }, ctx); - mcp.getTripDocumentSync({ tripId: 3 }, ctx); + await mcp.getTripDocumentSync({ tripId: 3 }, ctx); expect(sync.syncLink).not.toHaveBeenCalled(); expect(sync.status).not.toHaveBeenCalled(); expect(sync.issues).not.toHaveBeenCalled(); @@ -176,10 +176,10 @@ describe('DocSyncMcp access', () => { }); describe('get_trip_document_sync', () => { - it('hands back the trip status as the service reports it', () => { + it('hands back the trip status as the service reports it', async () => { const status = { links: [{ id: 1, remoteLabel: 'Japan 2026' }], items: { synced: 4, conflict: 1 } }; const { mcp, sync } = makeMcp({ status }); - expect(payload(mcp.getTripDocumentSync({ tripId: 3 }, ctx))).toEqual(status); + expect(payload(await mcp.getTripDocumentSync({ tripId: 3 }, ctx))).toEqual(status); expect(sync.status).toHaveBeenCalledWith(3); }); }); diff --git a/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts index 563295302..4610f45be 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts @@ -1083,39 +1083,39 @@ describe('DocSyncService', () => { expect(linkRow(link.id)).toEqual(before); }); - it('answers the same question for any caller that has to refuse up front', () => { + it('answers the same question for any caller that has to refuse up front', async () => { const link = makeLink(); - expect(service.isSwitchedOff(link)).toBe(false); + expect(await service.isSwitchedOff(link)).toBe(false); switchProvider('paperless', false); - expect(service.isSwitchedOff(link)).toBe(true); + expect(await service.isSwitchedOff(link)).toBe(true); switchProvider('paperless', true); addons.isAddonEnabled.mockReturnValue(false); - expect(service.isSwitchedOff(link)).toBe(true); + expect(await service.isSwitchedOff(link)).toBe(true); }); }); describe('status', () => { - it('reports the trip bindings together with a count per item state', () => { + it('reports the trip bindings together with a count per item state', async () => { const link = makeLink(); seedItem(link, { remoteId: 'r1', state: 'synced' }); seedItem(link, { remoteId: 'r2', state: 'synced' }); seedItem(link, { remoteId: 'r3', state: 'conflict' }); - const status = service.status(tripId); + const status = await service.status(tripId); expect(status.links).toHaveLength(1); expect(status.items).toEqual({ synced: 2, conflict: 1 }); }); - it('says which bindings are paused because their provider is switched off', () => { + it('says which bindings are paused because their provider is switched off', async () => { const off = makeLink(); const on = makeLink({ providerId: 'nextcloud' }); switchProvider('paperless', false); switchProvider('nextcloud', true); - const links = service.status(tripId).links as Array<{ id: number; providerOff: boolean }>; + const links = (await service.status(tripId)).links as Array<{ id: number; providerOff: boolean }>; expect(links.find((l) => l.id === off.id)?.providerOff).toBe(true); expect(links.find((l) => l.id === on.id)?.providerOff).toBe(false); @@ -2071,7 +2071,7 @@ describe('DocSyncService', () => { expect(itemRow(itemId).remote_missing_at).not.toBeNull(); expect(service.issues(tripId).map((r) => r.id)).not.toContain(itemId); // Closed is not "back at the store": the holdings must not count it there. - const [status] = (service.status(tripId) as { links: Array<{ holdings: { atProvider: number; missing: number } }> }).links; + const [status] = ((await service.status(tripId)) as { links: Array<{ holdings: { atProvider: number; missing: number } }> }).links; expect(status.holdings).toMatchObject({ atProvider: 0, missing: 0 }); expect(res.state).toBe('ok'); }); diff --git a/server/tests/unit/nest/features.controller.test.ts b/server/tests/unit/nest/features.controller.test.ts index 14c8c0029..7dfd2abcb 100644 --- a/server/tests/unit/nest/features.controller.test.ts +++ b/server/tests/unit/nest/features.controller.test.ts @@ -21,32 +21,32 @@ function make(available: boolean, aiEnabled: boolean) { } describe('FeaturesController (GET /api/health/features)', () => { - it('FEAT-001: reports both flags on', () => { + it('FEAT-001: reports both flags on', async () => { const { controller } = make(true, true); - expect(controller.features()).toEqual({ bookingImport: true, aiParsing: true }); + expect(await controller.features()).toEqual({ bookingImport: true, aiParsing: true }); }); - it('FEAT-002: reports both flags off', () => { + it('FEAT-002: reports both flags off', async () => { const { controller } = make(false, false); - expect(controller.features()).toEqual({ bookingImport: false, aiParsing: false }); + expect(await controller.features()).toEqual({ bookingImport: false, aiParsing: false }); }); - it('FEAT-003: the two flags are independent', () => { - expect(make(true, false).controller.features()).toEqual({ bookingImport: true, aiParsing: false }); - expect(make(false, true).controller.features()).toEqual({ bookingImport: false, aiParsing: true }); + it('FEAT-003: the two flags are independent', async () => { + expect(await make(true, false).controller.features()).toEqual({ bookingImport: true, aiParsing: false }); + expect(await make(false, true).controller.features()).toEqual({ bookingImport: false, aiParsing: true }); }); - it('FEAT-004: aiParsing asks the addons service for the LLM parsing addon specifically', () => { + it('FEAT-004: aiParsing asks the addons service for the LLM parsing addon specifically', async () => { const { controller, addons } = make(true, true); - controller.features(); + await controller.features(); expect(addons.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.LLM_PARSING); }); - it('FEAT-005: bookingImport is read live, not cached at construction', () => { + it('FEAT-005: bookingImport is read live, not cached at construction', async () => { const { controller, extractor } = make(false, false); - controller.features(); + await controller.features(); extractor.isAvailable.mockReturnValue(true); - expect(controller.features().bookingImport).toBe(true); + expect((await controller.features()).bookingImport).toBe(true); expect(extractor.isAvailable).toHaveBeenCalledTimes(2); }); diff --git a/server/tests/unit/nest/files.controller.test.ts b/server/tests/unit/nest/files.controller.test.ts index 47b539b2c..216f0b127 100644 --- a/server/tests/unit/nest/files.controller.test.ts +++ b/server/tests/unit/nest/files.controller.test.ts @@ -134,15 +134,15 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route }); }); - it('PUT /:id 403 without file_edit, 404 unknown, else updates + broadcasts', () => { + it('PUT /:id 403 without file_edit, 404 unknown, else updates + broadcasts', async () => { expect(thrown(() => fc(fsvc({ can: vi.fn().mockReturnValue(false) })).update(user, trip, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission to edit files' } }); expect(thrown(() => fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial)).update(user, trip, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } }); const updateFile = vi.fn().mockReturnValue({ id: 9 }); const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9, description: 'x' }), updateFile, broadcast: vi.fn() } as Partial); - expect(fc(s).update(user, trip, '5', '9', { description: 'new' })).toEqual({ file: { id: 9 } }); + expect(await fc(s).update(user, trip, '5', '9', { description: 'new' })).toEqual({ file: { id: 9 } }); }); - it('passes budget_item_id to assertLinkTargets and updateFile on update, rejecting foreign targets', () => { + it('passes budget_item_id to assertLinkTargets and updateFile on update, rejecting foreign targets', async () => { const rejectSvc = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), findForeignLinkTarget: vi.fn().mockReturnValue('budget_item_id'), @@ -158,7 +158,7 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route updateFile, broadcast: vi.fn(), }); - fc(okSvc).update(user, trip, '5', '9', { budget_item_id: '12' }); + await fc(okSvc).update(user, trip, '5', '9', { budget_item_id: '12' }); expect(updateFile).toHaveBeenCalledWith('9', { id: 9 }, { description: undefined, place_id: undefined, @@ -167,30 +167,30 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route }); }); - it('PATCH /:id/star 403/404, else toggles', () => { + it('PATCH /:id/star 403/404, else toggles', async () => { expect(thrown(() => fc(fsvc({ can: vi.fn().mockReturnValue(false) })).star(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } }); expect(thrown(() => fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial)).star(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } }); const toggleStarred = vi.fn().mockReturnValue({ id: 9, starred: 1 }); const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9, starred: 0 }), toggleStarred, broadcast: vi.fn() } as Partial); - expect(fc(s).star(user, trip, '5', '9')).toEqual({ file: { id: 9, starred: 1 } }); + expect(await fc(s).star(user, trip, '5', '9')).toEqual({ file: { id: 9, starred: 1 } }); expect(toggleStarred).toHaveBeenCalledWith('9', 0); }); - it('DELETE /:id soft-delete 403/404, else success', () => { + it('DELETE /:id soft-delete 403/404, else success', async () => { expect(thrown(() => fc(fsvc({ can: vi.fn().mockReturnValue(false) })).remove(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission to delete files' } }); expect(thrown(() => fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial)).remove(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } }); const softDeleteFile = vi.fn(); const broadcast = vi.fn(); const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), softDeleteFile, broadcast } as Partial); - expect(fc(s).remove(user, trip, '5', '9', 'sock')).toEqual({ success: true }); + expect(await fc(s).remove(user, trip, '5', '9', 'sock')).toEqual({ success: true }); expect(broadcast).toHaveBeenCalledWith('5', 'file:deleted', { fileId: 9 }, 'sock'); }); - it('POST /:id/restore 404 not in trash, else restores', () => { + it('POST /:id/restore 404 not in trash, else restores', async () => { expect(thrown(() => fc(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial)).restore(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found in trash' } }); const restoreFile = vi.fn().mockReturnValue({ id: 9 }); const s = fsvc({ getDeletedFile: vi.fn().mockReturnValue({ id: 9 }), restoreFile, broadcast: vi.fn() } as Partial); - expect(fc(s).restore(user, trip, '5', '9')).toEqual({ file: { id: 9 } }); + expect(await fc(s).restore(user, trip, '5', '9')).toEqual({ file: { id: 9 } }); }); it('DELETE /:id/permanent 404 not in trash, else deletes', async () => { @@ -206,16 +206,16 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route expect(await fc(s).emptyTrash(user, trip, '5')).toEqual({ success: true, deleted: 3 }); }); - it('POST /:id/link 404 unknown file, else links', () => { + it('POST /:id/link 404 unknown file, else links', async () => { expect(thrown(() => fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial)).link(user, trip, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } }); const createFileLink = vi.fn().mockReturnValue([{ id: 1 }]); const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), createFileLink } as Partial); - expect(fc(s).link(user, trip, '5', '9', { reservation_id: 2 })).toEqual({ success: true, links: [{ id: 1 }] }); + expect(await fc(s).link(user, trip, '5', '9', { reservation_id: 2 })).toEqual({ success: true, links: [{ id: 1 }] }); }); - it('DELETE /:id/link/:linkId removes the link; GET /:id/links lists', () => { + it('DELETE /:id/link/:linkId removes the link; GET /:id/links lists', async () => { const deleteFileLink = vi.fn(); - expect(fc(fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), deleteFileLink } as Partial)).unlink(user, trip, '5', '9', '3')).toEqual({ success: true }); + expect(await fc(fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), deleteFileLink } as Partial)).unlink(user, trip, '5', '9', '3')).toEqual({ success: true }); expect(deleteFileLink).toHaveBeenCalledWith('3', '9'); const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), getFileLinks: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); expect(fc(s).links(user, trip, '5', '9')).toEqual({ links: [{ id: 1 }] }); diff --git a/server/tests/unit/nest/files.service.test.ts b/server/tests/unit/nest/files.service.test.ts index 0b849dce6..eccb6f9d7 100644 --- a/server/tests/unit/nest/files.service.test.ts +++ b/server/tests/unit/nest/files.service.test.ts @@ -596,7 +596,7 @@ describe('verifyTripAccess / can / files.bridge', () => { expect(svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy(); }); - it('FILE-SVC-037: can() forwards to checkPermission with the shared-trip flag', () => { + it('FILE-SVC-037: can() forwards to checkPermission with the shared-trip flag', async () => { const { user, trip } = seedTrip(); const guest = { id: user.id + 1, role: 'user' } as unknown as User; checkPermission.mockReturnValue(true); @@ -604,10 +604,10 @@ describe('verifyTripAccess / can / files.bridge', () => { // TripAccess also carries currency, which no assertion here looks at. const tripRow = { id: trip.id, user_id: user.id } as TripAccess; - expect(svc.can('file_edit', tripRow, guest)).toBe(true); + expect(await svc.can('file_edit', tripRow, guest)).toBe(true); expect(checkPermission).toHaveBeenCalledWith('file_edit', 'user', user.id, guest.id, true); - svc.can('file_upload', tripRow, { id: user.id, role: 'user' } as unknown as User); + await svc.can('file_upload', tripRow, { id: user.id, role: 'user' } as unknown as User); expect(checkPermission).toHaveBeenLastCalledWith('file_upload', 'user', user.id, user.id, false); }); diff --git a/server/tests/unit/nest/google-route.test.ts b/server/tests/unit/nest/google-route.test.ts index 4f31afd7c..2bc78314c 100644 --- a/server/tests/unit/nest/google-route.test.ts +++ b/server/tests/unit/nest/google-route.test.ts @@ -2,16 +2,22 @@ import { describe, expect, it, vi } from 'vitest'; import Database from 'better-sqlite3'; import { GoogleRouteService } from '../../../src/nest/roadtrip/google-route.service'; import { safeFetchFollow } from '../../../src/utils/ssrfGuard'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetchFollow: vi.fn() })); -function setup() { +/** The stub db is a bare object, so the UnitOfWork rides its own handle; nothing + * in this suite opens a real transaction. */ +const routeTestDb = new Database(':memory:'); + +async function setup() { const maps = { geocodeQuery: vi.fn(), reverseGeocode: vi.fn().mockResolvedValue({ name: null, address: null }) }; const db = { canAccessTrip: vi.fn(() => ({ user_id: 7 })), get: () => ({ role: 'user' }), transaction: (fn: () => T) => fn() }; const places = { create: vi.fn((_trip: string, stop: { name: string }) => ({ id: stop.name })), broadcast: vi.fn() }; const assignments = { dayExists: vi.fn(() => true), createAssignment: vi.fn((dayId: number, placeId: string) => ({ dayId, placeId })), broadcast: vi.fn(), reconcile: vi.fn() }; const permissions = { checkPermission: vi.fn(() => true) }; - const service = new GoogleRouteService(maps as never, db as never, places as never, assignments as never, permissions as never); + const service = new GoogleRouteService(maps as never, db as never, places as never, assignments as never, permissions as never, await createTestUnitOfWork(routeTestDb)); return { service, maps, db, places, assignments, permissions }; } @@ -19,7 +25,7 @@ const input = { dayId: 3, stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: describe('Google route import', () => { it('reads coordinates in route order without geocoding or writing', async () => { - const { service, maps, places } = setup(); + const { service, maps, places } = await setup(); const preview = await service.preview('https://www.google.com/maps/dir/?api=1&origin=48,11&destination=41,12&waypoints=47,11'); expect(preview.stops.map(stop => stop.lat)).toEqual([48, 47, 41]); expect(maps.geocodeQuery).not.toHaveBeenCalled(); @@ -27,7 +33,7 @@ describe('Google route import', () => { }); it('keeps unresolved and dynamic locations visible in their original position', async () => { - const { service, maps } = setup(); + const { service, maps } = await setup(); maps.geocodeQuery.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ lat: 41, lng: 12 }); const preview = await service.preview('https://www.google.com/maps/dir/Your+location/Munich/Rome'); expect(preview.stops).toEqual([{ name: 'Your location', lat: null, lng: null }, { name: 'Munich', lat: null, lng: null }, { name: 'Rome', lat: 41, lng: 12 }]); @@ -35,7 +41,7 @@ describe('Google route import', () => { }); it('names coordinate stops without moving their supplied positions', async () => { - const { service, maps } = setup(); + const { service, maps } = await setup(); maps.reverseGeocode.mockResolvedValueOnce({ name: 'Munich', address: 'Munich' }).mockRejectedValueOnce(new Error('unavailable')); const preview = await service.preview('https://google.com/maps/dir/48,11/41,12'); expect(preview.stops).toEqual([{ name: 'Munich', lat: 48, lng: 11 }, { name: '41, 12', lat: 41, lng: 12 }]); @@ -43,7 +49,7 @@ describe('Google route import', () => { }); it('rejects unrelated URLs and oversized routes without fetching or truncating', async () => { - const { service, maps } = setup(); + const { service, maps } = await setup(); for (const url of ['https://example.com/maps/dir/A/B', 'http://google.com/maps/dir/A/B', 'https://user:pass@google.com/maps/dir/A/B', 'https://google.com/search?q=A']) { await expect(service.preview(url)).rejects.toThrow('Http Exception'); } @@ -52,7 +58,7 @@ describe('Google route import', () => { }); it('resolves short links through the safe fetcher and checks the destination', async () => { - const { service } = setup(); + const { service } = await setup(); const cancel = vi.fn(); vi.mocked(safeFetchFollow).mockResolvedValueOnce({ url: 'https://google.com/maps/dir/48,11/41,12', body: { cancel } } as never); expect((await service.preview('https://maps.app.goo.gl/example')).stops).toHaveLength(2); @@ -61,21 +67,21 @@ describe('Google route import', () => { await expect(service.preview('https://maps.app.goo.gl/example')).rejects.toThrow(); }); - it('checks membership, both editing permissions and the target day before writing', () => { - const { service, db, permissions, assignments, places } = setup(); + it('checks membership, both editing permissions and the target day before writing', async () => { + const { service, db, permissions, assignments, places } = await setup(); db.canAccessTrip.mockReturnValueOnce(null as never); - expect(() => service.import(1, 7, input)).toThrow(); + await expect(service.import(1, 7, input)).rejects.toThrow(); permissions.checkPermission.mockReturnValueOnce(false); - expect(() => service.import(1, 7, input)).toThrow(); + await expect(service.import(1, 7, input)).rejects.toThrow(); assignments.dayExists.mockReturnValueOnce(false); - expect(() => service.import(1, 7, input)).toThrow(); + await expect(service.import(1, 7, input)).rejects.toThrow(); expect(places.create).not.toHaveBeenCalled(); expect(assignments.dayExists).toHaveBeenCalledWith('3', '1'); }); - it('appends stops in order with car transport and broadcasts after saving', () => { - const { service, places, assignments, permissions } = setup(); - expect(service.import(1, 7, input)).toEqual({ imported: 2 }); + it('appends stops in order with car transport and broadcasts after saving', async () => { + const { service, places, assignments, permissions } = await setup(); + expect(await service.import(1, 7, input)).toEqual({ imported: 2 }); expect(places.create.mock.calls.map(call => call[1].name)).toEqual(['Munich', 'Rome']); expect(places.create).toHaveBeenCalledWith('1', expect.objectContaining({ transport_mode: 'car', duration_minutes: 0 })); expect(assignments.createAssignment.mock.calls).toEqual([[3, 'Munich'], [3, 'Rome']]); @@ -83,8 +89,8 @@ describe('Google route import', () => { expect(assignments.reconcile).toHaveBeenCalledWith(1, undefined); }); - it('rolls back every created stop when any assignment fails and sends no events', () => { - const { service, db, places, assignments } = setup(); + it('rolls back every created stop when any assignment fails and sends no events', async () => { + const { service, db, places, assignments } = await setup(); const sqlite = new Database(':memory:'); sqlite.exec('CREATE TABLE stops (name TEXT)'); db.transaction = fn => sqlite.transaction(fn)(); @@ -94,7 +100,7 @@ describe('Google route import', () => { }); assignments.createAssignment.mockImplementationOnce(() => ({ dayId: 3, placeId: 'Munich' })).mockImplementationOnce(() => { throw new Error('write failed'); }); try { - expect(() => service.import(1, 7, input)).toThrow('write failed'); + await expect(service.import(1, 7, input)).rejects.toThrow('write failed'); expect(sqlite.prepare('SELECT * FROM stops').all()).toEqual([]); expect(places.broadcast).not.toHaveBeenCalled(); expect(assignments.broadcast).not.toHaveBeenCalled(); diff --git a/server/tests/unit/nest/google-transit.provider.test.ts b/server/tests/unit/nest/google-transit.provider.test.ts index d5048ddc6..e75649d97 100644 --- a/server/tests/unit/nest/google-transit.provider.test.ts +++ b/server/tests/unit/nest/google-transit.provider.test.ts @@ -162,23 +162,23 @@ function subwayRoute(): FixtureRoutes { } describe('activation', () => { - it('GTRANSIT-001: stays off while the instance is on Transitous, even with a key', () => { + it('GTRANSIT-001: stays off while the instance is on Transitous, even with a key', async () => { const provider = new GoogleTransitProvider(stubDb({ maps_api_key: 'test-key' })); - expect(provider.isActive(1)).toBe(false); + expect(await provider.isActive(1)).toBe(false); }); - it('GTRANSIT-002: stays off when Google is selected but no key resolves', () => { + it('GTRANSIT-002: stays off when Google is selected but no key resolves', async () => { const provider = new GoogleTransitProvider(stubDb({ transit_provider: 'google' })); - expect(provider.isActive(1)).toBe(false); + expect(await provider.isActive(1)).toBe(false); }); - it('GTRANSIT-003: is on only with both the setting and a key', () => { - expect(new GoogleTransitProvider(googleDb()).isActive(1)).toBe(true); + it('GTRANSIT-003: is on only with both the setting and a key', async () => { + expect(await new GoogleTransitProvider(googleDb()).isActive(1)).toBe(true); }); - it('GTRANSIT-004: an unknown provider value falls back to Transitous', () => { + it('GTRANSIT-004: an unknown provider value falls back to Transitous', async () => { const provider = new GoogleTransitProvider(stubDb({ transit_provider: 'someday-maps', maps_api_key: 'test-key' })); - expect(provider.isActive(1)).toBe(false); + expect(await provider.isActive(1)).toBe(false); }); it('GTRANSIT-005: TransitService routes to Google only when it is active', async () => { diff --git a/server/tests/unit/nest/llm-parse/llm-config.resolver.test.ts b/server/tests/unit/nest/llm-parse/llm-config.resolver.test.ts index 8371e56f4..68c0e1b58 100644 --- a/server/tests/unit/nest/llm-parse/llm-config.resolver.test.ts +++ b/server/tests/unit/nest/llm-parse/llm-config.resolver.test.ts @@ -58,14 +58,14 @@ beforeEach(() => { }); describe('resolveLlmConfig', () => { - it('returns null when the addon is disabled', () => { + it('returns null when the addon is disabled', async () => { isAddonEnabled.mockReturnValue(false); - expect(resolver.resolve(1)).toBeNull(); + expect(await resolver.resolve(1)).toBeNull(); }); - it('uses instance config when present (and decrypts the key)', () => { + it('uses instance config when present (and decrypts the key)', async () => { setInstanceConfig({ provider: 'anthropic', model: 'claude-opus-4-8', apiKey: 'sk-plain', multimodal: true }); - expect(resolver.resolve(1)).toEqual({ + expect(await resolver.resolve(1)).toEqual({ provider: 'anthropic', model: 'claude-opus-4-8', baseUrl: undefined, @@ -74,17 +74,17 @@ describe('resolveLlmConfig', () => { }); }); - it('instance config with a base URL still wins for a plain user (#1772 does not touch it)', () => { + it('instance config with a base URL still wins for a plain user (#1772 does not touch it)', async () => { setInstanceConfig({ provider: 'local', model: 'nuextract', baseUrl: 'http://ollama:11434' }); setRole('user'); - expect(resolver.resolve(7)).toMatchObject({ provider: 'local', baseUrl: 'http://ollama:11434' }); + expect(await resolver.resolve(7)).toMatchObject({ provider: 'local', baseUrl: 'http://ollama:11434' }); }); - it('falls back to per-user config when instance config is incomplete', () => { + it('falls back to per-user config when instance config is incomplete', async () => { setInstanceConfig({ provider: 'anthropic' }); // no model → not usable getUserSettings.mockReturnValue({ llm_provider: 'anthropic', llm_model: 'claude-sonnet', llm_multimodal: true }); getDecryptedUserSetting.mockReturnValue('user-key'); - expect(resolver.resolve(7)).toEqual({ + expect(await resolver.resolve(7)).toEqual({ provider: 'anthropic', model: 'claude-sonnet', baseUrl: undefined, @@ -94,22 +94,22 @@ describe('resolveLlmConfig', () => { expect(getDecryptedUserSetting).toHaveBeenCalledWith(7, 'llm_api_key'); }); - it('returns null when neither instance nor user config is usable', () => { + it('returns null when neither instance nor user config is usable', async () => { getUserSettings.mockReturnValue({ llm_provider: 'openai' }); // no model - expect(resolver.resolve(1)).toBeNull(); + expect(await resolver.resolve(1)).toBeNull(); }); // #1772: the endpoint is instance configuration, so it may only come from an // admin-controlled source, whoever is asking. - it('#1772: picking local personally gets no config at all (no silent reroute)', () => { + it('#1772: picking local personally gets no config at all (no silent reroute)', async () => { getUserSettings.mockReturnValue({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' }); - expect(resolver.resolve(7)).toBeNull(); + expect(await resolver.resolve(7)).toBeNull(); }); - it('#1772: a personal OpenAI config survives but loses its own base URL', () => { + it('#1772: a personal OpenAI config survives but loses its own base URL', async () => { getUserSettings.mockReturnValue({ llm_provider: 'openai', llm_model: 'gpt-4o-mini', llm_base_url: 'http://192.168.1.5:11434' }); getDecryptedUserSetting.mockReturnValue('sk-user'); - expect(resolver.resolve(7)).toEqual({ + expect(await resolver.resolve(7)).toEqual({ provider: 'openai', model: 'gpt-4o-mini', baseUrl: undefined, @@ -118,21 +118,21 @@ describe('resolveLlmConfig', () => { }); }); - it('#1772: an admin-set instance default endpoint applies to everyone', () => { + it('#1772: an admin-set instance default endpoint applies to everyone', async () => { // getUserSettings merges the admin defaults in; getAdminUserDefaults is the // admin-controlled layer the endpoint is allowed to come from. getUserSettings.mockReturnValue({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://ollama.internal:11434' }); getAdminUserDefaults.mockReturnValue({ llm_provider: 'local', llm_base_url: 'http://ollama.internal:11434' }); - expect(resolver.resolve(7)).toMatchObject({ provider: 'local', baseUrl: 'http://ollama.internal:11434' }); + expect(await resolver.resolve(7)).toMatchObject({ provider: 'local', baseUrl: 'http://ollama.internal:11434' }); }); - it('#1772: the caller\'s role does not change the answer, and no role is looked up', () => { + it('#1772: the caller\'s role does not change the answer, and no role is looked up', async () => { // An instance has one endpoint. An admin who parked one in their own row is // in exactly the same position as anyone else, and the resolver no longer // reads the users table at all. setRole('admin'); getUserSettings.mockReturnValue({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' }); - expect(resolver.resolve(7)).toBeNull(); + expect(await resolver.resolve(7)).toBeNull(); expect(dbMock._role.get).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/nest/llm-parse/llm-parse.service.test.ts b/server/tests/unit/nest/llm-parse/llm-parse.service.test.ts index c7eefd84b..2cf3de75f 100644 --- a/server/tests/unit/nest/llm-parse/llm-parse.service.test.ts +++ b/server/tests/unit/nest/llm-parse/llm-parse.service.test.ts @@ -41,10 +41,10 @@ beforeEach(() => { }); describe('LlmParseService', () => { - it('isAvailable reflects whether a config resolves', () => { + it('isAvailable reflects whether a config resolves', async () => { resolveLlmConfig.mockReturnValueOnce(null); - expect(svc().isAvailable(1)).toBe(false); - expect(svc().isAvailable(1)).toBe(true); + expect(await svc().isAvailable(1)).toBe(false); + expect(await svc().isAvailable(1)).toBe(true); }); it('returns a not-configured warning when no config resolves', async () => { diff --git a/server/tests/unit/nest/mcp-shared.service.test.ts b/server/tests/unit/nest/mcp-shared.service.test.ts index f28ce5bf5..51c490ae9 100644 --- a/server/tests/unit/nest/mcp-shared.service.test.ts +++ b/server/tests/unit/nest/mcp-shared.service.test.ts @@ -25,9 +25,14 @@ import { McpSharedModule } from '../../../src/nest/mcp-shared/mcp-shared.module' import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; const dbs = new DatabaseService(testDb); -const svc = new McpToolGuardsService(dbs, new PermissionsService(dbs), new RealtimeService()); +let svc: McpToolGuardsService; +beforeAll(async () => { + svc = new McpToolGuardsService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new RealtimeService()); +}); function createTrip(ownerId: number): number { const r = testDb.prepare("INSERT INTO trips (user_id, title) VALUES (?, 'T')").run(ownerId); @@ -47,31 +52,31 @@ beforeEach(() => { }); describe('hasTripPermission', () => { - it('GRD-001: false for a missing trip', () => { + it('GRD-001: false for a missing trip', async () => { const { user } = createUser(testDb); - expect(svc.hasTripPermission('trip_edit', 99999, user.id)).toBe(false); + expect(await svc.hasTripPermission('trip_edit', 99999, user.id)).toBe(false); }); - it('GRD-002: the owner passes owner-level actions; a stranger does not', () => { + it('GRD-002: the owner passes owner-level actions; a stranger does not', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const tripId = createTrip(owner.id); - expect(svc.hasTripPermission('trip_delete', tripId, owner.id)).toBe(true); - expect(svc.hasTripPermission('trip_delete', tripId, stranger.id)).toBe(false); + expect(await svc.hasTripPermission('trip_delete', tripId, owner.id)).toBe(true); + expect(await svc.hasTripPermission('trip_delete', tripId, stranger.id)).toBe(false); }); - it('GRD-003: an unknown user falls back to the plain user role', () => { + it('GRD-003: an unknown user falls back to the plain user role', async () => { const { user: owner } = createUser(testDb); const tripId = createTrip(owner.id); - expect(svc.hasTripPermission('trip_delete', tripId, 424242)).toBe(false); + expect(await svc.hasTripPermission('trip_delete', tripId, 424242)).toBe(false); }); - it('GRD-004: a global admin passes regardless of membership', () => { + it('GRD-004: a global admin passes regardless of membership', async () => { const { user: owner } = createUser(testDb); const { user: admin } = createUser(testDb); testDb.prepare("UPDATE users SET role = 'admin' WHERE id = ?").run(admin.id); const tripId = createTrip(owner.id); - expect(svc.hasTripPermission('trip_delete', tripId, admin.id)).toBe(true); + expect(await svc.hasTripPermission('trip_delete', tripId, admin.id)).toBe(true); }); }); diff --git a/server/tests/unit/nest/memories.controller.test.ts b/server/tests/unit/nest/memories.controller.test.ts index 57ec25329..5c78d4ed6 100644 --- a/server/tests/unit/nest/memories.controller.test.ts +++ b/server/tests/unit/nest/memories.controller.test.ts @@ -50,19 +50,19 @@ function makeRes(): MockRes { // ───────────────────────────────────────────────────────────────────────────── describe('UnifiedMemoriesController (parity with /api/integrations/memories/unified)', () => { describe('GET /trips/:tripId/photos', () => { - it('returns the photos on success', () => { + it('returns the photos on success', async () => { const svc = makeService({ listTripPhotos: vi.fn().mockReturnValue({ data: [{ id: 1 }] }) }); const res = makeRes(); - new UnifiedMemoriesController(svc).listPhotos(user, '5', res); + await new UnifiedMemoriesController(svc).listPhotos(user, '5', res); expect(svc.listTripPhotos).toHaveBeenCalledWith('5', 7); expect(res.json).toHaveBeenCalledWith({ photos: [{ id: 1 }] }); expect(res.status).not.toHaveBeenCalled(); }); - it('maps the error envelope to its status + message', () => { + it('maps the error envelope to its status + message', async () => { const svc = makeService({ listTripPhotos: vi.fn().mockReturnValue({ error: { status: 404, message: 'Trip not found' } }) }); const res = makeRes(); - new UnifiedMemoriesController(svc).listPhotos(user, '5', res); + await new UnifiedMemoriesController(svc).listPhotos(user, '5', res); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'Trip not found' }); }); @@ -147,28 +147,28 @@ describe('UnifiedMemoriesController (parity with /api/integrations/memories/unif }); describe('GET /trips/:tripId/album-links', () => { - it('returns the links on success', () => { + it('returns the links on success', async () => { const svc = makeService({ listTripAlbumLinks: vi.fn().mockReturnValue({ data: [{ id: 'l1' }] }) }); const res = makeRes(); - new UnifiedMemoriesController(svc).listAlbumLinks(user, '5', res); + await new UnifiedMemoriesController(svc).listAlbumLinks(user, '5', res); expect(res.json).toHaveBeenCalledWith({ links: [{ id: 'l1' }] }); }); - it('maps the error envelope', () => { + it('maps the error envelope', async () => { const svc = makeService({ listTripAlbumLinks: vi.fn().mockReturnValue({ error: { status: 404, message: 'Trip not found' } }) }); const res = makeRes(); - new UnifiedMemoriesController(svc).listAlbumLinks(user, '5', res); + await new UnifiedMemoriesController(svc).listAlbumLinks(user, '5', res); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'Trip not found' }); }); }); describe('POST /trips/:tripId/album-links', () => { - it('forwards a coerced passphrase when present', () => { + it('forwards a coerced passphrase when present', async () => { const createTripAlbumLink = vi.fn().mockReturnValue({ data: {} }); const svc = makeService({ createTripAlbumLink }); const res = makeRes(); - new UnifiedMemoriesController(svc).createAlbumLink( + await new UnifiedMemoriesController(svc).createAlbumLink( user, '5', { provider: 'synologyphotos', album_id: 'a1', album_name: 'Trip', passphrase: 123 }, @@ -178,17 +178,17 @@ describe('UnifiedMemoriesController (parity with /api/integrations/memories/unif expect(res.json).toHaveBeenCalledWith({ success: true }); }); - it('passes undefined when the passphrase is absent or empty', () => { + it('passes undefined when the passphrase is absent or empty', async () => { const createTripAlbumLink = vi.fn().mockReturnValue({ data: {} }); const svc = makeService({ createTripAlbumLink }); - new UnifiedMemoriesController(svc).createAlbumLink(user, '5', { provider: 'immich', album_id: 'a1', album_name: 'Trip', passphrase: '' }, makeRes()); + await new UnifiedMemoriesController(svc).createAlbumLink(user, '5', { provider: 'immich', album_id: 'a1', album_name: 'Trip', passphrase: '' }, makeRes()); expect(createTripAlbumLink).toHaveBeenCalledWith('5', 7, 'immich', 'a1', 'Trip', undefined); }); - it('maps the error envelope', () => { + it('maps the error envelope', async () => { const svc = makeService({ createTripAlbumLink: vi.fn().mockReturnValue({ error: { status: 400, message: 'Invalid provider' } }) }); const res = makeRes(); - new UnifiedMemoriesController(svc).createAlbumLink(user, '5', {}, res); + await new UnifiedMemoriesController(svc).createAlbumLink(user, '5', {}, res); expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ error: 'Invalid provider' }); }); diff --git a/server/tests/unit/nest/memories.service.test.ts b/server/tests/unit/nest/memories.service.test.ts index e02ab3c24..bb11a37d7 100644 --- a/server/tests/unit/nest/memories.service.test.ts +++ b/server/tests/unit/nest/memories.service.test.ts @@ -88,7 +88,7 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () => }); it('unified methods delegate', async () => { - svc.listTripPhotos('5', 7); + await svc.listTripPhotos('5', 7); expect(unified.listTripPhotos).toHaveBeenCalledWith('5', 7); const selections = [{ provider: 'immich', asset_ids: ['a'] }]; @@ -101,18 +101,18 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () => svc.removeTripPhoto('5', 7, 9); expect(unified.removeTripPhoto).toHaveBeenCalledWith('5', 7, 9); - svc.listTripAlbumLinks('5', 7); + await svc.listTripAlbumLinks('5', 7); expect(unified.listTripAlbumLinks).toHaveBeenCalledWith('5', 7); svc.removeAlbumLink('5', 'l1', 7); expect(unified.removeAlbumLink).toHaveBeenCalledWith('5', 'l1', 7); }); - it('createTripAlbumLink forwards a passphrase when present and omits it when absent', () => { - svc.createTripAlbumLink('5', 7, 'immich', 'a1', 'Trip', 'secret'); + it('createTripAlbumLink forwards a passphrase when present and omits it when absent', async () => { + await svc.createTripAlbumLink('5', 7, 'immich', 'a1', 'Trip', 'secret'); expect(unified.createTripAlbumLink).toHaveBeenCalledWith('5', 7, 'immich', 'a1', 'Trip', 'secret'); - svc.createTripAlbumLink('5', 7, 'immich', 'a1', 'Trip'); + await svc.createTripAlbumLink('5', 7, 'immich', 'a1', 'Trip'); expect(unified.createTripAlbumLink).toHaveBeenLastCalledWith('5', 7, 'immich', 'a1', 'Trip', undefined); }); diff --git a/server/tests/unit/nest/oauth.controller.test.ts b/server/tests/unit/nest/oauth.controller.test.ts index f111229f6..868ee5442 100644 --- a/server/tests/unit/nest/oauth.controller.test.ts +++ b/server/tests/unit/nest/oauth.controller.test.ts @@ -50,24 +50,24 @@ beforeEach(() => vi.clearAllMocks()); describe('OauthPublicController /token', () => { function reqWith(body: Record): Request { return { ip: '7.7.7.7', body } as Request; } - it('404 (empty) when MCP is disabled', () => { + it('404 (empty) when MCP is disabled', async () => { const res = makeRes(); - opc(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).token(reqWith({}), res); + await opc(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).token(reqWith({}), res); expect(res.statusCode).toBe(404); expect(res.ended).toBe(true); }); - it('sets no-store headers + 401 without client_id', () => { + it('sets no-store headers + 401 without client_id', async () => { const res = makeRes(); - opc(osvc(), rl()).token(reqWith({}), res); + await opc(osvc(), rl()).token(reqWith({}), res); expect(res.headers['Cache-Control']).toBe('no-store'); expect(res.statusCode).toBe(401); expect(res.body).toEqual({ error: 'invalid_client', error_description: 'client_id is required' }); }); - it('authorization_code: invalid_grant on a bad code, success issues tokens', () => { + it('authorization_code: invalid_grant on a bad code, success issues tokens', async () => { const bad = makeRes(); - opc(osvc({ consumeAuthCode: vi.fn().mockReturnValue(null) }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), bad); + await opc(osvc({ consumeAuthCode: vi.fn().mockReturnValue(null) }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), bad); expect(bad.statusCode).toBe(400); expect(bad.body).toEqual({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' }); @@ -78,95 +78,95 @@ describe('OauthPublicController /token', () => { verifyPKCE: vi.fn().mockReturnValue(true), issueTokens: vi.fn().mockReturnValue({ access_token: 'at', token_type: 'Bearer' }), }); - opc(svc, rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), ok); + await opc(svc, rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), ok); expect(ok.body).toEqual({ access_token: 'at', token_type: 'Bearer' }); }); - it('authorization_code: maps client_id / redirect_uri / resource mismatches + pkce + client auth', () => { + it('authorization_code: maps client_id / redirect_uri / resource mismatches + pkce + client auth', async () => { const base = { grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }; // body is the loose form the controller actually reads off req.body, not the // shape of `base`, so a case can add a field such as `resource`. - const mk = (pending: Record, extra: Partial = {}, body: Record = base) => { + const mk = async (pending: Record, extra: Partial = {}, body: Record = base) => { const res = makeRes(); - opc(osvc({ consumeAuthCode: vi.fn().mockReturnValue(pending), authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), verifyPKCE: vi.fn().mockReturnValue(true), ...extra }), rl()).token(reqWith(body), res); + await opc(osvc({ consumeAuthCode: vi.fn().mockReturnValue(pending), authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), verifyPKCE: vi.fn().mockReturnValue(true), ...extra }), rl()).token(reqWith(body), res); return res; }; - expect(mk({ clientId: 'OTHER', redirectUri: 'u', userId: 1 }).statusCode).toBe(400); // client_id mismatch - expect(mk({ clientId: 'c', redirectUri: 'OTHER', userId: 1 }).statusCode).toBe(400); // redirect_uri mismatch - expect(mk({ clientId: 'c', redirectUri: 'u', userId: 1, resource: 'https://a' }, {}, { ...base, resource: 'https://b' }).statusCode).toBe(400); // resource mismatch - expect(mk({ clientId: 'c', redirectUri: 'u', userId: 1 }, { authenticateClient: vi.fn().mockReturnValue(null) }).statusCode).toBe(401); // bad client secret - expect(mk({ clientId: 'c', redirectUri: 'u', userId: 1, codeChallenge: 'cc' }, { verifyPKCE: vi.fn().mockReturnValue(false) }).statusCode).toBe(400); // pkce fail + expect((await mk({ clientId: 'OTHER', redirectUri: 'u', userId: 1 })).statusCode).toBe(400); // client_id mismatch + expect((await mk({ clientId: 'c', redirectUri: 'OTHER', userId: 1 })).statusCode).toBe(400); // redirect_uri mismatch + expect((await mk({ clientId: 'c', redirectUri: 'u', userId: 1, resource: 'https://a' }, {}, { ...base, resource: 'https://b' })).statusCode).toBe(400); // resource mismatch + expect((await mk({ clientId: 'c', redirectUri: 'u', userId: 1 }, { authenticateClient: vi.fn().mockReturnValue(null) })).statusCode).toBe(401); // bad client secret + expect((await mk({ clientId: 'c', redirectUri: 'u', userId: 1, codeChallenge: 'cc' }, { verifyPKCE: vi.fn().mockReturnValue(false) })).statusCode).toBe(400); // pkce fail }); - it('authorization_code: 400 when code/redirect/verifier missing', () => { + it('authorization_code: 400 when code/redirect/verifier missing', async () => { const res = makeRes(); - opc(osvc(), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c' }), res); + await opc(osvc(), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c' }), res); expect(res.statusCode).toBe(400); expect(res.body).toEqual({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' }); }); - it('refresh_token: 400 without a refresh_token, maps a service error, success', () => { + it('refresh_token: 400 without a refresh_token, maps a service error, success', async () => { const miss = makeRes(); - opc(osvc(), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c' }), miss); + await opc(osvc(), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c' }), miss); expect(miss.statusCode).toBe(400); const err = makeRes(); - opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant', status: 400 }) }), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), err); + await opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant', status: 400 }) }), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), err); expect(err.body).toEqual({ error: 'invalid_grant', error_description: 'Refresh token is invalid or expired' }); const ok = makeRes(); - opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ tokens: { access_token: 'new' } }) }), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), ok); + await opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ tokens: { access_token: 'new' } }) }), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), ok); expect(ok.body).toEqual({ access_token: 'new' }); }); - it('client_credentials: 401 without secret, invalid_scope for a disallowed scope', () => { + it('client_credentials: 401 without secret, invalid_scope for a disallowed scope', async () => { const noSecret = makeRes(); - opc(osvc(), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c' }), noSecret); + await opc(osvc(), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c' }), noSecret); expect(noSecret.statusCode).toBe(401); const badScope = makeRes(); - opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a"]' }) }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a zzz' }), badScope); + await opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a"]' }) }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a zzz' }), badScope); expect(badScope.statusCode).toBe(400); expect(badScope.body).toEqual({ error: 'invalid_scope', error_description: 'Scopes not allowed for this client: zzz' }); }); - it('client_credentials: unauthorized_client for a public client, else issues a token', () => { + it('client_credentials: unauthorized_client for a public client, else issues a token', async () => { const pub = makeRes(); - opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: true, user_id: null, allows_client_credentials: false, allowed_scopes: '[]' }) }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), pub); + await opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: true, user_id: null, allows_client_credentials: false, allowed_scopes: '[]' }) }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), pub); expect(pub.statusCode).toBe(400); expect(pub.body).toEqual({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' }); const ok = makeRes(); - opc(osvc({ + await opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a","b"]' }), issueClientCredentialsToken: vi.fn().mockReturnValue({ access_token: 'cc_at' }), }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), ok); expect(ok.body).toEqual({ access_token: 'cc_at' }); }); - it('unsupported grant -> 400', () => { + it('unsupported grant -> 400', async () => { const res = makeRes(); - opc(osvc(), rl()).token(reqWith({ grant_type: 'password', client_id: 'c' }), res); + await opc(osvc(), rl()).token(reqWith({ grant_type: 'password', client_id: 'c' }), res); expect(res.statusCode).toBe(400); expect(res.body).toEqual({ error: 'unsupported_grant_type', error_description: 'Unsupported grant_type: password' }); }); - it('429 when the token bucket is exhausted (per ip|client)', () => { + it('429 when the token bucket is exhausted (per ip|client)', async () => { const s = rl(); for (let i = 0; i < 30; i++) s.check('oauth_token', '7.7.7.7|c', 30, 60000, Date.now()); const res = makeRes(); - opc(osvc(), s).token(reqWith({ client_id: 'c' }), res); + await opc(osvc(), s).token(reqWith({ client_id: 'c' }), res); expect(res.statusCode).toBe(429); }); - it('falls back to {} when the body is not an object', () => { + it('falls back to {} when the body is not an object', async () => { const res = makeRes(); - opc(osvc(), rl()).token({ ip: '7.7.7.7', body: 'not-an-object' } as unknown as Request, res); + await opc(osvc(), rl()).token({ ip: '7.7.7.7', body: 'not-an-object' } as unknown as Request, res); // no client_id in the {} fallback -> 401 expect(res.statusCode).toBe(401); expect(res.body).toEqual({ error: 'invalid_client', error_description: 'client_id is required' }); }); - it('authorization_code: invalid client secret writes an audit + 401', () => { + it('authorization_code: invalid client secret writes an audit + 401', async () => { const res = makeRes(); - opc(osvc({ + await opc(osvc({ consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }), authenticateClient: vi.fn().mockReturnValue(null), }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), res); @@ -174,33 +174,33 @@ describe('OauthPublicController /token', () => { expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' }); }); - it('refresh_token: invalid_client maps to its specific 401 message', () => { + it('refresh_token: invalid_client maps to its specific 401 message', async () => { const res = makeRes(); - opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl()) + await opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl()) .token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res); expect(res.statusCode).toBe(401); expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' }); }); - it('refresh_token: defaults the status to 400 when the service omits it', () => { + it('refresh_token: defaults the status to 400 when the service omits it', async () => { const res = makeRes(); - opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant' }) }), rl()) + await opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant' }) }), rl()) .token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res); expect(res.statusCode).toBe(400); }); - it('client_credentials: 401 when the client cannot be authenticated', () => { + it('client_credentials: 401 when the client cannot be authenticated', async () => { const res = makeRes(); - opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) + await opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) .token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), res); expect(res.statusCode).toBe(401); expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' }); }); - it('client_credentials: honours a valid requested scope subset', () => { + it('client_credentials: honours a valid requested scope subset', async () => { const res = makeRes(); const issueClientCredentialsToken = vi.fn().mockReturnValue({ access_token: 'cc_at' }); - opc(osvc({ + await opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a","b"]' }), issueClientCredentialsToken, }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a' }), res); @@ -208,10 +208,10 @@ describe('OauthPublicController /token', () => { expect(issueClientCredentialsToken).toHaveBeenCalledWith('c', 1, ['a'], expect.any(String)); }); - it('client_credentials: derives the audience from an explicit resource', () => { + it('client_credentials: derives the audience from an explicit resource', async () => { const res = makeRes(); const issueClientCredentialsToken = vi.fn().mockReturnValue({ access_token: 'cc_at' }); - opc(osvc({ + await opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a"]' }), issueClientCredentialsToken, }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', resource: 'https://aud/' }), res); @@ -219,106 +219,106 @@ describe('OauthPublicController /token', () => { expect(issueClientCredentialsToken).toHaveBeenCalledWith('c', 1, ['a'], 'https://aud'); }); - it('logs a dash for a missing ip on the authorization_code client-auth failure', () => { + it('logs a dash for a missing ip on the authorization_code client-auth failure', async () => { getClientIpMock.mockReturnValueOnce(undefined); const res = makeRes(); - opc(osvc({ + await opc(osvc({ consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }), authenticateClient: vi.fn().mockReturnValue(null), }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), res); expect(res.statusCode).toBe(401); }); - it('logs a dash for a missing ip on the refresh invalid_client failure', () => { + it('logs a dash for a missing ip on the refresh invalid_client failure', async () => { getClientIpMock.mockReturnValueOnce(undefined); const res = makeRes(); - opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl()) + await opc(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl()) .token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res); expect(res.statusCode).toBe(401); }); - it('logs a dash for a missing ip on the client_credentials auth failure', () => { + it('logs a dash for a missing ip on the client_credentials auth failure', async () => { getClientIpMock.mockReturnValueOnce(undefined); const res = makeRes(); - opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) + await opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) .token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), res); expect(res.statusCode).toBe(401); }); }); describe('OauthPublicController /userinfo + /revoke', () => { - it('userinfo: 401 challenge without a Bearer, returns claims with a valid token', () => { + it('userinfo: 401 challenge without a Bearer, returns claims with a valid token', async () => { const r1 = makeRes(); - opc(osvc(), rl()).userinfo(undefined, r1); + await opc(osvc(), rl()).userinfo(undefined, r1); expect(r1.statusCode).toBe(401); expect(r1.headers['WWW-Authenticate']).toBe('Bearer realm="TREK MCP"'); const r2 = makeRes(); - opc(osvc({ getUserByAccessToken: vi.fn().mockReturnValue({ user: { id: 1, email: 'a@b.c', username: 'u' } }) }), rl()).userinfo('Bearer tok', r2); + await opc(osvc({ getUserByAccessToken: vi.fn().mockReturnValue({ user: { id: 1, email: 'a@b.c', username: 'u' } }) }), rl()).userinfo('Bearer tok', r2); expect(r2.body).toEqual({ sub: '1', email: 'a@b.c', email_verified: true, preferred_username: 'u' }); }); - it('userinfo: 404 empty when MCP is disabled', () => { + it('userinfo: 404 empty when MCP is disabled', async () => { const res = makeRes(); - opc(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).userinfo('Bearer tok', res); + await opc(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).userinfo('Bearer tok', res); expect(res.statusCode).toBe(404); expect(res.ended).toBe(true); }); - it('userinfo: 401 with the error challenge when the token is unknown', () => { + it('userinfo: 401 with the error challenge when the token is unknown', async () => { const res = makeRes(); - opc(osvc({ getUserByAccessToken: vi.fn().mockReturnValue(null) }), rl()).userinfo('Bearer tok', res); + await opc(osvc({ getUserByAccessToken: vi.fn().mockReturnValue(null) }), rl()).userinfo('Bearer tok', res); expect(res.statusCode).toBe(401); expect(res.headers['WWW-Authenticate']).toBe('Bearer realm="TREK MCP", error="invalid_token"'); expect(res.body).toEqual({ error: 'invalid_token' }); }); - it('revoke: 400 without token/client, always 200 once authenticated', () => { + it('revoke: 400 without token/client, always 200 once authenticated', async () => { const r1 = makeRes(); - opc(osvc(), rl()).revoke({ ip: '1', body: { client_id: 'c' } } as Request, r1); + await opc(osvc(), rl()).revoke({ ip: '1', body: { client_id: 'c' } } as Request, r1); expect(r1.statusCode).toBe(400); const r2 = makeRes(); const revokeToken = vi.fn(); - opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken }), rl()).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, r2); + await opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken }), rl()).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, r2); expect(r2.statusCode).toBe(200); expect(r2.body).toEqual({}); expect(revokeToken).toHaveBeenCalled(); }); - it('revoke: 404 empty when MCP is disabled', () => { + it('revoke: 404 empty when MCP is disabled', async () => { const res = makeRes(); - opc(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).revoke({ ip: '1', body: {} } as Request, res); + await opc(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).revoke({ ip: '1', body: {} } as Request, res); expect(res.statusCode).toBe(404); expect(res.ended).toBe(true); }); - it('revoke: 429 when the per-ip bucket is exhausted', () => { + it('revoke: 429 when the per-ip bucket is exhausted', async () => { const s = rl(); for (let i = 0; i < 10; i++) s.check('oauth_revoke', '1', 10, 60000, Date.now()); const res = makeRes(); - opc(osvc(), s).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res); + await opc(osvc(), s).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res); expect(res.statusCode).toBe(429); }); - it('revoke: falls back to a default ip key and {} body when both are missing', () => { + it('revoke: falls back to a default ip key and {} body when both are missing', async () => { const res = makeRes(); - opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken: vi.fn() }), rl()) + await opc(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken: vi.fn() }), rl()) .revoke({ body: undefined } as unknown as Request, res); // body fell back to {} -> token/client missing -> 400 expect(res.statusCode).toBe(400); }); - it('revoke: 401 when the client credentials are invalid', () => { + it('revoke: 401 when the client credentials are invalid', async () => { const res = makeRes(); - opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) + await opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) .revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res); expect(res.statusCode).toBe(401); expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' }); }); - it('revoke: logs a dash for a missing ip on the invalid-client failure', () => { + it('revoke: logs a dash for a missing ip on the invalid-client failure', async () => { getClientIpMock.mockReturnValueOnce(undefined); const res = makeRes(); - opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) + await opc(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl()) .revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res); expect(res.statusCode).toBe(401); }); @@ -328,42 +328,42 @@ describe('OauthApiController', () => { const req = { ip: '1.2.3.4', user: undefined as unknown } as Request; function makeRes2() { const r = { statusCode: 200, ended: false, status: vi.fn((c: number) => { r.statusCode = c; return r; }), end: vi.fn(() => { r.ended = true; }) }; return r as unknown as Response & { statusCode: number; ended: boolean }; } - it('validate: 404 empty when MCP off, loginRequired when anonymous + valid', () => { + it('validate: 404 empty when MCP off, loginRequired when anonymous + valid', async () => { const off = makeRes2(); - new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).validate({ ...req } as Request, {}, off); + await new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).validate({ ...req } as Request, {}, off); expect(off.statusCode).toBe(404); expect(off.ended).toBe(true); const anon = makeRes2(); - const r = new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true }) }), rl()).validate({ ...req, user: undefined } as Request, {}, anon); + const r = await new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true }) }), rl()).validate({ ...req, user: undefined } as Request, {}, anon); expect(r).toEqual({ valid: true, loginRequired: true }); }); - it('authorize: denied returns a redirect with access_denied, approved issues a code', () => { + it('authorize: denied returns a redirect with access_denied, approved issues a code', async () => { const deniedSvc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'] }) }); - const denied = new OauthApiController(deniedSvc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req); + const denied = await new OauthApiController(deniedSvc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req); expect((denied as { redirect: string }).redirect).toContain('error=access_denied'); const svc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: null }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue('the_code') }); - const ok = new OauthApiController(svc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req); + const ok = await new OauthApiController(svc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req); expect((ok as { redirect: string }).redirect).toContain('code=the_code'); }); - it('clients/sessions: 403 when MCP off, else CRUD', () => { + it('clients/sessions: 403 when MCP off, else CRUD', async () => { expect(thrown(() => new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).listClients(user))).toEqual({ status: 403, body: { error: 'MCP is not enabled' } }); - expect(new OauthApiController(osvc({ listOAuthClients: vi.fn().mockReturnValue([{ id: 'c1' }]) }), rl()).listClients(user)).toEqual({ clients: [{ id: 'c1' }] }); - expect(new OauthApiController(osvc({ createOAuthClient: vi.fn().mockReturnValue({ client_id: 'c1', client_secret: 's' }) }), rl()).createClient(user, { name: 'CLI', allowed_scopes: ['a'] }, req)).toEqual({ client_id: 'c1', client_secret: 's' }); - expect(new OauthApiController(osvc({ deleteOAuthClient: vi.fn().mockReturnValue({}) }), rl()).deleteClient(user, 'c1', req)).toEqual({ success: true }); - expect(new OauthApiController(osvc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) }), rl()).listSessions(user)).toEqual({ sessions: [{ id: 1 }] }); - expect(new OauthApiController(osvc({ revokeSession: vi.fn().mockReturnValue({}) }), rl()).revokeSession(user, '1', req)).toEqual({ success: true }); + expect(await new OauthApiController(osvc({ listOAuthClients: vi.fn().mockReturnValue([{ id: 'c1' }]) }), rl()).listClients(user)).toEqual({ clients: [{ id: 'c1' }] }); + expect(await new OauthApiController(osvc({ createOAuthClient: vi.fn().mockReturnValue({ client_id: 'c1', client_secret: 's' }) }), rl()).createClient(user, { name: 'CLI', allowed_scopes: ['a'] }, req)).toEqual({ client_id: 'c1', client_secret: 's' }); + expect(await new OauthApiController(osvc({ deleteOAuthClient: vi.fn().mockReturnValue({}) }), rl()).deleteClient(user, 'c1', req)).toEqual({ success: true }); + expect(await new OauthApiController(osvc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) }), rl()).listSessions(user)).toEqual({ sessions: [{ id: 1 }] }); + expect(await new OauthApiController(osvc({ revokeSession: vi.fn().mockReturnValue({}) }), rl()).revokeSession(user, '1', req)).toEqual({ success: true }); }); - it('rotate maps a service error, else returns the new secret', () => { + it('rotate maps a service error, else returns the new secret', async () => { expect(thrown(() => new OauthApiController(osvc({ rotateOAuthClientSecret: vi.fn().mockReturnValue({ error: 'not_found', status: 404 }) }), rl()).rotateClient(user, 'c1', req))).toEqual({ status: 404, body: { error: 'not_found' } }); - expect(new OauthApiController(osvc({ rotateOAuthClientSecret: vi.fn().mockReturnValue({ client_secret: 'new' }) }), rl()).rotateClient(user, 'c1', req)).toEqual({ client_secret: 'new' }); + expect(await new OauthApiController(osvc({ rotateOAuthClientSecret: vi.fn().mockReturnValue({ client_secret: 'new' }) }), rl()).rotateClient(user, 'c1', req)).toEqual({ client_secret: 'new' }); }); - it('validate: anonymous + invalid returns a generic error; create maps a service error', () => { + it('validate: anonymous + invalid returns a generic error; create maps a service error', async () => { const res = makeRes2(); - const anon = new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: false, error: 'x' }) }), rl()).validate({ ...req, user: undefined } as Request, {}, res); + const anon = await new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: false, error: 'x' }) }), rl()).validate({ ...req, user: undefined } as Request, {}, res); expect(anon).toEqual({ valid: false, error: 'invalid_request', error_description: 'Invalid authorization request' }); expect(thrown(() => new OauthApiController(osvc({ createOAuthClient: vi.fn().mockReturnValue({ error: 'invalid_redirect_uri', status: 400 }) }), rl()).createClient(user, { name: 'X', allowed_scopes: ['a'] }, req))).toEqual({ status: 400, body: { error: 'invalid_redirect_uri' } }); }); @@ -383,17 +383,17 @@ describe('OauthApiController', () => { }); }); - it('validate: falls back to the "unknown" rate-limit key when req.ip is absent', () => { + it('validate: falls back to the "unknown" rate-limit key when req.ip is absent', async () => { const res = makeRes2(); - const out = new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true }) }), rl()) + const out = await new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true }) }), rl()) .validate({ user: undefined } as unknown as Request, {}, res); expect(out).toEqual({ valid: true, loginRequired: true }); }); - it('validate: forwards the resource + returns the raw result for a logged-in user', () => { + it('validate: forwards the resource + returns the raw result for a logged-in user', async () => { const res = makeRes2(); const validateAuthorizeRequest = vi.fn().mockReturnValue({ valid: true, scopes: ['s'] }); - const out = new OauthApiController(osvc({ validateAuthorizeRequest }), rl()) + const out = await new OauthApiController(osvc({ validateAuthorizeRequest }), rl()) .validate({ ...req, user: { id: 9 } } as unknown as Request, { resource: 'https://r' }, res); expect(out).toEqual({ valid: true, scopes: ['s'] }); expect(validateAuthorizeRequest).toHaveBeenCalledWith(expect.objectContaining({ resource: 'https://r' }), 9); @@ -405,13 +405,13 @@ describe('OauthApiController', () => { .toEqual({ status: 403, body: { error: 'MCP is not enabled' } }); }); - it('authorize: carries the state through both the denied and approved redirects', () => { + it('authorize: carries the state through both the denied and approved redirects', async () => { const deniedSvc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'] }) }); - const denied = new OauthApiController(deniedSvc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', state: 'xyz', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req); + const denied = await new OauthApiController(deniedSvc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', state: 'xyz', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req); expect((denied as { redirect: string }).redirect).toContain('state=xyz'); const svc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: 'https://aud' }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue('the_code') }); - const ok = new OauthApiController(svc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', state: 'xyz', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req); + const ok = await new OauthApiController(svc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', state: 'xyz', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req); expect((ok as { redirect: string }).redirect).toContain('code=the_code'); expect((ok as { redirect: string }).redirect).toContain('state=xyz'); }); diff --git a/server/tests/unit/nest/oauth.service.test.ts b/server/tests/unit/nest/oauth.service.test.ts index 9d59c6df8..59eb832f3 100644 --- a/server/tests/unit/nest/oauth.service.test.ts +++ b/server/tests/unit/nest/oauth.service.test.ts @@ -1137,12 +1137,12 @@ describe('validateAuthorizeRequest — unauthenticated strips client info (H3)', // --------------------------------------------------------------------------- describe('addon gate and MCP endpoint', () => { - it('mcpEnabled checks the MCP addon flag', () => { + it('mcpEnabled checks the MCP addon flag', async () => { isAddonEnabled.mockReturnValue(true); - expect(svc.mcpEnabled()).toBe(true); + expect(await svc.mcpEnabled()).toBe(true); expect(isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.MCP); isAddonEnabled.mockReturnValue(false); - expect(svc.mcpEnabled()).toBe(false); + expect(await svc.mcpEnabled()).toBe(false); }); it('mcpSafeUrl forwards to the app-config helper', () => { diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts index bbf8b759a..fcf93d05a 100644 --- a/server/tests/unit/nest/oidc.service.test.ts +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -106,6 +106,8 @@ import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.se import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service'; import { OidcService } from '../../../src/nest/oidc/oidc.service'; import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; // MailerService is injected since the notifications fold — a stub instead of a // module mock. sendPasswordResetEmail is the only thing auth reaches for. @@ -116,17 +118,22 @@ const membership = new TripMembershipService(new DatabaseService(testDb)); // slot, which AuthService no longer takes at all, so webauthn/userCleanup/mailer // each landed one place too late and the EphemeralTokenService was missing // entirely. Nothing failed, because no case below reaches those collaborators. -const auth = new AuthService( + +let auth: AuthService; +let svc: OidcService; +beforeAll(async () => { + auth = new AuthService( new DatabaseService(testDb), - new PermissionsService(new DatabaseService(testDb)), + new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), membership, new WebauthnConfigService(new DatabaseService(testDb)), - new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new ExchangeRatesService(), new RealtimeService())), + new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService())), mailerStub, new EphemeralTokenService(), - new AllowedFileTypesService(new DatabaseService(testDb)), + new AllowedFileTypesService(new DatabaseService(testDb)), await createTestUnitOfWork(testDb), ); -const svc = new OidcService(new DatabaseService(testDb), auth, membership); + svc = new OidcService(new DatabaseService(testDb), auth, membership, await createTestUnitOfWork(testDb)); +}); const MOCK_CONFIG = { issuer: 'https://oidc.example.com', @@ -484,13 +491,13 @@ describe('getOidcConfig issuer trailing-slash regex', () => { // ── findOrCreateUser ────────────────────────────────────────────────────────── describe('findOrCreateUser', () => { - it('OIDC-SVC-020: finds existing user by oidc_sub', () => { + it('OIDC-SVC-020: finds existing user by oidc_sub', async () => { const { user } = createUser(testDb, { email: 'alice@example.com' }); // Link the sub manually testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') .run('sub-alice-123', MOCK_CONFIG.issuer, user.id); - const result = svc.findOrCreateUser( + const result = await svc.findOrCreateUser( { sub: 'sub-alice-123', email: 'alice@example.com', name: 'Alice' }, MOCK_CONFIG ); @@ -498,10 +505,10 @@ describe('findOrCreateUser', () => { expect((result as { user: any }).user.id).toBe(user.id); }); - it('OIDC-SVC-021: finds existing user by email when no sub match', () => { + it('OIDC-SVC-021: finds existing user by email when no sub match', async () => { const { user } = createUser(testDb, { email: 'bob@example.com' }); - const result = svc.findOrCreateUser( + const result = await svc.findOrCreateUser( { sub: 'sub-bob-new', email: 'bob@example.com', name: 'Bob', email_verified: true }, MOCK_CONFIG ); @@ -509,8 +516,8 @@ describe('findOrCreateUser', () => { expect((result as { user: any }).user.id).toBe(user.id); }); - it('OIDC-SVC-022: creates new user when registration is open', () => { - const result = svc.findOrCreateUser( + it('OIDC-SVC-022: creates new user when registration is open', async () => { + const result = await svc.findOrCreateUser( { sub: 'sub-new-1', email: 'newuser@example.com', name: 'New User' }, MOCK_CONFIG ); @@ -519,9 +526,9 @@ describe('findOrCreateUser', () => { expect(newUser).toBeDefined(); }); - it('OIDC-SVC-023: first user gets admin role', () => { + it('OIDC-SVC-023: first user gets admin role', async () => { // DB is empty after resetTestDb - const result = svc.findOrCreateUser( + const result = await svc.findOrCreateUser( { sub: 'sub-first', email: 'first@example.com', name: 'First' }, MOCK_CONFIG ); @@ -529,11 +536,11 @@ describe('findOrCreateUser', () => { expect((result as { user: any }).user.role).toBe('admin'); }); - it('OIDC-SVC-024: returns registration_disabled error when registration is off', () => { + it('OIDC-SVC-024: returns registration_disabled error when registration is off', async () => { createUser(testDb, { email: 'existing@example.com' }); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); - const result = svc.findOrCreateUser( + const result = await svc.findOrCreateUser( { sub: 'sub-blocked', email: 'blocked@example.com', name: 'Blocked' }, MOCK_CONFIG ); @@ -541,12 +548,12 @@ describe('findOrCreateUser', () => { expect((result as { error: string }).error).toBe('registration_disabled'); }); - it('OIDC-SVC-025: links oidc_sub when existing user has none (verified email)', () => { + it('OIDC-SVC-025: links oidc_sub when existing user has none (verified email)', async () => { const { user } = createUser(testDb, { email: 'charlie@example.com' }); // Ensure no oidc_sub set testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id); - svc.findOrCreateUser( + await svc.findOrCreateUser( { sub: 'sub-charlie-linked', email: 'charlie@example.com', name: 'Charlie', email_verified: true }, MOCK_CONFIG ); @@ -555,13 +562,13 @@ describe('findOrCreateUser', () => { expect(updated.oidc_sub).toBe('sub-charlie-linked'); }); - it('OIDC-SVC-025b: refuses to link an unverified email to an existing local account', () => { + it('OIDC-SVC-025b: refuses to link an unverified email to an existing local account', async () => { const { user } = createUser(testDb, { email: 'dora@example.com' }); testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id); // No email_verified claim — an IdP that lets users set arbitrary emails must // not be able to take over a pre-existing password account. - const result = svc.findOrCreateUser( + const result = await svc.findOrCreateUser( { sub: 'sub-dora-attacker', email: 'dora@example.com', name: 'Dora' }, MOCK_CONFIG ); @@ -572,7 +579,7 @@ describe('findOrCreateUser', () => { expect(updated.oidc_sub).toBeNull(); // account not linked / not hijacked }); - it('OIDC-SVC-026: existing user role is updated when OIDC claim mapping changes it', () => { + it('OIDC-SVC-026: existing user role is updated when OIDC claim mapping changes it', async () => { const { user } = createUser(testDb, { email: 'diana@example.com', role: 'user' }); // Link oidc_sub manually so the user is found by sub lookup testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') @@ -580,7 +587,7 @@ describe('findOrCreateUser', () => { process.env.OIDC_ADMIN_VALUE = 'admins'; - const result = svc.findOrCreateUser( + const result = await svc.findOrCreateUser( { sub: 'sub-diana-role', email: 'diana@example.com', name: 'Diana', groups: ['admins'] }, MOCK_CONFIG ); @@ -592,13 +599,13 @@ describe('findOrCreateUser', () => { expect(dbUser.role).toBe('admin'); }); - it('OIDC-SVC-027: new user with valid invite token increments used_count', () => { + it('OIDC-SVC-027: new user with valid invite token increments used_count', async () => { const { user: creator } = createUser(testDb, { email: 'creator@example.com' }); testDb.prepare( "INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-valid', 5, 0, ?)" ).run(creator.id); - const result = svc.findOrCreateUser( + const result = await svc.findOrCreateUser( { sub: 'sub-invite-user', email: 'invitee@example.com', name: 'Invitee' }, MOCK_CONFIG, 'tok-valid' @@ -610,13 +617,13 @@ describe('findOrCreateUser', () => { expect(token.used_count).toBe(1); }); - it('OIDC-SVC-028: new user with expired invite token is created but invite is ignored', () => { + it('OIDC-SVC-028: new user with expired invite token is created but invite is ignored', async () => { const { user: creator } = createUser(testDb, { email: 'creator2@example.com' }); testDb.prepare( "INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by) VALUES ('tok-expired', 5, 0, '2000-01-01T00:00:00.000Z', ?)" ).run(creator.id); - const result = svc.findOrCreateUser( + const result = await svc.findOrCreateUser( { sub: 'sub-expired-invite', email: 'expired-invitee@example.com', name: 'ExpiredInvitee' }, MOCK_CONFIG, 'tok-expired' @@ -632,13 +639,13 @@ describe('findOrCreateUser', () => { expect(token.used_count).toBe(0); }); - it('OIDC-SVC-029: new user with max_uses exceeded invite token is created but invite is ignored', () => { + it('OIDC-SVC-029: new user with max_uses exceeded invite token is created but invite is ignored', async () => { const { user: creator } = createUser(testDb, { email: 'creator3@example.com' }); testDb.prepare( "INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-full', 1, 1, ?)" ).run(creator.id); - const result = svc.findOrCreateUser( + const result = await svc.findOrCreateUser( { sub: 'sub-full-invite', email: 'full-invitee@example.com', name: 'FullInvitee' }, MOCK_CONFIG, 'tok-full' @@ -656,8 +663,8 @@ describe('findOrCreateUser', () => { // ── OIDC picture claim → avatar (#1399) ────────────────────────────────── - it('OIDC-SVC-040: new user stores the https picture claim as their avatar', () => { - const result = svc.findOrCreateUser( + it('OIDC-SVC-040: new user stores the https picture claim as their avatar', async () => { + const result = await svc.findOrCreateUser( { sub: 'sub-pic-1', email: 'pic1@example.com', name: 'Pic One', picture: 'https://idp.example.com/u/pic1.png' }, MOCK_CONFIG ); @@ -666,8 +673,8 @@ describe('findOrCreateUser', () => { expect(row.avatar).toBe('https://idp.example.com/u/pic1.png'); }); - it('OIDC-SVC-041: new user with a non-https picture claim stores no avatar', () => { - svc.findOrCreateUser( + it('OIDC-SVC-041: new user with a non-https picture claim stores no avatar', async () => { + await svc.findOrCreateUser( { sub: 'sub-pic-2', email: 'pic2@example.com', name: 'Pic Two', picture: 'http://idp.example.com/u/pic2.png' }, MOCK_CONFIG ); @@ -675,11 +682,11 @@ describe('findOrCreateUser', () => { expect(row.avatar).toBeNull(); }); - it('OIDC-SVC-042: existing user with no avatar gets the OIDC picture', () => { + it('OIDC-SVC-042: existing user with no avatar gets the OIDC picture', async () => { const { user } = createUser(testDb, { email: 'pic3@example.com' }); testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = NULL WHERE id = ?') .run('sub-pic-3', MOCK_CONFIG.issuer, user.id); - svc.findOrCreateUser( + await svc.findOrCreateUser( { sub: 'sub-pic-3', email: 'pic3@example.com', name: 'Pic Three', picture: 'https://idp.example.com/u/pic3.png' }, MOCK_CONFIG ); @@ -687,11 +694,11 @@ describe('findOrCreateUser', () => { expect(row.avatar).toBe('https://idp.example.com/u/pic3.png'); }); - it('OIDC-SVC-043: a custom uploaded avatar is never overwritten by the OIDC picture', () => { + it('OIDC-SVC-043: a custom uploaded avatar is never overwritten by the OIDC picture', async () => { const { user } = createUser(testDb, { email: 'pic4@example.com' }); testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?') .run('sub-pic-4', MOCK_CONFIG.issuer, 'uploaded-abc.jpg', user.id); - svc.findOrCreateUser( + await svc.findOrCreateUser( { sub: 'sub-pic-4', email: 'pic4@example.com', name: 'Pic Four', picture: 'https://idp.example.com/u/pic4.png' }, MOCK_CONFIG ); @@ -699,11 +706,11 @@ describe('findOrCreateUser', () => { expect(row.avatar).toBe('uploaded-abc.jpg'); }); - it('OIDC-SVC-044: a previously stored OIDC picture URL is refreshed on next login', () => { + it('OIDC-SVC-044: a previously stored OIDC picture URL is refreshed on next login', async () => { const { user } = createUser(testDb, { email: 'pic5@example.com' }); testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?') .run('sub-pic-5', MOCK_CONFIG.issuer, 'https://idp.example.com/u/old.png', user.id); - svc.findOrCreateUser( + await svc.findOrCreateUser( { sub: 'sub-pic-5', email: 'pic5@example.com', name: 'Pic Five', picture: 'https://idp.example.com/u/new.png' }, MOCK_CONFIG ); @@ -715,12 +722,12 @@ describe('findOrCreateUser', () => { // because the verified-email lookup finds the row, but everything the old provider put // on it used to survive: an avatar URL on a host this instance no longer talks to, and // a sub/issuer pair pinning the account to a provider that is gone. - it('OIDC-SVC-060: a provider without a picture claim clears the previous provider avatar', () => { + it('OIDC-SVC-060: a provider without a picture claim clears the previous provider avatar', async () => { const { user } = createUser(testDb, { email: 'switch1@example.com' }); testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?') .run('sub-old-1', 'https://old-idp.example.com', 'https://old-idp.example.com/u/me.png', user.id); - svc.findOrCreateUser( + await svc.findOrCreateUser( { sub: 'sub-new-1', email: 'switch1@example.com', name: 'Switcher', email_verified: true }, { ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' }, ); @@ -729,13 +736,13 @@ describe('findOrCreateUser', () => { expect(row.avatar).toBeNull(); }); - it('OIDC-SVC-061: an uploaded avatar survives the same switch', () => { + it('OIDC-SVC-061: an uploaded avatar survives the same switch', async () => { const { user } = createUser(testDb, { email: 'switch2@example.com' }); // A local upload is a bare filename, not a URL, and belongs to the user. testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?') .run('sub-old-2', 'https://old-idp.example.com', 'uploaded-abc.jpg', user.id); - svc.findOrCreateUser( + await svc.findOrCreateUser( { sub: 'sub-new-2', email: 'switch2@example.com', name: 'Switcher', email_verified: true }, { ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' }, ); @@ -744,12 +751,12 @@ describe('findOrCreateUser', () => { expect(row.avatar).toBe('uploaded-abc.jpg'); }); - it('OIDC-SVC-062: the account is relinked to the new provider sub and issuer', () => { + it('OIDC-SVC-062: the account is relinked to the new provider sub and issuer', async () => { const { user } = createUser(testDb, { email: 'switch3@example.com' }); testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') .run('sub-old-3', 'https://old-idp.example.com', user.id); - svc.findOrCreateUser( + await svc.findOrCreateUser( { sub: 'sub-new-3', email: 'switch3@example.com', name: 'Switcher', email_verified: true }, { ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' }, ); @@ -759,14 +766,14 @@ describe('findOrCreateUser', () => { expect(row.oidc_issuer).toBe('https://new-idp.example.com'); }); - it('OIDC-SVC-053: returns no_email when the email claim is missing (no throw)', () => { - const result = svc.findOrCreateUser({ sub: 'sub-no-email', name: 'No Email' }, MOCK_CONFIG); + it('OIDC-SVC-053: returns no_email when the email claim is missing (no throw)', async () => { + const result = await svc.findOrCreateUser({ sub: 'sub-no-email', name: 'No Email' }, MOCK_CONFIG); expect('error' in result).toBe(true); expect((result as { error: string }).error).toBe('no_email'); }); - it('OIDC-SVC-054: a new user is returned as the re-selected DB row, not a hand-built partial', () => { - const result = svc.findOrCreateUser( + it('OIDC-SVC-054: a new user is returned as the re-selected DB row, not a hand-built partial', async () => { + const result = await svc.findOrCreateUser( { sub: 'sub-full-row', email: 'fullrow@example.com', name: 'Full Row' }, MOCK_CONFIG ); @@ -779,14 +786,14 @@ describe('findOrCreateUser', () => { expect(user.email).toBe('fullrow@example.com'); }); - it('OIDC-SVC-045: a trip-bound invite auto-adds the new SSO user as a trip member (#1402)', () => { + it('OIDC-SVC-045: a trip-bound invite auto-adds the new SSO user as a trip member (#1402)', async () => { const { user: admin } = createUser(testDb, { role: 'admin' }); const trip = createTrip(testDb, admin.id); testDb.prepare( 'INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by, trip_id) VALUES (?, 5, 0, NULL, ?, ?)' ).run('inv-trip-join', admin.id, trip.id); - const result = svc.findOrCreateUser( + const result = await svc.findOrCreateUser( { sub: 'sub-trip-join', email: 'joiner@example.com', name: 'Joiner' }, MOCK_CONFIG, 'inv-trip-join' @@ -810,7 +817,7 @@ describe('findOrCreateUser role mapping', () => { } const storedRole = (id: number) => (testDb.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role: string }).role; - it('OIDC-SVC-063: an admin keeps the role when the configured claim is absent from the payload', () => { + it('OIDC-SVC-063: an admin keeps the role when the configured claim is absent from the payload', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'entitlements'; // A second admin row, so the #1274 last-admin guard cannot be what saves them. @@ -819,10 +826,10 @@ describe('findOrCreateUser role mapping', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { - const result = svc.findOrCreateUser( + const result = (await svc.findOrCreateUser( { sub: 'sub-keep-admin', email: 'sso-admin@example.com', groups: ['authentik Admins'] }, MOCK_CONFIG - ) as RoleResult; + )) as RoleResult; expect(result.user.role).toBe('admin'); expect(storedRole(user.id)).toBe('admin'); @@ -832,17 +839,17 @@ describe('findOrCreateUser role mapping', () => { } }); - it('OIDC-SVC-064: an absent claim does not promote a plain user either', () => { + it('OIDC-SVC-064: an absent claim does not promote a plain user either', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'entitlements'; const user = ssoUser('plain@example.com', 'sub-keep-user', 'user'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { - const result = svc.findOrCreateUser( + const result = (await svc.findOrCreateUser( { sub: 'sub-keep-user', email: 'plain@example.com', groups: ['authentik Admins'] }, MOCK_CONFIG - ) as RoleResult; + )) as RoleResult; expect(result.user.role).toBe('user'); expect(storedRole(user.id)).toBe('user'); @@ -852,46 +859,46 @@ describe('findOrCreateUser role mapping', () => { } }); - it('OIDC-SVC-065: a custom claim that does arrive still promotes, and reports the change', () => { + it('OIDC-SVC-065: a custom claim that does arrive still promotes, and reports the change', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'entitlements'; const user = ssoUser('promote@example.com', 'sub-promote', 'user'); - const result = svc.findOrCreateUser( + const result = (await svc.findOrCreateUser( { sub: 'sub-promote', email: 'promote@example.com', entitlements: ['trek-users', 'trek-admins'] }, MOCK_CONFIG - ) as RoleResult; + )) as RoleResult; expect(storedRole(user.id)).toBe('admin'); expect(result.roleChange).toEqual({ from: 'user', to: 'admin', claim: 'entitlements' }); }); - it('OIDC-SVC-066: a claim that arrives without the admin value still demotes', () => { + it('OIDC-SVC-066: a claim that arrives without the admin value still demotes', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'entitlements'; createUser(testDb, { email: 'bootstrap2@example.com', role: 'admin' }); const user = ssoUser('demote@example.com', 'sub-demote', 'admin'); - const result = svc.findOrCreateUser( + const result = (await svc.findOrCreateUser( { sub: 'sub-demote', email: 'demote@example.com', entitlements: ['trek-users'] }, MOCK_CONFIG - ) as RoleResult; + )) as RoleResult; expect(storedRole(user.id)).toBe('user'); expect(result.roleChange).toEqual({ from: 'admin', to: 'user', claim: 'entitlements' }); }); - it('OIDC-SVC-067: the only admin is still kept when the claim arrives without the admin value (#1274)', () => { + it('OIDC-SVC-067: the only admin is still kept when the claim arrives without the admin value (#1274)', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'entitlements'; const user = ssoUser('lonely@example.com', 'sub-lonely', 'admin'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { - const result = svc.findOrCreateUser( + const result = (await svc.findOrCreateUser( { sub: 'sub-lonely', email: 'lonely@example.com', entitlements: ['trek-users'] }, MOCK_CONFIG - ) as RoleResult; + )) as RoleResult; expect(storedRole(user.id)).toBe('admin'); expect(result.roleChange).toBeUndefined(); @@ -901,19 +908,19 @@ describe('findOrCreateUser role mapping', () => { } }); - it('OIDC-SVC-068: for a plain user the absent-claim warning names the claim and the keys that arrived, never their values, and fires once', () => { + it('OIDC-SVC-068: for a plain user the absent-claim warning names the claim and the keys that arrived, never their values, and fires once', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'trek_roles'; ssoUser('warned@example.com', 'sub-warned', 'user'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); // The warning is deduped per claim name for the life of the process, so this // case needs an instance that has not seen the claim yet. - const fresh = new OidcService(new DatabaseService(testDb), auth, membership); + const fresh = new OidcService(new DatabaseService(testDb), auth, membership, await createTestUnitOfWork(testDb)); try { const info = { sub: 'sub-warned', email: 'warned@example.com', groups: ['authentik Admins'] }; - fresh.findOrCreateUser(info, MOCK_CONFIG); - fresh.findOrCreateUser(info, MOCK_CONFIG); + await fresh.findOrCreateUser(info, MOCK_CONFIG); + await fresh.findOrCreateUser(info, MOCK_CONFIG); expect(warn).toHaveBeenCalledTimes(1); const line = warn.mock.calls[0][0] as string; @@ -928,17 +935,17 @@ describe('findOrCreateUser role mapping', () => { } }); - it('OIDC-SVC-069: a new account is registered with the default role when the claim is absent', () => { + it('OIDC-SVC-069: a new account is registered with the default role when the claim is absent', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'reg_entitlements'; createUser(testDb, { email: 'someone@example.com' }); // not the first user any more const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { - const result = svc.findOrCreateUser( + const result = (await svc.findOrCreateUser( { sub: 'sub-fresh', email: 'fresh@example.com', name: 'Fresh', groups: ['authentik Admins'] }, MOCK_CONFIG - ) as RoleResult; + )) as RoleResult; expect(storedRole(result.user.id)).toBe('user'); expect(result.roleChange).toBeUndefined(); @@ -948,15 +955,15 @@ describe('findOrCreateUser role mapping', () => { } }); - it('OIDC-SVC-070: a claim that already agrees with the stored role reports no change', () => { + it('OIDC-SVC-070: a claim that already agrees with the stored role reports no change', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'entitlements'; const user = ssoUser('steady@example.com', 'sub-steady', 'admin'); - const result = svc.findOrCreateUser( + const result = (await svc.findOrCreateUser( { sub: 'sub-steady', email: 'steady@example.com', entitlements: ['trek-admins'] }, MOCK_CONFIG - ) as RoleResult; + )) as RoleResult; expect(storedRole(user.id)).toBe('admin'); expect(result.roleChange).toBeUndefined(); @@ -975,7 +982,7 @@ describe('findOrCreateUser role mapping', () => { expect(svc.resolveOidcRoleDetailed({ ...info, entitlements: ['trek-admins'] }, false)).toMatchObject({ role: 'admin', claimMissing: false }); }); - it('OIDC-SVC-072: a stored admin whose claim never arrives is warned about on every login, by id, with the admin panel as the way out', () => { + it('OIDC-SVC-072: a stored admin whose claim never arrives is warned about on every login, by id, with the admin panel as the way out', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'kept_admin_roles'; createUser(testDb, { email: 'bootstrap3@example.com', role: 'admin' }); @@ -984,8 +991,8 @@ describe('findOrCreateUser role mapping', () => { try { const info = { sub: 'sub-kept-admin', email: 'kept@example.com', groups: ['authentik Admins'] }; - svc.findOrCreateUser(info, MOCK_CONFIG); - svc.findOrCreateUser(info, MOCK_CONFIG); + await svc.findOrCreateUser(info, MOCK_CONFIG); + await svc.findOrCreateUser(info, MOCK_CONFIG); // No dedup here: this is the login where an IdP-side revocation quietly fails. expect(warn).toHaveBeenCalledTimes(2); @@ -1004,19 +1011,19 @@ describe('findOrCreateUser role mapping', () => { } }); - it('OIDC-SVC-073: warning about a stored admin does not use up the once-per-claim warning for everybody else', () => { + it('OIDC-SVC-073: warning about a stored admin does not use up the once-per-claim warning for everybody else', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'shared_roles'; createUser(testDb, { email: 'bootstrap4@example.com', role: 'admin' }); ssoUser('shared-admin@example.com', 'sub-shared-admin', 'admin'); ssoUser('shared-plain@example.com', 'sub-shared-plain', 'user'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const fresh = new OidcService(new DatabaseService(testDb), auth, membership); + const fresh = new OidcService(new DatabaseService(testDb), auth, membership, await createTestUnitOfWork(testDb)); try { - fresh.findOrCreateUser({ sub: 'sub-shared-admin', email: 'shared-admin@example.com' }, MOCK_CONFIG); - fresh.findOrCreateUser({ sub: 'sub-shared-plain', email: 'shared-plain@example.com' }, MOCK_CONFIG); - fresh.findOrCreateUser({ sub: 'sub-shared-plain', email: 'shared-plain@example.com' }, MOCK_CONFIG); + await fresh.findOrCreateUser({ sub: 'sub-shared-admin', email: 'shared-admin@example.com' }, MOCK_CONFIG); + await fresh.findOrCreateUser({ sub: 'sub-shared-plain', email: 'shared-plain@example.com' }, MOCK_CONFIG); + await fresh.findOrCreateUser({ sub: 'sub-shared-plain', email: 'shared-plain@example.com' }, MOCK_CONFIG); expect(warn).toHaveBeenCalledTimes(2); expect(warn.mock.calls[0][0]).toContain('is stored as an admin'); diff --git a/server/tests/unit/nest/packing.service.test.ts b/server/tests/unit/nest/packing.service.test.ts index 22ae0c0c7..f1f130784 100644 --- a/server/tests/unit/nest/packing.service.test.ts +++ b/server/tests/unit/nest/packing.service.test.ts @@ -803,8 +803,8 @@ describe('create-path fields + bag trip scope (#2154)', () => { // ── Wrapper helpers (carried over from the old delegation suite) ────────────── describe('canEdit', () => { - it('delegates to checkPermission with packing_edit', () => { - svc.canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never); + it('delegates to checkPermission with packing_edit', async () => { + await svc.canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never); expect(checkPermission).toHaveBeenCalledWith('packing_edit', 'user', 2, 1, true); }); }); diff --git a/server/tests/unit/nest/passkey.service.test.ts b/server/tests/unit/nest/passkey.service.test.ts index e60cca554..572aae2d9 100644 --- a/server/tests/unit/nest/passkey.service.test.ts +++ b/server/tests/unit/nest/passkey.service.test.ts @@ -89,6 +89,8 @@ import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service' import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service'; import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; // MailerService is injected since the notifications fold — a stub instead of a // module mock. sendPasswordResetEmail is the only thing auth reaches for. @@ -109,17 +111,22 @@ const webauthn = { resolve: resolveWebauthnConfigMock } as unknown as WebauthnCo // undefined so a future case that does reach one gets a working object; each // takes only the DatabaseService. AuthService's own webauthn is the real // resolver, separate from the `webauthn` switch PasskeyService is handed. -const auth = new AuthService( + +let auth: AuthService; +let svc: PasskeyService; +beforeAll(async () => { + auth = new AuthService( new DatabaseService(testDb), - new PermissionsService(new DatabaseService(testDb)), + new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new TripMembershipService(new DatabaseService(testDb)), new WebauthnConfigService(new DatabaseService(testDb)), - new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new ExchangeRatesService(), new RealtimeService())), + new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService())), mailerStub, new EphemeralTokenService(), - new AllowedFileTypesService(new DatabaseService(testDb)), + new AllowedFileTypesService(new DatabaseService(testDb)), await createTestUnitOfWork(testDb), ); -const svc = new PasskeyService(new DatabaseService(testDb), auth, webauthn); + svc = new PasskeyService(new DatabaseService(testDb), auth, webauthn); +}); const CFG = { rpID: 'trek.example.com', rpName: 'TREK', origins: ['https://trek.example.com'], explicitOrigins: false }; // The unconfigured fallback resolve() yields when APP_URL is unset (#2147). diff --git a/server/tests/unit/nest/permissions.service.test.ts b/server/tests/unit/nest/permissions.service.test.ts index e38e200ab..5b57a8ac9 100644 --- a/server/tests/unit/nest/permissions.service.test.ts +++ b/server/tests/unit/nest/permissions.service.test.ts @@ -47,8 +47,13 @@ import { getPermissionsCache, invalidatePermissionsCache as invalidateSharedCache, } from '../../../src/nest/permissions/permissions-cache'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; -const svc = new PermissionsService(new DatabaseService(testDb)); +let svc: PermissionsService; +beforeAll(async () => { + svc = new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)); +}); beforeAll(() => { createTables(testDb); @@ -68,17 +73,17 @@ afterAll(() => { // ── checkPermission ─────────────────────────────────────────────────────────── describe('checkPermission — admin bypass', () => { - it('PERM-SVC-001: admin always passes regardless of permission level', () => { + it('PERM-SVC-001: admin always passes regardless of permission level', async () => { for (const action of PERMISSION_ACTIONS) { - expect(svc.checkPermission(action.key, 'admin', 1, 1, false)).toBe(true); - expect(svc.checkPermission(action.key, 'admin', 99, 1, false)).toBe(true); + expect(await svc.checkPermission(action.key, 'admin', 1, 1, false)).toBe(true); + expect(await svc.checkPermission(action.key, 'admin', 99, 1, false)).toBe(true); } }); }); describe('checkPermission — everybody level', () => { - it('PERM-SVC-002: trip_create (everybody) allows any authenticated user', () => { - expect(svc.checkPermission('trip_create', 'user', null, 42, false)).toBe(true); + it('PERM-SVC-002: trip_create (everybody) allows any authenticated user', async () => { + expect(await svc.checkPermission('trip_create', 'user', null, 42, false)).toBe(true); }); }); @@ -86,16 +91,16 @@ describe('checkPermission — trip_owner level', () => { const ownerId = 10; const memberId = 20; - it('PERM-SVC-003: trip owner passes trip_owner check', () => { - expect(svc.checkPermission('trip_delete', 'user', ownerId, ownerId, false)).toBe(true); + it('PERM-SVC-003: trip owner passes trip_owner check', async () => { + expect(await svc.checkPermission('trip_delete', 'user', ownerId, ownerId, false)).toBe(true); }); - it('PERM-SVC-004: member fails trip_owner check', () => { - expect(svc.checkPermission('trip_delete', 'user', ownerId, memberId, true)).toBe(false); + it('PERM-SVC-004: member fails trip_owner check', async () => { + expect(await svc.checkPermission('trip_delete', 'user', ownerId, memberId, true)).toBe(false); }); - it('PERM-SVC-005: non-member non-owner fails trip_owner check', () => { - expect(svc.checkPermission('trip_delete', 'user', ownerId, memberId, false)).toBe(false); + it('PERM-SVC-005: non-member non-owner fails trip_owner check', async () => { + expect(await svc.checkPermission('trip_delete', 'user', ownerId, memberId, false)).toBe(false); }); }); @@ -104,23 +109,23 @@ describe('checkPermission — trip_member level', () => { const memberId = 20; const outsiderId = 30; - it('PERM-SVC-006: trip owner passes trip_member check', () => { - expect(svc.checkPermission('day_edit', 'user', ownerId, ownerId, false)).toBe(true); + it('PERM-SVC-006: trip owner passes trip_member check', async () => { + expect(await svc.checkPermission('day_edit', 'user', ownerId, ownerId, false)).toBe(true); }); - it('PERM-SVC-007: trip member passes trip_member check', () => { - expect(svc.checkPermission('day_edit', 'user', ownerId, memberId, true)).toBe(true); + it('PERM-SVC-007: trip member passes trip_member check', async () => { + expect(await svc.checkPermission('day_edit', 'user', ownerId, memberId, true)).toBe(true); }); - it('PERM-SVC-008: outsider fails trip_member check', () => { - expect(svc.checkPermission('day_edit', 'user', ownerId, outsiderId, false)).toBe(false); + it('PERM-SVC-008: outsider fails trip_member check', async () => { + expect(await svc.checkPermission('day_edit', 'user', ownerId, outsiderId, false)).toBe(false); }); }); // ── getPermissionLevel ──────────────────────────────────────────────────────── describe('getPermissionLevel — defaults', () => { - it('PERM-SVC-009: returns default level for known actions (no DB overrides)', () => { + it('PERM-SVC-009: returns default level for known actions (no DB overrides)', async () => { const defaults: Record = { trip_create: 'everybody', trip_delete: 'trip_owner', @@ -128,28 +133,28 @@ describe('getPermissionLevel — defaults', () => { budget_edit: 'trip_member', }; for (const [key, expected] of Object.entries(defaults)) { - expect(svc.getPermissionLevel(key)).toBe(expected); + expect(await svc.getPermissionLevel(key)).toBe(expected); } }); - it('PERM-SVC-010: returns trip_owner for unknown action key', () => { - expect(svc.getPermissionLevel('nonexistent_action')).toBe('trip_owner'); + it('PERM-SVC-010: returns trip_owner for unknown action key', async () => { + expect(await svc.getPermissionLevel('nonexistent_action')).toBe('trip_owner'); }); }); // ── savePermissions ─────────────────────────────────────────────────────────── describe('savePermissions — invalid input is silently skipped', () => { - it('PERM-SVC-011: returns skipped array containing invalid action key, writes no row', () => { - const result = svc.savePermissions({ nonexistent_action: 'trip_member' }); + it('PERM-SVC-011: returns skipped array containing invalid action key, writes no row', async () => { + const result = await svc.savePermissions({ nonexistent_action: 'trip_member' }); expect(result.skipped).toContain('nonexistent_action'); const rows = testDb.prepare("SELECT key FROM app_settings WHERE key LIKE 'perm_%'").all(); expect(rows).toEqual([]); }); - it('PERM-SVC-012: returns skipped array when level is not in allowedLevels for the action', () => { + it('PERM-SVC-012: returns skipped array when level is not in allowedLevels for the action', async () => { // trip_delete only allows ['admin', 'trip_owner'], so 'trip_member' is invalid - const result = svc.savePermissions({ trip_delete: 'trip_member' }); + const result = await svc.savePermissions({ trip_delete: 'trip_member' }); expect(result.skipped).toContain('trip_delete'); const rows = testDb.prepare("SELECT key FROM app_settings WHERE key LIKE 'perm_%'").all(); expect(rows).toEqual([]); @@ -157,127 +162,129 @@ describe('savePermissions — invalid input is silently skipped', () => { }); describe('corrupt stored levels', () => { - it('PERM-SVC-013: an unrecognized stored level is ignored — every reader falls back to the default', () => { + it('PERM-SVC-013: an unrecognized stored level is ignored — every reader falls back to the default', async () => { testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'unknown_level'); testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_delete', ''); svc.invalidatePermissionsCache(); // Since the quirk fix the corrupt rows never enter the cache, so // getPermissionLevel, getAllPermissions and checkPermission agree on the // default instead of the old display-default/deny-in-check split. - expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); - expect(svc.getAllPermissions().trip_edit).toBe('trip_owner'); - expect(svc.checkPermission('trip_edit', 'user', 10, 10, false)).toBe(true); // owner passes the default - expect(svc.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(false); // member still denied - expect(svc.getPermissionLevel('trip_delete')).toBe('trip_owner'); + expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); + expect((await svc.getAllPermissions()).trip_edit).toBe('trip_owner'); + expect(await svc.checkPermission('trip_edit', 'user', 10, 10, false)).toBe(true); // owner passes the default + expect(await svc.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(false); // member still denied + expect(await svc.getPermissionLevel('trip_delete')).toBe('trip_owner'); }); - it('PERM-SVC-021: a stored level outside the action\'s allowedLevels is ignored too', () => { + it('PERM-SVC-021: a stored level outside the action\'s allowedLevels is ignored too', async () => { // trip_edit only allows trip_owner/trip_member — a raw 'everybody' row must not widen it. testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'everybody'); svc.invalidatePermissionsCache(); - expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); - expect(svc.checkPermission('trip_edit', 'user', 10, 30, false)).toBe(false); + expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); + expect(await svc.checkPermission('trip_edit', 'user', 10, 30, false)).toBe(false); }); }); describe('load failures', () => { - it('PERM-SVC-022: a missing app_settings table stays silent and serves defaults; other DB failures are logged', () => { + it('PERM-SVC-022: a missing app_settings table stays silent and serves defaults; other DB failures are logged', async () => { const bareDb = new Database(':memory:'); - const bareSvc = new PermissionsService(new DatabaseService(bareDb)); + const bareSvc = new PermissionsService(new DatabaseService(bareDb), await createTestUnitOfWork(bareDb)); bareSvc.invalidatePermissionsCache(); - expect(bareSvc.getPermissionLevel('trip_edit')).toBe('trip_owner'); + expect(await bareSvc.getPermissionLevel('trip_edit')).toBe('trip_owner'); expect(logError).not.toHaveBeenCalled(); // An unexpected failure (closed connection) must leave a trace. bareDb.close(); bareSvc.invalidatePermissionsCache(); - expect(bareSvc.getPermissionLevel('trip_edit')).toBe('trip_owner'); + expect(await bareSvc.getPermissionLevel('trip_edit')).toBe('trip_owner'); expect(logError).toHaveBeenCalledWith(expect.stringMatching(/^Permissions load failed: /)); svc.invalidatePermissionsCache(); // don't leak the bare-DB cache to later tests }); - it('PERM-SVC-024: a failed read serves defaults without installing them, and a later read populates the cache', () => { + it('PERM-SVC-024: a failed read serves defaults without installing them, and a later read populates the cache', async () => { const failing = { all: vi.fn((): { key: string; value: string }[] => { throw new Error('database connection is closed'); }) }; - const flakySvc = new PermissionsService(failing as unknown as DatabaseService); + // The stub never opens a transaction, so the suite's own UnitOfWork is the + // honest thing to hand it. + const flakySvc = new PermissionsService(failing as unknown as DatabaseService, await createTestUnitOfWork(testDb)); flakySvc.invalidatePermissionsCache(); - expect(flakySvc.getPermissionLevel('trip_edit')).toBe('trip_owner'); + expect(await flakySvc.getPermissionLevel('trip_edit')).toBe('trip_owner'); // Nothing installed: an admin's stricter stored level would otherwise stay // invisible until somebody invalidated by hand. expect(getPermissionsCache()).toBe(null); failing.all.mockReturnValue([{ key: 'perm_trip_edit', value: 'trip_member' }]); - expect(flakySvc.getPermissionLevel('trip_edit')).toBe('trip_member'); + expect(await flakySvc.getPermissionLevel('trip_edit')).toBe('trip_member'); expect(getPermissionsCache()).not.toBe(null); svc.invalidatePermissionsCache(); // don't leak the stub cache to later tests }); - it('PERM-SVC-023: an all-skipped save writes nothing and leaves the cache untouched', () => { - expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); // prime the cache + it('PERM-SVC-023: an all-skipped save writes nothing and leaves the cache untouched', async () => { + expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); // prime the cache testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_member'); - const result = svc.savePermissions({ bogus: 'trip_member', trip_delete: 'trip_member' }); + const result = await svc.savePermissions({ bogus: 'trip_member', trip_delete: 'trip_member' }); expect(result.skipped).toEqual(['bogus', 'trip_delete']); // No valid entries → no transaction and no cache flush: the raw row above // stays invisible until an explicit invalidation. - expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); + expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); svc.invalidatePermissionsCache(); - expect(svc.getPermissionLevel('trip_edit')).toBe('trip_member'); + expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member'); }); }); // ── cache semantics (real DB) ───────────────────────────────────────────────── describe('stored overrides + cache', () => { - it('PERM-SVC-014: stored perm_ row overrides the default after invalidation', () => { + it('PERM-SVC-014: stored perm_ row overrides the default after invalidation', async () => { testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_member'); svc.invalidatePermissionsCache(); - expect(svc.getPermissionLevel('trip_edit')).toBe('trip_member'); + expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member'); // A plain member now passes what defaults to a trip_owner-only action. - expect(svc.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(true); + expect(await svc.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(true); }); - it('PERM-SVC-015: savePermissions persists the row and self-invalidates the cache', () => { + it('PERM-SVC-015: savePermissions persists the row and self-invalidates the cache', async () => { // Prime the cache with the defaults first. - expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); - const result = svc.savePermissions({ trip_edit: 'trip_member' }); + expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); + const result = await svc.savePermissions({ trip_edit: 'trip_member' }); expect(result.skipped).toEqual([]); const row = testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('perm_trip_edit') as { value: string }; expect(row.value).toBe('trip_member'); // No manual invalidation — savePermissions did it. - expect(svc.getPermissionLevel('trip_edit')).toBe('trip_member'); + expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member'); }); - it('PERM-SVC-016: the cache memoizes until invalidated', () => { - expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); + it('PERM-SVC-016: the cache memoizes until invalidated', async () => { + expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); // Raw SQL write bypasses savePermissions' self-invalidation → stale value served. testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_member'); - expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); + expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); svc.invalidatePermissionsCache(); - expect(svc.getPermissionLevel('trip_edit')).toBe('trip_member'); + expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member'); }); }); // ── module-scoped cache across instances ────────────────────────────────────── -describe('module-scoped permissions cache', () => { - const secondInstance = new PermissionsService(new DatabaseService(testDb)); +describe('module-scoped permissions cache', async () => { + const secondInstance = new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)); - it('PERM-SVC-017: checkPermission agrees across independently built instances', () => { - expect(secondInstance.checkPermission('trip_create', 'user', null, 42, false)).toBe(true); - expect(secondInstance.checkPermission('trip_delete', 'user', 10, 20, true)).toBe(false); + it('PERM-SVC-017: checkPermission agrees across independently built instances', async () => { + expect(await secondInstance.checkPermission('trip_create', 'user', null, 42, false)).toBe(true); + expect(await secondInstance.checkPermission('trip_delete', 'user', 10, 20, true)).toBe(false); }); - it('PERM-SVC-020: the cache is module-scoped — shared by every service instance', () => { + it('PERM-SVC-020: the cache is module-scoped — shared by every service instance', async () => { // Save through the DI instance; a second instance sees it immediately // (checkPermission for a plain member flips with the stored level). - svc.savePermissions({ trip_edit: 'trip_member' }); - expect(secondInstance.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(true); + await svc.savePermissions({ trip_edit: 'trip_member' }); + expect(await secondInstance.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(true); // Raw SQL write, then invalidate through permissions-cache — the plain // function backup.impl.ts calls after a restore. Both service instances // must serve the fresh value afterwards. testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_owner'); - expect(svc.getPermissionLevel('trip_edit')).toBe('trip_member'); // still cached + expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member'); // still cached invalidateSharedCache(); - expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); - expect(secondInstance.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(false); + expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); + expect(await secondInstance.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(false); }); }); diff --git a/server/tests/unit/nest/places.controller.test.ts b/server/tests/unit/nest/places.controller.test.ts index 7dfa107eb..e463bcdee 100644 --- a/server/tests/unit/nest/places.controller.test.ts +++ b/server/tests/unit/nest/places.controller.test.ts @@ -46,9 +46,9 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou // The trip 404 for this handler is TripAccessGuard's now (see // trip-access.guard.test.ts and the places e2e), so it is no longer reachable // by calling the method directly. - it('GET / lists with filters', () => { + it('GET / lists with filters', async () => { const list = vi.fn().mockReturnValue([{ id: 1 }]); - expect(new PlacesController(svc({ list } as Partial), new RuntimeEnvService(), storageStub).list(user, '5', 'beach', 'cat', 'tag')).toEqual({ places: [{ id: 1 }] }); + expect(await new PlacesController(svc({ list } as Partial), new RuntimeEnvService(), storageStub).list(user, '5', 'beach', 'cat', 'tag')).toEqual({ places: [{ id: 1 }] }); expect(list).toHaveBeenCalledWith('5', { search: 'beach', category: 'cat', tag: 'tag' }); }); @@ -64,11 +64,11 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou // The legacy 'Place name is required' 400 is gone: placeCreateRequestSchema // pins `name`, so the ZodValidationPipe rejects a nameless body before the // handler runs (see the e2e suite for the envelope it produces). - it('403 without place_edit, then creates + hooks', () => { + it('403 without place_edit, then creates + hooks', async () => { expect(thrown(() => new PlacesController(svc({ canEdit: vi.fn().mockReturnValue(false) }), new RuntimeEnvService(), storageStub).create(user, '5', { name: 'Spot' }))).toEqual({ status: 403, body: { error: 'No permission' } }); const create = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn(); const onCreated = vi.fn(); const s = svc({ create, broadcast, onCreated } as Partial); - expect(new PlacesController(s, new RuntimeEnvService(), storageStub).create(user, '5', { name: 'Spot' }, 'sock')).toEqual({ place: { id: 9 } }); + expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).create(user, '5', { name: 'Spot' }, 'sock')).toEqual({ place: { id: 9 } }); expect(broadcast).toHaveBeenCalledWith('5', 'place:created', { place: { id: 9 } }, 'sock'); expect(onCreated).toHaveBeenCalledWith('5', 9); }); @@ -89,10 +89,10 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou status: 400, body: { error: 'No matching places found in GPX file' }, }); }); - it('imports and broadcasts per place', () => { + it('imports and broadcasts per place', async () => { const broadcast = vi.fn(); const s = svc({ importGpx: vi.fn().mockReturnValue({ places: [{ id: 1 }, { id: 2 }], count: 2, skipped: 0 }), broadcast } as Partial); - expect(new PlacesController(s, new RuntimeEnvService(), storageStub).importGpx(user, '5', file, {}, 'sock')).toEqual({ places: [{ id: 1 }, { id: 2 }], count: 2, skipped: 0 }); + expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).importGpx(user, '5', file, {}, 'sock')).toEqual({ places: [{ id: 1 }, { id: 2 }], count: 2, skipped: 0 }); expect(broadcast).toHaveBeenCalledTimes(2); }); }); diff --git a/server/tests/unit/nest/places.directions.test.ts b/server/tests/unit/nest/places.directions.test.ts index d67e0a62c..0f8d63600 100644 --- a/server/tests/unit/nest/places.directions.test.ts +++ b/server/tests/unit/nest/places.directions.test.ts @@ -54,6 +54,8 @@ import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/plac import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; import { makeStorageFixture } from '../../helpers/storage-fixture'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; const dbs = new DatabaseService(testDb); const photoCacheStub = { removeIfUnreferenced: vi.fn() } as unknown as PlacePhotoCacheService; @@ -65,10 +67,10 @@ const hit = (name: string, lat: number, lng: number) => ({ lat, lng, rating: null, website: null, phone: null, source: 'openstreetmap' as const, }); -function svc(searchNominatim: MapsService['searchNominatim']): PlacesService { +async function svc(searchNominatim: MapsService['searchNominatim']): Promise { return new PlacesService( dbs, - new PermissionsService(dbs), + new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new RealtimeService(), // The address backfill runs fire-and-forget after every import, so the stub answers // it too — otherwise every passing test prints a rejected promise. @@ -90,7 +92,7 @@ function svc(searchNominatim: MapsService['searchNominatim']): PlacesService { photoCacheStub, new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)), storageFx.storage, - accommodationsOver(dbs), + await accommodationsOver(dbs), await createTestUnitOfWork(dbs.connection), ); } @@ -124,7 +126,7 @@ describe('PlacesService.importGoogleDirections', () => { const search = geocoder(); const url = 'https://www.google.com/maps/dir/Berlin/Dresden/@51.5,13.5,8z/' + 'data=!4m14!1m5!1m1!1s0x0:0x0!2m2!1d13.404954!2d52.520008!1m5!1m1!1s0x0:0x0!2m2!1d13.737262!2d51.050409'; - const result = await svc(search).importGoogleDirections(tripId, url); + const result = await (await svc(search)).importGoogleDirections(tripId, url); expect('error' in result).toBe(false); if ('error' in result) return; @@ -136,7 +138,7 @@ describe('PlacesService.importGoogleDirections', () => { it('PLACES-DIR-002: a stop that is only a name is geocoded, and keeps the name from the link', async () => { const search = geocoder(); - const result = await svc(search).importGoogleDirections( + const result = await (await svc(search)).importGoogleDirections( tripId, 'https://www.google.com/maps/dir/Berlin/Dresden/Prague', ); @@ -149,7 +151,7 @@ describe('PlacesService.importGoogleDirections', () => { it('PLACES-DIR-003: a stop nobody can place is left out, not made up, and is counted', async () => { const search = geocoder(); - const result = await svc(search).importGoogleDirections( + const result = await (await svc(search)).importGoogleDirections( tripId, 'https://www.google.com/maps/dir/Berlin/Somewhere+Nobody+Knows/Prague', ); @@ -167,7 +169,7 @@ describe('PlacesService.importGoogleDirections', () => { if (query === 'Dresden') throw new Error('Nominatim 429'); return [hit(query, 52.52, 13.405)]; }) as unknown as MapsService['searchNominatim']; - const result = await svc(search).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin/Dresden/Prague'); + const result = await (await svc(search)).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin/Dresden/Prague'); expect('error' in result).toBe(false); if ('error' in result) return; @@ -179,7 +181,7 @@ describe('PlacesService.importGoogleDirections', () => { createPlace(testDb, Number(tripId), { name: 'Berlin', lat: 52.520008, lng: 13.404954 }); const url = 'https://www.google.com/maps/dir/Berlin/Dresden/@51.5,13.5,8z/' + 'data=!4m14!1m5!1m1!1s0x0:0x0!2m2!1d13.404954!2d52.520008!1m5!1m1!1s0x0:0x0!2m2!1d13.737262!2d51.050409'; - const result = await svc(geocoder()).importGoogleDirections(tripId, url); + const result = await (await svc(geocoder())).importGoogleDirections(tripId, url); expect('error' in result).toBe(false); if ('error' in result) return; @@ -188,7 +190,7 @@ describe('PlacesService.importGoogleDirections', () => { }); it('PLACES-DIR-006: a link that is not Google\'s is refused after it is resolved', async () => { - const result = await svc(geocoder()).importGoogleDirections(tripId, 'https://evil.example.com/maps/dir/Berlin/Dresden'); + const result = await (await svc(geocoder())).importGoogleDirections(tripId, 'https://evil.example.com/maps/dir/Berlin/Dresden'); expect(result).toEqual({ error: 'That link is not a Google Maps link.', status: 400 }); }); @@ -196,7 +198,7 @@ describe('PlacesService.importGoogleDirections', () => { safeFetchFollow.mockResolvedValue({ url: 'https://www.google.com/maps/dir/Berlin/Dresden', } as unknown as Response); - const result = await svc(geocoder()).importGoogleDirections(tripId, 'https://maps.app.goo.gl/abc123'); + const result = await (await svc(geocoder())).importGoogleDirections(tripId, 'https://maps.app.goo.gl/abc123'); expect(safeFetchFollow).toHaveBeenCalledTimes(1); expect('error' in result).toBe(false); @@ -206,12 +208,12 @@ describe('PlacesService.importGoogleDirections', () => { it('PLACES-DIR-008: a blocked URL never reaches the parser', async () => { checkSsrf.mockResolvedValue({ allowed: false } as never); - const result = await svc(geocoder()).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin/Dresden'); + const result = await (await svc(geocoder())).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin/Dresden'); expect(result).toEqual({ error: 'URL is not allowed', status: 400 }); }); it('PLACES-DIR-009: a link with nothing to read says what to do instead', async () => { - const result = await svc(geocoder()).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin'); + const result = await (await svc(geocoder())).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin'); expect(result).toMatchObject({ status: 400 }); expect((result as { error: string }).error).toMatch(/Share button/); }); @@ -225,7 +227,7 @@ describe('PlacesService.importGoogleDirections', () => { // search queue behind it for half a minute. An index hit costs no slot. const nominatim = vi.fn(async () => [{ lat: 52.52, lng: 13.405 }]) as unknown as MapsService['searchNominatim']; const geocode = vi.fn(async () => ({ lat: 52.52, lng: 13.405 })); - const service = svc(nominatim); + const service = await svc(nominatim); (service as unknown as { maps: Partial }).maps.geocodeQuery = geocode as unknown as MapsService['geocodeQuery']; @@ -249,7 +251,7 @@ describe('PlacesService.importGoogleDirections', () => { url: 'https://www.google.com/maps/dir/52.52,13.405/51.05,13.74', } as never); - const result = await svc(geocoder()).importGoogleList(tripId, 'https://maps.app.goo.gl/aBcDeF12345'); + const result = await (await svc(geocoder())).importGoogleList(tripId, 'https://maps.app.goo.gl/aBcDeF12345'); expect('error' in result).toBe(false); if ('error' in result) return; @@ -262,7 +264,7 @@ describe('PlacesService.importGoogleDirections', () => { it('PLACES-DIR-010: a route where only one stop can be placed is not half an import', async () => { const search = vi.fn(async () => []) as unknown as MapsService['searchNominatim']; const url = 'https://www.google.com/maps/dir/52.52,13.405/Nowhere/Nowhere+Else'; - const result = await svc(search).importGoogleDirections(tripId, url); + const result = await (await svc(search)).importGoogleDirections(tripId, url); expect(result).toEqual({ error: 'None of the stops in that link could be placed on the map.', status: 400 }); }); }); diff --git a/server/tests/unit/nest/places.service.test.ts b/server/tests/unit/nest/places.service.test.ts index 0989b3712..2d1e26ca6 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -78,6 +78,8 @@ import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpe import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; import { makeStorageFixture } from '../../helpers/storage-fixture'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx'); const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml'); @@ -95,10 +97,10 @@ const dbs = new DatabaseService(testDb); */ const placesStorageFx = makeStorageFixture(''); -function makePlacesService(maps: MapsService = new MapsService(dbs, photoCacheStub)): PlacesService { +async function makePlacesService(maps: MapsService = new MapsService(dbs, photoCacheStub)): Promise { return new PlacesService( dbs, - new PermissionsService(dbs), + new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new RealtimeService(), maps, new QueryHelpersService(dbs), @@ -106,12 +108,17 @@ function makePlacesService(maps: MapsService = new MapsService(dbs, photoCacheSt photoCacheStub, new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)), placesStorageFx.storage, - accommodationsOver(dbs), + await accommodationsOver(dbs), await createTestUnitOfWork(dbs.connection), ); } -const accommodations = accommodationsOver(dbs); -const svc = makePlacesService(); + +let accommodations: Awaited>; +let svc: Awaited>; +beforeAll(async () => { + accommodations = await accommodationsOver(dbs); + svc = await makePlacesService(); +}); beforeAll(() => { createTables(testDb); @@ -129,59 +136,59 @@ afterAll(() => { // ── list ────────────────────────────────────────────────────────────────────── describe('list', () => { - it('PLACE-SVC-001 — returns empty array when trip has no places', () => { + it('PLACE-SVC-001 — returns empty array when trip has no places', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - expect(svc.list(String(trip.id), {})).toEqual([]); + expect(await svc.list(String(trip.id), {})).toEqual([]); }); - it('PLACE-SVC-002 — returns all places for a trip', () => { + it('PLACE-SVC-002 — returns all places for a trip', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); createPlace(testDb, trip.id, { name: 'Alpha' }); createPlace(testDb, trip.id, { name: 'Beta' }); - const places = svc.list(String(trip.id), {}) as any[]; + const places = (await svc.list(String(trip.id), {})) as any[]; expect(places).toHaveLength(2); }); - it('PLACE-SVC-003 — does not return places from other trips', () => { + it('PLACE-SVC-003 — does not return places from other trips', async () => { const { user } = createUser(testDb); const t1 = createTrip(testDb, user.id); const t2 = createTrip(testDb, user.id); createPlace(testDb, t1.id, { name: 'T1 Place' }); createPlace(testDb, t2.id, { name: 'T2 Place' }); - const places = svc.list(String(t1.id), {}) as any[]; + const places = (await svc.list(String(t1.id), {})) as any[]; expect(places).toHaveLength(1); expect(places[0].name).toBe('T1 Place'); }); - it('PLACE-SVC-004 — filters by search term (name)', () => { + it('PLACE-SVC-004 — filters by search term (name)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); createPlace(testDb, trip.id, { name: 'Louvre Museum' }); - const places = svc.list(String(trip.id), { search: 'Eiffel' }) as any[]; + const places = (await svc.list(String(trip.id), { search: 'Eiffel' })) as any[]; expect(places).toHaveLength(1); expect(places[0].name).toBe('Eiffel Tower'); }); - it('PLACE-SVC-005 — attaches tags array to each place (empty when none)', () => { + it('PLACE-SVC-005 — attaches tags array to each place (empty when none)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); createPlace(testDb, trip.id, { name: 'No Tags' }); - const places = svc.list(String(trip.id), {}) as any[]; + const places = (await svc.list(String(trip.id), {})) as any[]; expect(Array.isArray(places[0].tags)).toBe(true); expect(places[0].tags).toHaveLength(0); }); - it('PLACE-SVC-006 — attaches category object when place has a category', () => { + it('PLACE-SVC-006 — attaches category object when place has a category', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const cat = createCategory(testDb, { name: 'Museum', user_id: user.id }) as any; const place = createPlace(testDb, trip.id, { name: 'Art Museum' }) as any; testDb.prepare('UPDATE places SET category_id = ? WHERE id = ?').run(cat.id, place.id); - const places = svc.list(String(trip.id), {}) as any[]; + const places = (await svc.list(String(trip.id), {})) as any[]; expect(places[0].category).toBeDefined(); expect(places[0].category!.name).toBe('Museum'); }); @@ -422,7 +429,7 @@ describe('remove', () => { const p1 = createPlace(testDb, trip.id, { name: 'Keep' }) as any; const p2 = createPlace(testDb, trip.id, { name: 'Remove' }) as any; await svc.remove(String(trip.id), String(p2.id)); - const remaining = svc.list(String(trip.id), {}) as any[]; + const remaining = (await svc.list(String(trip.id), {})) as any[]; expect(remaining).toHaveLength(1); expect(remaining[0].id).toBe(p1.id); }); @@ -436,9 +443,9 @@ describe('remove', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }) as any; - const { accommodation } = accommodations.createAccommodation(trip.id, { + const { accommodation } = (await accommodations.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, - }) as any; + })) as any; expect(testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accommodation.id)).toBeTruthy(); await svc.remove(String(trip.id), String(place.id)); @@ -454,9 +461,9 @@ describe('remove', () => { const day = createDay(testDb, trip.id); const hotel = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }) as any; const museum = createPlace(testDb, trip.id, { name: 'Pergamon' }) as any; - const { accommodation } = accommodations.createAccommodation(trip.id, { + const { accommodation } = (await accommodations.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, - }) as any; + })) as any; await svc.remove(String(trip.id), String(museum.id)); @@ -545,9 +552,9 @@ describe('removeMany', () => { const hotel = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }) as any; // Booked through the accommodations domain, so it gets its partner hotel // reservation the way the booking form writes one. - const { accommodation } = accommodations.createAccommodation(trip.id, { + const { accommodation } = (await accommodations.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, - }) as { accommodation: { id: number } }; + })) as { accommodation: { id: number } }; const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accommodation.id) as { id: number }; // An expense hung off the reservation rather than the place: linkedExpenseIds // selects on budget_items.place_id and never finds this one. @@ -972,7 +979,7 @@ describe('searchImage', () => { // ── Import deduplication ────────────────────────────────────────────────────── describe('importGpx deduplication', () => { - it('PLACE-SVC-033 — skips waypoints already in trip by name', () => { + it('PLACE-SVC-033 — skips waypoints already in trip by name', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const buf = fs.readFileSync(GPX_FIXTURE); @@ -987,11 +994,11 @@ describe('importGpx deduplication', () => { expect(second.skipped).toBe(first.count); // Total places in DB should equal first import count - const total = (svc.list(String(trip.id), {}) as any[]).length; + const total = ((await svc.list(String(trip.id), {})) as any[]).length; expect(total).toBe(first.count); }); - it('PLACE-SVC-034 — imports new places while skipping existing ones', () => { + it('PLACE-SVC-034 — imports new places while skipping existing ones', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const buf = fs.readFileSync(GPX_FIXTURE); @@ -1004,7 +1011,7 @@ describe('importGpx deduplication', () => { const second = svc.importGpx(String(trip.id), buf) as any; expect(second.count).toBe(0); - const total = (svc.list(String(trip.id), {}) as any[]).length; + const total = ((await svc.list(String(trip.id), {})) as any[]).length; expect(total).toBe(first.count + 1); }); }); @@ -1279,14 +1286,14 @@ describe('enrichImportedPlaces', () => { it('PLACE-SVC-058 — no-ops when no Google Maps key is configured', async () => { const searchPlaces = vi.fn(); - const svcNoKey = enrichSvc({ getMapsKey: vi.fn(() => null), searchPlaces }); + const svcNoKey = await enrichSvc({ getMapsKey: vi.fn(() => null), searchPlaces }); await svcNoKey.enrichImportedPlaces('1', 1, [{ id: 1, name: 'A', lat: 1, lng: 2 }]); expect(searchPlaces).not.toHaveBeenCalled(); }); it('PLACE-SVC-059 — no-ops for an empty batch without touching the provider', async () => { const getMapsKey = vi.fn(() => 'key'); - await enrichSvc({ getMapsKey }).enrichImportedPlaces('1', 1, []); + await (await enrichSvc({ getMapsKey })).enrichImportedPlaces('1', 1, []); expect(getMapsKey).not.toHaveBeenCalled(); }); @@ -1297,7 +1304,7 @@ describe('enrichImportedPlaces', () => { // An address the import already captured must survive the COALESCE. testDb.prepare('UPDATE places SET address = ? WHERE id = ?').run('Imported address', place.id); - const svcWithMaps = enrichSvc({ + const svcWithMaps = await enrichSvc({ getMapsKey: vi.fn(() => 'key'), searchPlaces: vi.fn(async () => ({ source: 'google', @@ -1322,7 +1329,7 @@ describe('enrichImportedPlaces', () => { const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any; - const svcWithMaps = enrichSvc({ + const svcWithMaps = await enrichSvc({ getMapsKey: vi.fn(() => 'key'), // ~1.2 km away — beyond MATCH_RADIUS_METERS. searchPlaces: vi.fn(async () => ({ source: 'google', places: [{ google_place_id: 'ChIJfar', lat: 48.86, lng: 2.36 }] })), @@ -1339,7 +1346,7 @@ describe('enrichImportedPlaces', () => { const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any; - const svcWithMaps = enrichSvc({ + const svcWithMaps = await enrichSvc({ getMapsKey: vi.fn(() => 'key'), searchPlaces: vi.fn(async () => ({ source: 'google', places: [{ google_place_id: 'ChIJ1', lat: 48.85, lng: 2.35 }] })), getPlacePhoto: vi.fn(async () => { throw new Error('provider down'); }), @@ -1356,7 +1363,7 @@ describe('enrichImportedPlaces', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const err = vi.spyOn(console, 'error').mockImplementation(() => {}); - const svcWithMaps = enrichSvc({ + const svcWithMaps = await enrichSvc({ getMapsKey: vi.fn(() => 'key'), searchPlaces: vi.fn(async () => { throw new Error('lookup exploded'); }), } as never); @@ -1370,7 +1377,7 @@ describe('enrichImportedPlaces', () => { it('PLACE-SVC-064 — skips a place that is already linked or has no coordinates', async () => { const searchPlaces = vi.fn(); - const svcWithMaps = enrichSvc({ getMapsKey: vi.fn(() => 'key'), searchPlaces } as never); + const svcWithMaps = await enrichSvc({ getMapsKey: vi.fn(() => 'key'), searchPlaces } as never); await svcWithMaps.enrichImportedPlaces('1', 1, [ { id: 1, name: 'Linked', lat: 1, lng: 2, google_place_id: 'ChIJalready' }, { id: 2, name: 'Coordless', lat: null as never, lng: null as never }, @@ -1440,7 +1447,7 @@ describe('zero-valued numeric fields', () => { // ── LIKE metacharacter escaping (#1745) ─────────────────────────────────────── describe('list search escaping', () => { - it('PLACE-SVC-068 — a % or _ in the search term matches literally, not as a wildcard', () => { + it('PLACE-SVC-068 — a % or _ in the search term matches literally, not as a wildcard', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); createPlace(testDb, trip.id, { name: '50% off shop' }); @@ -1449,17 +1456,17 @@ describe('list search escaping', () => { createPlace(testDb, trip.id, { name: 'axb cafe' }); // '%' used to match every row. - expect((svc.list(String(trip.id), { search: '%' }) as any[]).map(p => p.name)).toEqual(['50% off shop']); + expect(((await svc.list(String(trip.id), { search: '%' })) as any[]).map(p => p.name)).toEqual(['50% off shop']); // '_' used to match any single character. - expect((svc.list(String(trip.id), { search: 'a_b' }) as any[]).map(p => p.name)).toEqual(['a_b cafe']); + expect(((await svc.list(String(trip.id), { search: 'a_b' })) as any[]).map(p => p.name)).toEqual(['a_b cafe']); }); - it('PLACE-SVC-069 — ordinary search terms are unaffected', () => { + it('PLACE-SVC-069 — ordinary search terms are unaffected', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); createPlace(testDb, trip.id, { name: 'Louvre' }); - expect((svc.list(String(trip.id), { search: 'eiff' }) as any[]).map(p => p.name)).toEqual(['Eiffel Tower']); + expect(((await svc.list(String(trip.id), { search: 'eiff' })) as any[]).map(p => p.name)).toEqual(['Eiffel Tower']); }); }); @@ -1606,7 +1613,7 @@ describe('backfillMissingAddresses', () => { const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any; const reverseGeocode = vi.fn(async () => ({ name: null, address: '1 Rue de Rivoli, Paris' })); - await backfillSvc(reverseGeocode).backfillMissingAddresses(String(trip.id), [ + await (await backfillSvc(reverseGeocode)).backfillMissingAddresses(String(trip.id), [ { id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 }, ]); @@ -1622,7 +1629,7 @@ describe('backfillMissingAddresses', () => { testDb.prepare('UPDATE places SET address = ? WHERE id = ?').run('Imported address', place.id); const reverseGeocode = vi.fn(async () => ({ name: null, address: 'Nominatim address' })); - await backfillSvc(reverseGeocode).backfillMissingAddresses(String(trip.id), [ + await (await backfillSvc(reverseGeocode)).backfillMissingAddresses(String(trip.id), [ { id: place.id, name: 'Bar', lat: 48.85, lng: 2.35, address: 'Imported address' }, ]); @@ -1636,7 +1643,7 @@ describe('backfillMissingAddresses', () => { const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any; - await backfillSvc(vi.fn(async () => ({ name: null, address: null }))).backfillMissingAddresses(String(trip.id), [ + await (await backfillSvc(vi.fn(async () => ({ name: null, address: null })))).backfillMissingAddresses(String(trip.id), [ { id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 }, ]); @@ -1655,7 +1662,7 @@ describe('backfillMissingAddresses', () => { .mockResolvedValueOnce({ name: null, address: 'Second address' }); await expect( - backfillSvc(reverseGeocode as unknown as MapsService['reverseGeocode']).backfillMissingAddresses(String(trip.id), [ + (await backfillSvc(reverseGeocode as unknown as MapsService['reverseGeocode'])).backfillMissingAddresses(String(trip.id), [ { id: first.id, name: 'A', lat: 1, lng: 2 }, { id: second.id, name: 'B', lat: 3, lng: 4 }, ]), @@ -1673,13 +1680,13 @@ describe('backfillMissingAddresses', () => { const batch = Array.from({ length: ADDRESS_BACKFILL_MAX_PLACES + 1 }, (_, i) => ({ id: i + 1, name: `P${i}`, lat: 1, lng: 2, })); - await backfillSvc(reverseGeocode as unknown as MapsService['reverseGeocode']).backfillMissingAddresses('1', batch); + await (await backfillSvc(reverseGeocode as unknown as MapsService['reverseGeocode'])).backfillMissingAddresses('1', batch); expect(reverseGeocode).not.toHaveBeenCalled(); }); it('PLACE-SVC-083 — a place without coordinates is skipped', async () => { const reverseGeocode = vi.fn(); - await backfillSvc(reverseGeocode as unknown as MapsService['reverseGeocode']).backfillMissingAddresses('1', [ + await (await backfillSvc(reverseGeocode as unknown as MapsService['reverseGeocode'])).backfillMissingAddresses('1', [ { id: 1, name: 'A', lat: null as unknown as number, lng: null as unknown as number }, ]); expect(reverseGeocode).not.toHaveBeenCalled(); diff --git a/server/tests/unit/nest/public-api-scopes.test.ts b/server/tests/unit/nest/public-api-scopes.test.ts index 8074c3dee..e8faa147f 100644 --- a/server/tests/unit/nest/public-api-scopes.test.ts +++ b/server/tests/unit/nest/public-api-scopes.test.ts @@ -265,20 +265,20 @@ describe('ApiTokenGuard — the grant it leaves behind', () => { // --------------------------------------------------------------------------- describe('PublicApiController — what a narrowed key reaches', () => { - it('PUBAPI-SCOPE-U020: an un-narrowed key reads every route, exactly as before', () => { + it('PUBAPI-SCOPE-U020: an un-narrowed key reads every route, exactly as before', async () => { const listTrips = vi.fn().mockReturnValue([TRIP]); const listBucketList = vi.fn().mockReturnValue([]); const getTrip = vi.fn().mockReturnValue(TRIP); const ctl = apiController({ listTrips, listBucketList, getTrip }); - expect(ctl.listTrips(req(ALL))).toEqual({ trips: [TRIP] }); + expect(await ctl.listTrips(req(ALL))).toEqual({ trips: [TRIP] }); expect(ctl.listBucketList(req(ALL))).toEqual({ items: [] }); expect(ctl.getTrip(req(ALL), '12', undefined)).toEqual(TRIP); expect(getTrip).toHaveBeenCalledWith(12, 7, [...PUBLIC_API_INCLUDES], [...PUBLIC_API_SCOPES]); }); - it('PUBAPI-SCOPE-U021: a trips-only key reads the trip list', () => { + it('PUBAPI-SCOPE-U021: a trips-only key reads the trip list', async () => { const listTrips = vi.fn().mockReturnValue([TRIP]); - expect(apiController({ listTrips }).listTrips(req(limited('trips')))).toEqual({ trips: [TRIP] }); + expect(await apiController({ listTrips }).listTrips(req(limited('trips')))).toEqual({ trips: [TRIP] }); }); it('PUBAPI-SCOPE-U022: and is refused the bucket list before the service is ever asked', () => { diff --git a/server/tests/unit/nest/public-api.controller.test.ts b/server/tests/unit/nest/public-api.controller.test.ts index e3cd60040..8ac04788c 100644 --- a/server/tests/unit/nest/public-api.controller.test.ts +++ b/server/tests/unit/nest/public-api.controller.test.ts @@ -86,15 +86,15 @@ describe('PublicApiController', () => { }); describe('GET /api/v1/trips', () => { - it('returns the accessible trips for the token owner', () => { + it('returns the accessible trips for the token owner', async () => { const listTrips = vi.fn().mockReturnValue([TRIP]); - expect(makeController({ listTrips }).listTrips(req(7))).toEqual({ trips: [TRIP] }); + expect(await makeController({ listTrips }).listTrips(req(7))).toEqual({ trips: [TRIP] }); expect(listTrips).toHaveBeenCalledWith(7); }); - it('returns an empty list rather than 404 when the user has no trips', () => { + it('returns an empty list rather than 404 when the user has no trips', async () => { const listTrips = vi.fn().mockReturnValue([]); - expect(makeController({ listTrips }).listTrips(req(7))).toEqual({ trips: [] }); + expect(await makeController({ listTrips }).listTrips(req(7))).toEqual({ trips: [] }); }); it('401s if the guard was somehow bypassed and no user is attached', () => { diff --git a/server/tests/unit/nest/realtime.gateway.test.ts b/server/tests/unit/nest/realtime.gateway.test.ts index ee3df7cb7..3b8a5886a 100644 --- a/server/tests/unit/nest/realtime.gateway.test.ts +++ b/server/tests/unit/nest/realtime.gateway.test.ts @@ -67,10 +67,10 @@ const tokens = { consumeWithMeta } as unknown as EphemeralTokenService; const canAccessJourney = vi.fn((journeyId: number) => (journeyId === 4 ? null : { id: journeyId })); const journeys = { canAccessJourney } as unknown as JourneyDomainService; -function connect(url: string) { +async function connect(url: string) { const gw = new RealtimeGateway(db, tokens, journeys); const ws = socket(); - gw.handleConnection(ws, { url } as never); + await gw.handleConnection(ws, { url } as never); return { gw, ws }; } @@ -82,74 +82,74 @@ beforeEach(() => { }); describe('RealtimeGateway handshake', () => { - it('WSGW-001: refuses a connect with no token before touching the store', () => { - const { ws } = connect('/ws'); + it('WSGW-001: refuses a connect with no token before touching the store', async () => { + const { ws } = await connect('/ws'); expect(ws.closedWith).toEqual([4001, 'Authentication required']); expect(consumeWithMeta).not.toHaveBeenCalled(); }); - it('WSGW-002: refuses an unknown or spent token', () => { + it('WSGW-002: refuses an unknown or spent token', async () => { consumeWithMeta.mockReturnValue(null); - expect(connect('/ws?token=x').ws.closedWith).toEqual([4001, 'Invalid or expired token']); + expect((await connect('/ws?token=x')).ws.closedWith).toEqual([4001, 'Invalid or expired token']); }); - it('WSGW-003: rejects a token minted before a password change', () => { + it('WSGW-003: rejects a token minted before a password change', async () => { // The pv gate. Same close reason as an unknown token on purpose: a client // must not be able to tell a stale token from a forged one. consumeWithMeta.mockReturnValue({ userId: 3, pv: 1 }); - expect(connect('/ws?token=x').ws.closedWith).toEqual([4001, 'Invalid or expired token']); + expect((await connect('/ws?token=x')).ws.closedWith).toEqual([4001, 'Invalid or expired token']); }); - it('WSGW-004: treats a token minted without a pv as version 0', () => { + it('WSGW-004: treats a token minted without a pv as version 0', async () => { consumeWithMeta.mockReturnValue({ userId: 3 }); rows.set('user', { id: 3, email: 'm@x.test', role: 'user', mfa_enabled: 0, password_version: 0 }); - expect(connect('/ws?token=x').ws.closedWith).toBeNull(); + expect((await connect('/ws?token=x')).ws.closedWith).toBeNull(); }); - it('WSGW-005: enforces the MFA policy with its own close code', () => { + it('WSGW-005: enforces the MFA policy with its own close code', async () => { rows.set('mfa', { value: 'true' }); - expect(connect('/ws?token=x').ws.closedWith).toEqual([4403, 'MFA required']); + expect((await connect('/ws?token=x')).ws.closedWith).toEqual([4403, 'MFA required']); }); - it('WSGW-006: admits an MFA-enabled user while the policy is on', () => { + it('WSGW-006: admits an MFA-enabled user while the policy is on', async () => { rows.set('mfa', { value: 'true' }); rows.set('user', { id: 3, email: 'm@x.test', role: 'user', mfa_enabled: 1, password_version: 2 }); - expect(connect('/ws?token=x').ws.closedWith).toBeNull(); + expect((await connect('/ws?token=x')).ws.closedWith).toBeNull(); }); - it('WSGW-007: the welcome frame carries a NUMERIC socket id', () => { + it('WSGW-007: the welcome frame carries a NUMERIC socket id', async () => { // Load-bearing: the client echoes this back as X-Socket-Id and broadcast // excludes the originator with Number(excludeSid). A uuid would be NaN, // NaN === NaN is false, and every client would receive its own writes back. // Nothing throws; it shows up as drag-and-drop that jumps under the cursor. - const { ws } = connect('/ws?token=x'); + const { ws } = await connect('/ws?token=x'); const welcome = JSON.parse(ws.sent[0]) as { type: string; socketId: unknown }; expect(welcome.type).toBe('welcome'); expect(Number.isInteger(welcome.socketId)).toBe(true); }); - it('WSGW-007b: survives an upgrade request with no url, rather than throwing at it', () => { + it('WSGW-007b: survives an upgrade request with no url, rather than throwing at it', async () => { const gw = new RealtimeGateway(db, tokens, journeys); const ws = socket(); - expect(() => gw.handleConnection(ws, {} as never)).not.toThrow(); + await expect(gw.handleConnection(ws, {} as never)).resolves.not.toThrow(); expect(ws.closedWith).toEqual([4001, 'Authentication required']); }); - it('WSGW-007c: a user row with no password_version reads as version 0', () => { + it('WSGW-007c: a user row with no password_version reads as version 0', async () => { // Legacy rows predate the column. Treating a missing value as 0 is what // makes a legacy token match a legacy row instead of being refused forever. consumeWithMeta.mockReturnValue({ userId: 3, pv: 0 }); rows.set('user', { id: 3, email: 'm@x.test', role: 'user', mfa_enabled: 0 }); - expect(connect('/ws?token=x').ws.closedWith).toBeNull(); + expect((await connect('/ws?token=x')).ws.closedWith).toBeNull(); }); - it('WSGW-008: never leaks password_version past the handshake', () => { + it('WSGW-008: never leaks password_version past the handshake', async () => { // Asserted on what the socket registry HOLDS, not on the frames it sends. // The frames never carry the user object at all, so the earlier version of // this case passed with the strip deleted: it proved nothing. What matters // is that the retained identity is clean, because that object is what // getOnlineUserIds and the onlyUserId filter read. - const { ws } = connect('/ws?token=x'); + const { ws } = await connect('/ws?token=x'); const held = userOf(ws) as unknown as Record | undefined; expect(held).toBeDefined(); expect(held).not.toHaveProperty('password_version'); @@ -158,20 +158,20 @@ describe('RealtimeGateway handshake', () => { }); describe('RealtimeGateway rooms', () => { - it('WSGW-010: join refuses a trip the user cannot reach', () => { - const { gw, ws } = connect('/ws?token=x'); - expect(gw.handleJoin({ tripId: 99 }, ws)).toEqual({ type: 'error', message: 'Access denied' }); + it('WSGW-010: join refuses a trip the user cannot reach', async () => { + const { gw, ws } = await connect('/ws?token=x'); + expect(await gw.handleJoin({ tripId: 99 }, ws)).toEqual({ type: 'error', message: 'Access denied' }); }); - it('WSGW-011: join and leave answer flat, keyed by type', () => { - const { gw, ws } = connect('/ws?token=x'); - expect(gw.handleJoin({ tripId: 7 }, ws)).toEqual({ type: 'joined', tripId: 7 }); + it('WSGW-011: join and leave answer flat, keyed by type', async () => { + const { gw, ws } = await connect('/ws?token=x'); + expect(await gw.handleJoin({ tripId: 7 }, ws)).toEqual({ type: 'joined', tripId: 7 }); expect(gw.handleLeave({ tripId: 7 }, ws)).toEqual({ type: 'left', tripId: 7 }); }); - it('WSGW-012: a frame with no tripId is ignored rather than answered', () => { - const { gw, ws } = connect('/ws?token=x'); - expect(gw.handleJoin({}, ws)).toBeUndefined(); + it('WSGW-012: a frame with no tripId is ignored rather than answered', async () => { + const { gw, ws } = await connect('/ws?token=x'); + expect(await gw.handleJoin({}, ws)).toBeUndefined(); expect(gw.handleLeave({}, ws)).toBeUndefined(); }); }); @@ -218,8 +218,8 @@ describe('RealtimeGateway heartbeat', () => { } }); - it('WSGW-022: a pong marks the socket live again', () => { - const { ws } = connect('/ws?token=x'); + it('WSGW-022: a pong marks the socket live again', async () => { + const { ws } = await connect('/ws?token=x'); const pong = (ws.on as unknown as { mock: { calls: [string, () => void][] } }).mock.calls .find(([event]) => event === 'pong'); expect(pong).toBeDefined(); @@ -313,36 +313,36 @@ describe('ws-state fan-out', () => { describe('book rooms', () => { let nextJourney = 100; - function joined(journeyId: number) { + async function joined(journeyId: number) { const gw = new RealtimeGateway(db, tokens, journeys); const ws = socket(); registerSocket(ws, { id: 3, username: 'm' } as User); - const reply = gw.handleBookJoin({ journeyId }, ws); + const reply = await gw.handleBookJoin({ journeyId }, ws); return { gw, ws, reply }; } - it('WSGW-BOOK-001: admits a socket to a journey it may see', () => { + it('WSGW-BOOK-001: admits a socket to a journey it may see', async () => { const j = nextJourney++; - const { reply } = joined(j); + const { reply } = await joined(j); expect(reply).toEqual({ type: 'book:joined', journeyId: j }); expect(bookPeers(j).map(p => p.userId)).toEqual([3]); }); /* Same shape as the trip room's refusal, and for the same reason. */ - it('WSGW-BOOK-002: refuses a journey the user cannot see, and adds nobody', () => { + it('WSGW-BOOK-002: refuses a journey the user cannot see, and adds nobody', async () => { const gw = new RealtimeGateway(db, tokens, journeys); const ws = socket(); registerSocket(ws, { id: 3, username: 'm' } as User); - expect(gw.handleBookJoin({ journeyId: 4 }, ws)).toEqual({ type: 'error', message: 'Access denied' }); + expect(await gw.handleBookJoin({ journeyId: 4 }, ws)).toEqual({ type: 'error', message: 'Access denied' }); expect(bookPeers(4)).toEqual([]); }); - it('WSGW-BOOK-003: tells everyone in the book who is in it', () => { + it('WSGW-BOOK-003: tells everyone in the book who is in it', async () => { const j = nextJourney++; - const { ws: first } = joined(j); + const { ws: first } = await joined(j); first.sent.length = 0; - joined(j); + await joined(j); const peers = first.sent.map(raw => JSON.parse(raw)).filter(m => m.type === 'journey:book:peers'); expect(peers).toHaveLength(1); @@ -350,9 +350,9 @@ describe('book rooms', () => { expect(peers[0].journeyId).toBe(j); }); - it('WSGW-BOOK-004: leaving empties the room and says so', () => { + it('WSGW-BOOK-004: leaving empties the room and says so', async () => { const j = nextJourney++; - const { gw, ws } = joined(j); + const { gw, ws } = await joined(j); expect(gw.handleBookLeave({ journeyId: j }, ws)).toEqual({ type: 'book:left', journeyId: j }); expect(bookPeers(j)).toEqual([]); }); @@ -361,10 +361,10 @@ describe('book rooms', () => { * The one that leaves a ghost if it is missed: a closed tab whose arrow stays * on everyone else's page, belonging to nobody. */ - it('WSGW-BOOK-005: a dropped connection leaves the book too', () => { + it('WSGW-BOOK-005: a dropped connection leaves the book too', async () => { const j = nextJourney++; - const { gw, ws } = joined(j); - const other = joined(j); + const { gw, ws } = await joined(j); + const other = await joined(j); other.ws.sent.length = 0; expect(bookPeers(j)).toHaveLength(2); @@ -379,15 +379,15 @@ describe('book rooms', () => { describe('book pointers', () => { let nextJourney = 200; - function pair() { + async function pair() { const journeyId = nextJourney++; const gw = new RealtimeGateway(db, tokens, journeys); const mine = socket(); const theirs = socket(); registerSocket(mine, { id: 3, username: 'm' } as User); registerSocket(theirs, { id: 4, username: 'other' } as User); - gw.handleBookJoin({ journeyId }, mine); - gw.handleBookJoin({ journeyId }, theirs); + await gw.handleBookJoin({ journeyId }, mine); + await gw.handleBookJoin({ journeyId }, theirs); mine.sent.length = 0; theirs.sent.length = 0; return { gw, mine, theirs, journeyId }; @@ -396,8 +396,8 @@ describe('book pointers', () => { const cursorsIn = (ws: FakeSocket) => ws.sent.map(raw => JSON.parse(raw)).filter(m => m.type === 'journey:book:cursor'); - it('WSGW-CUR-001: forwards a pointer to the others, not back to the sender', () => { - const { gw, mine, theirs, journeyId } = pair(); + it('WSGW-CUR-001: forwards a pointer to the others, not back to the sender', async () => { + const { gw, mine, theirs, journeyId } = await pair(); gw.handleBookCursor({ journeyId, spreadIndex: 2, x: 105.5, y: 60 }, mine); expect(cursorsIn(mine)).toEqual([]); @@ -410,8 +410,8 @@ describe('book pointers', () => { * this path — it runs ten times a second — so a socket that never joined has * to reach nobody. */ - it('WSGW-CUR-002: a socket that never joined reaches nobody', () => { - const { gw, theirs, journeyId } = pair(); + it('WSGW-CUR-002: a socket that never joined reaches nobody', async () => { + const { gw, theirs, journeyId } = await pair(); const stranger = socket(); registerSocket(stranger, { id: 5, username: 'x' } as User); @@ -419,14 +419,14 @@ describe('book pointers', () => { expect(cursorsIn(theirs)).toEqual([]); }); - it('WSGW-CUR-003: a pointer leaving the page travels as null', () => { - const { gw, mine, theirs, journeyId } = pair(); + it('WSGW-CUR-003: a pointer leaving the page travels as null', async () => { + const { gw, mine, theirs, journeyId } = await pair(); gw.handleBookCursor({ journeyId, spreadIndex: 0, x: null, y: null }, mine); expect(cursorsIn(theirs)[0]).toMatchObject({ x: null, y: null }); }); - it('WSGW-CUR-004: refuses nonsense coordinates rather than passing them on', () => { - const { gw, mine, theirs, journeyId } = pair(); + it('WSGW-CUR-004: refuses nonsense coordinates rather than passing them on', async () => { + const { gw, mine, theirs, journeyId } = await pair(); gw.handleBookCursor({ journeyId, spreadIndex: -4, x: Number.NaN, y: Infinity }, mine); expect(cursorsIn(theirs)[0]).toMatchObject({ spreadIndex: 0, x: null, y: null }); @@ -445,30 +445,30 @@ describe('book pointers', () => { describe('book messages that are refused', () => { let nextJourney = 300; - it('WSGW-BOOK-006: a join without a journey id is ignored', () => { + it('WSGW-BOOK-006: a join without a journey id is ignored', async () => { const gw = new RealtimeGateway(db, tokens, journeys); const ws = socket(); registerSocket(ws, { id: 3, username: 'm' } as User); - expect(gw.handleBookJoin({}, ws)).toBeUndefined(); + expect(await gw.handleBookJoin({}, ws)).toBeUndefined(); expect(ws.sent).toEqual([]); }); /* An unauthenticated socket never got as far as the registry. */ - it('WSGW-BOOK-007: a join from a socket with no user is ignored', () => { + it('WSGW-BOOK-007: a join from a socket with no user is ignored', async () => { const gw = new RealtimeGateway(db, tokens, journeys); const j = nextJourney++; - expect(gw.handleBookJoin({ journeyId: j }, socket())).toBeUndefined(); + expect(await gw.handleBookJoin({ journeyId: j }, socket())).toBeUndefined(); expect(bookPeers(j)).toEqual([]); }); - it('WSGW-BOOK-008: a journey id that is not a number is refused, not joined', () => { + it('WSGW-BOOK-008: a journey id that is not a number is refused, not joined', async () => { const gw = new RealtimeGateway(db, tokens, journeys); const ws = socket(); registerSocket(ws, { id: 3, username: 'm' } as User); - expect(gw.handleBookJoin({ journeyId: 'not-a-journey' }, ws)) + expect(await gw.handleBookJoin({ journeyId: 'not-a-journey' }, ws)) .toEqual({ type: 'error', message: 'Access denied' }); }); @@ -491,27 +491,27 @@ describe('book messages that are refused', () => { expect(bookPeers(j)).toEqual([]); }); - it('WSGW-CUR-005: a pointer without a journey id reaches nobody', () => { + it('WSGW-CUR-005: a pointer without a journey id reaches nobody', async () => { const gw = new RealtimeGateway(db, tokens, journeys); const mine = socket(); const theirs = socket(); registerSocket(mine, { id: 3, username: 'm' } as User); registerSocket(theirs, { id: 4, username: 'o' } as User); const j = nextJourney++; - gw.handleBookJoin({ journeyId: j }, mine); - gw.handleBookJoin({ journeyId: j }, theirs); + await gw.handleBookJoin({ journeyId: j }, mine); + await gw.handleBookJoin({ journeyId: j }, theirs); theirs.sent.length = 0; expect(gw.handleBookCursor({ x: 1, y: 1 }, mine)).toBeUndefined(); expect(theirs.sent).toEqual([]); }); - it('WSGW-CUR-006: a pointer from a socket with no user reaches nobody', () => { + it('WSGW-CUR-006: a pointer from a socket with no user reaches nobody', async () => { const gw = new RealtimeGateway(db, tokens, journeys); const theirs = socket(); registerSocket(theirs, { id: 4, username: 'o' } as User); const j = nextJourney++; - gw.handleBookJoin({ journeyId: j }, theirs); + await gw.handleBookJoin({ journeyId: j }, theirs); theirs.sent.length = 0; expect(gw.handleBookCursor({ journeyId: j, x: 1, y: 1 }, socket())).toBeUndefined(); diff --git a/server/tests/unit/nest/reservations.controller.test.ts b/server/tests/unit/nest/reservations.controller.test.ts index bd21888d2..07156372b 100644 --- a/server/tests/unit/nest/reservations.controller.test.ts +++ b/server/tests/unit/nest/reservations.controller.test.ts @@ -46,12 +46,12 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese // The bespoke 'Title is required' 400 moved to the global ZodValidationPipe // (ReservationCreateDto) — covered by the e2e suite. - it('creates, runs budget sync, broadcasts accommodation + reservation, notifies', () => { + it('creates, runs budget sync, broadcasts accommodation + reservation, notifies', async () => { const create = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationCreated: true }); const broadcast = vi.fn(); const syncBudgetOnCreate = vi.fn(); const notifyBookingChange = vi.fn(); const svc = makeService({ create, broadcast, syncBudgetOnCreate, notifyBookingChange } as Partial); const body = { title: 'Hotel', type: 'lodging', create_budget_entry: { total_price: 200 } }; - expect(new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock')).toEqual({ reservation: { id: 9 } }); + expect(await new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock')).toEqual({ reservation: { id: 9 } }); expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:created', {}, 'sock'); expect(syncBudgetOnCreate).toHaveBeenCalledWith('5', 9, 'Hotel', 'lodging', { total_price: 200 }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', { reservation: { id: 9 } }, 'sock'); @@ -115,12 +115,12 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese expect(thrown(() => new ReservationsController(svc, airtrailLink).update(user, '5', '9', { title: 'X' }))).toEqual({ status: 404, body: { error: 'Reservation not found' } }); }); - it('updates, syncs budget with current fallbacks, broadcasts + notifies', () => { + it('updates, syncs budget with current fallbacks, broadcasts + notifies', async () => { const getReservation = vi.fn().mockReturnValue({ title: 'Old', type: 'lodging' }); const update = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationChanged: true }); const broadcast = vi.fn(); const syncBudgetOnUpdate = vi.fn(); const notifyBookingChange = vi.fn(); const svc = makeService({ getReservation, update, broadcast, syncBudgetOnUpdate, notifyBookingChange } as Partial); - new ReservationsController(svc, airtrailLink).update(user, '5', '9', { create_budget_entry: { total_price: 50 } }, 'sock'); + await new ReservationsController(svc, airtrailLink).update(user, '5', '9', { create_budget_entry: { total_price: 50 } }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:updated', {}, 'sock'); expect(syncBudgetOnUpdate).toHaveBeenCalledWith('5', '9', '', undefined, 'Old', 'lodging', { total_price: 50 }, 'sock'); expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Old', 'lodging'); @@ -180,11 +180,11 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese expect(thrown(() => new ReservationsController(svc, airtrailLink).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Reservation not found' } }); }); - it('broadcasts the accommodation + budget cascade then reservation:deleted', () => { + it('broadcasts the accommodation + budget cascade then reservation:deleted', async () => { const remove = vi.fn().mockReturnValue({ deleted: { id: 9, title: 'Hotel', type: 'lodging', accommodation_id: 3 }, accommodationDeleted: true, deletedBudgetItemId: 7 }); const broadcast = vi.fn(); const notifyBookingChange = vi.fn(); const svc = makeService({ remove, broadcast, notifyBookingChange } as Partial); - expect(new ReservationsController(svc, airtrailLink).remove(user, '5', '9', 'sock')).toEqual({ success: true }); + expect(await new ReservationsController(svc, airtrailLink).remove(user, '5', '9', 'sock')).toEqual({ success: true }); expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:deleted', { accommodationId: 3 }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 9 }, 'sock'); diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index 87ff3945c..f5b9f7d5a 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -76,8 +76,13 @@ const bridge = { import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { notificationsStub } from '../../helpers/notifications'; import { makeAccommodationsService } from '../../helpers/accommodations-service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; -const svc = new ReservationsService(new DatabaseService(testDb), permissionsStub, budget as unknown as BudgetService, new RealtimeService(), notificationsStub(notif.send), new ReservationsReadRepository(new DatabaseService(testDb)), makeAccommodationsService(testDb)); +let svc: ReservationsService; +beforeAll(async () => { + svc = new ReservationsService(new DatabaseService(testDb), permissionsStub, budget as unknown as BudgetService, new RealtimeService(), notificationsStub(notif.send), new ReservationsReadRepository(new DatabaseService(testDb)), await makeAccommodationsService(testDb), await createTestUnitOfWork(testDb)); +}); beforeAll(() => { createTables(testDb); runMigrations(testDb); }); beforeEach(() => { @@ -95,15 +100,15 @@ function ownerTrip(overrides: Parameters[2] = {}) { } describe('ReservationsService (DI-native, real SQL)', () => { - it('RESV-SVC-001: canEdit delegates to checkPermission with reservation_edit', () => { - svc.canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never); + it('RESV-SVC-001: canEdit delegates to checkPermission with reservation_edit', async () => { + await svc.canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never); expect(checkPermission).toHaveBeenCalledWith('reservation_edit', 'user', 2, 1, true); }); describe('create', () => { - it('RESV-SVC-002: applies the legacy falsy-coercion defaults and re-selects the joined row', () => { + it('RESV-SVC-002: applies the legacy falsy-coercion defaults and re-selects the joined row', async () => { const { trip } = ownerTrip(); - const { reservation, accommodationCreated } = svc.create(String(trip.id), { title: 'Dinner', location: '' } as never); + const { reservation, accommodationCreated } = await svc.create(String(trip.id), { title: 'Dinner', location: '' } as never); expect(accommodationCreated).toBe(false); expect(reservation).toMatchObject({ title: 'Dinner', @@ -116,22 +121,22 @@ describe('ReservationsService (DI-native, real SQL)', () => { }); }); - it('RESV-SVC-003: derives day_id from reservation_time for non-hotel bookings', () => { + it('RESV-SVC-003: derives day_id from reservation_time for non-hotel bookings', async () => { const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' }); const day2 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(trip.id, '2030-05-02') as { id: number }; - const { reservation } = svc.create(String(trip.id), { title: 'Tour', type: 'tour', reservation_time: '2030-05-02T10:00:00' }); + const { reservation } = await svc.create(String(trip.id), { title: 'Tour', type: 'tour', reservation_time: '2030-05-02T10:00:00' }); expect(reservation.day_id).toBe(day2.id); // Out-of-range time clamps to the nearest day (create path clamps). const day3 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(trip.id, '2030-05-03') as { id: number }; - const { reservation: clamped } = svc.create(String(trip.id), { title: 'Late', type: 'tour', reservation_time: '2030-06-20T10:00:00' }); + const { reservation: clamped } = await svc.create(String(trip.id), { title: 'Late', type: 'tour', reservation_time: '2030-06-20T10:00:00' }); expect(clamped.day_id).toBe(day3.id); }); - it('RESV-SVC-004: auto-creates the accommodation for a hotel with create_accommodation', () => { + it('RESV-SVC-004: auto-creates the accommodation for a hotel with create_accommodation', async () => { const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' }); const place = createPlace(testDb, trip.id); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; - const { reservation, accommodationCreated } = svc.create(String(trip.id), { + const { reservation, accommodationCreated } = await svc.create(String(trip.id), { title: 'Hotel', type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[2].id, check_in: '15:00' }, confirmation_number: 'ABC123', @@ -144,25 +149,25 @@ describe('ReservationsService (DI-native, real SQL)', () => { expect(reservation.accommodation_name).toBe(place.name); }); - it('RESV-SVC-005: saves endpoints, skipping null-coordinate rows; sequence defaults to the post-filter index', () => { + it('RESV-SVC-005: saves endpoints, skipping null-coordinate rows; sequence defaults to the post-filter index', async () => { const { trip } = ownerTrip(); const endpoints = [ { role: 'from', name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null }, { role: 'to', name: 'NoGeo', code: null, lat: null, lng: null, timezone: null, local_time: null, local_date: null }, { role: 'to', name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null }, ]; - const { reservation } = svc.create(String(trip.id), { title: 'Bus', type: 'other', endpoints } as never); + const { reservation } = await svc.create(String(trip.id), { title: 'Bus', type: 'other', endpoints } as never); const rows = testDb.prepare('SELECT name, sequence FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence').all(reservation.id) as { name: string; sequence: number }[]; // The index fallback runs AFTER the null-coord filter, so 'B' (third on // the wire, second surviving row) gets sequence 1. expect(rows).toEqual([{ name: 'A', sequence: 0 }, { name: 'B', sequence: 1 }]); }); - it('RESV-SVC-006 (quirk fixed): metadata check-in sync keys off the resolved id, so an auto-created accommodation gets its times too', () => { + it('RESV-SVC-006 (quirk fixed): metadata check-in sync keys off the resolved id, so an auto-created accommodation gets its times too', async () => { const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' }); const place = createPlace(testDb, trip.id); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; - svc.create(String(trip.id), { + await svc.create(String(trip.id), { title: 'Hotel', type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[1].id }, metadata: { check_in_time: '16:00' }, @@ -174,35 +179,35 @@ describe('ReservationsService (DI-native, real SQL)', () => { }); describe('update', () => { - it('RESV-SVC-007: an empty-string title silently keeps the old value (COALESCE + `|| null`)', () => { + it('RESV-SVC-007: an empty-string title silently keeps the old value (COALESCE + `|| null`)', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'Old title' }); const current = svc.getReservation(String(res.id), String(trip.id))!; - const { reservation } = svc.update(String(res.id), String(trip.id), { title: '', notes: 'updated' }, current); + const { reservation } = await svc.update(String(res.id), String(trip.id), { title: '', notes: 'updated' }, current); expect(reservation.title).toBe('Old title'); expect(reservation.notes).toBe('updated'); }); - it('RESV-SVC-008: switching to hotel forces reservation_time/reservation_end_time to null', () => { + it('RESV-SVC-008: switching to hotel forces reservation_time/reservation_end_time to null', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'X', type: 'tour' }); testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2030-05-01T10:00:00', res.id); const current = svc.getReservation(String(res.id), String(trip.id))!; - const { reservation } = svc.update(String(res.id), String(trip.id), { type: 'hotel' }, current); + const { reservation } = await svc.update(String(res.id), String(trip.id), { type: 'hotel' }, current); expect(reservation.reservation_time).toBeNull(); expect(reservation.reservation_end_time).toBeNull(); }); - it('RESV-SVC-009: a stale accommodation_id pointing at a deleted row is nulled out', () => { + it('RESV-SVC-009: a stale accommodation_id pointing at a deleted row is nulled out', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' }); testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run('999', res.id); const current = svc.getReservation(String(res.id), String(trip.id))!; - const { reservation } = svc.update(String(res.id), String(trip.id), { notes: 'n' }, current); + const { reservation } = await svc.update(String(res.id), String(trip.id), { notes: 'n' }, current); expect(reservation.accommodation_id).toBeNull(); }); - it('RESV-SVC-033: a metadata payload without `price` keeps the linked expense price (and currency) from the stored metadata', () => { + it('RESV-SVC-033: a metadata payload without `price` keeps the linked expense price (and currency) from the stored metadata', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' }); const item = createBudgetItem(testDb, trip.id, { name: 'Flight', total_price: 2040 }); @@ -211,11 +216,11 @@ describe('ReservationsService (DI-native, real SQL)', () => { .run(JSON.stringify({ airline: 'CZ', seat: '72K', price: '2040', priceCurrency: 'CNY' }), res.id); const current = svc.getReservation(String(res.id), String(trip.id))!; // What the booking form sends back: rebuilt from its fields, price not among them. - const { reservation } = svc.update(String(res.id), String(trip.id), { metadata: { airline: 'CZ', seat: '12A' } }, current); + const { reservation } = await svc.update(String(res.id), String(trip.id), { metadata: { airline: 'CZ', seat: '12A' } }, current); expect(JSON.parse(reservation.metadata as string)).toEqual({ airline: 'CZ', seat: '12A', price: '2040', priceCurrency: 'CNY' }); }); - it('RESV-SVC-034: a payload that names `price` is taken at face value, set or cleared', () => { + it('RESV-SVC-034: a payload that names `price` is taken at face value, set or cleared', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'Linked', type: 'flight' }); const item = createBudgetItem(testDb, trip.id, { name: 'Linked', total_price: 100 }); @@ -223,17 +228,17 @@ describe('ReservationsService (DI-native, real SQL)', () => { testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify({ price: '100' }), res.id); let current = svc.getReservation(String(res.id), String(trip.id))!; - let { reservation } = svc.update(String(res.id), String(trip.id), { metadata: { price: '150' } }, current); + let { reservation } = await svc.update(String(res.id), String(trip.id), { metadata: { price: '150' } }, current); expect(JSON.parse(reservation.metadata as string)).toEqual({ price: '150' }); // Naming the key with a null is how a caller removes the price on purpose; // it must not be read as "the form did not mention it". current = svc.getReservation(String(res.id), String(trip.id))!; - ({ reservation } = svc.update(String(res.id), String(trip.id), { metadata: { price: null, seat: '1A' } }, current)); + ({ reservation } = await svc.update(String(res.id), String(trip.id), { metadata: { price: null, seat: '1A' } }, current)); expect(JSON.parse(reservation.metadata as string)).toEqual({ price: null, seat: '1A' }); }); - it('RESV-SVC-036: a price with no linked expense is kept too, because the importer stamps one either way', () => { + it('RESV-SVC-036: a price with no linked expense is kept too, because the importer stamps one either way', async () => { // booking-import writes metadata.price unconditionally but only creates the // linked cost when the Costs addon is on and the price is above zero, so an // instance with that addon off has bookings carrying a price and no budget @@ -244,36 +249,36 @@ describe('ReservationsService (DI-native, real SQL)', () => { .run(JSON.stringify({ price: '999', priceCurrency: 'EUR' }), res.id); const current = svc.getReservation(String(res.id), String(trip.id))!; - const { reservation } = svc.update(String(res.id), String(trip.id), { metadata: { seat: '1A' } }, current); + const { reservation } = await svc.update(String(res.id), String(trip.id), { metadata: { seat: '1A' } }, current); expect(JSON.parse(reservation.metadata as string)).toEqual({ seat: '1A', price: '999', priceCurrency: 'EUR' }); }); - it('RESV-SVC-035: metadata null still clears the stored metadata, linked expense or not', () => { + it('RESV-SVC-035: metadata null still clears the stored metadata, linked expense or not', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' }); const item = createBudgetItem(testDb, trip.id, { name: 'Flight', total_price: 50 }); testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id); testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify({ price: '50' }), res.id); const current = svc.getReservation(String(res.id), String(trip.id))!; - const { reservation } = svc.update(String(res.id), String(trip.id), { metadata: null }, current); + const { reservation } = await svc.update(String(res.id), String(trip.id), { metadata: null }, current); expect(reservation.metadata).toBeNull(); }); - it('RESV-SVC-010: endpoints [] wipes stored endpoints; an absent field leaves them alone', () => { + it('RESV-SVC-010: endpoints [] wipes stored endpoints; an absent field leaves them alone', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'Bus' }); testDb.prepare('INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, 0, ?, 1, 2)').run(res.id, 'from', 'A'); let current = svc.getReservation(String(res.id), String(trip.id))!; - svc.update(String(res.id), String(trip.id), { notes: 'no endpoints field' }, current); + await svc.update(String(res.id), String(trip.id), { notes: 'no endpoints field' }, current); expect(testDb.prepare('SELECT COUNT(*) as c FROM reservation_endpoints WHERE reservation_id = ?').get(res.id)).toEqual({ c: 1 }); current = svc.getReservation(String(res.id), String(trip.id))!; - svc.update(String(res.id), String(trip.id), { endpoints: [] }, current); + await svc.update(String(res.id), String(trip.id), { endpoints: [] }, current); expect(testDb.prepare('SELECT COUNT(*) as c FROM reservation_endpoints WHERE reservation_id = ?').get(res.id)).toEqual({ c: 0 }); }); }); describe('remove', () => { - it('RESV-SVC-011: cascades to the linked accommodation and budget item, reporting both', () => { + it('RESV-SVC-011: cascades to the linked accommodation and budget item, reporting both', async () => { const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' }); const place = createPlace(testDb, trip.id); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; @@ -283,7 +288,7 @@ describe('ReservationsService (DI-native, real SQL)', () => { const item = createBudgetItem(testDb, trip.id); testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id); - const result = svc.remove(String(res.id), String(trip.id)); + const result = await svc.remove(String(res.id), String(trip.id)); expect(result.deleted).toMatchObject({ id: res.id, title: 'Hotel', type: 'hotel' }); expect(result.accommodationDeleted).toBe(true); expect(result.deletedBudgetItemId).toBe(item.id); @@ -292,9 +297,9 @@ describe('ReservationsService (DI-native, real SQL)', () => { expect(testDb.prepare('SELECT COUNT(*) as c FROM budget_items WHERE id = ?').get(item.id)).toEqual({ c: 0 }); }); - it('RESV-SVC-012: returns the empty shape when the reservation is missing', () => { + it('RESV-SVC-012: returns the empty shape when the reservation is missing', async () => { const { trip } = ownerTrip(); - expect(svc.remove('999', String(trip.id))).toEqual({ deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null }); + expect(await svc.remove('999', String(trip.id))).toEqual({ deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null }); }); }); @@ -731,9 +736,9 @@ describe('ReservationsService — the surface the deleted bridge exposed', () => expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ day_id: days[1].id }); }); - it('RESV-BRIDGE-004: createReservation creates through the bridge', () => { + it('RESV-BRIDGE-004: createReservation creates through the bridge', async () => { const { trip } = ownerTrip(); - const { reservation } = bridge.createReservation(trip.id, { title: 'Bridge' }); + const { reservation } = await bridge.createReservation(trip.id, { title: 'Bridge' }); expect(reservation).toMatchObject({ title: 'Bridge', type: 'other' }); }); @@ -750,18 +755,18 @@ describe('ReservationsService — the surface the deleted bridge exposed', () => expect(bridge.getReservationWithJoins(res.id)).toMatchObject({ id: res.id, endpoints: [], travelers: [] }); }); - it('RESV-BRIDGE-007: updateReservation updates through the bridge', () => { + it('RESV-BRIDGE-007: updateReservation updates through the bridge', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'Old' }); const current = bridge.getReservation(res.id, trip.id)!; - const { reservation } = bridge.updateReservation(res.id, trip.id, { title: 'New' }, current); + const { reservation } = await bridge.updateReservation(res.id, trip.id, { title: 'New' }, current); expect(reservation.title).toBe('New'); }); - it('RESV-BRIDGE-008: deleteReservation deletes through the bridge', () => { + it('RESV-BRIDGE-008: deleteReservation deletes through the bridge', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id); - const { deleted } = bridge.deleteReservation(res.id, trip.id); + const { deleted } = await bridge.deleteReservation(res.id, trip.id); expect(deleted).toMatchObject({ id: res.id }); expect(bridge.getReservation(res.id, trip.id)).toBeUndefined(); }); @@ -774,12 +779,12 @@ describe('ReservationsService — the surface the deleted bridge exposed', () => }); describe('ReservationsService — legacy branch parity (coverage of the folded conditionals)', () => { - it('RESV-SVC-019: create with a linked accommodation_id syncs metadata times + confirmation onto it', () => { + it('RESV-SVC-019: create with a linked accommodation_id syncs metadata times + confirmation onto it', async () => { const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' }); const place = createPlace(testDb, trip.id); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; const acc = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id); - svc.create(String(trip.id), { + await svc.create(String(trip.id), { title: 'Hotel', type: 'hotel', accommodation_id: acc.id, metadata: { check_in_time: '15:00', check_out_time: '11:00' }, confirmation_number: 'CN-1', @@ -788,13 +793,13 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c expect(row).toEqual({ check_in: '15:00', check_out: '11:00', confirmation: 'CN-1' }); }); - it('RESV-SVC-020: create with a malformed reservation_time derives no day', () => { + it('RESV-SVC-020: create with a malformed reservation_time derives no day', async () => { const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' }); - const { reservation } = svc.create(String(trip.id), { title: 'X', type: 'tour', reservation_time: 'not-a-date' }); + const { reservation } = await svc.create(String(trip.id), { title: 'X', type: 'tour', reservation_time: 'not-a-date' }); expect(reservation.day_id).toBeNull(); }); - it('RESV-SVC-021: hotel update with create_accommodation updates the linked accommodation in place', () => { + it('RESV-SVC-021: hotel update with create_accommodation updates the linked accommodation in place', async () => { const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' }); const place = createPlace(testDb, trip.id); const place2 = createPlace(testDb, trip.id, { name: 'Other Hotel' }); @@ -803,7 +808,7 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' }); testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(String(acc.id), res.id); const current = svc.getReservation(String(res.id), String(trip.id))!; - const { accommodationChanged } = svc.update(String(res.id), String(trip.id), { + const { accommodationChanged } = await svc.update(String(res.id), String(trip.id), { type: 'hotel', create_accommodation: { place_id: place2.id, start_day_id: days[1].id, end_day_id: days[2].id, check_in: '14:00' }, confirmation_number: 'CN-2', @@ -813,13 +818,13 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c expect(row).toEqual({ place_id: place2.id, start_day_id: days[1].id, end_day_id: days[2].id, check_in: '14:00', confirmation: 'CN-2' }); }); - it('RESV-SVC-022: hotel update inserts a new accommodation when none is linked and a place is given', () => { + it('RESV-SVC-022: hotel update inserts a new accommodation when none is linked and a place is given', async () => { const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' }); const place = createPlace(testDb, trip.id); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' }); const current = svc.getReservation(String(res.id), String(trip.id))!; - const { reservation, accommodationChanged } = svc.update(String(res.id), String(trip.id), { + const { reservation, accommodationChanged } = await svc.update(String(res.id), String(trip.id), { type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[1].id }, }, current); @@ -828,13 +833,13 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c expect(testDb.prepare('SELECT COUNT(*) as c FROM day_accommodations WHERE trip_id = ?').get(trip.id)).toEqual({ c: 1 }); }); - it('RESV-SVC-023: explicit update fields bind their values; empty strings null; absent fields keep current', () => { + it('RESV-SVC-023: explicit update fields bind their values; empty strings null; absent fields keep current', async () => { const { trip } = ownerTrip(); const place = createPlace(testDb, trip.id); const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour' }); testDb.prepare("UPDATE reservations SET location = 'Loc', notes = 'N', url = 'U', confirmation_number = 'C' WHERE id = ?").run(res.id); let current = svc.getReservation(String(res.id), String(trip.id))!; - let { reservation } = svc.update(String(res.id), String(trip.id), { + let { reservation } = await svc.update(String(res.id), String(trip.id), { location: 'NewLoc', notes: '', url: 'https://x', confirmation_number: 'NEW', status: 'confirmed', needs_review: true, metadata: { a: 1 }, place_id: place.id, }, current); @@ -843,28 +848,28 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c status: 'confirmed', needs_review: 1, metadata: JSON.stringify({ a: 1 }), place_id: place.id, }); current = svc.getReservation(String(res.id), String(trip.id))!; - ({ reservation } = svc.update(String(res.id), String(trip.id), { title: 'T2' }, current)); + ({ reservation } = await svc.update(String(res.id), String(trip.id), { title: 'T2' }, current)); expect(reservation).toMatchObject({ title: 'T2', location: 'NewLoc', url: 'https://x', place_id: place.id }); }); - it('RESV-SVC-024: explicit day_id / end_day_id updates win over derivation; explicit null end clears', () => { + it('RESV-SVC-024: explicit day_id / end_day_id updates win over derivation; explicit null end clears', async () => { const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' }); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour' }); let current = svc.getReservation(String(res.id), String(trip.id))!; - let { reservation } = svc.update(String(res.id), String(trip.id), { day_id: days[1].id, end_day_id: days[2].id }, current); + let { reservation } = await svc.update(String(res.id), String(trip.id), { day_id: days[1].id, end_day_id: days[2].id }, current); expect(reservation).toMatchObject({ day_id: days[1].id, end_day_id: days[2].id }); current = svc.getReservation(String(res.id), String(trip.id))!; - ({ reservation } = svc.update(String(res.id), String(trip.id), { end_day_id: null, reservation_end_time: '2030-05-02T18:00:00' }, current)); + ({ reservation } = await svc.update(String(res.id), String(trip.id), { end_day_id: null, reservation_end_time: '2030-05-02T18:00:00' }, current)); // explicit end_day_id null wins over the end-time derivation expect(reservation.end_day_id).toBeNull(); current = svc.getReservation(String(res.id), String(trip.id))!; - ({ reservation } = svc.update(String(res.id), String(trip.id), { reservation_end_time: '2030-05-02T18:00:00' }, current)); + ({ reservation } = await svc.update(String(res.id), String(trip.id), { reservation_end_time: '2030-05-02T18:00:00' }, current)); // no explicit end day + an end time -> derived expect(reservation.end_day_id).toBe(days[1].id); }); - it('RESV-SVC-025: with metadata absent, the stored metadata JSON drives the accommodation sync (confirmation falls back to current)', () => { + it('RESV-SVC-025: with metadata absent, the stored metadata JSON drives the accommodation sync (confirmation falls back to current)', async () => { const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' }); const place = createPlace(testDb, trip.id); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; @@ -873,7 +878,7 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c testDb.prepare("UPDATE reservations SET accommodation_id = ?, metadata = ?, confirmation_number = 'KEEP' WHERE id = ?") .run(String(acc.id), JSON.stringify({ check_in_time: '16:00' }), res.id); const current = svc.getReservation(String(res.id), String(trip.id))!; - svc.update(String(res.id), String(trip.id), { notes: 'touch' }, current); + await svc.update(String(res.id), String(trip.id), { notes: 'touch' }, current); const row = testDb.prepare('SELECT check_in, confirmation FROM day_accommodations WHERE id = ?').get(acc.id); expect(row).toEqual({ check_in: '16:00', confirmation: 'KEEP' }); }); @@ -951,27 +956,27 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c }); describe('ReservationsService — quirk fixes (post-fold)', () => { - it('RESV-FIX-001: create is atomic — a failing endpoint save rolls back the reservation AND the auto-created accommodation', () => { + it('RESV-FIX-001: create is atomic — a failing endpoint save rolls back the reservation AND the auto-created accommodation', async () => { const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' }); const place = createPlace(testDb, trip.id); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; // sequence is an unbindable object -> the endpoint INSERT throws mid-write. const badEndpoints = [{ role: 'from', name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_time: null, local_date: null, sequence: {} }]; - expect(() => svc.create(String(trip.id), { + await expect(svc.create(String(trip.id), { title: 'Hotel', type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[1].id }, endpoints: badEndpoints, - } as never)).toThrow(); + } as never)).rejects.toThrow(); expect(testDb.prepare('SELECT COUNT(*) as c FROM reservations WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 }); expect(testDb.prepare('SELECT COUNT(*) as c FROM day_accommodations WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 }); }); - it('RESV-FIX-002: update is atomic — a failing endpoint save rolls back the field update', () => { + it('RESV-FIX-002: update is atomic — a failing endpoint save rolls back the field update', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'Old' }); const current = svc.getReservation(String(res.id), String(trip.id))!; const badEndpoints = [{ role: 'from', name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_time: null, local_date: null, sequence: {} }]; - expect(() => svc.update(String(res.id), String(trip.id), { title: 'New', endpoints: badEndpoints } as never, current)).toThrow(); + await expect(svc.update(String(res.id), String(trip.id), { title: 'New', endpoints: badEndpoints } as never, current)).rejects.toThrow(); expect(testDb.prepare('SELECT title FROM reservations WHERE id = ?').get(res.id)).toEqual({ title: 'Old' }); }); }); @@ -997,7 +1002,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { })).toEqual(['day_id', 'place_id', 'accommodation_id']); }); - it('RESV-SCOPE-005: a dangling id is not an offender, so the booking stays editable', () => { + it('RESV-SCOPE-005: a dangling id is not an offender, so the booking stays editable', async () => { const { mine } = twoTrips(); const place = createPlace(testDb, mine.id); const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(mine.id) as { id: number }; @@ -1011,7 +1016,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { expect(svc.referencesOutsideTrip(String(mine.id), { title: 'x', accommodation_id: acc.id })).toEqual([]); const current = svc.getReservation(String(res.id), String(mine.id))!; - svc.update(String(res.id), String(mine.id), { accommodation_id: acc.id, title: 'Hotel renamed' } as never, current); + await svc.update(String(res.id), String(mine.id), { accommodation_id: acc.id, title: 'Hotel renamed' } as never, current); expect(testDb.prepare('SELECT title, accommodation_id FROM reservations WHERE id = ?').get(res.id)) .toEqual({ title: 'Hotel renamed', accommodation_id: null }); }); @@ -1025,7 +1030,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { expect(svc.referencesOutsideTrip(String(mine.id), { title: 'x' })).toEqual([]); }); - it('RESV-SCOPE-003: a stored foreign accommodation_id is not deleted with the reservation', () => { + it('RESV-SCOPE-003: a stored foreign accommodation_id is not deleted with the reservation', async () => { const { mine, theirs } = twoTrips(); const foreignPlace = createPlace(testDb, theirs.id); const foreignDay = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(theirs.id) as { id: number }; @@ -1034,13 +1039,13 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { const res = createReservation(testDb, mine.id, { title: 'Hotel', type: 'hotel' }); testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(foreignAcc.id, res.id); - const { accommodationDeleted } = svc.remove(String(res.id), String(mine.id)); + const { accommodationDeleted } = await svc.remove(String(res.id), String(mine.id)); expect(accommodationDeleted).toBe(false); expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(foreignAcc.id)).toBeTruthy(); }); - it('RESV-SCOPE-004: an update carrying a foreign accommodation_id does not write through to it', () => { + it('RESV-SCOPE-004: an update carrying a foreign accommodation_id does not write through to it', async () => { const { mine, theirs } = twoTrips(); const foreignPlace = createPlace(testDb, theirs.id); const foreignDay = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(theirs.id) as { id: number }; @@ -1048,7 +1053,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { const res = createReservation(testDb, mine.id, { title: 'Hotel', type: 'hotel' }); const current = svc.getReservation(String(res.id), String(mine.id))!; - svc.update(String(res.id), String(mine.id), { + await svc.update(String(res.id), String(mine.id), { accommodation_id: foreignAcc.id, metadata: { check_in_time: '23:00' }, } as never, current); @@ -1110,7 +1115,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { expect(svc.unresolvedReferences(String(mine.id), body)).toEqual(['place_id']); }); - it('RESV-SCOPE-009: an update heals the stored ids whose rows are gone instead of failing on them', () => { + it('RESV-SCOPE-009: an update heals the stored ids whose rows are gone instead of failing on them', async () => { const { mine } = twoTrips(); const res = createReservation(testDb, mine.id, { title: 'Dinner' }); withForeignKeysOff(() => { @@ -1120,34 +1125,34 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { const current = svc.getReservation(String(res.id), String(mine.id))!; // The body names none of them, so the statement rebinds what the row holds. - svc.update(String(res.id), String(mine.id), { title: 'Dinner, later' }, current); + await svc.update(String(res.id), String(mine.id), { title: 'Dinner, later' }, current); expect(testDb.prepare('SELECT title, day_id, end_day_id, place_id, assignment_id FROM reservations WHERE id = ?').get(res.id)) .toEqual({ title: 'Dinner, later', day_id: null, end_day_id: null, place_id: null, assignment_id: null }); }); - it('RESV-SCOPE-010: a supplied id that resolves to nothing is stored as NULL, on create and on update', () => { + it('RESV-SCOPE-010: a supplied id that resolves to nothing is stored as NULL, on create and on update', async () => { const { mine } = twoTrips(); - const { reservation } = svc.create(String(mine.id), { + const { reservation } = await svc.create(String(mine.id), { title: 'Dinner', day_id: 999999, end_day_id: 999998, place_id: 999997, assignment_id: 999996, }); expect(testDb.prepare('SELECT day_id, end_day_id, place_id, assignment_id FROM reservations WHERE id = ?').get(reservation.id)) .toEqual({ day_id: null, end_day_id: null, place_id: null, assignment_id: null }); const current = svc.getReservation(String(reservation.id), String(mine.id))!; - svc.update(String(reservation.id), String(mine.id), { place_id: 999997 }, current); + await svc.update(String(reservation.id), String(mine.id), { place_id: 999997 }, current); expect(testDb.prepare('SELECT place_id FROM reservations WHERE id = ?').get(reservation.id)).toEqual({ place_id: null }); }); - it('RESV-SCOPE-011: a stay whose refs resolve to nothing is refused, never written and never dropped in silence', () => { + it('RESV-SCOPE-011: a stay whose refs resolve to nothing is refused, never written and never dropped in silence', async () => { const { mine } = twoTrips(); const place = createPlace(testDb, mine.id); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(mine.id) as { id: number }[]; - expect(() => svc.create(String(mine.id), { + await expect(svc.create(String(mine.id), { title: 'Hotel', type: 'hotel', create_accommodation: { place_id: 999999, start_day_id: 999998, end_day_id: 999997 }, - })).toThrow('Unknown reference: create_accommodation.place_id, create_accommodation.start_day_id, create_accommodation.end_day_id'); + })).rejects.toThrow('Unknown reference: create_accommodation.place_id, create_accommodation.start_day_id, create_accommodation.end_day_id'); expect(testDb.prepare('SELECT COUNT(*) as c FROM day_accommodations WHERE trip_id = ?').get(mine.id)).toEqual({ c: 0 }); expect(testDb.prepare('SELECT COUNT(*) as c FROM reservations WHERE trip_id = ?').get(mine.id)).toEqual({ c: 0 }); @@ -1158,17 +1163,17 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(acc.id, res.id); const current = svc.getReservation(String(res.id), String(mine.id))!; - expect(() => svc.update(String(res.id), String(mine.id), { + await expect(svc.update(String(res.id), String(mine.id), { type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: 999999 }, - }, current)).toThrow('Unknown reference: create_accommodation.end_day_id'); + }, current)).rejects.toThrow('Unknown reference: create_accommodation.end_day_id'); expect(testDb.prepare('SELECT end_day_id FROM day_accommodations WHERE id = ?').get(acc.id)).toEqual({ end_day_id: days[1].id }); }); - it('RESV-SCOPE-012: a stay booked without a place is still written', () => { + it('RESV-SCOPE-012: a stay booked without a place is still written', async () => { const { mine } = twoTrips(); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(mine.id) as { id: number }[]; - const { reservation, accommodationCreated } = svc.create(String(mine.id), { + const { reservation, accommodationCreated } = await svc.create(String(mine.id), { title: 'Hotel', type: 'hotel', create_accommodation: { start_day_id: days[0].id, end_day_id: days[1].id, check_in: '15:00' }, }); @@ -1178,7 +1183,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { .toEqual({ place_id: null, start_day_id: days[0].id }); }); - it('RESV-SCOPE-013: the refusal carries the 400 the write surfaces send, not a status the filter has to guess', () => { + it('RESV-SCOPE-013: the refusal carries the 400 the write surfaces send, not a status the filter has to guess', async () => { const { mine } = twoTrips(); // The write surfaces name the field before the service ever sees it, so the @@ -1187,7 +1192,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { // a class it cannot place would become 'Internal server error' (#2355). let thrown: unknown; try { - svc.create(String(mine.id), { + await svc.create(String(mine.id), { title: 'Hotel', type: 'hotel', create_accommodation: { start_day_id: 999998, end_day_id: 999997 }, }); @@ -1217,11 +1222,11 @@ describe('the day stop a hotel booking implies', () => { return { trip, days }; }; - it('RESV-STAY-001: a hotel booked on the form lands on its check-in day', () => { + it('RESV-STAY-001: a hotel booked on the form lands on its check-in day', async () => { const { trip, days } = tripWithDays(); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - const { reservation } = svc.create(String(trip.id), { + const { reservation } = await svc.create(String(trip.id), { title: 'Hotel', type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[2].id }, }); @@ -1233,22 +1238,22 @@ describe('the day stop a hotel booking implies', () => { expect(stopsOn(days[2].id)).toEqual([]); }); - it('RESV-STAY-002: a booking that is not a hotel puts nothing on the plan', () => { + it('RESV-STAY-002: a booking that is not a hotel puts nothing on the plan', async () => { const { trip, days } = tripWithDays(); - svc.create(String(trip.id), { title: 'Museum tour', type: 'tour', day_id: days[0].id }); + await svc.create(String(trip.id), { title: 'Museum tour', type: 'tour', day_id: days[0].id }); expect(stopsOn(days[0].id)).toEqual([]); }); - it('RESV-STAY-003: moving the booking to another day takes its stop along', () => { + it('RESV-STAY-003: moving the booking to another day takes its stop along', async () => { const { trip, days } = tripWithDays(); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - const { reservation } = svc.create(String(trip.id), { + const { reservation } = await svc.create(String(trip.id), { title: 'Hotel', type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[0].id }, }); const current = svc.getReservation(String(reservation.id), String(trip.id))!; - svc.update(String(reservation.id), String(trip.id), { + await svc.update(String(reservation.id), String(trip.id), { type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[1].id, end_day_id: days[2].id }, } as never, current); @@ -1259,15 +1264,15 @@ describe('the day stop a hotel booking implies', () => { expect(stopsOn(days[1].id)).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: Number(reservation.accommodation_id) })]); }); - it('RESV-STAY-004: an edit that first creates the stay writes the stop too', () => { + it('RESV-STAY-004: an edit that first creates the stay writes the stop too', async () => { // A booking saved without a date range, then given one. resolvedAccId is null on // the way in, so this takes the insert branch rather than the update branch. const { trip, days } = tripWithDays(); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - const { reservation } = svc.create(String(trip.id), { title: 'Hotel', type: 'hotel' }); + const { reservation } = await svc.create(String(trip.id), { title: 'Hotel', type: 'hotel' }); const current = svc.getReservation(String(reservation.id), String(trip.id))!; - svc.update(String(reservation.id), String(trip.id), { + await svc.update(String(reservation.id), String(trip.id), { type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[1].id, end_day_id: days[1].id }, } as never, current); @@ -1276,15 +1281,15 @@ describe('the day stop a hotel booking implies', () => { expect(stopsOn(days[1].id)).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: stay.id })]); }); - it('RESV-STAY-005: deleting the booking takes the stop with the stay', () => { + it('RESV-STAY-005: deleting the booking takes the stop with the stay', async () => { const { trip, days } = tripWithDays(); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - const { reservation } = svc.create(String(trip.id), { + const { reservation } = await svc.create(String(trip.id), { title: 'Hotel', type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[0].id }, }); - const { accommodationDeleted } = svc.remove(String(reservation.id), String(trip.id)); + const { accommodationDeleted } = await svc.remove(String(reservation.id), String(trip.id)); expect(accommodationDeleted).toBe(true); // A stop still carrying a dead booking's id is one the day list hides and @@ -1292,12 +1297,12 @@ describe('the day stop a hotel booking implies', () => { expect(stopsOn(days[0].id)).toEqual([]); }); - it('RESV-STAY-006: a place the traveller already planned that day keeps its own row', () => { + it('RESV-STAY-006: a place the traveller already planned that day keeps its own row', async () => { const { trip, days } = tripWithDays(); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); const own = testDb.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, 0)').run(days[0].id, place.id); - const { reservation } = svc.create(String(trip.id), { + const { reservation } = await svc.create(String(trip.id), { title: 'Hotel', type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[0].id }, }); @@ -1305,11 +1310,11 @@ describe('the day stop a hotel booking implies', () => { // No second row, and the booking claims neither: cancelling it must not delete // a stop the traveller placed. expect(stopsOn(days[0].id)).toEqual([{ id: Number(own.lastInsertRowid), place_id: place.id, accommodation_id: null }]); - svc.remove(String(reservation.id), String(trip.id)); + await svc.remove(String(reservation.id), String(trip.id)); expect(stopsOn(days[0].id)).toHaveLength(1); }); - it('RESV-STAY-007: a foreign accommodation_id reaches no stop on the other trip', () => { + it('RESV-STAY-007: a foreign accommodation_id reaches no stop on the other trip', async () => { // The trip_id guard on the stay delete is what denies that reach; the stops go // by accommodation id alone and would otherwise follow it straight over. const { user: attacker } = createUser(testDb); @@ -1324,7 +1329,7 @@ describe('the day stop a hotel booking implies', () => { const res = createReservation(testDb, mine.id, { title: 'Hotel', type: 'hotel' }); testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(foreignAcc.id, res.id); - svc.remove(String(res.id), String(mine.id)); + await svc.remove(String(res.id), String(mine.id)); expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(Number(theirStop.lastInsertRowid))).toBeTruthy(); }); diff --git a/server/tests/unit/nest/roadtrip-planning.test.ts b/server/tests/unit/nest/roadtrip-planning.test.ts index 0d3e26e0b..26e79408b 100644 --- a/server/tests/unit/nest/roadtrip-planning.test.ts +++ b/server/tests/unit/nest/roadtrip-planning.test.ts @@ -247,6 +247,7 @@ describe('Roadtrip MCP registration and search', () => { {} as never, {} as never, guards as never, + {} as never, ); const input = { tripId: 1, @@ -269,7 +270,7 @@ describe('Roadtrip MCP registration and search', () => { await mcp.importGpx(input, ctx); expect(places.importGpx).not.toHaveBeenCalled(); }); - it('hides addon tools when disabled and separates settings reads from writes', () => { + it('hides addon tools when disabled and separates settings reads from writes', async () => { const s = setup(); const addons = { isAddonEnabled: vi.fn(() => true) }; const registry = createTestRegistry( @@ -285,12 +286,12 @@ describe('Roadtrip MCP registration and search', () => { names.push(name); }, }; - registry.attach(registrar as never, { ...ctx, scopes: ['trips:read'] }); + await registry.attach(registrar as never, { ...ctx, scopes: ['trips:read'] }); expect(names).toContain('get_roadtrip_settings'); expect(names).not.toContain('update_roadtrip_settings'); names.length = 0; addons.isAddonEnabled.mockReturnValue(false); - registry.attach(registrar as never, { ...ctx, scopes: null }); + await registry.attach(registrar as never, { ...ctx, scopes: null }); expect(names).toEqual([]); }); it('returns corridor sources, truncation and matching brands without adding stops', async () => { diff --git a/server/tests/unit/nest/roadtrip-read-tools.test.ts b/server/tests/unit/nest/roadtrip-read-tools.test.ts index 0f8f948b8..7f53a922b 100644 --- a/server/tests/unit/nest/roadtrip-read-tools.test.ts +++ b/server/tests/unit/nest/roadtrip-read-tools.test.ts @@ -32,14 +32,14 @@ describe('roadtrip read tools', () => { expect(routes.import).toHaveBeenCalledWith(10, 5, input); }); - it('refuses the Google import for the demo account, like every other write tool', () => { + it('refuses the Google import for the demo account, like every other write tool', async () => { // The one non-admin write tool that had no gate: a demo session could write // thirty places and their assignments onto the shared demo trip. const routes = { preview: vi.fn(), import: vi.fn() }; const auth = { isDemoUser: vi.fn(() => true) }; const tool = new GoogleRouteMcp(routes as never, auth as never, {} as never); - const res = tool.import({ tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] } as never, ctx); + const res = await tool.import({ tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] } as never, ctx); expect(res.isError).toBe(true); expect(routes.import).not.toHaveBeenCalled(); @@ -59,7 +59,7 @@ describe('roadtrip read tools', () => { const routes = { preview: refuse('Use a Google Maps directions link.', 400), import: refuse('Permission denied', 403) }; const google = new GoogleRouteMcp(routes as never, auth as never, {} as never); expect(text(await google.preview({ url: 'https://www.google.com/maps/place/A' }))).toEqual([true, 'Use a Google Maps directions link.']); - expect(text(google.import({ tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] } as never, ctx))).toEqual([true, 'Permission denied']); + expect(text(await google.import({ tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] } as never, ctx))).toEqual([true, 'Permission denied']); const realtime = { broadcast: vi.fn() }; const guards = { hasTripPermission: vi.fn(() => true) }; diff --git a/server/tests/unit/nest/share.controller.test.ts b/server/tests/unit/nest/share.controller.test.ts index d1f02b819..3d52b19cf 100644 --- a/server/tests/unit/nest/share.controller.test.ts +++ b/server/tests/unit/nest/share.controller.test.ts @@ -44,41 +44,41 @@ describe('TripShareController', () => { expect(thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false) })).create(user, '5', {}, res()))).toEqual({ status: 403, body: { error: 'No permission' } }); }); - it('POST answers 201 on create, 200 on update', () => { + it('POST answers 201 on create, 200 on update', async () => { const createdRes = res(); const c1 = new TripShareController(svc({ createOrUpdate: vi.fn().mockReturnValue({ token: 't', created: true }) } as Partial)); - expect(c1.create(user, '5', { share_map: true }, createdRes)).toEqual({ token: 't' }); + expect(await c1.create(user, '5', { share_map: true }, createdRes)).toEqual({ token: 't' }); expect(createdRes.statusCode).toBe(201); const updatedRes = res(); const c2 = new TripShareController(svc({ createOrUpdate: vi.fn().mockReturnValue({ token: 't', created: false }) } as Partial)); - expect(c2.create(user, '5', {}, updatedRes)).toEqual({ token: 't' }); + expect(await c2.create(user, '5', {}, updatedRes)).toEqual({ token: 't' }); expect(updatedRes.statusCode).toBe(200); }); - it('GET 404 without access, 403 without share_manage, returns info or a null token', () => { + it('GET 404 without access, 403 without share_manage, returns info or a null token', async () => { expect(thrown(() => new TripShareController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).get(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); // Reading returns the token itself, so it needs the same permission as // creating it — a member with plain trip access must not get a copy. const get = vi.fn(); expect(thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false), get } as Partial)).get(user, '5'))).toEqual({ status: 403, body: { error: 'No permission' } }); expect(get).not.toHaveBeenCalled(); - expect(new TripShareController(svc({ get: vi.fn().mockReturnValue({ token: 't' }) } as Partial)).get(user, '5')).toEqual({ token: 't' }); - expect(new TripShareController(svc({ get: vi.fn().mockReturnValue(null) } as Partial)).get(user, '5')).toEqual({ token: null }); + expect(await new TripShareController(svc({ get: vi.fn().mockReturnValue({ token: 't' }) } as Partial)).get(user, '5')).toEqual({ token: 't' }); + expect(await new TripShareController(svc({ get: vi.fn().mockReturnValue(null) } as Partial)).get(user, '5')).toEqual({ token: null }); }); - it('DELETE 403 without share_manage, else removes', () => { + it('DELETE 403 without share_manage, else removes', async () => { expect(thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false) })).remove(user, '5'))).toEqual({ status: 403, body: { error: 'No permission' } }); const remove = vi.fn(); - expect(new TripShareController(svc({ remove } as Partial)).remove(user, '5')).toEqual({ success: true }); + expect(await new TripShareController(svc({ remove } as Partial)).remove(user, '5')).toEqual({ success: true }); expect(remove).toHaveBeenCalledWith('5'); }); }); describe('SharedController', () => { - it('404 for an invalid token, else returns the snapshot', () => { + it('404 for an invalid token, else returns the snapshot', async () => { expect(thrown(() => new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue(null) } as Partial), storageStub).read('bad'))).toEqual({ status: 404, body: { error: 'Invalid or expired link' } }); - expect(new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue({ trip: { id: 9 } }) } as Partial), storageStub).read('tok')).toEqual({ trip: { id: 9 } }); + expect(await new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue({ trip: { id: 9 } }) } as Partial), storageStub).read('tok')).toEqual({ trip: { id: 9 } }); }); describe('place-photo proxy', () => { diff --git a/server/tests/unit/nest/share.service.test.ts b/server/tests/unit/nest/share.service.test.ts index 604eaf0cb..a26454a5a 100644 --- a/server/tests/unit/nest/share.service.test.ts +++ b/server/tests/unit/nest/share.service.test.ts @@ -107,14 +107,14 @@ describe('verifyTripAccess and canManage', () => { expect(svc.verifyTripAccess(String(trip.id), stranger.id)).toBeFalsy(); }); - it('SHARE-SVC-002: canManage forwards the share_manage check with the ownership flag', () => { + it('SHARE-SVC-002: canManage forwards the share_manage check with the ownership flag', async () => { checkPermission.mockReturnValue(true); const trip = { id: 5, user_id: 1 } as any; const owner = { id: 1, role: 'user' } as User; const member = { id: 2, role: 'user' } as User; - expect(svc.canManage(trip, owner)).toBe(true); + expect(await svc.canManage(trip, owner)).toBe(true); expect(checkPermission).toHaveBeenLastCalledWith('share_manage', 'user', 1, 1, false); - svc.canManage(trip, member); + await svc.canManage(trip, member); expect(checkPermission).toHaveBeenLastCalledWith('share_manage', 'user', 1, 2, true); }); }); @@ -218,34 +218,34 @@ describe('get and remove', () => { // ── getSharedTripData ───────────────────────────────────────────────────────── describe('getSharedTripData', () => { - it('SHARE-SVC-010: returns null for an unknown token', () => { - expect(svc.getSharedTripData('nope')).toBeNull(); + it('SHARE-SVC-010: returns null for an unknown token', async () => { + expect(await svc.getSharedTripData('nope')).toBeNull(); }); - it('SHARE-SVC-011: returns null for an expired token but honours NULL expiry (legacy rows)', () => { + it('SHARE-SVC-011: returns null for an expired token but honours NULL expiry (legacy rows)', async () => { const { trip, token } = seedSharedTrip(); testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id); - expect(svc.getSharedTripData(token)).toBeNull(); + expect(await svc.getSharedTripData(token)).toBeNull(); testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id); - expect(svc.getSharedTripData(token)).not.toBeNull(); + expect(await svc.getSharedTripData(token)).not.toBeNull(); }); - it('SHARE-SVC-012: returns null when the trip row is gone', () => { + it('SHARE-SVC-012: returns null when the trip row is gone', async () => { const { trip, token } = seedSharedTrip(); // The token normally cascades away with its trip; orphan it deliberately // to pin the defensive `if (!trip) return null` branch. testDb.exec('PRAGMA foreign_keys = OFF'); testDb.prepare('DELETE FROM trips WHERE id = ?').run(trip.id); testDb.exec('PRAGMA foreign_keys = ON'); - expect(svc.getSharedTripData(token)).toBeNull(); + expect(await svc.getSharedTripData(token)).toBeNull(); }); - it('SHARE-SVC-013: returns the trip projection, coerced permissions and grouped itinerary', () => { + it('SHARE-SVC-013: returns the trip projection, coerced permissions and grouped itinerary', async () => { const { trip, token } = seedSharedTrip(); const day = createDay(testDb, trip.id, { date: '2025-06-01' }); const place = createPlace(testDb, trip.id, { name: 'Louvre' }); createDayAssignment(testDb, day.id, place.id, { notes: 'go early' }); - const data = svc.getSharedTripData(token)!; + const data = await (await svc.getSharedTripData(token))!; expect(data).not.toBeNull(); expect(data.trip).toEqual(expect.objectContaining({ id: trip.id, title: trip.title })); // Explicit column list — no owner id or internal fields on the trip row. @@ -266,22 +266,22 @@ describe('getSharedTripData', () => { expect(data.collab).toEqual([]); }); - it('SHARE-SVC-014: COALESCEs assignment times over place times', () => { + it('SHARE-SVC-014: COALESCEs assignment times over place times', async () => { const { trip, token } = seedSharedTrip(); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); testDb.prepare('UPDATE places SET place_time = ?, end_time = ? WHERE id = ?').run('09:00', '10:00', place.id); const a = createDayAssignment(testDb, day.id, place.id); - let entry = svc.getSharedTripData(token)!.assignments[day.id][0]; + let entry = (await (await svc.getSharedTripData(token))!).assignments[day.id][0]; expect(entry.place.place_time).toBe('09:00'); expect(entry.place.end_time).toBe('10:00'); testDb.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?').run('14:00', '15:00', a.id); - entry = svc.getSharedTripData(token)!.assignments[day.id][0]; + entry = (await (await svc.getSharedTripData(token))!).assignments[day.id][0]; expect(entry.place.place_time).toBe('14:00'); expect(entry.place.end_time).toBe('15:00'); }); - it('SHARE-SVC-015: orders assignments by order_index, then created_at as tiebreaker', () => { + it('SHARE-SVC-015: orders assignments by order_index, then created_at as tiebreaker', async () => { const { trip, token } = seedSharedTrip(); const day = createDay(testDb, trip.id); const p1 = createPlace(testDb, trip.id, { name: 'First' }); @@ -292,45 +292,45 @@ describe('getSharedTripData', () => { testDb.prepare( "INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T10:00:00')" ).run(day.id, p1.id); - const entries = svc.getSharedTripData(token)!.assignments[day.id]; + const entries = (await (await svc.getSharedTripData(token))!).assignments[day.id]; expect(entries.map((e: any) => e.place.name)).toEqual(['First', 'Second']); }); - it('SHARE-SVC-016: attaches reservation day_positions, null when a reservation has none', () => { + it('SHARE-SVC-016: attaches reservation day_positions, null when a reservation has none', async () => { const { trip, token } = seedSharedTrip(); const day = createDay(testDb, trip.id); const r1 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id); const r2 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Hotel', 'hotel')").run(trip.id); testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, 3)') .run(r1.lastInsertRowid, day.id); - const reservations = svc.getSharedTripData(token)!.reservations; + const reservations = (await (await svc.getSharedTripData(token))!).reservations; const flight = reservations.find((r: any) => r.id === r1.lastInsertRowid); const hotel = reservations.find((r: any) => r.id === r2.lastInsertRowid); expect(flight.day_positions).toEqual({ [day.id]: 3 }); expect(hotel.day_positions).toBeNull(); }); - it('SHARE-SVC-017: excludes private packing items from the public payload (#858)', () => { + it('SHARE-SVC-017: excludes private packing items from the public payload (#858)', async () => { const { user, trip, token } = seedSharedTrip(); testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Common', 0, ?)").run(trip.id, user.id); testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, user.id); - const packing = svc.getSharedTripData(token)!.packing; + const packing = (await (await svc.getSharedTripData(token))!).packing; expect(packing.map((p: any) => p.name)).toEqual(['Common']); }); - it('SHARE-SVC-018: includes non-deleted collab messages only when share_collab is on', () => { + it('SHARE-SVC-018: includes non-deleted collab messages only when share_collab is on', async () => { const { user, trip, token } = seedSharedTrip(); testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Hello', 0)").run(trip.id, user.id); testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Gone', 1)").run(trip.id, user.id); - const collab = svc.getSharedTripData(token)!.collab; + const collab = (await (await svc.getSharedTripData(token))!).collab; expect(collab).toHaveLength(1); expect(collab[0]).toEqual(expect.objectContaining({ text: 'Hello', username: user.username })); svc.createOrUpdate(String(trip.id), user.id, { share_collab: false }); - expect(svc.getSharedTripData(token)!.collab).toEqual([]); + expect((await (await svc.getSharedTripData(token))!).collab).toEqual([]); }); - it('SHARE-SVC-019: withholds each section when its flag is off', () => { + it('SHARE-SVC-019: withholds each section when its flag is off', async () => { const { trip, token } = seedSharedTrip({ share_map: false, share_bookings: false, share_packing: false, share_budget: false, share_collab: false, }); @@ -340,7 +340,7 @@ describe('getSharedTripData', () => { testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id); testDb.prepare("INSERT INTO packing_items (trip_id, name) VALUES (?, 'Socks')").run(trip.id); testDb.prepare("INSERT INTO budget_items (trip_id, name, category, total_price) VALUES (?, 'Food', 'food', 10)").run(trip.id); - const data = svc.getSharedTripData(token)!; + const data = await (await svc.getSharedTripData(token))!; expect(data.days).toEqual([]); expect(data.assignments).toEqual({}); expect(data.dayNotes).toEqual({}); @@ -352,16 +352,16 @@ describe('getSharedTripData', () => { expect(data.collab).toEqual([]); }); - it('SHARE-SVC-020: baseCurrency falls back trip currency → EUR, with the owner default_currency winning (#1361)', () => { + it('SHARE-SVC-020: baseCurrency falls back trip currency → EUR, with the owner default_currency winning (#1361)', async () => { const { user, trip, token } = seedSharedTrip(); - expect(svc.getSharedTripData(token)!.baseCurrency).toBe('EUR'); + expect((await (await svc.getSharedTripData(token))!).baseCurrency).toBe('EUR'); testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run('USD', trip.id); - expect(svc.getSharedTripData(token)!.baseCurrency).toBe('USD'); + expect((await (await svc.getSharedTripData(token))!).baseCurrency).toBe('USD'); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ' CHF ')").run(user.id); - expect(svc.getSharedTripData(token)!.baseCurrency).toBe('CHF'); + expect((await (await svc.getSharedTripData(token))!).baseCurrency).toBe('CHF'); }); - it('SHARE-SVC-021: rewrites place-photo proxy URLs to the token-scoped route, passing others through', () => { + it('SHARE-SVC-021: rewrites place-photo proxy URLs to the token-scoped route, passing others through', async () => { const { trip, token } = seedSharedTrip(); const proxied = createPlace(testDb, trip.id, { name: 'Proxied' }); const uploaded = createPlace(testDb, trip.id, { name: 'Uploaded' }); @@ -369,47 +369,47 @@ describe('getSharedTripData', () => { testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', proxied.id); testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/pic.jpg', uploaded.id); const byName = Object.fromEntries( - svc.getSharedTripData(token)!.places.map((p: any) => [p.name, p.image_url]), + (await (await svc.getSharedTripData(token))!).places.map((p: any) => [p.name, p.image_url]), ); expect(byName['Proxied']).toBe(`/api/shared/${token}/place-photo/ChIJabc/bytes`); expect(byName['Uploaded']).toBe('/uploads/pic.jpg'); expect(byName['Bare']).toBeNull(); }); - it('SHARE-SVC-027: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', () => { + it('SHARE-SVC-027: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', async () => { const { user, token } = seedSharedTrip(); - expect(svc.getSharedTripData(token)!.cartoApiKey).toBe(''); + expect((await (await svc.getSharedTripData(token))!).cartoApiKey).toBe(''); testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run(); - expect(svc.getSharedTripData(token)!.cartoApiKey).toBe('instance-key'); + expect((await (await svc.getSharedTripData(token))!).cartoApiKey).toBe('instance-key'); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', ' owner-key ')").run(user.id); - expect(svc.getSharedTripData(token)!.cartoApiKey).toBe('owner-key'); + expect((await (await svc.getSharedTripData(token))!).cartoApiKey).toBe('owner-key'); }); - it('SHARE-SVC-029: staged bookings stay out of the public payload, confirmation number included', () => { + it('SHARE-SVC-029: staged bookings stay out of the public payload, confirmation number included', async () => { const { trip, token } = seedSharedTrip(); testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number, ingest_state) VALUES (?, 'Parked Flight', 'flight', 'confirmed', 'SECRET1', 'staged')`).run(trip.id); testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number) VALUES (?, 'Booked Flight', 'flight', 'confirmed', 'OPEN1')`).run(trip.id); - const data = svc.getSharedTripData(token)!; + const data = await (await svc.getSharedTripData(token))!; expect((data.reservations as any[]).map((r) => r.title)).toEqual(['Booked Flight']); // SELECT * hands out notes, url and metadata too, so check the whole payload. expect(JSON.stringify(data)).not.toContain('SECRET1'); }); - it('SHARE-SVC-030: every booking that predates the column stays in the payload', () => { + it('SHARE-SVC-030: every booking that predates the column stays in the payload', async () => { const { trip, token } = seedSharedTrip(); for (let i = 0; i < 5; i++) { testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status) VALUES (?, ?, 'flight', 'confirmed')`).run(trip.id, `Booking ${i}`); } - expect((svc.getSharedTripData(token)!.reservations as any[])).toHaveLength(5); + expect(((await (await svc.getSharedTripData(token))!).reservations as any[])).toHaveLength(5); }); - it('SHARE-SVC-031: accommodations backed only by a staged booking are withheld, unlinked ones are kept', () => { + it('SHARE-SVC-031: accommodations backed only by a staged booking are withheld, unlinked ones are kept', async () => { const { trip, token } = seedSharedTrip(); const place = createPlace(testDb, trip.id, { name: 'Hotel Bellevue' }); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); @@ -422,7 +422,7 @@ describe('getSharedTripData', () => { VALUES (?, 'Parked Hotel', 'hotel', 'confirmed', ?, 'staged')`).run(trip.id, String(stagedStay)); const unlinkedStay = stay(); - const rows = svc.getSharedTripData(token)!.accommodations as any[]; + const rows = (await (await svc.getSharedTripData(token))!).accommodations as any[]; expect(rows.map((a) => a.id)).toEqual([unlinkedStay]); }); @@ -431,14 +431,14 @@ describe('getSharedTripData', () => { // ── #2320: what the public payload carries, and what it withholds ─────────── describe('getSharedTripData redaction (#2320)', () => { - it('SHARE-SVC-032: a booking travels without its confirmation, import trail or travellers', () => { + it('SHARE-SVC-032: a booking travels without its confirmation, import trail or travellers', async () => { const { trip, token } = seedSharedTrip(); testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number, notes, url, external_source, external_id, sync_enabled, needs_review) VALUES (?, 'Night train', 'train', 'confirmed', 'PNR9XY', 'Bring the tickets', 'https://bahn.example/booking/1', 'kitinerary', 'ext-42', 1, 0)`) .run(trip.id); - const [row] = svc.getSharedTripData(token)!.reservations as any[]; + const [row] = (await (await svc.getSharedTripData(token))!).reservations as any[]; expect(Object.keys(row).sort()).toEqual([ 'accommodation_id', 'created_at', 'day_id', 'day_positions', 'end_day_id', 'endpoints', 'id', 'location', @@ -446,11 +446,11 @@ describe('getSharedTripData redaction (#2320)', () => { ]); expect(row.notes).toBe('Bring the tickets'); expect(row.url).toBe('https://bahn.example/booking/1'); - expect(JSON.stringify(svc.getSharedTripData(token))).not.toContain('PNR9XY'); - expect(JSON.stringify(svc.getSharedTripData(token))).not.toContain('ext-42'); + expect(JSON.stringify(await svc.getSharedTripData(token))).not.toContain('PNR9XY'); + expect(JSON.stringify(await svc.getSharedTripData(token))).not.toContain('ext-42'); }); - it('SHARE-SVC-033: booking metadata keeps the journey and drops the ticket', () => { + it('SHARE-SVC-033: booking metadata keeps the journey and drops the ticket', async () => { const { trip, token } = seedSharedTrip(); const metadata = JSON.stringify({ airline: 'LH', flight_number: 'LH400', departure_airport: 'FRA', arrival_airport: 'JFK', @@ -463,7 +463,7 @@ describe('getSharedTripData redaction (#2320)', () => { testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, metadata) VALUES (?, 'To New York', 'flight', 'confirmed', ?)`).run(trip.id, metadata); - const data = svc.getSharedTripData(token)!; + const data = await (await svc.getSharedTripData(token))!; const [row] = data.reservations as any[]; const meta = JSON.parse(row.metadata); @@ -480,7 +480,7 @@ describe('getSharedTripData redaction (#2320)', () => { } }); - it('SHARE-SVC-034: a booking carries its ordered stops', () => { + it('SHARE-SVC-034: a booking carries its ordered stops', async () => { const { trip, token } = seedSharedTrip(); const id = testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status) VALUES (?, 'Coach', 'bus', 'confirmed')`).run(trip.id).lastInsertRowid; @@ -489,7 +489,7 @@ describe('getSharedTripData redaction (#2320)', () => { ins.run(id, 'to', 1, 'Lyon', null, 45.76, 4.84); ins.run(id, 'from', 0, 'Paris', 'PAR', 48.85, 2.35); - const [row] = svc.getSharedTripData(token)!.reservations as any[]; + const [row] = (await (await svc.getSharedTripData(token))!).reservations as any[]; expect(row.endpoints.map((e: any) => [e.role, e.name])).toEqual([['from', 'Paris'], ['to', 'Lyon']]); expect(Object.keys(row.endpoints[0]).sort()).toEqual( @@ -497,7 +497,7 @@ describe('getSharedTripData redaction (#2320)', () => { ); }); - it('SHARE-SVC-035: only http(s) links reach the page, on bookings and places alike', () => { + it('SHARE-SVC-035: only http(s) links reach the page, on bookings and places alike', async () => { const { trip, token } = seedSharedTrip(); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); for (const [title, url] of [['ok', 'https://example.com/x'], ['js', 'javascript:alert(1)'], ['data', 'data:text/html,hi'], ['junk', 'not a url'], ['blank', ' ']]) { @@ -507,7 +507,7 @@ describe('getSharedTripData redaction (#2320)', () => { testDb.prepare('UPDATE places SET website = ?, phone = ? WHERE id = ?').run('javascript:alert(2)', '+43 1 234', place.id); createDayAssignment(testDb, day.id, place.id, {}); - const data = svc.getSharedTripData(token)!; + const data = await (await svc.getSharedTripData(token))!; const urls = Object.fromEntries((data.reservations as any[]).map((r) => [r.title, r.url])); expect(urls).toEqual({ ok: 'https://example.com/x', js: null, data: null, junk: null, blank: null }); expect(data.assignments[day.id][0].place.website).toBeNull(); @@ -515,7 +515,7 @@ describe('getSharedTripData redaction (#2320)', () => { expect((data.places as any[])[0].website).toBeNull(); }); - it('SHARE-SVC-036: an assignment carries the place notes, duration and contact, the pool carries no booking notes', () => { + it('SHARE-SVC-036: an assignment carries the place notes, duration and contact, the pool carries no booking notes', async () => { const { trip, token } = seedSharedTrip(); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); const place = createPlace(testDb, trip.id, { name: 'Louvre' }); @@ -523,7 +523,7 @@ describe('getSharedTripData redaction (#2320)', () => { duration_minutes = 180, website = 'https://louvre.fr', phone = '+33 1', reservation_notes = 'Booked under Ada', google_place_id = 'ChIJ123' WHERE id = ?`).run(place.id); createDayAssignment(testDb, day.id, place.id, { notes: 'go early' }); - const data = svc.getSharedTripData(token)!; + const data = await (await svc.getSharedTripData(token))!; const entry = data.assignments[day.id][0]; expect(entry.notes).toBe('go early'); expect(entry.place).toEqual(expect.objectContaining({ @@ -538,14 +538,14 @@ describe('getSharedTripData redaction (#2320)', () => { expect(JSON.stringify(data)).not.toContain('ChIJ123'); }); - it('SHARE-SVC-037: a stay carries its desk times and note, not its confirmation', () => { + it('SHARE-SVC-037: a stay carries its desk times and note, not its confirmation', async () => { const { trip, token } = seedSharedTrip(); const place = createPlace(testDb, trip.id, { name: 'Hotel' }); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); testDb.prepare(`INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) VALUES (?, ?, ?, ?, '15:00', '11:00', 'HOTEL-777', 'Ask for a quiet room')`).run(trip.id, place.id, day.id, day.id); - const data = svc.getSharedTripData(token)!; + const data = await (await svc.getSharedTripData(token))!; const [stay] = data.accommodations as any[]; expect(stay).toEqual(expect.objectContaining({ check_in: '15:00', check_out: '11:00', notes: 'Ask for a quiet room', place_name: 'Hotel' })); expect(stay).not.toHaveProperty('confirmation'); diff --git a/server/tests/unit/nest/storage/storage-admin.controller.test.ts b/server/tests/unit/nest/storage/storage-admin.controller.test.ts index cfcea78f2..fd8cd4c64 100644 --- a/server/tests/unit/nest/storage/storage-admin.controller.test.ts +++ b/server/tests/unit/nest/storage/storage-admin.controller.test.ts @@ -67,9 +67,9 @@ describe('StorageAdminController', () => { expect(service.state).toHaveBeenCalledTimes(1); }); - it('STORCTL-002 PUT applies, audits with secrets redacted, and answers the fresh state', () => { + it('STORCTL-002 PUT applies, audits with secrets redacted, and answers the fresh state', async () => { const { controller, service, writeAudit } = makeController(); - const result = controller.update(user, CONFIG, req); + const result = await controller.update(user, CONFIG, req); expect(service.applyConfig).toHaveBeenCalledWith(CONFIG); expect(result).toBe(FRESH_STATE); // never echoes the request expect(writeAudit).toHaveBeenCalledWith( @@ -80,14 +80,14 @@ describe('StorageAdminController', () => { expect(details.backends[0]!.options.accessKeyId).toBe('ak'); // names/shape survive redaction }); - it('STORCTL-003 PUT maps pipeline refusals to a 400 with the message verbatim, no audit', () => { + it('STORCTL-003 PUT maps pipeline refusals to a 400 with the message verbatim, no audit', async () => { const { controller, writeAudit } = makeController({ applyConfig: vi.fn(() => { throw new StorageBackendError("category 'backups' maps to unknown backend 'nope'"); }), }); try { - controller.update(user, CONFIG, req); + await controller.update(user, CONFIG, req); expect.unreachable('should have thrown'); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -99,7 +99,7 @@ describe('StorageAdminController', () => { expect(writeAudit).not.toHaveBeenCalled(); }); - it('STORCTL-023 PUT maps StorageConflictError to a 409, ahead of the blanket 400, no audit', () => { + it('STORCTL-023 PUT maps StorageConflictError to a 409, ahead of the blanket 400, no audit', async () => { const { controller, writeAudit } = makeController({ applyConfig: vi.fn(() => { // StorageConflictError IS an Error — this pins that the instanceof @@ -108,7 +108,7 @@ describe('StorageAdminController', () => { }), }); try { - controller.update(user, CONFIG, req); + await controller.update(user, CONFIG, req); expect.unreachable('should have thrown'); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -148,9 +148,9 @@ describe('StorageAdminController', () => { ).rejects.toMatchObject({ status: 400 }); }); - it('STORCTL-007 POST backfill starts the sync, audits the backend name, and answers { started: true }', () => { + it('STORCTL-007 POST backfill starts the sync, audits the backend name, and answers { started: true }', async () => { const { controller, service, writeAudit } = makeController({ startBackfill: vi.fn() }); - const result = controller.backfillStart(user, 'm', req); + const result = await controller.backfillStart(user, 'm', req); expect(service.startBackfill).toHaveBeenCalledWith('m'); expect(result).toEqual({ started: true }); expect(writeAudit).toHaveBeenCalledWith( @@ -158,14 +158,14 @@ describe('StorageAdminController', () => { ); }); - it('STORCTL-008 POST backfill maps BackfillTargetError to 404, no audit', () => { + it('STORCTL-008 POST backfill maps BackfillTargetError to 404, no audit', async () => { const { controller, writeAudit } = makeController({ startBackfill: vi.fn(() => { throw new BackfillTargetError("'ghost' is not a mirror routed by any category"); }), }); try { - controller.backfillStart(user, 'ghost', req); + await controller.backfillStart(user, 'ghost', req); expect.unreachable('should have thrown'); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -175,14 +175,14 @@ describe('StorageAdminController', () => { expect(writeAudit).not.toHaveBeenCalled(); }); - it('STORCTL-009 POST backfill maps BackfillBusyError to 409, no audit', () => { + it('STORCTL-009 POST backfill maps BackfillBusyError to 409, no audit', async () => { const { controller, writeAudit } = makeController({ startBackfill: vi.fn(() => { throw new BackfillBusyError('a sync is already running — one backfill at a time'); }), }); try { - controller.backfillStart(user, 'm', req); + await controller.backfillStart(user, 'm', req); expect.unreachable('should have thrown'); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -191,20 +191,20 @@ describe('StorageAdminController', () => { expect(writeAudit).not.toHaveBeenCalled(); }); - it('STORCTL-010 POST backfill rethrows an unrecognized error untouched', () => { + it('STORCTL-010 POST backfill rethrows an unrecognized error untouched', async () => { const boom = new Error('unexpected registry failure'); const { controller, writeAudit } = makeController({ startBackfill: vi.fn(() => { throw boom; }), }); - expect(() => controller.backfillStart(user, 'm', req)).toThrow(boom); + await expect(controller.backfillStart(user, 'm', req)).rejects.toThrow(boom); expect(writeAudit).not.toHaveBeenCalled(); }); - it('STORCTL-011 DELETE backfill cancels, audits, and answers { cancelled: true } when a sync is active', () => { + it('STORCTL-011 DELETE backfill cancels, audits, and answers { cancelled: true } when a sync is active', async () => { const { controller, service, writeAudit } = makeController({ cancelBackfill: vi.fn(() => true) }); - const result = controller.backfillCancel(user, 'm', req); + const result = await controller.backfillCancel(user, 'm', req); expect(service.cancelBackfill).toHaveBeenCalledWith('m'); expect(result).toEqual({ cancelled: true }); expect(writeAudit).toHaveBeenCalledWith( @@ -212,10 +212,10 @@ describe('StorageAdminController', () => { ); }); - it('STORCTL-012 DELETE backfill answers 404 with no audit when there is no active sync', () => { + it('STORCTL-012 DELETE backfill answers 404 with no audit when there is no active sync', async () => { const { controller, writeAudit } = makeController({ cancelBackfill: vi.fn(() => false) }); try { - controller.backfillCancel(user, 'ghost', req); + await controller.backfillCancel(user, 'ghost', req); expect.unreachable('should have thrown'); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -257,9 +257,9 @@ describe('StorageAdminController', () => { expect(writeAudit).not.toHaveBeenCalled(); }); - it('STORCTL-020 POST migrations starts and audits', () => { + it('STORCTL-020 POST migrations starts and audits', async () => { const { controller, service, writeAudit } = makeController({ startMigration: vi.fn() }); - const result = controller.migrationStart(user, { category: 'files', to: 'dest' }, req); + const result = await controller.migrationStart(user, { category: 'files', to: 'dest' }, req); expect(service.startMigration).toHaveBeenCalledWith('files', 'dest'); expect(result).toEqual({ started: true }); expect(writeAudit).toHaveBeenCalledWith( @@ -271,14 +271,14 @@ describe('StorageAdminController', () => { ); }); - it('STORCTL-021 POST maps MigrationRequestError→400, MigrationTargetError→404, BackfillBusyError→409, others rethrow', () => { + it('STORCTL-021 POST maps MigrationRequestError→400, MigrationTargetError→404, BackfillBusyError→409, others rethrow', async () => { const { controller: reqController, writeAudit: reqAudit } = makeController({ startMigration: vi.fn(() => { throw new MigrationRequestError("'files' is already on 'dest'"); }), }); try { - reqController.migrationStart(user, { category: 'files', to: 'dest' }, req); + await reqController.migrationStart(user, { category: 'files', to: 'dest' }, req); expect.unreachable('should have thrown'); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -293,7 +293,7 @@ describe('StorageAdminController', () => { }), }); try { - targetController.migrationStart(user, { category: 'files', to: 'ghost' }, req); + await targetController.migrationStart(user, { category: 'files', to: 'ghost' }, req); expect.unreachable('should have thrown'); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -308,7 +308,7 @@ describe('StorageAdminController', () => { }), }); try { - busyController.migrationStart(user, { category: 'files', to: 'dest' }, req); + await busyController.migrationStart(user, { category: 'files', to: 'dest' }, req); expect.unreachable('should have thrown'); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -322,13 +322,13 @@ describe('StorageAdminController', () => { throw boom; }), }); - expect(() => boomController.migrationStart(user, { category: 'files', to: 'dest' }, req)).toThrow(boom); + await expect(boomController.migrationStart(user, { category: 'files', to: 'dest' }, req)).rejects.toThrow(boom); expect(boomAudit).not.toHaveBeenCalled(); }); - it('STORCTL-022 DELETE migrations/:category cancels + audits; 404 when none running', () => { + it('STORCTL-022 DELETE migrations/:category cancels + audits; 404 when none running', async () => { const { controller, service, writeAudit } = makeController({ cancelMigration: vi.fn(() => true) }); - const result = controller.migrationCancel(user, 'files', req); + const result = await controller.migrationCancel(user, 'files', req); expect(service.cancelMigration).toHaveBeenCalledWith('files'); expect(result).toEqual({ cancelled: true }); expect(writeAudit).toHaveBeenCalledWith( @@ -339,7 +339,7 @@ describe('StorageAdminController', () => { cancelMigration: vi.fn(() => false), }); try { - noneController.migrationCancel(user, 'ghost', req); + await noneController.migrationCancel(user, 'ghost', req); expect.unreachable('should have thrown'); } catch (err) { expect(err).toBeInstanceOf(HttpException); diff --git a/server/tests/unit/nest/system-notices.controller.test.ts b/server/tests/unit/nest/system-notices.controller.test.ts index c81995437..8f0049762 100644 --- a/server/tests/unit/nest/system-notices.controller.test.ts +++ b/server/tests/unit/nest/system-notices.controller.test.ts @@ -30,52 +30,52 @@ function thrown(fn: () => unknown): { status: number; body: unknown } { describe('SystemNoticesController (parity with the legacy /api/system-notices route)', () => { describe('GET /active', () => { - it('returns the evaluated notices for the current user', () => { + it('returns the evaluated notices for the current user', async () => { const getActiveFor = vi.fn().mockReturnValue([notice]); - expect(makeController({ getActiveFor }).active(user)).toEqual([notice]); + expect(await makeController({ getActiveFor }).active(user)).toEqual([notice]); expect(getActiveFor).toHaveBeenCalledWith(7, new Set(), undefined); }); // The layouts a bundle announces with `?supports=` reach the service as a set, so // a bundle that predates the parameter announces nothing. - it('passes the announced layouts through as a set', () => { + it('passes the announced layouts through as a set', async () => { const getActiveFor = vi.fn().mockReturnValue([]); - makeController({ getActiveFor }).active(user, 'release'); + await makeController({ getActiveFor }).active(user, 'release'); expect(getActiveFor).toHaveBeenCalledWith(7, new Set(['release']), undefined); }); - it('passes the bundle version through, trimmed, and only when it is one string', () => { + it('passes the bundle version through, trimmed, and only when it is one string', async () => { const getActiveFor = vi.fn().mockReturnValue([]); const ctrl = makeController({ getActiveFor }); - ctrl.active(user, 'release', ' 4.3.0 '); + await ctrl.active(user, 'release', ' 4.3.0 '); expect(getActiveFor).toHaveBeenLastCalledWith(7, new Set(['release']), '4.3.0'); - ctrl.active(user, 'release', ['4.3.0', '4.3.1']); + await ctrl.active(user, 'release', ['4.3.0', '4.3.1']); expect(getActiveFor).toHaveBeenLastCalledWith(7, new Set(['release']), undefined); }); - it('splits a comma separated list and a repeated parameter alike', () => { + it('splits a comma separated list and a repeated parameter alike', async () => { const getActiveFor = vi.fn().mockReturnValue([]); const ctrl = makeController({ getActiveFor }); - ctrl.active(user, ' release, banner ,,'); + await ctrl.active(user, ' release, banner ,,'); expect(getActiveFor).toHaveBeenLastCalledWith(7, new Set(['release', 'banner']), undefined); - ctrl.active(user, ['release', 'banner']); + await ctrl.active(user, ['release', 'banner']); expect(getActiveFor).toHaveBeenLastCalledWith(7, new Set(['release', 'banner']), undefined); }); - it('announces nothing for an empty or missing parameter', () => { + it('announces nothing for an empty or missing parameter', async () => { const getActiveFor = vi.fn().mockReturnValue([]); const ctrl = makeController({ getActiveFor }); - ctrl.active(user, ''); + await ctrl.active(user, ''); expect(getActiveFor).toHaveBeenLastCalledWith(7, new Set(), undefined); - ctrl.active(user, undefined); + await ctrl.active(user, undefined); expect(getActiveFor).toHaveBeenLastCalledWith(7, new Set(), undefined); }); }); describe('POST /:id/dismiss', () => { - it('returns nothing (204) when the dismiss succeeds', () => { + it('returns nothing (204) when the dismiss succeeds', async () => { const dismiss = vi.fn().mockReturnValue(true); - expect(makeController({ dismiss }).dismiss(user, 'welcome')).toBeUndefined(); + expect(await makeController({ dismiss }).dismiss(user, 'welcome')).toBeUndefined(); expect(dismiss).toHaveBeenCalledWith(7, 'welcome'); }); diff --git a/server/tests/unit/nest/system-notices.service.test.ts b/server/tests/unit/nest/system-notices.service.test.ts index 1fbf25779..14e521a6e 100644 --- a/server/tests/unit/nest/system-notices.service.test.ts +++ b/server/tests/unit/nest/system-notices.service.test.ts @@ -29,9 +29,9 @@ beforeEach(() => { }); describe('SystemNoticesService', () => { - it('getActiveFor threads a live addonEnabled check over the injected AddonsService', () => { + it('getActiveFor threads a live addonEnabled check over the injected AddonsService', async () => { mockGetActive.mockReturnValue([]); - svc.getActiveFor(7); + await svc.getActiveFor(7); expect(mockGetActive).toHaveBeenCalledWith(7, expect.any(Function), false); const addonEnabled = mockGetActive.mock.calls[0][1] as (id: string) => boolean; expect(addonEnabled('journey')).toBe(true); @@ -39,9 +39,9 @@ describe('SystemNoticesService', () => { expect(isAddonEnabled).toHaveBeenLastCalledWith('vacay'); }); - it('dismiss passes through to the upstream service', () => { + it('dismiss passes through to the upstream service', async () => { mockDismiss.mockReturnValue(true); - expect(svc.dismiss(7, 'welcome')).toBe(true); + expect(await svc.dismiss(7, 'welcome')).toBe(true); expect(mockDismiss).toHaveBeenCalledWith(7, 'welcome'); }); @@ -61,47 +61,47 @@ describe('SystemNoticesService', () => { release: { version: '4.3.0', headlineKey: 'system_notice.release_notes.headline' }, }; - it('drops a notice with a release block when nothing is announced', () => { + it('drops a notice with a release block when nothing is announced', async () => { mockGetActive.mockReturnValue([release, generic]); - expect(svc.getActiveFor(7).map(n => n.id)).toEqual(['outage']); + expect((await svc.getActiveFor(7)).map(n => n.id)).toEqual(['outage']); }); - it('drops it when other layouts are announced but not release', () => { + it('drops it when other layouts are announced but not release', async () => { mockGetActive.mockReturnValue([release, generic]); - expect(svc.getActiveFor(7, new Set(['banner'])).map(n => n.id)).toEqual(['outage']); + expect((await svc.getActiveFor(7, new Set(['banner']))).map(n => n.id)).toEqual(['outage']); }); - it('delivers it, in place, once the client announces the release layout for the running version', () => { + it('delivers it, in place, once the client announces the release layout for the running version', async () => { mockGetActive.mockReturnValue([release, generic]); - expect(svc.getActiveFor(7, new Set(['release']), '4.3.0').map(n => n.id)).toEqual(['release-notes', 'outage']); + expect((await svc.getActiveFor(7, new Set(['release']), '4.3.0')).map(n => n.id)).toEqual(['release-notes', 'outage']); }); - it('drops it for a bundle that announces the layout but was built for another version', () => { + it('drops it for a bundle that announces the layout but was built for another version', async () => { // The shell the service worker serves right after an update: it can draw the // layout, but with the texts of the version it was built for, and its X would // use the notice up for the version now running. mockGetActive.mockReturnValue([release, generic]); - expect(svc.getActiveFor(7, new Set(['release']), '4.2.1').map(n => n.id)).toEqual(['outage']); + expect((await svc.getActiveFor(7, new Set(['release']), '4.2.1')).map(n => n.id)).toEqual(['outage']); mockAppVersion.mockReturnValueOnce('4.3.1'); - expect(svc.getActiveFor(7, new Set(['release']), '4.3.0').map(n => n.id)).toEqual(['outage']); + expect((await svc.getActiveFor(7, new Set(['release']), '4.3.0')).map(n => n.id)).toEqual(['outage']); }); - it('drops it for a bundle that names no version at all', () => { + it('drops it for a bundle that names no version at all', async () => { mockGetActive.mockReturnValue([release, generic]); - expect(svc.getActiveFor(7, new Set(['release'])).map(n => n.id)).toEqual(['outage']); - expect(svc.getActiveFor(7, new Set(['release']), '').map(n => n.id)).toEqual(['outage']); - expect(svc.getActiveFor(7, new Set(['release']), 'dev').map(n => n.id)).toEqual(['outage']); + expect((await svc.getActiveFor(7, new Set(['release']))).map(n => n.id)).toEqual(['outage']); + expect((await svc.getActiveFor(7, new Set(['release']), '')).map(n => n.id)).toEqual(['outage']); + expect((await svc.getActiveFor(7, new Set(['release']), 'dev')).map(n => n.id)).toEqual(['outage']); }); - it('reads the version loosely, as the server reads its own', () => { + it('reads the version loosely, as the server reads its own', async () => { mockGetActive.mockReturnValue([release, generic]); - expect(svc.getActiveFor(7, new Set(['release']), 'v4.3.0').map(n => n.id)).toEqual(['release-notes', 'outage']); + expect((await svc.getActiveFor(7, new Set(['release']), 'v4.3.0')).map(n => n.id)).toEqual(['release-notes', 'outage']); }); - it('always delivers a notice without a release block', () => { + it('always delivers a notice without a release block', async () => { mockGetActive.mockReturnValue([generic]); - expect(svc.getActiveFor(7).map(n => n.id)).toEqual(['outage']); - expect(svc.getActiveFor(7, new Set(['release']), '4.3.0').map(n => n.id)).toEqual(['outage']); + expect((await svc.getActiveFor(7)).map(n => n.id)).toEqual(['outage']); + expect((await svc.getActiveFor(7, new Set(['release']), '4.3.0')).map(n => n.id)).toEqual(['outage']); }); }); @@ -115,7 +115,7 @@ describe('SystemNoticesService', () => { const { SystemNoticesService: Reloaded } = await import('../../../src/nest/system-notices/system-notices.service'); const inst = new Reloaded({ isAddonEnabled } as unknown as AddonsService, { isManaged: () => false } as unknown as RuntimeEnvService); mockGetActive.mockReturnValue([]); - inst.getActiveFor(1); + await inst.getActiveFor(1); expect(mockGetActive).toHaveBeenCalledWith(1, expect.any(Function), false); vi.doUnmock('../../../src/nest/addons/addons.service'); }); diff --git a/server/tests/unit/nest/tags.controller.test.ts b/server/tests/unit/nest/tags.controller.test.ts index be2f2d709..3a02a6f24 100644 --- a/server/tests/unit/nest/tags.controller.test.ts +++ b/server/tests/unit/nest/tags.controller.test.ts @@ -14,9 +14,9 @@ function makeController(svc: Partial) { const tag: Tag = { id: 1, user_id: 5, name: 'Beach', color: '#10b981' }; -function thrown(fn: () => unknown): { status: number; body: unknown } { +async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> { try { - fn(); + await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; @@ -26,61 +26,61 @@ function thrown(fn: () => unknown): { status: number; body: unknown } { } describe('TagsController (parity with the legacy /api/tags route)', () => { - it('GET / returns the caller\'s tags wrapped in { tags }', () => { + it('GET / returns the caller\'s tags wrapped in { tags }', async () => { const list = vi.fn().mockReturnValue([tag]); - expect(makeController({ list }).list(user)).toEqual({ tags: [tag] }); + expect(await makeController({ list }).list(user)).toEqual({ tags: [tag] }); expect(list).toHaveBeenCalledWith(5); }); describe('POST /', () => { - it('400 when name is missing', () => { + it('400 when name is missing', async () => { const create = vi.fn(); - expect(thrown(() => makeController({ create }).create(user, anyBody()))).toEqual({ + expect(await thrown(() => makeController({ create }).create(user, anyBody()))).toEqual({ status: 400, body: { error: 'Tag name is required' }, }); expect(create).not.toHaveBeenCalled(); }); - it('creates a tag for the caller', () => { + it('creates a tag for the caller', async () => { const create = vi.fn().mockReturnValue(tag); - expect(makeController({ create }).create(user, anyBody({ name: 'Beach', color: '#10b981' }))).toEqual({ tag }); + expect(await makeController({ create }).create(user, anyBody({ name: 'Beach', color: '#10b981' }))).toEqual({ tag }); expect(create).toHaveBeenCalledWith(5, 'Beach', '#10b981'); }); }); describe('PUT /:id', () => { - it('404 when the tag is not owned by the caller', () => { + it('404 when the tag is not owned by the caller', async () => { const getByIdAndUser = vi.fn().mockReturnValue(undefined); const update = vi.fn(); - expect(thrown(() => makeController({ getByIdAndUser, update }).update(user, '9', anyBody({ name: 'X' })))).toEqual({ + expect(await thrown(() => makeController({ getByIdAndUser, update }).update(user, '9', anyBody({ name: 'X' })))).toEqual({ status: 404, body: { error: 'Tag not found' }, }); expect(getByIdAndUser).toHaveBeenCalledWith('9', 5); expect(update).not.toHaveBeenCalled(); }); - it('updates an owned tag', () => { + it('updates an owned tag', async () => { const getByIdAndUser = vi.fn().mockReturnValue(tag); const update = vi.fn().mockReturnValue({ ...tag, name: 'Hike' }); - expect(makeController({ getByIdAndUser, update }).update(user, '1', anyBody({ name: 'Hike' }))).toEqual({ tag: { ...tag, name: 'Hike' } }); + expect(await makeController({ getByIdAndUser, update }).update(user, '1', anyBody({ name: 'Hike' }))).toEqual({ tag: { ...tag, name: 'Hike' } }); expect(update).toHaveBeenCalledWith('1', 'Hike', undefined); }); }); describe('DELETE /:id', () => { - it('404 when the tag is not owned by the caller', () => { + it('404 when the tag is not owned by the caller', async () => { const getByIdAndUser = vi.fn().mockReturnValue(undefined); const remove = vi.fn(); - expect(thrown(() => makeController({ getByIdAndUser, remove }).remove(user, '9'))).toEqual({ + expect(await thrown(() => makeController({ getByIdAndUser, remove }).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Tag not found' }, }); expect(remove).not.toHaveBeenCalled(); }); - it('deletes an owned tag', () => { + it('deletes an owned tag', async () => { const getByIdAndUser = vi.fn().mockReturnValue(tag); const remove = vi.fn(); - expect(makeController({ getByIdAndUser, remove }).remove(user, '1')).toEqual({ success: true }); + expect(await makeController({ getByIdAndUser, remove }).remove(user, '1')).toEqual({ success: true }); expect(remove).toHaveBeenCalledWith('1'); }); }); diff --git a/server/tests/unit/nest/tags.service.test.ts b/server/tests/unit/nest/tags.service.test.ts index f5847ee57..257da9d78 100644 --- a/server/tests/unit/nest/tags.service.test.ts +++ b/server/tests/unit/nest/tags.service.test.ts @@ -58,27 +58,27 @@ afterAll(() => { // ── list ────────────────────────────────────────────────────────────────────── describe('list', () => { - it('TAG-SVC-001 — returns empty array when user has no tags', () => { + it('TAG-SVC-001 — returns empty array when user has no tags', async () => { const { user } = createUser(testDb); - expect(svc.list(user.id)).toEqual([]); + expect(await svc.list(user.id)).toEqual([]); }); - it('TAG-SVC-002 — returns only tags belonging to the user', () => { + it('TAG-SVC-002 — returns only tags belonging to the user', async () => { const { user: a } = createUser(testDb); const { user: b } = createUser(testDb); - svc.create(a.id, 'A-Tag'); - svc.create(b.id, 'B-Tag'); - const tags = svc.list(a.id); + await svc.create(a.id, 'A-Tag'); + await svc.create(b.id, 'B-Tag'); + const tags = await svc.list(a.id); expect(tags).toHaveLength(1); expect(tags[0].name).toBe('A-Tag'); }); - it('TAG-SVC-003 — results are ordered by name ascending', () => { + it('TAG-SVC-003 — results are ordered by name ascending', async () => { const { user } = createUser(testDb); - svc.create(user.id, 'Zebra'); - svc.create(user.id, 'Apple'); - svc.create(user.id, 'Mango'); - const names = svc.list(user.id).map((t) => t.name); + await svc.create(user.id, 'Zebra'); + await svc.create(user.id, 'Apple'); + await svc.create(user.id, 'Mango'); + const names = (await svc.list(user.id)).map((t) => t.name); expect(names).toEqual(['Apple', 'Mango', 'Zebra']); }); }); @@ -86,23 +86,23 @@ describe('list', () => { // ── create ──────────────────────────────────────────────────────────────────── describe('create', () => { - it('TAG-SVC-004 — creates a tag with provided name and color', () => { + it('TAG-SVC-004 — creates a tag with provided name and color', async () => { const { user } = createUser(testDb); - const tag = svc.create(user.id, 'Beach', '#ff0000'); + const tag = await svc.create(user.id, 'Beach', '#ff0000'); expect(tag.name).toBe('Beach'); expect(tag.color).toBe('#ff0000'); expect(tag.user_id).toBe(user.id); }); - it('TAG-SVC-005 — defaults to #10b981 when no color provided', () => { + it('TAG-SVC-005 — defaults to #10b981 when no color provided', async () => { const { user } = createUser(testDb); - const tag = svc.create(user.id, 'Default'); + const tag = await svc.create(user.id, 'Default'); expect(tag.color).toBe('#10b981'); }); - it('TAG-SVC-006 — returns the inserted row with an id', () => { + it('TAG-SVC-006 — returns the inserted row with an id', async () => { const { user } = createUser(testDb); - const tag = svc.create(user.id, 'WithId'); + const tag = await svc.create(user.id, 'WithId'); expect(typeof tag.id).toBe('number'); expect(tag.id).toBeGreaterThan(0); }); @@ -111,50 +111,50 @@ describe('create', () => { // ── getByIdAndUser ──────────────────────────────────────────────────────────── describe('getByIdAndUser', () => { - it('TAG-SVC-007 — returns the tag when id and user_id match', () => { + it('TAG-SVC-007 — returns the tag when id and user_id match', async () => { const { user } = createUser(testDb); - const created = svc.create(user.id, 'Find Me'); - const found = svc.getByIdAndUser(created.id, user.id); + const created = await svc.create(user.id, 'Find Me'); + const found = await svc.getByIdAndUser(created.id, user.id); expect(found).toBeDefined(); expect(found?.name).toBe('Find Me'); }); - it('TAG-SVC-008 — returns undefined when tag belongs to different user', () => { + it('TAG-SVC-008 — returns undefined when tag belongs to different user', async () => { const { user: a } = createUser(testDb); const { user: b } = createUser(testDb); - const tag = svc.create(a.id, 'Private'); - expect(svc.getByIdAndUser(tag.id, b.id)).toBeUndefined(); + const tag = await svc.create(a.id, 'Private'); + expect(await svc.getByIdAndUser(tag.id, b.id)).toBeUndefined(); }); - it('TAG-SVC-009 — returns undefined for non-existent tag id', () => { + it('TAG-SVC-009 — returns undefined for non-existent tag id', async () => { const { user } = createUser(testDb); - expect(svc.getByIdAndUser(99999, user.id)).toBeUndefined(); + expect(await svc.getByIdAndUser(99999, user.id)).toBeUndefined(); }); }); // ── update ──────────────────────────────────────────────────────────────────── describe('update', () => { - it('TAG-SVC-010 — updates both name and color', () => { + it('TAG-SVC-010 — updates both name and color', async () => { const { user } = createUser(testDb); - const tag = svc.create(user.id, 'Old', '#aaaaaa'); - const updated = svc.update(tag.id, 'New', '#bbbbbb'); + const tag = await svc.create(user.id, 'Old', '#aaaaaa'); + const updated = await svc.update(tag.id, 'New', '#bbbbbb'); expect(updated.name).toBe('New'); expect(updated.color).toBe('#bbbbbb'); }); - it('TAG-SVC-011 — COALESCE: omitting name preserves existing name', () => { + it('TAG-SVC-011 — COALESCE: omitting name preserves existing name', async () => { const { user } = createUser(testDb); - const tag = svc.create(user.id, 'KeepMe', '#aaaaaa'); - const updated = svc.update(tag.id, undefined, '#cccccc'); + const tag = await svc.create(user.id, 'KeepMe', '#aaaaaa'); + const updated = await svc.update(tag.id, undefined, '#cccccc'); expect(updated.name).toBe('KeepMe'); expect(updated.color).toBe('#cccccc'); }); - it('TAG-SVC-012 — COALESCE: omitting color preserves existing color', () => { + it('TAG-SVC-012 — COALESCE: omitting color preserves existing color', async () => { const { user } = createUser(testDb); - const tag = svc.create(user.id, 'ColorKeep', '#dddddd'); - const updated = svc.update(tag.id, 'NewName', undefined); + const tag = await svc.create(user.id, 'ColorKeep', '#dddddd'); + const updated = await svc.update(tag.id, 'NewName', undefined); expect(updated.name).toBe('NewName'); expect(updated.color).toBe('#dddddd'); }); @@ -163,23 +163,23 @@ describe('update', () => { // ── remove ──────────────────────────────────────────────────────────────────── describe('remove', () => { - it('TAG-SVC-013 — deletes the tag from the database', () => { + it('TAG-SVC-013 — deletes the tag from the database', async () => { const { user } = createUser(testDb); - const tag = svc.create(user.id, 'ToDelete'); - svc.remove(tag.id); - expect(svc.getByIdAndUser(tag.id, user.id)).toBeUndefined(); + const tag = await svc.create(user.id, 'ToDelete'); + await svc.remove(tag.id); + expect(await svc.getByIdAndUser(tag.id, user.id)).toBeUndefined(); }); - it('TAG-SVC-014 — deleting a non-existent tag does not throw', () => { - expect(() => svc.remove(99999)).not.toThrow(); + it('TAG-SVC-014 — deleting a non-existent tag does not throw', async () => { + await expect(svc.remove(99999)).resolves.not.toThrow(); }); - it('TAG-SVC-015 — deleting one tag does not affect other tags', () => { + it('TAG-SVC-015 — deleting one tag does not affect other tags', async () => { const { user } = createUser(testDb); - const t1 = svc.create(user.id, 'Keep'); - const t2 = svc.create(user.id, 'Remove'); - svc.remove(t2.id); - const remaining = svc.list(user.id); + const t1 = await svc.create(user.id, 'Keep'); + const t2 = await svc.create(user.id, 'Remove'); + await svc.remove(t2.id); + const remaining = await svc.list(user.id); expect(remaining).toHaveLength(1); expect(remaining[0].id).toBe(t1.id); }); diff --git a/server/tests/unit/nest/todo.service.test.ts b/server/tests/unit/nest/todo.service.test.ts index b3fcc351e..ef86f1c28 100644 --- a/server/tests/unit/nest/todo.service.test.ts +++ b/server/tests/unit/nest/todo.service.test.ts @@ -48,8 +48,13 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { TodoService } from '../../../src/nest/todo/todo.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; -const svc = new TodoService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); +let svc: TodoService; +beforeAll(async () => { + svc = new TodoService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService()); +}); beforeAll(() => { createTables(testDb); @@ -326,21 +331,21 @@ describe('getCategoryAssignees / updateCategoryAssignees', () => { * an HTTP guard, and it is tested directly here for the same reason. */ describe('TodoService.canEdit', () => { - it('TODO-SVC-090 asks for packing_edit and flags a non-owner as shared', () => { + it('TODO-SVC-090 asks for packing_edit and flags a non-owner as shared', async () => { const checkPermission = vi.fn(() => true); const permissions = { checkPermission } as unknown as PermissionsService; const withStub = new TodoService(new DatabaseService(testDb), permissions, new RealtimeService()); const trip = { id: 1, user_id: 1 } as never; - expect(withStub.canEdit(trip, { id: 1, role: 'user' } as never)).toBe(true); + expect(await withStub.canEdit(trip, { id: 1, role: 'user' } as never)).toBe(true); expect(checkPermission).toHaveBeenLastCalledWith('packing_edit', 'user', 1, 1, false); - withStub.canEdit(trip, { id: 2, role: 'user' } as never); + await withStub.canEdit(trip, { id: 2, role: 'user' } as never); // The shared flag is what the guard has to reproduce; getting it wrong would give a // member the owner's rights on somebody else's trip. expect(checkPermission).toHaveBeenLastCalledWith('packing_edit', 'user', 1, 2, true); checkPermission.mockReturnValue(false); - expect(withStub.canEdit(trip, { id: 2, role: 'user' } as never)).toBe(false); + expect(await withStub.canEdit(trip, { id: 2, role: 'user' } as never)).toBe(false); }); }); diff --git a/server/tests/unit/nest/trip-access.guard.test.ts b/server/tests/unit/nest/trip-access.guard.test.ts index c84a1351f..ea842ab77 100644 --- a/server/tests/unit/nest/trip-access.guard.test.ts +++ b/server/tests/unit/nest/trip-access.guard.test.ts @@ -50,9 +50,9 @@ function ctx(request: Record) { } as never; } -const thrown = (run: () => unknown) => { +const thrown = async (run: () => unknown) => { try { - run(); + await run(); return null; } catch (e) { return e instanceof HttpException ? { status: e.getStatus(), body: e.getResponse() } : e; @@ -60,86 +60,86 @@ const thrown = (run: () => unknown) => { }; describe('TripAccessGuard', () => { - it('TRIPGUARD-001 lets a member through and parks the trip row on the request', () => { + it('TRIPGUARD-001 lets a member through and parks the trip row on the request', async () => { const { guard, canAccessTrip } = makeGuard(); const request = { user: member, params: { tripId: '5' } } as Record; - expect(guard.canActivate(ctx(request))).toBe(true); + expect(await guard.canActivate(ctx(request))).toBe(true); expect(canAccessTrip).toHaveBeenCalledWith(5, 7); expect(request[TRIP_REQUEST_KEY]).toBe(TRIP); }); - it('TRIPGUARD-002 a trip the user cannot reach is 404 "Trip not found", never 403', () => { + it('TRIPGUARD-002 a trip the user cannot reach is 404 "Trip not found", never 403', async () => { const { guard } = makeGuard(); const stranger = { user: { id: 99, role: 'user' }, params: { tripId: '5' } }; - expect(thrown(() => guard.canActivate(ctx(stranger)))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(await thrown(() => guard.canActivate(ctx(stranger)))).toEqual({ status: 404, body: { error: 'Trip not found' } }); const missing = { user: owner, params: { tripId: '404' } }; - expect(thrown(() => guard.canActivate(ctx(missing)))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(await thrown(() => guard.canActivate(ctx(missing)))).toEqual({ status: 404, body: { error: 'Trip not found' } }); }); - it('TRIPGUARD-003 a non-numeric tripId is refused without touching the database', () => { + it('TRIPGUARD-003 a non-numeric tripId is refused without touching the database', async () => { const { guard, canAccessTrip } = makeGuard(); const request = { user: owner, params: { tripId: 'not-a-number' } }; - expect(thrown(() => guard.canActivate(ctx(request)))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(await thrown(() => guard.canActivate(ctx(request)))).toEqual({ status: 404, body: { error: 'Trip not found' } }); expect(canAccessTrip).not.toHaveBeenCalled(); }); - it('TRIPGUARD-004 a missing tripId param is refused rather than read as NaN', () => { + it('TRIPGUARD-004 a missing tripId param is refused rather than read as NaN', async () => { const { guard } = makeGuard(); - expect(thrown(() => guard.canActivate(ctx({ user: owner, params: {} })))).toEqual({ + expect(await thrown(() => guard.canActivate(ctx({ user: owner, params: {} })))).toEqual({ status: 404, body: { error: 'Trip not found' }, }); - expect(thrown(() => guard.canActivate(ctx({ user: owner })))).toEqual({ + expect(await thrown(() => guard.canActivate(ctx({ user: owner })))).toEqual({ status: 404, body: { error: 'Trip not found' }, }); }); - it('TRIPGUARD-005 no authenticated user is a 401, not a crash on user.id', () => { + it('TRIPGUARD-005 no authenticated user is a 401, not a crash on user.id', async () => { // JwtAuthGuard runs first, so this only happens when a route was wired without it. // Refusing beats turning a wiring mistake into a 500 nobody can read. const { guard, canAccessTrip } = makeGuard(); - expect(thrown(() => guard.canActivate(ctx({ params: { tripId: '5' } })))).toEqual({ + expect(await thrown(() => guard.canActivate(ctx({ params: { tripId: '5' } })))).toEqual({ status: 401, body: { error: 'Unauthorized' }, }); expect(canAccessTrip).not.toHaveBeenCalled(); }); - it('TRIPGUARD-006 without @RequirePermission the permission service is never consulted', () => { + it('TRIPGUARD-006 without @RequirePermission the permission service is never consulted', async () => { const { guard, checkPermission } = makeGuard(); - guard.canActivate(ctx({ user: member, params: { tripId: '5' } })); + await guard.canActivate(ctx({ user: member, params: { tripId: '5' } })); expect(checkPermission).not.toHaveBeenCalled(); }); - it('TRIPGUARD-007 @RequirePermission passes the action and the shared flag through', () => { + it('TRIPGUARD-007 @RequirePermission passes the action and the shared flag through', async () => { const checkPermission = vi.fn(() => true); const { guard } = makeGuard({ checkPermission, action: 'day_edit' }); // A member editing somebody else's trip is the SHARED case… - guard.canActivate(ctx({ user: member, params: { tripId: '5' } })); + await guard.canActivate(ctx({ user: member, params: { tripId: '5' } })); expect(checkPermission).toHaveBeenLastCalledWith('day_edit', 'user', 42, 7, true); // …and the owner editing their own is not. - guard.canActivate(ctx({ user: owner, params: { tripId: '5' } })); + await guard.canActivate(ctx({ user: owner, params: { tripId: '5' } })); expect(checkPermission).toHaveBeenLastCalledWith('day_edit', 'user', 42, 42, false); }); - it('TRIPGUARD-008 a refused permission is 403 "No permission", and access still came first', () => { + it('TRIPGUARD-008 a refused permission is 403 "No permission", and access still came first', async () => { const { guard } = makeGuard({ checkPermission: vi.fn(() => false), action: 'day_edit' }); - expect(thrown(() => guard.canActivate(ctx({ user: member, params: { tripId: '5' } })))).toEqual({ + expect(await thrown(() => guard.canActivate(ctx({ user: member, params: { tripId: '5' } })))).toEqual({ status: 403, body: { error: 'No permission' }, }); // A stranger gets the 404 rather than the 403: access is checked before rights, so // the permission answer never leaks that the trip exists. - expect(thrown(() => guard.canActivate(ctx({ user: { id: 99, role: 'user' }, params: { tripId: '5' } })))).toEqual({ + expect(await thrown(() => guard.canActivate(ctx({ user: { id: 99, role: 'user' }, params: { tripId: '5' } })))).toEqual({ status: 404, body: { error: 'Trip not found' }, }); }); - it('TRIPGUARD-009 the metadata is read from the handler first, then the class', () => { + it('TRIPGUARD-009 the metadata is read from the handler first, then the class', async () => { const { guard, reflector } = makeGuard({ action: 'day_edit' }); - guard.canActivate(ctx({ user: owner, params: { tripId: '5' } })); + await guard.canActivate(ctx({ user: owner, params: { tripId: '5' } })); expect(reflector.getAllAndOverride).toHaveBeenCalledWith(TRIP_PERMISSION_KEY, [expect.any(Function), expect.any(Function)]); }); diff --git a/server/tests/unit/nest/trip-invite.service.test.ts b/server/tests/unit/nest/trip-invite.service.test.ts index 06d62d25a..e4c60aff2 100644 --- a/server/tests/unit/nest/trip-invite.service.test.ts +++ b/server/tests/unit/nest/trip-invite.service.test.ts @@ -40,11 +40,16 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service'; import { TripInviteService } from '../../../src/nest/trip-invite/trip-invite.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; // One DatabaseService over the shared in-memory handle, so every collaborator // reads and writes the same rows. const dbs = new DatabaseService(testDb); -const svc = new TripInviteService(dbs, new PermissionsService(dbs), new TripMembershipService(dbs)); +let svc: TripInviteService; +beforeAll(async () => { + svc = new TripInviteService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new TripMembershipService(dbs)); +}); beforeAll(() => { createTables(testDb); runMigrations(testDb); }); beforeEach(() => resetTestDb(testDb)); diff --git a/server/tests/unit/nest/trip-members.controller.test.ts b/server/tests/unit/nest/trip-members.controller.test.ts index 06748b868..ff240991c 100644 --- a/server/tests/unit/nest/trip-members.controller.test.ts +++ b/server/tests/unit/nest/trip-members.controller.test.ts @@ -56,12 +56,12 @@ describe('members', () => { expect(tc(s).members(user, '9')).toEqual({ owner: { id: 1 }, members: [], current_user_id: 1 }); }); - it('POST 403 without member_manage, else adds + notifies', () => { + it('POST 403 without member_manage, else adds + notifies', async () => { expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({ status: 403, body: { error: 'No permission to manage members' } }); const addMember = vi.fn().mockReturnValue({ member: { id: 2, email: 'bob@x.y' }, targetUserId: 2, tripTitle: 'T' }); const notifyInvite = vi.fn(); const s = svc({ addMember, notifyInvite } as Partial); - expect(tc(s).addMember(user, '9', { identifier: 'bob@x.y' })).toEqual({ member: { id: 2, email: 'bob@x.y' } }); + expect(await tc(s).addMember(user, '9', { identifier: 'bob@x.y' })).toEqual({ member: { id: 2, email: 'bob@x.y' } }); expect(notifyInvite).toHaveBeenCalledWith('9', user, 2, 'T', 'bob@x.y'); }); @@ -70,13 +70,13 @@ describe('members', () => { expect(thrown(() => tc(s).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({ status: 404, body: { error: 'Trip not found' } }); }); - it('POST maps NotFoundError to 404, ValidationError to 400, re-throws others', () => { + it('POST maps NotFoundError to 404, ValidationError to 400, re-throws others', async () => { const nf = svc({ addMember: vi.fn().mockImplementation(() => { throw new NotFoundError('no user'); }) } as Partial); expect(thrown(() => tc(nf).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({ status: 404, body: { error: 'no user' } }); const ve = svc({ addMember: vi.fn().mockImplementation(() => { throw new ValidationError('already a member'); }) } as Partial); expect(thrown(() => tc(ve).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({ status: 400, body: { error: 'already a member' } }); const other = svc({ addMember: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial); - expect(() => tc(other).addMember(user, '9', { identifier: 'bob@x.y' })).toThrow('boom'); + await expect(tc(other).addMember(user, '9', { identifier: 'bob@x.y' })).rejects.toThrow('boom'); }); it('DELETE 404 without trip access', () => { @@ -84,11 +84,11 @@ describe('members', () => { expect(thrown(() => tc(s).removeMember(user, '9', '2'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); }); - it('DELETE self needs no permission; removing others needs member_manage', () => { + it('DELETE self needs no permission; removing others needs member_manage', async () => { const removeMember = vi.fn(); const s = svc({ can: vi.fn().mockReturnValue(false), removeMember } as Partial); // self-removal (targetId === user.id) bypasses the permission check - expect(tc(s).removeMember(user, '9', '1')).toEqual({ success: true }); + expect(await tc(s).removeMember(user, '9', '1')).toEqual({ success: true }); expect(thrown(() => tc(s).removeMember(user, '9', '2'))).toEqual({ status: 403, body: { error: 'No permission to remove members' } }); }); }); @@ -107,12 +107,12 @@ describe('POST /:id/transfer (#973)', () => { expect(tripTransferOwnershipRequestSchema.safeParse({}).success).toBe(false); }); - it('transfers, audits and broadcasts the refreshed trip', () => { + it('transfers, audits and broadcasts the refreshed trip', async () => { const transferOwnership = vi.fn().mockReturnValue({ tripTitle: 'Roadtrip', fromEmail: 'a@x.y', toEmail: 'b@x.y' }); const getTripForViewer = vi.fn().mockReturnValue({ id: 9, user_id: 2 }); const broadcast = vi.fn(); const s = svc({ transferOwnership, getTripForViewer, broadcast } as Partial); - expect(tc(s).transferOwnership(user, '9', { newOwnerId: 2 }, req, 'sock')).toEqual({ success: true }); + expect(await tc(s).transferOwnership(user, '9', { newOwnerId: 2 }, req, 'sock')).toEqual({ success: true }); expect(transferOwnership).toHaveBeenCalledWith('9', 2, user.id); expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { trip: { id: 9, user_id: 2 } }, 'sock'); }); diff --git a/server/tests/unit/nest/trip-members.service.test.ts b/server/tests/unit/nest/trip-members.service.test.ts index a37806f9f..98246b185 100644 --- a/server/tests/unit/nest/trip-members.service.test.ts +++ b/server/tests/unit/nest/trip-members.service.test.ts @@ -65,10 +65,17 @@ import { TripMembersService } from '../../../src/nest/trip-members/trip-members. import { NotFoundError, ValidationError } from '../../../src/nest/common/domain-errors'; import type { User } from '../../../src/types'; import { notificationsStub } from '../../helpers/notifications'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; const dbs = () => new DatabaseService(testDb); -const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService()); -const roster = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(notifySend)); + +let budgetSvc: BudgetService; +let roster: TripMembersService; +beforeAll(async () => { + budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService()); + roster = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(notifySend)); +}); /** * A roster whose connection reports "no such row" for the first result of every @@ -77,7 +84,7 @@ const roster = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(d * the read is the only way to keep those branches honest. Same Proxy shape as * the failingConnection helper in trips.service.test.ts. */ -function rosterWithMissingRow(match: string) { +async function rosterWithMissingRow(match: string) { const conn = new Proxy(testDb, { get(target, prop) { if (prop === 'prepare') { @@ -98,7 +105,7 @@ function rosterWithMissingRow(match: string) { }, }); const frozen = { connection: conn, canAccessTrip: dbMock.canAccessTrip, isOwner: dbMock.isOwner } as unknown as DatabaseService; - return new TripMembersService(frozen, budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(notifySend)); + return new TripMembersService(frozen, budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(notifySend)); } beforeAll(() => { @@ -134,15 +141,15 @@ describe('TripMembersService delegation', () => { expect(roster.canAccessTrip(trip.id, stranger.id)).toBeUndefined(); }); - it('MEMBERS-SVC-002: can() resolves member_manage at its trip_owner default and lets admins through', () => { + it('MEMBERS-SVC-002: can() resolves member_manage at its trip_owner default and lets admins through', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); // Inviting and evicting both hang off this single call, so a member must not // pass it while the action sits at its default level. - expect(roster.can('member_manage', 'user', owner.id, owner.id, false)).toBe(true); - expect(roster.can('member_manage', 'user', owner.id, member.id, true)).toBe(false); - expect(roster.can('member_manage', 'admin', owner.id, member.id, true)).toBe(true); + expect(await roster.can('member_manage', 'user', owner.id, owner.id, false)).toBe(true); + expect(await roster.can('member_manage', 'user', owner.id, member.id, true)).toBe(false); + expect(await roster.can('member_manage', 'admin', owner.id, member.id, true)).toBe(true); }); it('MEMBERS-SVC-003: broadcast forwards the socket id so the originating client is not echoed', () => { @@ -201,7 +208,7 @@ describe('TripMembersService delegation', () => { // ── addMember fallbacks ────────────────────────────────────────────────────── describe('addMember fallbacks', () => { - it("MEMBERS-SVC-007: addMember still reports a title when the trip row cannot be read ('Untitled')", () => { + it("MEMBERS-SVC-007: addMember still reports a title when the trip row cannot be read ('Untitled')", async () => { const { user: owner } = createUser(testDb); const { user: invitee } = createUser(testDb); const trip = createTrip(testDb, owner.id, { title: 'Readable' }); @@ -209,7 +216,7 @@ describe('addMember fallbacks', () => { // The title only feeds the invite notification, and it is read after the // membership is inserted — losing that row must not cost the invitee their // access or throw on an undefined title. - const broken = rosterWithMissingRow('SELECT title FROM trips WHERE id = ?'); + const broken = await rosterWithMissingRow('SELECT title FROM trips WHERE id = ?'); const result = broken.addMember(trip.id, invitee.email, owner.id, owner.id); expect(result.tripTitle).toBe('Untitled'); expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id)).toBeDefined(); @@ -251,7 +258,7 @@ describe('transferOwnership guard rails', () => { expect((testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }).user_id).toBe(owner.id); }); - it('MEMBERS-SVC-011: completes with an empty fromEmail when the former owner cannot be read', () => { + it('MEMBERS-SVC-011: completes with an empty fromEmail when the former owner cannot be read', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); @@ -260,7 +267,7 @@ describe('transferOwnership guard rails', () => { // fromEmail is audit detail only; an unreadable row must not abort the // handover halfway, which would leave the owner pointer and the member rows // disagreeing about who owns the trip. - const broken = rosterWithMissingRow('SELECT email FROM users WHERE id = ?'); + const broken = await rosterWithMissingRow('SELECT email FROM users WHERE id = ?'); const result = broken.transferOwnership(trip.id, member.id, owner.id); expect(result.fromEmail).toBe(''); expect(result.toEmail).toBe(member.email); diff --git a/server/tests/unit/nest/trip-membership/trip-membership.service.test.ts b/server/tests/unit/nest/trip-membership/trip-membership.service.test.ts index 5fd50335a..5c10e3d1a 100644 --- a/server/tests/unit/nest/trip-membership/trip-membership.service.test.ts +++ b/server/tests/unit/nest/trip-membership/trip-membership.service.test.ts @@ -32,39 +32,39 @@ function memberRow(tripId: number, userId: number) { } describe('joinTripAsMember', () => { - it('TRIP-JOIN-001: adds a non-member and reports joined', () => { + it('TRIP-JOIN-001: adds a non-member and reports joined', async () => { const { user: owner } = createUser(testDb); const { user: joiner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const r = joinTripAsMember(trip.id, joiner.id, null); + const r = await joinTripAsMember(trip.id, joiner.id, null); expect(r).toEqual({ joined: true, tripId: trip.id }); expect(memberRow(trip.id, joiner.id)).toBeTruthy(); }); - it('TRIP-JOIN-002: never adds the trip owner as a member', () => { + it('TRIP-JOIN-002: never adds the trip owner as a member', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const r = joinTripAsMember(trip.id, owner.id, null); + const r = await joinTripAsMember(trip.id, owner.id, null); expect(r.joined).toBe(false); expect(memberRow(trip.id, owner.id)).toBeUndefined(); }); - it('TRIP-JOIN-003: is idempotent for an existing member (no duplicate row)', () => { + it('TRIP-JOIN-003: is idempotent for an existing member (no duplicate row)', async () => { const { user: owner } = createUser(testDb); const { user: joiner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - expect(joinTripAsMember(trip.id, joiner.id, owner.id).joined).toBe(true); - expect(joinTripAsMember(trip.id, joiner.id, owner.id).joined).toBe(false); + expect((await joinTripAsMember(trip.id, joiner.id, owner.id)).joined).toBe(true); + expect((await joinTripAsMember(trip.id, joiner.id, owner.id)).joined).toBe(false); const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, joiner.id) as { n: number }; expect(count.n).toBe(1); }); - it('TRIP-JOIN-004: no-ops for a missing trip', () => { + it('TRIP-JOIN-004: no-ops for a missing trip', async () => { const { user: joiner } = createUser(testDb); - const r = joinTripAsMember(999999, joiner.id, null); + const r = await joinTripAsMember(999999, joiner.id, null); expect(r.joined).toBe(false); }); }); @@ -74,25 +74,25 @@ describe('joinTripAsMember', () => { describe('leaf membership reads', () => { const svc = () => new TripMembershipService(new DatabaseService(testDb)); - it('TRIP-READ-001: getOwnerId answers the owner and null for a missing trip', () => { + it('TRIP-READ-001: getOwnerId answers the owner and null for a missing trip', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - expect(svc().getOwnerId(trip.id)).toBe(owner.id); - expect(svc().getOwnerId(999999)).toBeNull(); + expect(await svc().getOwnerId(trip.id)).toBe(owner.id); + expect(await svc().getOwnerId(999999)).toBeNull(); }); - it('TRIP-READ-002: listMemberUserIds excludes the owner and follows added_at order', () => { + it('TRIP-READ-002: listMemberUserIds excludes the owner and follows added_at order', async () => { const { user: owner } = createUser(testDb); const { user: m1 } = createUser(testDb); const { user: m2 } = createUser(testDb); const trip = createTrip(testDb, owner.id); testDb.prepare("INSERT INTO trip_members (trip_id, user_id, added_at) VALUES (?, ?, '2026-01-02')").run(trip.id, m2.id); testDb.prepare("INSERT INTO trip_members (trip_id, user_id, added_at) VALUES (?, ?, '2026-01-01')").run(trip.id, m1.id); - expect(svc().listMemberUserIds(trip.id)).toEqual([m1.id, m2.id]); - expect(svc().listMemberUserIds(999999)).toEqual([]); + expect(await svc().listMemberUserIds(trip.id)).toEqual([m1.id, m2.id]); + expect(await svc().listMemberUserIds(999999)).toEqual([]); }); - it('TRIP-READ-003: listAccessibleTripIds unions owned and member trips, newest first', () => { + it('TRIP-READ-003: listAccessibleTripIds unions owned and member trips, newest first', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); const owned = createTrip(testDb, user.id); @@ -102,7 +102,7 @@ describe('leaf membership reads', () => { // Distinct created_at so the ORDER BY is actually asserted, not assumed. testDb.prepare("UPDATE trips SET created_at = '2026-01-01' WHERE id = ?").run(owned.id); testDb.prepare("UPDATE trips SET created_at = '2026-01-02' WHERE id = ?").run(memberOf.id); - const ids = svc().listAccessibleTripIds(user.id); + const ids = await svc().listAccessibleTripIds(user.id); expect(ids).toEqual([memberOf.id, owned.id]); expect(ids).not.toContain(foreign.id); }); diff --git a/server/tests/unit/nest/trip-owner.guard.test.ts b/server/tests/unit/nest/trip-owner.guard.test.ts index 5debb93a0..3eadf9247 100644 --- a/server/tests/unit/nest/trip-owner.guard.test.ts +++ b/server/tests/unit/nest/trip-owner.guard.test.ts @@ -39,9 +39,9 @@ function ctx(request: Record) { } as never; } -const thrown = (run: () => unknown) => { +const thrown = async (run: () => unknown) => { try { - run(); + await run(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; @@ -51,28 +51,28 @@ const thrown = (run: () => unknown) => { }; describe('TripOwnerGuard', () => { - it('OWNER-001: the owner passes', () => { + it('OWNER-001: the owner passes', async () => { const { guard } = makeGuard(); - expect(guard.canActivate(ctx({ user: owner, params: { tripId: '5' } }))).toBe(true); + expect(await guard.canActivate(ctx({ user: owner, params: { tripId: '5' } }))).toBe(true); }); - it('OWNER-002: a member of the trip is refused with the route own message', () => { + it('OWNER-002: a member of the trip is refused with the route own message', async () => { const { guard } = makeGuard({ message: 'Only the owner can manage guests' }); - expect(thrown(() => guard.canActivate(ctx({ user: member, params: { tripId: '5' } })))).toEqual({ + expect(await thrown(() => guard.canActivate(ctx({ user: member, params: { tripId: '5' } })))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' }, }); }); - it('OWNER-003: a stranger gets 404, never 403 — a 403 would confirm the id exists', () => { + it('OWNER-003: a stranger gets 404, never 403 — a 403 would confirm the id exists', async () => { const { guard } = makeGuard(); - expect(thrown(() => guard.canActivate(ctx({ user: { id: 123, role: 'user' } as User, params: { tripId: '5' } })))).toEqual({ + expect(await thrown(() => guard.canActivate(ctx({ user: { id: 123, role: 'user' } as User, params: { tripId: '5' } })))).toEqual({ status: 404, body: { error: 'Trip not found' }, }); }); - it('OWNER-004: an admin who does not own the trip is refused', () => { + it('OWNER-004: an admin who does not own the trip is refused', async () => { // The regression this guard must not introduce. PermissionsService.checkPermission // returns true for every admin and the trip actions are admin-lowerable, so routing // ownership through it would hand admins other people's trips. The guard does not @@ -80,59 +80,59 @@ describe('TripOwnerGuard', () => { const canAccessTrip = vi.fn(() => TRIP); const reflector = { getAllAndOverride: vi.fn(() => ({ message: 'Only the owner can transfer ownership' })) } as unknown as Reflector; const guard = new TripOwnerGuard({ canAccessTrip } as unknown as DatabaseService, reflector); - expect(thrown(() => guard.canActivate(ctx({ user: admin, params: { tripId: '5' } })))).toEqual({ + expect(await thrown(() => guard.canActivate(ctx({ user: admin, params: { tripId: '5' } })))).toEqual({ status: 403, body: { error: 'Only the owner can transfer ownership' }, }); }); - it('OWNER-005: reads the param the metadata names, so the :id routes work', () => { + it('OWNER-005: reads the param the metadata names, so the :id routes work', async () => { const { guard, canAccessTrip } = makeGuard({ message: 'no', param: 'id' }); - expect(guard.canActivate(ctx({ user: owner, params: { id: '5' } }))).toBe(true); + expect(await guard.canActivate(ctx({ user: owner, params: { id: '5' } }))).toBe(true); expect(canAccessTrip).toHaveBeenCalledWith(5, 42); }); - it('OWNER-006: reuses the row TripAccessGuard resolved instead of looking it up twice', () => { + it('OWNER-006: reuses the row TripAccessGuard resolved instead of looking it up twice', async () => { const { guard, canAccessTrip } = makeGuard(); const request = { user: owner, params: {}, [TRIP_REQUEST_KEY]: TRIP }; - expect(guard.canActivate(ctx(request))).toBe(true); + expect(await guard.canActivate(ctx(request))).toBe(true); expect(canAccessTrip).not.toHaveBeenCalled(); }); - it('OWNER-007: parks the row it resolved, so a later @Trip() reads it', () => { + it('OWNER-007: parks the row it resolved, so a later @Trip() reads it', async () => { const { guard } = makeGuard(); const request: Record = { user: owner, params: { tripId: '5' } }; - guard.canActivate(ctx(request)); + await guard.canActivate(ctx(request)); expect(request[TRIP_REQUEST_KEY]).toEqual(TRIP); }); - it('OWNER-008: a missing user is a 401, not a 500 on undefined', () => { + it('OWNER-008: a missing user is a 401, not a 500 on undefined', async () => { const { guard } = makeGuard(); - expect(thrown(() => guard.canActivate(ctx({ params: { tripId: '5' } })))).toEqual({ + expect(await thrown(() => guard.canActivate(ctx({ params: { tripId: '5' } })))).toEqual({ status: 401, body: { error: 'Unauthorized' }, }); }); - it('OWNER-009: a non-numeric or absent id is a 404 without touching the database', () => { + it('OWNER-009: a non-numeric or absent id is a 404 without touching the database', async () => { const { guard, canAccessTrip } = makeGuard(); - expect(thrown(() => guard.canActivate(ctx({ user: owner, params: { tripId: 'abc' } })))).toEqual({ + expect(await thrown(() => guard.canActivate(ctx({ user: owner, params: { tripId: 'abc' } })))).toEqual({ status: 404, body: { error: 'Trip not found' }, }); - expect(thrown(() => guard.canActivate(ctx({ user: owner, params: {} })))).toEqual({ + expect(await thrown(() => guard.canActivate(ctx({ user: owner, params: {} })))).toEqual({ status: 404, body: { error: 'Trip not found' }, }); expect(canAccessTrip).not.toHaveBeenCalled(); }); - it('OWNER-010: without metadata it still refuses a non-owner, with a generic message', () => { + it('OWNER-010: without metadata it still refuses a non-owner, with a generic message', async () => { // Built inline: passing undefined to makeGuard would hit its own default. const canAccessTrip = vi.fn(() => TRIP); const reflector = { getAllAndOverride: vi.fn(() => undefined) } as unknown as Reflector; const guard = new TripOwnerGuard({ canAccessTrip } as unknown as DatabaseService, reflector); - expect(thrown(() => guard.canActivate(ctx({ user: member, params: { tripId: '5' } })))).toEqual({ + expect(await thrown(() => guard.canActivate(ctx({ user: member, params: { tripId: '5' } })))).toEqual({ status: 403, body: { error: 'Only the trip owner can do this' }, }); diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index f00cbab23..88d83b20e 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -78,39 +78,56 @@ import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.serv import { makeStorageFixture } from '../../helpers/storage-fixture'; import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; // Real sibling services over the same in-memory DB — the aggregation runs the // actual SQL of every domain it fans out to, so a shape change downstream shows // up here instead of being papered over by a stub. const dbs = () => new DatabaseService(testDb); -const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService()); -const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs())); + + // One shared cache instance (the PlacePhotoCacheService rule): the in-flight dedup in // PlacePhotoCacheService only works while both consumers hold the same object. const photoCache = new PlacePhotoCacheService(dbs(), makeStorageFixture('photos/google/').storage); -const placesSvc = new PlacesService( - dbs(), new PermissionsService(dbs()), new RealtimeService(), + +let accommodationsSvc: Awaited>; +beforeAll(async () => { + accommodationsSvc = await makeAccommodationsService(testDb); +}); +let budgetSvc: BudgetService; +let daysSvc: DaysService; +let placesSvc: PlacesService; +let membersSvc: TripMembersService; +beforeAll(async () => { + budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService()); + daysSvc = new DaysService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new QueryHelpersService(dbs())); + placesSvc = new PlacesService( + dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()), new UnsplashService(dbs(), new RuntimeEnvService(), makeStorageFixture('').storage), photoCache, new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())), makeStorageFixture('').storage, - accommodationsOver(dbs()), + await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection), ); -const accommodationsSvc = makeAccommodationsService(testDb); -const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()); + membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()); +}); -const buildReadModel = (database: DatabaseService, roster: TripMembersService = membersSvc) => +const buildReadModel = async (database: DatabaseService, roster: TripMembersService = membersSvc) => new TripReadModelService( database, roster, daysSvc, accommodationsSvc, budgetSvc, - new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()), - new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsOver(dbs())), - new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(), makeStorageFixture('').storage, new RateLimitService()), + new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()), + new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection)), + new CollabService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), makeStorageFixture('').storage, new RateLimitService()), placesSvc, - new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()), - new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new EphemeralTokenService(), makeStorageFixture('').storage), + new TodoService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService()), + new FilesService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new EphemeralTokenService(), makeStorageFixture('').storage), ); -const svc = buildReadModel(dbs()); +let svc: Awaited>; +beforeAll(async () => { + svc = await buildReadModel(dbs()); +}); beforeAll(() => { createTables(testDb); @@ -158,31 +175,31 @@ function ownerlessDbs(): DatabaseService { // ── Tests ───────────────────────────────────────────────────────────────────── describe('getTripSummary guards', () => { - it('TRIP-READ-001: returns null for a missing trip instead of throwing', () => { + it('TRIP-READ-001: returns null for a missing trip instead of throwing', async () => { // The MCP get_trip_summary tool hands over whatever id the model produced, so // an unknown id has to come back as an empty answer; a throw there surfaces as // a tool error rather than "no such trip". - expect(svc.getTripSummary(99999)).toBeNull(); - expect(svc.getTripSummary(99999, 1)).toBeNull(); + expect(await svc.getTripSummary(99999)).toBeNull(); + expect(await svc.getTripSummary(99999, 1)).toBeNull(); }); - it('TRIP-READ-002: returns null when the owner row cannot be read', () => { + it('TRIP-READ-002: returns null when the owner row cannot be read', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); // Trip row and owner id are two separate SELECTs; if the second one comes back // empty (trip deleted in between) the guard has to stop. Without it listMembers // runs with an undefined owner id and the summary reports an ownerless trip. - expect(buildReadModel(ownerlessDbs()).getTripSummary(trip.id, owner.id)).toBeNull(); + expect(await (await buildReadModel(ownerlessDbs())).getTripSummary(trip.id, owner.id)).toBeNull(); // Same trip through the real connection still aggregates — the null above is // the missing owner row, not a broken fixture. - expect(svc.getTripSummary(trip.id, owner.id)).not.toBeNull(); + expect(await svc.getTripSummary(trip.id, owner.id)).not.toBeNull(); }); }); describe('getTripSummary shaping', () => { - it('TRIP-READ-003: folds a falsy total_price into the budget total instead of poisoning it', () => { + it('TRIP-READ-003: folds a falsy total_price into the budget total instead of poisoning it', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); addBudgetItem(trip.id, 'Dinner', 40); @@ -193,13 +210,13 @@ describe('getTripSummary shaping', () => { // the offline clients both render as an empty budget. addBudgetItem(trip.id, 'Free walking tour', 0); - const summary = svc.getTripSummary(trip.id, owner.id)!; + const summary = await (await svc.getTripSummary(trip.id, owner.id))!; expect(summary.budget.item_count).toBe(2); expect(summary.budget.total).toBe(40); expect(summary.budget.currency).toBe('EUR'); }); - it('TRIP-READ-004: counts only checked packing items, not the whole list', () => { + it('TRIP-READ-004: counts only checked packing items, not the whole list', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); addPackingItem(trip.id, 'Socks', 1); @@ -208,14 +225,14 @@ describe('getTripSummary shaping', () => { // total and checked come from the same array; if the filter is ever widened the // packing progress the summary reports jumps to 100% while items are still open. - const summary = svc.getTripSummary(trip.id, owner.id)!; + const summary = await (await svc.getTripSummary(trip.id, owner.id))!; expect(summary.packing.total).toBe(3); expect(summary.packing.checked).toBe(2); }); }); describe('bundle shaping', () => { - it('TRIP-READ-005: keeps the member list a flat array when the roster has no members', () => { + it('TRIP-READ-005: keeps the member list a flat array when the roster has no members', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); @@ -227,14 +244,14 @@ describe('bundle shaping', () => { owner: { id: owner.id, username: 'solo' }, members: undefined, } as never); try { - const result = svc.bundle(String(trip.id), { user_id: owner.id }, owner.id) as any; + const result = (await svc.bundle(String(trip.id), { user_id: owner.id }, owner.id)) as any; expect(result.members).toEqual([{ id: owner.id, username: 'solo' }]); } finally { roster.mockRestore(); } }); - it('TRIP-READ-006: drops a falsy owner rather than shipping a hole in the member list', () => { + it('TRIP-READ-006: drops a falsy owner rather than shipping a hole in the member list', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); @@ -245,7 +262,7 @@ describe('bundle shaping', () => { owner: undefined, members: [{ id: 42, username: 'left-behind' }], } as never); try { - const result = svc.bundle(String(trip.id), { user_id: owner.id }, owner.id) as any; + const result = (await svc.bundle(String(trip.id), { user_id: owner.id }, owner.id)) as any; expect(result.members).toEqual([{ id: 42, username: 'left-behind' }]); } finally { roster.mockRestore(); @@ -254,7 +271,7 @@ describe('bundle shaping', () => { }); describe('private packing items stay viewer-scoped (#858)', () => { - it("TRIP-READ-007: neither summary nor bundle leaks another member's private item", () => { + it("TRIP-READ-007: neither summary nor bundle leaks another member's private item", async () => { const { user: owner } = createUser(testDb); const { user: viewer } = createUser(testDb); const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' }); @@ -267,17 +284,17 @@ describe('private packing items stay viewer-scoped (#858)', () => { // ONLY thing filtering the list. If either call site loses it, listItems falls // back to the unfiltered query and the surprise the owner is carrying shows up // in the other member's MCP summary and in their offline cache. - const asViewer = svc.getTripSummary(trip.id, viewer.id)!; + const asViewer = await (await svc.getTripSummary(trip.id, viewer.id))!; expect(asViewer.packing.items.map((i: any) => i.name)).toEqual(['Tent']); expect(asViewer.packing.total).toBe(1); - const bundled = svc.bundle(String(trip.id), { user_id: owner.id }, viewer.id) as any; + const bundled = (await svc.bundle(String(trip.id), { user_id: owner.id }, viewer.id)) as any; expect(bundled.packingItems.map((i: any) => i.name)).toEqual(['Tent']); // The owner still sees their own private item through both paths, so the // assertions above are the filter working, not an empty fixture. - expect(svc.getTripSummary(trip.id, owner.id)!.packing.items.map((i: any) => i.name)).toEqual(['Ring', 'Tent']); - expect((svc.bundle(String(trip.id), { user_id: owner.id }, owner.id) as any) + expect((await (await svc.getTripSummary(trip.id, owner.id))!).packing.items.map((i: any) => i.name)).toEqual(['Ring', 'Tent']); + expect(((await svc.bundle(String(trip.id), { user_id: owner.id }, owner.id)) as any) .packingItems.map((i: any) => i.name)).toEqual(['Ring', 'Tent']); }); }); diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index b675a1bb2..b8797af8a 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -156,9 +156,9 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(tripCreateRequestSchema.safeParse({ title: '' }).success).toBe(false); }); - it('infers end_date from start_date (+6 days) and creates', () => { + it('infers end_date from start_date (+6 days) and creates', async () => { const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); - tc(svc({ create } as Partial)).create(user, { title: 'T', start_date: '2026-07-01' }, req); + await tc(svc({ create } as Partial)).create(user, { title: 'T', start_date: '2026-07-01' }, req); expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ start_date: '2026-07-01', end_date: '2026-07-07' })); }); @@ -168,21 +168,21 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { }); }); - it('infers start_date from end_date (-6 days) and parses day_count', () => { + it('infers start_date from end_date (-6 days) and parses day_count', async () => { const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); - tc(svc({ create } as Partial)).create(user, prePipeCreateBody({ title: 'T', end_date: '2026-07-07', day_count: '40' }), req); + await tc(svc({ create } as Partial)).create(user, prePipeCreateBody({ title: 'T', end_date: '2026-07-07', day_count: '40' }), req); expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ start_date: '2026-07-01', end_date: '2026-07-07', day_count: 40 })); }); - it('clamps a non-numeric day_count to the default of 7', () => { + it('clamps a non-numeric day_count to the default of 7', async () => { const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); - tc(svc({ create } as Partial)).create(user, prePipeCreateBody({ title: 'T', day_count: 'abc' }), req); + await tc(svc({ create } as Partial)).create(user, prePipeCreateBody({ title: 'T', day_count: 'abc' }), req); expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: 7 })); }); - it('clamps day_count to MAX_TRIP_DAYS', () => { + it('clamps day_count to MAX_TRIP_DAYS', async () => { const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); - tc(svc({ create } as Partial)).create(user, { title: 'T', day_count: MAX_TRIP_DAYS + 1 }, req); + await tc(svc({ create } as Partial)).create(user, { title: 'T', day_count: MAX_TRIP_DAYS + 1 }, req); expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: MAX_TRIP_DAYS })); }); @@ -193,14 +193,14 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { }); }); - it('re-throws an unknown error from create', () => { + it('re-throws an unknown error from create', async () => { const create = vi.fn().mockImplementation(() => { throw new Error('boom'); }); - expect(() => tc(svc({ create } as Partial)).create(user, { title: 'T' }, req)).toThrow('boom'); + await expect(tc(svc({ create } as Partial)).create(user, { title: 'T' }, req)).rejects.toThrow('boom'); }); - it('logs the reminder when reminderDays is set', () => { + it('logs the reminder when reminderDays is set', async () => { const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 3 }); - expect(tc(svc({ create } as Partial)).create(user, { title: 'T' }, req)).toEqual({ trip: { id: 9 } }); + expect(await tc(svc({ create } as Partial)).create(user, { title: 'T' }, req)).toEqual({ trip: { id: 9 } }); }); }); @@ -285,9 +285,9 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).copy(user, '9', {}, req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); }); - it('copies + returns the new trip', () => { + it('copies + returns the new trip', async () => { const s = svc({ copy: vi.fn().mockReturnValue(42), getCopiedTrip: vi.fn().mockReturnValue({ id: 42 }) } as Partial); - expect(tc(s).copy(user, '9', { title: 'Copy' }, req)).toEqual({ trip: { id: 42 } }); + expect(await tc(s).copy(user, '9', { title: 'Copy' }, req)).toEqual({ trip: { id: 42 } }); }); }); @@ -304,26 +304,26 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(s.remove).not.toHaveBeenCalled(); }); - it('still lets an admin delete a trip they are not a member of', () => { + it('still lets an admin delete a trip they are not a member of', async () => { const admin = { id: 1, role: 'admin', email: 'a@example.test' } as User; const remove = vi.fn().mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: true, ownerEmail: 'owner@x.y' }); const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 2 }), canAccessTrip: vi.fn().mockReturnValue(null), remove, broadcast: vi.fn() } as Partial); - expect(tc(s).remove(admin, '9', req)).toEqual({ success: true }); + expect(await tc(s).remove(admin, '9', req)).toEqual({ success: true }); expect(remove).toHaveBeenCalledWith('9', 1, 'admin'); }); - it('deletes, audits and broadcasts', () => { + it('deletes, audits and broadcasts', async () => { const remove = vi.fn().mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: false }); const broadcast = vi.fn(); const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 1 }), remove, broadcast } as Partial); - expect(tc(s).remove(user, '9', req, 'sock')).toEqual({ success: true }); + expect(await tc(s).remove(user, '9', req, 'sock')).toEqual({ success: true }); expect(broadcast).toHaveBeenCalledWith('9', 'trip:deleted', { id: 9 }, 'sock'); }); - it('admin delete logs the owner', () => { + it('admin delete logs the owner', async () => { const remove = vi.fn().mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: true, ownerEmail: 'owner@x.y' }); const broadcast = vi.fn(); const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 2 }), remove, broadcast } as Partial); - expect(tc(s).remove(user, '9', req)).toEqual({ success: true }); + expect(await tc(s).remove(user, '9', req)).toEqual({ success: true }); expect(broadcast).toHaveBeenCalledWith('9', 'trip:deleted', { id: 9 }, undefined); }); }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index b478a4a83..675cdc31f 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -88,12 +88,14 @@ import fs from 'fs'; import path from 'path'; import { notificationsStub } from '../../helpers/notifications'; import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; // Real sibling services over the same in-memory DB — updateTrip's date-shift // resyncs and the summary/bundle aggregation run their actual SQL. const dbs = () => new DatabaseService(testDb); -const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService()); -const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs())); + + // Same collaborator set the container hands PlacesService (see places.service.test.ts). // Only the read-model aggregation reaches into places here, but the photo cache, // Unsplash and journey domain are real instances over the same in-memory DB @@ -103,9 +105,29 @@ const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new Realti // production, where the in-flight dedup only works on a shared instance. const photoCache = new PlacePhotoCacheService(dbs(), makeStorageFixture('photos/google/').storage); const coversFx = makeStorageFixture('covers/'); -const placesSvc = new PlacesService( + + +let accommodationsSvc: Awaited>; +let createAccommodation: typeof accommodationsSvc.createAccommodation; +beforeAll(async () => { + accommodationsSvc = await makeAccommodationsService(testDb); + createAccommodation = accommodationsSvc.createAccommodation.bind(accommodationsSvc); +}); + + + +let budgetSvc: BudgetService; +let daysSvc: DaysService; +let placesSvc: PlacesService; +let svc: TripsService; +let membersSvc: TripMembersService; +let readModelSvc: TripReadModelService; +beforeAll(async () => { + budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService()); + daysSvc = new DaysService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new QueryHelpersService(dbs())); + placesSvc = new PlacesService( dbs(), - new PermissionsService(dbs()), + new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()), @@ -113,32 +135,30 @@ const placesSvc = new PlacesService( photoCache, new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())), makeStorageFixture('').storage, - accommodationsOver(dbs()), + await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection), ); -const accommodationsSvc = makeAccommodationsService(testDb); -const createAccommodation = accommodationsSvc.createAccommodation.bind(accommodationsSvc); - -const svc = new TripsService( + svc = new TripsService( dbs(), - new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc), + new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)), daysSvc, - new PermissionsService(dbs()), + new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new VacayService(dbs(), new RealtimeService(), notificationsStub()), new RealtimeService(), undefined as never, // unsplash — not exercised here coversFx.storage, ); -const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()); -const readModelSvc = new TripReadModelService( + membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()); + readModelSvc = new TripReadModelService( dbs(), membersSvc, daysSvc, accommodationsSvc, budgetSvc, - new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()), - new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc), - new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(), coversFx.storage, new RateLimitService()), + new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()), + new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)), + new CollabService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), coversFx.storage, new RateLimitService()), placesSvc, - new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()), - new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new EphemeralTokenService(), coversFx.storage), + new TodoService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService()), + new FilesService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new EphemeralTokenService(), coversFx.storage), ); +}); beforeAll(() => { @@ -472,11 +492,11 @@ describe('resyncAccommodationDays (#1288)', () => { const dayFor = (tripId: number, date: string) => (testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id; - const insertAccommodation = (tripId: number, startDayId: number, endDayId: number) => { + const insertAccommodation = async (tripId: number, startDayId: number, endDayId: number) => { const place = createPlace(testDb, tripId, { name: 'Grand Hotel' }); - const { accommodation: acc } = createAccommodation(tripId, { + const { accommodation: acc } = (await createAccommodation(tripId, { place_id: place.id, start_day_id: startDayId, end_day_id: endDayId, - }) as { accommodation: { id: number } }; + })) as { accommodation: { id: number } }; const linkedRes = testDb.prepare( 'SELECT id FROM reservations WHERE accommodation_id = ?', ).get(acc.id) as { id: number }; @@ -490,10 +510,10 @@ describe('resyncAccommodationDays (#1288)', () => { testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(id) as { day_id: number | null; reservation_time: string | null }; - it('TRIP-SVC-035: extending the start keeps an accommodation on its absolute dates', () => { + it('TRIP-SVC-035: extending the start keeps an accommodation on its absolute dates', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }); - const { accId, linkedResId } = insertAccommodation(trip.id, dayFor(trip.id, '2025-06-11'), dayFor(trip.id, '2025-06-13')); + const { accId, linkedResId } = await insertAccommodation(trip.id, dayFor(trip.id, '2025-06-11'), dayFor(trip.id, '2025-06-13')); // Add a day at the start: days re-date positionally (old 06-11 row becomes 06-10, …). svc.updateTrip(trip.id, user.id, { start_date: '2025-06-09', end_date: '2025-06-14' }, 'user'); const acc = getAcc(accId); @@ -504,13 +524,13 @@ describe('resyncAccommodationDays (#1288)', () => { expect(res.reservation_time?.slice(0, 10)).toBe('2025-06-11'); }); - it('TRIP-SVC-059: the day stop a booking wrote follows it when the trip is re-dated', () => { + it('TRIP-SVC-059: the day stop a booking wrote follows it when the trip is re-dated', async () => { // Booking a night also puts its place on the check-in day. Re-dating the trip moves // the stay to whichever day row now carries its date, and the stop has to go with // it, or the route runs through a day the traveller is no longer staying on. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }); - const { accId } = insertAccommodation(trip.id, dayFor(trip.id, '2025-06-11'), dayFor(trip.id, '2025-06-13')); + const { accId } = await insertAccommodation(trip.id, dayFor(trip.id, '2025-06-11'), dayFor(trip.id, '2025-06-13')); const stopOf = () => testDb.prepare('SELECT day_id FROM day_assignments WHERE accommodation_id = ?').get(accId) as { day_id: number }; expect(stopOf().day_id).toBe(dayFor(trip.id, '2025-06-11')); @@ -520,12 +540,12 @@ describe('resyncAccommodationDays (#1288)', () => { expect(stopOf().day_id).toBe(dayFor(trip.id, '2025-06-11')); }); - it('TRIP-SVC-036: moving the whole trip out of the old range keeps the accommodation glued to its days', () => { + it('TRIP-SVC-036: moving the whole trip out of the old range keeps the accommodation glued to its days', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); const startDayId = dayFor(trip.id, '2025-06-02'); const endDayId = dayFor(trip.id, '2025-06-03'); - const { accId, linkedResId } = insertAccommodation(trip.id, startDayId, endDayId); + const { accId, linkedResId } = await insertAccommodation(trip.id, startDayId, endDayId); svc.updateTrip(trip.id, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' }, 'user'); const acc = getAcc(accId); expect(acc.start_day_id).toBe(startDayId); @@ -536,14 +556,14 @@ describe('resyncAccommodationDays (#1288)', () => { expect(res.reservation_time?.slice(0, 10)).toBe('2025-07-02'); }); - it("TRIP-SVC-038: date_shift_mode 'shift_all' glues bookings to their days and restamps their times", () => { + it("TRIP-SVC-038: date_shift_mode 'shift_all' glues bookings to their days and restamps their times", async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); const origDayId = dayFor(trip.id, '2025-06-02'); const resId = Number(testDb.prepare( "INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', '2025-06-02T19:00:00', 'restaurant', 'pending')", ).run(trip.id, origDayId).lastInsertRowid); - const { accId } = insertAccommodation(trip.id, origDayId, dayFor(trip.id, '2025-06-03')); + const { accId } = await insertAccommodation(trip.id, origDayId, dayFor(trip.id, '2025-06-03')); svc.updateTrip(trip.id, user.id, { start_date: '2025-06-03', end_date: '2025-06-07', date_shift_mode: 'shift_all' }, 'user'); // The booking stays on its day row (now 2025-06-04) and its time follows. const res = testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(resId) as @@ -692,7 +712,7 @@ describe('guest members (#1362)', () => { // ── Folded CRUD SQL (summary / list / create / delete / copy) ───────────────── describe('folded trip CRUD', () => { - it('TRIP-SVC-042: getTripSummary aggregates members, days, budget, packing and reservations', () => { + it('TRIP-SVC-042: getTripSummary aggregates members, days, budget, packing and reservations', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' }); @@ -700,7 +720,7 @@ describe('folded trip CRUD', () => { testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'food', 'Dinner', 40)").run(trip.id); testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id); - const summary = readModelSvc.getTripSummary(trip.id, owner.id)!; + const summary = await (await readModelSvc.getTripSummary(trip.id, owner.id))!; expect(summary).toBeTruthy(); expect((summary.trip as any).id).toBe(trip.id); expect(summary.members.owner.id).toBe(owner.id); @@ -714,7 +734,7 @@ describe('folded trip CRUD', () => { expect(summary.collab_notes).toEqual([]); // Missing trips return null instead of throwing. - expect(readModelSvc.getTripSummary(99999)).toBeNull(); + expect(await readModelSvc.getTripSummary(99999)).toBeNull(); }); it('TRIP-SVC-043: list returns owned + shared trips with is_owner, honoring the archived filter', () => { @@ -996,12 +1016,12 @@ describe('TripsService wrapper helpers', () => { } }); - it('canAccessTrip delegates to the db helper; can() delegates to checkPermission; broadcast forwards', () => { + it('canAccessTrip delegates to the db helper; can() delegates to checkPermission; broadcast forwards', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); expect(svc.canAccessTrip(String(trip.id), user.id)).toMatchObject({ user_id: user.id }); - expect(svc.can('trip_edit', 'user', user.id, user.id, false)).toBe(true); + expect(await svc.can('trip_edit', 'user', user.id, user.id, false)).toBe(true); svc.broadcast('9', 'trip:updated', { a: 1 } as never, 'sock'); expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { a: 1 }, 'sock'); @@ -1015,7 +1035,7 @@ describe('TripsService wrapper helpers', () => { expect(row.is_owner).toBe(1); }); - it('bundle aggregates every sub-collection + the member list, scoping packing to the viewer (#858)', () => { + it('bundle aggregates every sub-collection + the member list, scoping packing to the viewer (#858)', async () => { const { user: owner } = createUser(testDb); const { user: viewer } = createUser(testDb); const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' }); @@ -1024,7 +1044,7 @@ describe('TripsService wrapper helpers', () => { testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, owner.id); testDb.prepare("INSERT INTO packing_items (trip_id, name) VALUES (?, 'Shared')").run(trip.id); - const result = readModelSvc.bundle(String(trip.id), { user_id: owner.id }, viewer.id) as any; + const result = (await readModelSvc.bundle(String(trip.id), { user_id: owner.id }, viewer.id)) as any; expect(result.days).toHaveLength(2); expect(result.members.map((m: any) => m.id).sort()).toEqual([owner.id, viewer.id].sort()); expect(result.packingItems.map((p: any) => p.name)).toEqual(['Shared']); @@ -1181,13 +1201,13 @@ describe('quirk fixes', () => { return { connection: conn, canAccessTrip: dbMock.canAccessTrip, isOwner: dbMock.isOwner } as unknown as import('../../../src/nest/database/database.service').DatabaseService; } - function failingTrips(match: string) { + async function failingTrips(match: string) { const fdbs = failingConnection(match); return new TripsService( fdbs, - new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc), + new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)), daysSvc, - new PermissionsService(dbs()), + new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new VacayService(dbs(), new RealtimeService(), notificationsStub()), new RealtimeService(), @@ -1197,15 +1217,15 @@ describe('quirk fixes', () => { } /** Same frozen connection, for the guest deletion that now lives on the roster. */ - function failingMembers(match: string) { + async function failingMembers(match: string) { return new TripMembersService( failingConnection(match), budgetSvc, new UserCleanupService(dbs(), budgetSvc), - new PermissionsService(dbs()), new RealtimeService(), + new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), ); } - it('TRIP-SVC-051: remove is atomic — a failed trip DELETE keeps the journey entries intact', () => { + it('TRIP-SVC-051: remove is atomic — a failed trip DELETE keeps the journey entries intact', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const journeyId = Number(testDb.prepare( @@ -1215,7 +1235,7 @@ describe('quirk fixes', () => { "INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'skeleton', 'S', '2025-06-01', 0, 0)", ).run(journeyId, trip.id, user.id); - const broken = failingTrips('DELETE FROM trips WHERE id = ?'); + const broken = await failingTrips('DELETE FROM trips WHERE id = ?'); expect(() => broken.remove(trip.id, user.id, 'user')).toThrow('boom'); // The skeleton cleanup rolled back with the failed delete. @@ -1223,13 +1243,13 @@ describe('quirk fixes', () => { expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeDefined(); }); - it('TRIP-SVC-052: deleteGuest is atomic — a failed user DELETE rolls the budget re-split back', () => { + it('TRIP-SVC-052: deleteGuest is atomic — a failed user DELETE rolls the budget re-split back', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const { member: guest } = membersSvc.createGuest(trip.id, 'Gia', owner.id); const item = budgetSvc.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, guest.id] }); - const broken = failingMembers('DELETE FROM users WHERE id = ? AND is_guest = 1'); + const broken = await failingMembers('DELETE FROM users WHERE id = ? AND is_guest = 1'); expect(() => broken.deleteGuest(trip.id, guest.id)).toThrow('boom'); // Neither the guest nor their split membership was touched. diff --git a/server/tests/unit/nest/user-cleanup.service.test.ts b/server/tests/unit/nest/user-cleanup.service.test.ts index f8cdfa1af..4885266ab 100644 --- a/server/tests/unit/nest/user-cleanup.service.test.ts +++ b/server/tests/unit/nest/user-cleanup.service.test.ts @@ -49,10 +49,17 @@ import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.se import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { BudgetService } from '../../../src/nest/budget/budget.service'; import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; const dbs = new DatabaseService(testDb); -const budget = new BudgetService(dbs, new PermissionsService(dbs), new ExchangeRatesService(), new RealtimeService()); -const svc = new UserCleanupService(dbs, budget); + +let budget: BudgetService; +let svc: UserCleanupService; +beforeAll(async () => { + budget = new BudgetService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new ExchangeRatesService(), new RealtimeService()); + svc = new UserCleanupService(dbs, budget); +}); const installPlugin = (id: string, permissions: string[] | null) => { testDb.prepare('INSERT INTO plugins (id, name, version, permissions) VALUES (?, ?, ?, ?)') diff --git a/server/tests/unit/plugins/dependencies.test.ts b/server/tests/unit/plugins/dependencies.test.ts index 47d186f61..4d024a504 100644 --- a/server/tests/unit/plugins/dependencies.test.ts +++ b/server/tests/unit/plugins/dependencies.test.ts @@ -36,10 +36,10 @@ describe('parseDependencies', () => { }); describe('disabledRequiredAddons', () => { - it('lists only the disabled required addons', () => { + it('lists only the disabled required addons', async () => { const deps = parseDependencies(JSON.stringify({ requiredAddons: ['budget', 'journey'], pluginDependencies: [] })); - expect(disabledRequiredAddons(deps, (a) => a === 'budget')).toEqual(['journey']); - expect(disabledRequiredAddons(deps, () => true)).toEqual([]); + expect(await disabledRequiredAddons(deps, (a) => Promise.resolve(a === 'budget'))).toEqual(['journey']); + expect(await disabledRequiredAddons(deps, () => Promise.resolve(true))).toEqual([]); }); }); diff --git a/server/tests/unit/plugins/host-rpc.test.ts b/server/tests/unit/plugins/host-rpc.test.ts index 51e3eb2ff..361107b13 100644 --- a/server/tests/unit/plugins/host-rpc.test.ts +++ b/server/tests/unit/plugins/host-rpc.test.ts @@ -75,7 +75,7 @@ import type { RpcError, RpcResponse } from '../../../src/nest/plugins/protocol/e // Typed from the real method rather than from the always-true body below, so a case that // swaps in an implementation reading the action key (HOSTRPC-015) still type-checks. -const checkPermission = vi.fn(() => true); +const checkPermission = vi.fn(() => Promise.resolve(true)); const permissions = { checkPermission } as unknown as PermissionsService; const addons = { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService; const notifications = { send: notifySend } as unknown as NotificationsService; @@ -255,7 +255,7 @@ describe('DbRpc — the unconditional three', () => { describe('MetaRpc — namespaced entity metadata', () => { beforeEach(() => { checkPermission.mockReset(); - checkPermission.mockReturnValue(true); + checkPermission.mockResolvedValue(true); }); afterAll(() => closePluginDataDb('meta')); @@ -315,7 +315,7 @@ describe('MetaRpc — namespaced entity metadata', () => { const seen: string[] = []; checkPermission.mockImplementation((action) => { seen.push(action); - return true; + return Promise.resolve(true); }); for (const [entityType, entityId] of [['trip', 1], ['place', 7], ['day', 3], ['reservation', 40], ['accommodation', 11]] as const) { expect((await call(host, 'meta.set', { entityType, entityId, key: 'k', value: 1 })).ok).toBe(true); @@ -323,7 +323,7 @@ describe('MetaRpc — namespaced entity metadata', () => { // Accommodations deliberately ride on day_edit, like the accommodation write path. expect(seen).toEqual(['trip_edit', 'place_edit', 'day_edit', 'reservation_edit', 'day_edit']); - checkPermission.mockReturnValue(false); + checkPermission.mockResolvedValue(false); const write = await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: 2 }); expect(write.error?.code).toBe('RESOURCE_FORBIDDEN'); // …but a READ is only access-gated, so it still works for the same user. @@ -359,7 +359,7 @@ describe('MetaRpc — namespaced entity metadata', () => { describe('HostSurfaceRpc — users, broadcasts, notify, ai, oauth, scheduler', () => { beforeEach(() => { checkPermission.mockReset(); - checkPermission.mockReturnValue(true); + checkPermission.mockResolvedValue(true); notifySend.mockClear(); llmExtract.mockClear(); broadcast.mockClear(); diff --git a/server/tests/unit/plugins/mcp-tool-sink.test.ts b/server/tests/unit/plugins/mcp-tool-sink.test.ts index 041e8fcc2..cb51277fd 100644 --- a/server/tests/unit/plugins/mcp-tool-sink.test.ts +++ b/server/tests/unit/plugins/mcp-tool-sink.test.ts @@ -47,30 +47,30 @@ describe('setPluginMcpToolSource', () => { }); describe('registerTools wiring', () => { - it('MCPSINK-003: passes no dynamic source when none is registered', () => { + it('MCPSINK-003: passes no dynamic source when none is registered', async () => { const { attach, registry } = spyRegistry(); - registerTools(registry, new McpServer({ name: 't', version: '1' }), 1, null); + await registerTools(registry, new McpServer({ name: 't', version: '1' }), 1, null); expect(attach.mock.calls[0][2]).toEqual({ onInvoke: undefined, dynamicTools: undefined }); }); - it('MCPSINK-004: passes the registered source through to attach', () => { + it('MCPSINK-004: passes the registered source through to attach', async () => { const source = (_ctx: McpContext) => [echo]; setPluginMcpToolSource(source); const { attach, registry } = spyRegistry(); - registerTools(registry, new McpServer({ name: 't', version: '1' }), 1, null); + await registerTools(registry, new McpServer({ name: 't', version: '1' }), 1, null); expect(attach.mock.calls[0][2].dynamicTools).toBe(source); }); - it('MCPSINK-005: an explicit argument wins over the process-level source', () => { + it('MCPSINK-005: an explicit argument wins over the process-level source', async () => { setPluginMcpToolSource(() => [echo]); const explicit = () => []; const { attach, registry } = spyRegistry(); - registerTools(registry, new McpServer({ name: 't', version: '1' }), 1, null, false, undefined, undefined, explicit); + await registerTools(registry, new McpServer({ name: 't', version: '1' }), 1, null, false, undefined, undefined, explicit); expect(attach.mock.calls[0][2].dynamicTools).toBe(explicit); }); diff --git a/server/tests/unit/plugins/operator-egress.test.ts b/server/tests/unit/plugins/operator-egress.test.ts index 93adbdcd8..de7ca24e0 100644 --- a/server/tests/unit/plugins/operator-egress.test.ts +++ b/server/tests/unit/plugins/operator-egress.test.ts @@ -42,11 +42,11 @@ function install(id: string, operatorEgress: boolean, perms: string[] = ['http:o let rt: PluginRuntimeService; beforeAll(() => { createTables(testDb); runMigrations(testDb); }); -beforeEach(() => { +beforeEach(async () => { testDb.prepare('DELETE FROM plugins').run(); testDb.prepare('DELETE FROM plugin_egress_hosts').run(); testDb.prepare('DELETE FROM plugin_actions').run(); - rt = createPluginRuntime(new DatabaseService(dbConn)); + rt = await createPluginRuntime(new DatabaseService(dbConn)); }); describe('operator-supplied egress hosts', () => { @@ -172,18 +172,18 @@ describe('the admin list surfaces operator egress (so the chip can be shown)', ( // list() resolves required-addon dependencies through AddonsService, so it gets a real // one over the same DB. These fixtures declare no dependencies, so it is never consulted. - const listPlugins = () => { + const listPlugins = async () => { const dbs = new DatabaseService(dbConn); - return new PluginsService(dbs, new AddonsService(dbs)).list().plugins; + return (await new PluginsService(dbs, new AddonsService(dbs)).list()).plugins; }; - const before = listPlugins(); + const before = await listPlugins(); expect(before.find(p => p.id === 'gotify')).toMatchObject({ operatorEgress: true, egressHostCount: 0 }); // A plugin that never asked for it must never invite the admin to add hosts. expect(before.find(p => p.id === 'plain')).toMatchObject({ operatorEgress: false, egressHostCount: 0 }); await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']); - const after = listPlugins(); + const after = await listPlugins(); expect(after.find(p => p.id === 'gotify')!.egressHostCount).toBe(2); delete process.env.TREK_PLUGINS_ENABLED; }); diff --git a/server/tests/unit/plugins/plugin-guards.test.ts b/server/tests/unit/plugins/plugin-guards.test.ts index 2cea6fc06..15c99147a 100644 --- a/server/tests/unit/plugins/plugin-guards.test.ts +++ b/server/tests/unit/plugins/plugin-guards.test.ts @@ -96,61 +96,55 @@ describe('PluginGuards — requireActor', () => { }); describe('PluginGuards — requireTripEdit and canEditAs', () => { - it('PGUARD-008 passes when the user may access and edit', () => { + it('PGUARD-008 passes when the user may access and edit', async () => { const { guards, permissions } = build({ allow: true }); - expect(() => guards.requireTripEdit(1, 42, 'trip_edit')).not.toThrow(); + await expect(guards.requireTripEdit(1, 42, 'trip_edit')).resolves.not.toThrow(); expect(permissions.checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 42, 42, false); }); - it('PGUARD-009 no access wins over no permission, and names the trip', () => { + it('PGUARD-009 no access wins over no permission, and names the trip', async () => { const { guards } = build({ allow: false }); - expect(() => guards.requireTripEdit(2, 42, 'trip_edit')).toThrow( - new ForbiddenResource('no access to trip 2'), - ); + await expect(guards.requireTripEdit(2, 42, 'trip_edit')).rejects.toThrow(new ForbiddenResource('no access to trip 2')); }); - it('PGUARD-010 access without the edit permission is a different message', () => { + it('PGUARD-010 access without the edit permission is a different message', async () => { const { guards } = build({ allow: false }); - expect(() => guards.requireTripEdit(1, 42, 'trip_edit')).toThrow( - new ForbiddenResource('no permission to edit trip 1'), - ); + await expect(guards.requireTripEdit(1, 42, 'trip_edit')).rejects.toThrow(new ForbiddenResource('no permission to edit trip 1')); }); - it('PGUARD-011 canEditAs returns false rather than throwing when there is no access', () => { - expect(build().guards.canEditAs('trip_edit', 2, 42)).toBe(false); + it('PGUARD-011 canEditAs returns false rather than throwing when there is no access', async () => { + expect(await build().guards.canEditAs('trip_edit', 2, 42)).toBe(false); }); - it('PGUARD-012 canEditAs returns false when the user row is gone', () => { - expect(build({ role: undefined }).guards.canEditAs('trip_edit', 1, 42)).toBe(false); + it('PGUARD-012 canEditAs returns false when the user row is gone', async () => { + expect(await build({ role: undefined }).guards.canEditAs('trip_edit', 1, 42)).toBe(false); }); - it('PGUARD-013 a missing role column falls back to "user"', () => { + it('PGUARD-013 a missing role column falls back to "user"', async () => { const { guards, permissions } = build({ role: null as unknown as string }); - guards.canEditAs('trip_edit', 1, 42); + await guards.canEditAs('trip_edit', 1, 42); expect(permissions.checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 42, 42, false); }); - it('PGUARD-014 a non-owner member is flagged as shared', () => { + it('PGUARD-014 a non-owner member is flagged as shared', async () => { const db = { canAccessTrip: vi.fn(() => ({ id: 1, user_id: 7 })), prepare: vi.fn(() => ({ get: () => ({ role: 'user' }) })), } as unknown as DatabaseService; const permissions = { checkPermission: vi.fn(() => true) } as unknown as PermissionsService; const guards = new PluginGuards(db, permissions, {} as AddonsService); - guards.canEditAs('trip_edit', 1, 42); + await guards.canEditAs('trip_edit', 1, 42); expect(permissions.checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 7, 42, true); }); }); describe('PluginGuards — requireAddon', () => { - it('PGUARD-015 an enabled addon passes', () => { - expect(() => build({ addonOn: true }).guards.requireAddon('budget', 'costs')).not.toThrow(); + it('PGUARD-015 an enabled addon passes', async () => { + await expect(build({ addonOn: true }).guards.requireAddon('budget', 'costs')).resolves.not.toThrow(); }); - it('PGUARD-016 a disabled addon is refused with the noun in the message', () => { - expect(() => build({ addonOn: false }).guards.requireAddon('budget', 'costs')).toThrow( - new ForbiddenResource('the costs addon is disabled'), - ); + it('PGUARD-016 a disabled addon is refused with the noun in the message', async () => { + await expect(build({ addonOn: false }).guards.requireAddon('budget', 'costs')).rejects.toThrow(new ForbiddenResource('the costs addon is disabled')); }); }); diff --git a/server/tests/unit/plugins/plugin-instance-settings.test.ts b/server/tests/unit/plugins/plugin-instance-settings.test.ts index 6880e69fc..44c0eb518 100644 --- a/server/tests/unit/plugins/plugin-instance-settings.test.ts +++ b/server/tests/unit/plugins/plugin-instance-settings.test.ts @@ -109,19 +109,19 @@ describe('instance settings fields', () => { expect(svc().instanceSettingsFields('p')[0].options).toEqual(['fast', 'slow']); }); - it('INS-003 — the admin list carries the instance-field count (gates the menu item)', () => { + it('INS-003 — the admin list carries the instance-field count (gates the menu item)', async () => { install('with-fields'); install('plain'); declareField('with-fields', 'apiKey', 'instance', { secret: true }); declareField('with-fields', 'apiUrl', 'instance'); declareField('with-fields', 'units', 'user'); // user fields must not count - const plugins = svc().list().plugins; + const plugins = (await svc().list()).plugins; expect(plugins.find((p) => p.id === 'with-fields')).toMatchObject({ instanceSettingsCount: 2 }); expect(plugins.find((p) => p.id === 'plain')).toMatchObject({ instanceSettingsCount: 0 }); }); - it('INS-004 — the admin list carries the instance-action count (gates the menu item even with no settings fields)', () => { + it('INS-004 — the admin list carries the instance-action count (gates the menu item even with no settings fields)', async () => { install('with-action'); install('plain'); testDb @@ -131,7 +131,7 @@ describe('instance settings fields', () => { .prepare('INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)') .run('with-action', 'notify', 'Notify', 'user'); // user-scope actions must not count - const plugins = svc().list().plugins; + const plugins = (await svc().list()).plugins; expect(plugins.find((p) => p.id === 'with-action')).toMatchObject({ instanceSettingsCount: 0, instanceActionsCount: 1 }); expect(plugins.find((p) => p.id === 'plain')).toMatchObject({ instanceSettingsCount: 0, instanceActionsCount: 0 }); testDb.prepare("DELETE FROM plugin_actions WHERE plugin_id = 'with-action'").run(); @@ -212,13 +212,13 @@ describe('required settings are enforced on save', () => { describe('respawn on save (runtime)', () => { it('INS-004 — an inactive plugin is left alone (no respawn, reports false)', async () => { install('p'); - const rt = createPluginRuntime(new DatabaseService(dbConn)); + const rt = await createPluginRuntime(new DatabaseService(dbConn)); await expect(rt.respawnIfActive('p')).resolves.toBe(false); }); it('INS-005 — an active plugin is stopped and re-activated so the child re-reads config', async () => { install('p'); - const rt = createPluginRuntime(new DatabaseService(dbConn)); + const rt = await createPluginRuntime(new DatabaseService(dbConn)); const calls: string[] = []; vi.spyOn(rt, 'isActive').mockReturnValue(true); vi.spyOn(rt, 'activate').mockImplementation(async () => { calls.push('activate'); }); @@ -334,7 +334,7 @@ describe('defaults reach the child at spawn', () => { const s = svc(); s.updateInstanceConfig('fixture-id', { api_url: 'https://mine.example' }); - const rt = createPluginRuntime(new DatabaseService(dbConn)); + const rt = await createPluginRuntime(new DatabaseService(dbConn)); const sup = (rt as unknown as { supervisor: { activate: (...a: unknown[]) => Promise } }).supervisor; const activate = vi.spyOn(sup, 'activate').mockResolvedValue(undefined); diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index d7cffc5a8..48590fcae 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -36,13 +36,13 @@ afterEach(() => { }); describe('PluginsService.list', () => { - it('returns the installed plugins and the runtime-enabled flag', () => { + it('returns the installed plugins and the runtime-enabled flag', async () => { testDb .prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)') .run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0'); process.env.TREK_PLUGINS_ENABLED = 'true'; - const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); + const out = await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); expect(out.enabled).toBe(true); expect(out.plugins).toHaveLength(1); expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' }); @@ -60,19 +60,19 @@ describe('PluginsService.list', () => { .prepare("INSERT INTO plugins (id, name, type, status, version, trek_range) VALUES ('old','Old','widget','inactive','1.0.0','>=3.0.0 <4.0.0')") .run(); - it('reports the switch off and an outgrown plugin as hostIncompatible by default', () => { + it('reports the switch off and an outgrown plugin as hostIncompatible by default', async () => { process.env.APP_VERSION = '4.1.0'; seed(); - const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); + const out = await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); expect(out.ignoreTrekRange).toBe(false); expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'hostIncompatible', trekRangeBypassed: null }); }); - it('with the switch on, the plugin may activate but the row still says it is outside its range', () => { + it('with the switch on, the plugin may activate but the row still says it is outside its range', async () => { process.env.APP_VERSION = '4.1.0'; process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1'; seed(); - const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); + const out = await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); expect(out.ignoreTrekRange).toBe(true); expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'ok', @@ -80,16 +80,16 @@ describe('PluginsService.list', () => { }); }); - it('a plugin inside its range carries no marker even with the switch on', () => { + it('a plugin inside its range carries no marker even with the switch on', async () => { process.env.APP_VERSION = '3.5.0'; process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1'; seed(); - const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); + const out = await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'ok', trekRangeBypassed: null }); }); }); - it('surfaces updateHold as a boolean (held plugins leave the update banner)', () => { + it('surfaces updateHold as a boolean (held plugins leave the update banner)', async () => { testDb .prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)") .run(); @@ -97,7 +97,7 @@ describe('PluginsService.list', () => { .prepare("INSERT INTO plugins (id, name, type, status, version) VALUES ('free','Free','widget','inactive','1.0.0')") .run(); - const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); + const out = await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); expect(out.plugins.find((p) => p.id === 'held')).toMatchObject({ updateHold: true }); expect(out.plugins.find((p) => p.id === 'free')).toMatchObject({ updateHold: false }); }); @@ -113,19 +113,19 @@ describe('PluginsService.list', () => { expect(svc.resumeUpdates('ghost')).toBe(false); }); - it('reports enabled by default (no kill switch set)', () => { + it('reports enabled by default (no kill switch set)', async () => { testDb .prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)') .run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0'); - const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); + const out = await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); expect(out.enabled).toBe(true); expect(out.plugins).toHaveLength(1); }); - it('reports disabled when the kill switch is off (TREK_PLUGINS_ENABLED=false)', () => { + it('reports disabled when the kill switch is off (TREK_PLUGINS_ENABLED=false)', async () => { process.env.TREK_PLUGINS_ENABLED = 'false'; - const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); + const out = await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); expect(out.enabled).toBe(false); expect(out.plugins).toEqual([]); }); @@ -141,11 +141,11 @@ describe('PluginsService.list', () => { .prepare('INSERT INTO plugins (id, name, type, status, version, source_repo, author_pubkey) VALUES (?,?,?,?,?,?,?)') .run(id, id, 'widget', 'inactive', '1.0.0', sourceRepo, pubkey); - it('reports signed + a display fingerprint for a registry plugin with a pinned key', () => { + it('reports signed + a display fingerprint for a registry plugin with a pinned key', async () => { const key = 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd'; insert('signed-one', 'acme/signed-one', key); - const p = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list().plugins[0]; + const p = (await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list()).plugins[0]; expect(p.signed).toBe(true); // Short head…tail, for eyeballing against what the author reads out over the // phone. NOT a confidentiality measure — the key is public, and the re-trust @@ -153,41 +153,41 @@ describe('PluginsService.list', () => { expect(p.keyFingerprint).toBe(`${key.slice(0, 8)}…${key.slice(-8)}`); }); - it('reports unsigned for a registry plugin with no pinned key', () => { + it('reports unsigned for a registry plugin with no pinned key', async () => { insert('plain', 'acme/plain', null); - const p = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list().plugins[0]; + const p = (await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list()).plugins[0]; expect(p.signed).toBe(false); expect(p.keyFingerprint).toBeNull(); }); - it('reports unsigned for a sideloaded and a dev-linked plugin (they carry no key)', () => { + it('reports unsigned for a sideloaded and a dev-linked plugin (they carry no key)', async () => { insert('uploaded', 'local:upload', null); insert('linked', 'local:link', null); - const plugins = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list().plugins; + const plugins = (await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list()).plugins; expect(plugins.map((p) => [p.id, p.signed, p.source_repo])).toEqual([ ['linked', false, 'local:link'], ['uploaded', false, 'local:upload'], ]); }); - it('surfaces a recorded update block, and reports none when there is none', () => { + it('surfaces a recorded update block, and reports none when there is none', async () => { insert('blocked', 'acme/blocked', 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd'); insert('fine', 'acme/fine', null); testDb .prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?') .run('SIGNATURE_KEY_CHANGED', 'the key changed', '2.0.0', 'blocked'); - const byId = Object.fromEntries(new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list().plugins.map((p) => [p.id, p])); + const byId = Object.fromEntries((await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list()).plugins.map((p) => [p.id, p])); expect(byId.blocked.updateBlock).toEqual({ code: 'SIGNATURE_KEY_CHANGED', detail: 'the key changed', version: '2.0.0' }); expect(byId.fine.updateBlock).toBeNull(); }); - it('never leaks the raw pinned key into the list response (only the fingerprint)', () => { + it('never leaks the raw pinned key into the list response (only the fingerprint)', async () => { insert('signed-one', 'acme/signed-one', 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd'); // PluginListItem is an interface, so it has no implicit index signature and cannot // be narrowed to a record directly. Widening through unknown is what lets this case // probe for a key the contract deliberately does not declare. - const p = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list() + const p = (await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list()) .plugins[0] as unknown as Record; expect(p.author_pubkey).toBeUndefined(); }); diff --git a/server/tests/unit/plugins/provider-hook-grant.test.ts b/server/tests/unit/plugins/provider-hook-grant.test.ts index 404ed8731..7afe368a8 100644 --- a/server/tests/unit/plugins/provider-hook-grant.test.ts +++ b/server/tests/unit/plugins/provider-hook-grant.test.ts @@ -101,7 +101,7 @@ describe('providersOf enforces the hook:* grant', () => { describe('runtime.invokeHook defense-in-depth', () => { it('refuses a plugin id that is not a granted provider of the hook, even if passed directly', async () => { - const rt = createPluginRuntime(new DatabaseService(db)); + const rt = await createPluginRuntime(new DatabaseService(db)); // one legitimate granted provider exists, so providersOf('placeDetailProvider') = ['ok'] // eslint-disable-next-line @typescript-eslint/no-explicit-any (rt as any).supervisor.running.set('ok', { id: 'ok', status: 'active', hooks: ['placeDetailProvider'], events: [], granted: new Set(['hook:place-detail-provider']) }); diff --git a/server/tests/unit/services/airtrailImport.test.ts b/server/tests/unit/services/airtrailImport.test.ts index 6afd8b2d2..a950fd3a2 100644 --- a/server/tests/unit/services/airtrailImport.test.ts +++ b/server/tests/unit/services/airtrailImport.test.ts @@ -23,6 +23,8 @@ import type { AirtrailClient } from '../../../src/nest/integrations/airtrail.cli import type { AirtrailService } from '../../../src/nest/integrations/airtrail.service'; import { notificationsStub } from '../../helpers/notifications'; import { accommodationsOver } from '../../helpers/accommodations-service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; // The client and the per-user credentials are the only stubs; the reservation // writes go through the real service against the real test DB, as before. They @@ -30,9 +32,9 @@ import { accommodationsOver } from '../../helpers/accommodations-service'; const listFlights = vi.fn(); const broadcast = vi.fn(); -function makeImportService(): AirtrailImportService { +async function makeImportService(): Promise { const dbs = () => new DatabaseService(db); - const permissions = new PermissionsService(dbs()); + const permissions = new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)); const realtime = { broadcast } as unknown as RealtimeService; return new AirtrailImportService( dbs(), @@ -44,7 +46,7 @@ function makeImportService(): AirtrailImportService { realtime, notificationsStub(), new ReservationsReadRepository(dbs()), - accommodationsOver(dbs()), + await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection), ), { listFlights } as unknown as AirtrailClient, { @@ -53,9 +55,9 @@ function makeImportService(): AirtrailImportService { ); } -const importAirtrailFlights = ( +const importAirtrailFlights = async ( ...args: Parameters -) => makeImportService().importAirtrailFlights(...args); +) => (await makeImportService()).importAirtrailFlights(...args); const BRU: AirtrailAirport = { id: 1, icao: 'EBBR', iata: 'BRU', name: 'Brussels', lat: 50.9014, lon: 4.4844, tz: 'Europe/Brussels', country: 'BE' }; const HEL: AirtrailAirport = { id: 2, icao: 'EFHK', iata: 'HEL', name: 'Helsinki-Vantaa', lat: 60.3172, lon: 24.9633, tz: 'Europe/Helsinki', country: 'FI' }; diff --git a/server/tests/unit/services/conflictUpdate.test.ts b/server/tests/unit/services/conflictUpdate.test.ts index 8559292ad..87b3a1a68 100644 --- a/server/tests/unit/services/conflictUpdate.test.ts +++ b/server/tests/unit/services/conflictUpdate.test.ts @@ -54,6 +54,8 @@ import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repos import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; import { notificationsStub } from '../../helpers/notifications'; import { makeStorageFixture } from '../../helpers/storage-fixture'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; const dbs = new DatabaseService(testDb); const realtime = new RealtimeService(); @@ -62,10 +64,14 @@ const runtimeEnv = new RuntimeEnvService(); // the service's stampede guard only works if there is exactly one of them. const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/google/').storage); -const packing = new PackingService(dbs, new PermissionsService(dbs), realtime, notificationsStub()); -const places = new PlacesService( + +let packing: PackingService; +let places: PlacesService; +beforeAll(async () => { + packing = new PackingService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), realtime, notificationsStub()); + places = new PlacesService( dbs, - new PermissionsService(dbs), + new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), realtime, new MapsService(dbs, photoCache), new QueryHelpersService(dbs), @@ -73,8 +79,9 @@ const places = new PlacesService( photoCache, new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)), makeStorageFixture('').storage, - accommodationsOver(dbs), + await accommodationsOver(dbs), await createTestUnitOfWork(dbs.connection), ); +}); beforeAll(() => { createTables(testDb); diff --git a/server/tests/unit/tags/tags.rpc.test.ts b/server/tests/unit/tags/tags.rpc.test.ts index 389690d7b..b5a19ab5e 100644 --- a/server/tests/unit/tags/tags.rpc.test.ts +++ b/server/tests/unit/tags/tags.rpc.test.ts @@ -139,12 +139,12 @@ describe('TagsRpc handlers', () => { rpc = new TagsRpc(tags); }); - it('TAGS-RPC-008 list returns the acting user\'s tags', () => { - expect(rpc.list({}, ctx(42))).toEqual([{ id: 1, user_id: 42, name: 'work' }]); + it('TAGS-RPC-008 list returns the acting user\'s tags', async () => { + expect(await rpc.list({}, ctx(42))).toEqual([{ id: 1, user_id: 42, name: 'work' }]); }); - it('TAGS-RPC-009 create passes name and colour through', () => { - expect(rpc.create({ input: { name: 'work', color: '#abc' } }, ctx(42))).toEqual({ + it('TAGS-RPC-009 create passes name and colour through', async () => { + expect(await rpc.create({ input: { name: 'work', color: '#abc' } }, ctx(42))).toEqual({ id: 9, user_id: 42, name: 'work', @@ -152,44 +152,38 @@ describe('TagsRpc handlers', () => { }); }); - it('TAGS-RPC-010 a non-string colour is dropped rather than passed on', () => { - rpc.create({ input: { name: 'work', color: 123 } }, ctx(42)); + it('TAGS-RPC-010 a non-string colour is dropped rather than passed on', async () => { + await rpc.create({ input: { name: 'work', color: 123 } }, ctx(42)); expect(tags.create).toHaveBeenCalledWith(42, 'work', undefined); }); - it('TAGS-RPC-011 a non-object input is wrapped, so the name check still refuses it', () => { - expect(() => rpc.create({ input: 'work' }, ctx(42))).toThrow(new BadParams('tag name is required')); + it('TAGS-RPC-011 a non-object input is wrapped, so the name check still refuses it', async () => { + await expect(rpc.create({ input: 'work' }, ctx(42))).rejects.toThrow(new BadParams('tag name is required')); }); - it('TAGS-RPC-012 a userless write says "writes", not "reads"', () => { - expect(() => rpc.create({ input: { name: 'x' } }, ctx(undefined))).toThrow( - new ForbiddenResource('tag writes require an authenticated user context'), - ); - expect(() => rpc.update({ tagId: 1 }, ctx(undefined))).toThrow( - new ForbiddenResource('tag writes require an authenticated user context'), - ); - expect(() => rpc.delete({ tagId: 1 }, ctx(undefined))).toThrow( - new ForbiddenResource('tag writes require an authenticated user context'), - ); + it('TAGS-RPC-012 a userless write says "writes", not "reads"', async () => { + await expect(rpc.create({ input: { name: 'x' } }, ctx(undefined))).rejects.toThrow(new ForbiddenResource('tag writes require an authenticated user context')); + await expect(rpc.update({ tagId: 1 }, ctx(undefined))).rejects.toThrow(new ForbiddenResource('tag writes require an authenticated user context')); + await expect(rpc.delete({ tagId: 1 }, ctx(undefined))).rejects.toThrow(new ForbiddenResource('tag writes require an authenticated user context')); }); - it('TAGS-RPC-013 update only forwards the fields it was given', () => { - rpc.update({ tagId: 1, input: { name: 'renamed' } }, ctx(42)); + it('TAGS-RPC-013 update only forwards the fields it was given', async () => { + await rpc.update({ tagId: 1, input: { name: 'renamed' } }, ctx(42)); expect(tags.update).toHaveBeenCalledWith(1, 'renamed', undefined); }); - it('TAGS-RPC-014 a missing tagId is BAD_PARAMS before the ownership check runs', () => { - expect(() => rpc.update({ input: { name: 'x' } }, ctx(42))).toThrow(new BadParams('tagId must be a number')); + it('TAGS-RPC-014 a missing tagId is BAD_PARAMS before the ownership check runs', async () => { + await expect(rpc.update({ input: { name: 'x' } }, ctx(42))).rejects.toThrow(new BadParams('tagId must be a number')); expect(tags.getByIdAndUser).not.toHaveBeenCalled(); }); - it('TAGS-RPC-015 delete re-checks ownership and reports the deletion', () => { - expect(rpc.delete({ tagId: 1 }, ctx(42))).toEqual({ deleted: true }); + it('TAGS-RPC-015 delete re-checks ownership and reports the deletion', async () => { + expect(await rpc.delete({ tagId: 1 }, ctx(42))).toEqual({ deleted: true }); expect(tags.remove).toHaveBeenCalledWith(1); }); - it('TAGS-RPC-016 deleting another user\'s tag never reaches remove()', () => { - expect(() => rpc.delete({ tagId: 2 }, ctx(42))).toThrow(new ForbiddenResource('no tag 2 for this user')); + it('TAGS-RPC-016 deleting another user\'s tag never reaches remove()', async () => { + await expect(rpc.delete({ tagId: 2 }, ctx(42))).rejects.toThrow(new ForbiddenResource('no tag 2 for this user')); expect(tags.remove).not.toHaveBeenCalled(); }); });