From aa587e2fb6b2b1a15a3297d46dc3449852795900 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 21 Sep 2026 22:39:11 +0200 Subject: [PATCH] refactor(budget, common, day-notes, days): async DB-touching methods; transactions through UnitOfWork --- server/src/nest/admin/admin.controller.ts | 2 +- server/src/nest/admin/admin.service.ts | 4 +- .../src/nest/assignments/assignments.mcp.ts | 4 +- server/src/nest/auth/auth.controller.ts | 2 +- server/src/nest/auth/auth.service.ts | 4 +- server/src/nest/auth/user-cleanup.service.ts | 12 +- .../booking-import/booking-import.service.ts | 2 +- server/src/nest/budget/budget.controller.ts | 44 +-- server/src/nest/budget/budget.mcp.ts | 100 +++--- server/src/nest/budget/budget.service.ts | 168 ++++----- server/src/nest/budget/costs.rpc.ts | 14 +- server/src/nest/categories/categories.mcp.ts | 10 +- server/src/nest/common/demo-write.ts | 2 +- .../nest/common/idempotency-cleanup.job.ts | 4 +- server/src/nest/common/idempotency-cleanup.ts | 4 +- .../nest/common/idempotency.interceptor.ts | 46 ++- .../nest/day-notes/day-notes.controller.ts | 22 +- server/src/nest/day-notes/day-notes.mcp.ts | 22 +- server/src/nest/day-notes/day-notes.rpc.ts | 18 +- .../src/nest/day-notes/day-notes.service.ts | 14 +- server/src/nest/days/days.controller.ts | 20 +- server/src/nest/days/days.mcp.ts | 24 +- server/src/nest/days/days.rpc.ts | 8 +- server/src/nest/days/days.service.ts | 42 +-- server/src/nest/feeds/feeds.mcp.ts | 16 +- .../contributions/plugin-mcp-tools.service.ts | 2 +- .../reservations/reservations.controller.ts | 4 +- .../src/nest/reservations/reservations.mcp.ts | 50 +-- .../src/nest/reservations/reservations.rpc.ts | 4 +- .../nest/reservations/reservations.service.ts | 14 +- server/src/nest/transit/transit.mcp.ts | 2 +- .../src/nest/trip-invite/trip-invite.mcp.ts | 8 +- .../trip-members/trip-members.controller.ts | 4 +- .../nest/trip-members/trip-members.service.ts | 10 +- .../trip-read-model.service.ts | 4 +- server/src/nest/trips/trips.mcp.ts | 2 +- server/src/nest/trips/trips.rpc.ts | 2 +- server/src/nest/trips/trips.service.ts | 14 +- server/tests/e2e/trips.e2e.test.ts | 2 +- server/tests/helpers/mcp-test-controllers.ts | 11 +- server/tests/helpers/plugin-host.ts | 8 +- server/tests/integration/dayReorder.test.ts | 22 +- server/tests/unit/idempotency-cleanup.test.ts | 16 +- .../unit/mcp/tools-budget-advanced.test.ts | 2 +- server/tests/unit/mcp/tools-prompts.test.ts | 3 +- server/tests/unit/nest/admin.service.test.ts | 14 +- .../nest/admin.version-notification.test.ts | 2 +- server/tests/unit/nest/auth.service.test.ts | 14 +- .../tests/unit/nest/budget.controller.test.ts | 84 ++--- .../unit/nest/budget.service.calc.test.ts | 191 +++++----- .../tests/unit/nest/budget.service.db.test.ts | 338 +++++++++--------- server/tests/unit/nest/budget.service.test.ts | 56 +-- .../tests/unit/nest/calendar.service.test.ts | 2 +- .../tests/unit/nest/day-notes.service.test.ts | 138 +++---- .../tests/unit/nest/days.controller.test.ts | 86 ++--- server/tests/unit/nest/days.service.test.ts | 102 +++--- .../unit/nest/idempotency.interceptor.test.ts | 35 +- server/tests/unit/nest/oidc.service.test.ts | 2 +- .../tests/unit/nest/passkey.service.test.ts | 2 +- .../unit/nest/reservations.controller.test.ts | 36 +- .../unit/nest/reservations.service.test.ts | 40 +-- .../unit/nest/trip-members.controller.test.ts | 52 +-- .../unit/nest/trip-members.service.test.ts | 10 +- .../unit/nest/trip-read-model.service.test.ts | 6 +- server/tests/unit/nest/trips.service.test.ts | 76 ++-- .../unit/nest/user-cleanup.service.test.ts | 26 +- .../unit/services/airtrailImport.test.ts | 2 +- 67 files changed, 1082 insertions(+), 1024 deletions(-) diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index f86557001..b220b8206 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -95,7 +95,7 @@ export class AdminController { @Delete('users/:id') async deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { - const result = ok(this.admin.deleteUser(id, user.id)); + const result = ok(await this.admin.deleteUser(id, user.id)); 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 }; diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 065443f99..0e23d1391 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -248,7 +248,7 @@ export class AdminService { }; } - deleteUser(id: string, currentUserId: number) { + async deleteUser(id: string, currentUserId: number) { if (Number.parseInt(id) === currentUserId) { return { error: 'Cannot delete own account', status: 400 }; } @@ -256,7 +256,7 @@ export class AdminService { const userToDel = this.db.get<{ id: number; email: string }>('SELECT id, email FROM users WHERE id = ?', id); if (!userToDel) return { error: 'User not found', status: 404 }; - this.userCleanup.deleteUserCompletely(userToDel.id); + await this.userCleanup.deleteUserCompletely(userToDel.id); emitUserDeleted(userToDel.id); // let plugins erase their own per-user data return { email: userToDel.email }; } diff --git a/server/src/nest/assignments/assignments.mcp.ts b/server/src/nest/assignments/assignments.mcp.ts index 6240db808..b4e050b38 100644 --- a/server/src/nest/assignments/assignments.mcp.ts +++ b/server/src/nest/assignments/assignments.mcp.ts @@ -222,7 +222,7 @@ export class AssignmentsMcp { if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); 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.'); + if (!(await this.days.getDay(newDayId, tripId))) return errorResult('Day not found.'); 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 @@ -294,7 +294,7 @@ export class AssignmentsMcp { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); - if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.'); + if (!(await 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, // so broadcasting the tool-input key name emptied the day for collaborators. diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts index 9551e6248..eadbd6fff 100644 --- a/server/src/nest/auth/auth.controller.ts +++ b/server/src/nest/auth/auth.controller.ts @@ -126,7 +126,7 @@ export class AuthController { @Delete('me') async deleteAccount(@CurrentUser() user: User, @Req() req: Request) { - const result = this.auth.deleteAccount(user.id, user.email, user.role); + const result = await this.auth.deleteAccount(user.id, user.email, user.role); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index 8993ec5ae..8d4de3678 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -610,7 +610,7 @@ export class AuthService { return { success: true, token }; } - deleteAccount(userId: number, userEmail: string, userRole: string): { error?: string; status?: number; success?: boolean } { + async deleteAccount(userId: number, userEmail: string, userRole: string): Promise<{ error?: string; status?: number; success?: boolean }> { if (readEnv().demo.enabled && isDemoEmail(userEmail)) { return { error: 'Account deletion is disabled in demo mode.', status: 403 }; } @@ -620,7 +620,7 @@ export class AuthService { return { error: 'Cannot delete the last admin account', status: 400 }; } } - this.userCleanup.deleteUserCompletely(userId); + await this.userCleanup.deleteUserCompletely(userId); emitUserDeleted(userId); // let plugins erase their own per-user data return { success: true }; } diff --git a/server/src/nest/auth/user-cleanup.service.ts b/server/src/nest/auth/user-cleanup.service.ts index 987855f2a..ee6b70768 100644 --- a/server/src/nest/auth/user-cleanup.service.ts +++ b/server/src/nest/auth/user-cleanup.service.ts @@ -6,6 +6,7 @@ import { DatabaseService } from '../database/database.service'; // AuthModule -> BudgetModule cycle that used to force budget.bridge here. import { BudgetService } from '../budget/budget.service'; import { pluginsDataRoot } from '../plugins/paths'; +import { UnitOfWork } from '../database/unit-of-work'; /** * Account erasure — everything that has to happen around `DELETE FROM users` @@ -24,6 +25,7 @@ export class UserCleanupService { constructor( private readonly db: DatabaseService, private readonly budget: BudgetService, + private readonly uow: UnitOfWork, ) {} /** @@ -63,10 +65,10 @@ export class UserCleanupService { } catch { /* plugins / queue table absent (slim schema) */ } } - private cleanupUserReferences(userId: number): void { + private async cleanupUserReferences(userId: number): Promise { this.db.run('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?', userId); this.db.run('UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?', userId); - this.budget.removeUserFromBudgetItems(userId); + await this.budget.removeUserFromBudgetItems(userId); this.db.run('DELETE FROM share_tokens WHERE created_by = ?', userId); this.db.run('DELETE FROM journey_share_tokens WHERE created_by = ?', userId); // Owned journeys cascade-delete their entries/contributors/share_tokens/photos via journey_id FKs @@ -76,9 +78,9 @@ export class UserCleanupService { this.db.run('DELETE FROM journey_contributors WHERE user_id = ?', userId); } - deleteUserCompletely(userId: number): void { - this.db.transaction(() => { - this.cleanupUserReferences(userId); + async deleteUserCompletely(userId: number): Promise { + await this.uow.transactional(async () => { + await this.cleanupUserReferences(userId); this.erasePluginUserData(userId); this.db.run('DELETE FROM users WHERE id = ?', userId); }); diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts index fcd9ec93f..022c5dffd 100644 --- a/server/src/nest/booking-import/booking-import.service.ts +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -327,7 +327,7 @@ export class BookingImportService { // Freeze the live FX rate for a foreign-currency booking price so a // settled position isn't re-opened when live rates drift (#1445). await this.budget.freezeForeignRate(tripId, budgetData); - const budgetItem = this.budget.createBudgetItem(tripId, budgetData); + const budgetItem = await this.budget.createBudgetItem(tripId, budgetData); this.realtime.broadcast(tripId, 'budget:created', { item: budgetItem }, socketId); } catch (err) { console.error( diff --git a/server/src/nest/budget/budget.controller.ts b/server/src/nest/budget/budget.controller.ts index e575bd441..8962d0c1e 100644 --- a/server/src/nest/budget/budget.controller.ts +++ b/server/src/nest/budget/budget.controller.ts @@ -57,17 +57,17 @@ export class BudgetController { @Get() - list(@CurrentUser() user: User, @Param('tripId') tripId: string) { - return { items: this.budget.list(tripId) }; + async list(@CurrentUser() user: User, @Param('tripId') tripId: string) { + return { items: await this.budget.list(tripId) }; } @Get('summary/per-person') - perPerson(@CurrentUser() user: User, @Param('tripId') tripId: string) { - return { summary: this.budget.perPersonSummary(tripId) }; + async perPerson(@CurrentUser() user: User, @Param('tripId') tripId: string) { + return { summary: await this.budget.perPersonSummary(tripId) }; } @Get('settlement') - settlement( + async settlement( @CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @@ -77,8 +77,8 @@ export class BudgetController { } @Get('settlements') - listSettlements(@CurrentUser() user: User, @Param('tripId') tripId: string) { - return { settlements: this.budget.listSettlements(tripId) }; + async listSettlements(@CurrentUser() user: User, @Param('tripId') tripId: string) { + return { settlements: await this.budget.listSettlements(tripId) }; } @RequirePermission('budget_edit') @@ -128,13 +128,13 @@ export class BudgetController { @RequirePermission('budget_edit') @Delete('settlements/:settlementId') - deleteSettlement( + async deleteSettlement( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('settlementId') settlementId: string, @Headers('x-socket-id') socketId?: string, ) { - if (!this.budget.deleteSettlement(settlementId, tripId)) { + if (!(await this.budget.deleteSettlement(settlementId, tripId))) { throw new HttpException({ error: 'Settlement not found' }, 404); } this.budget.broadcast(tripId, 'budget:settlement-deleted', { settlementId: Number(settlementId) }, socketId); @@ -156,26 +156,26 @@ export class BudgetController { @RequirePermission('budget_edit') @Put('reorder/items') - reorderItems( + async reorderItems( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: BudgetReorderItemsDto, @Headers('x-socket-id') socketId?: string, ) { - this.budget.reorderItems(tripId, body.orderedIds); + await this.budget.reorderItems(tripId, body.orderedIds); this.budget.broadcast(tripId, 'budget:reordered', { orderedIds: body.orderedIds }, socketId); return { success: true }; } @RequirePermission('budget_edit') @Put('reorder/categories') - reorderCategories( + async reorderCategories( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: BudgetReorderCategoriesDto, @Headers('x-socket-id') socketId?: string, ) { - this.budget.reorderCategories(tripId, body.orderedCategories); + await this.budget.reorderCategories(tripId, body.orderedCategories); this.budget.broadcast(tripId, 'budget:reordered', { orderedCategories: body.orderedCategories }, socketId); return { success: true }; } @@ -194,7 +194,7 @@ export class BudgetController { throw new HttpException({ error: 'Budget item not found' }, 404); } if (updated.reservation_id && body.total_price !== undefined) { - this.budget.syncReservationPrice(tripId, updated.reservation_id, updated.total_price, socketId); + await this.budget.syncReservationPrice(tripId, updated.reservation_id, updated.total_price, socketId); } this.budget.broadcast(tripId, 'budget:updated', { item: updated }, socketId); return { item: updated }; @@ -202,14 +202,14 @@ export class BudgetController { @RequirePermission('budget_edit') @Put(':id/members') - updateMembers( + async updateMembers( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: BudgetUpdateMembersDto, @Headers('x-socket-id') socketId?: string, ) { - const result = this.budget.updateMembers(id, tripId, body.user_ids); + const result = await this.budget.updateMembers(id, tripId, body.user_ids); if (!result) { throw new HttpException({ error: 'Budget item not found' }, 404); } @@ -219,14 +219,14 @@ export class BudgetController { @RequirePermission('budget_edit') @Put(':id/payers') - setPayers( + async setPayers( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: BudgetUpdatePayersDto, @Headers('x-socket-id') socketId?: string, ) { - const item = this.budget.setPayers(id, tripId, body.payers); + const item = await this.budget.setPayers(id, tripId, body.payers); if (!item) { throw new HttpException({ error: 'Budget item not found' }, 404); } @@ -236,7 +236,7 @@ export class BudgetController { @RequirePermission('budget_edit') @Put(':id/members/:userId/paid') - toggleMemberPaid( + async toggleMemberPaid( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -244,20 +244,20 @@ export class BudgetController { @Body() body: BudgetToggleMemberPaidDto, @Headers('x-socket-id') socketId?: string, ) { - const member = this.budget.toggleMemberPaid(id, tripId, userId, body.paid); + const member = await this.budget.toggleMemberPaid(id, tripId, userId, body.paid); this.budget.broadcast(tripId, 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: body.paid ? 1 : 0 }, socketId); return { member }; } @RequirePermission('budget_edit') @Delete(':id') - remove( + async remove( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { - if (!this.budget.remove(id, tripId)) { + if (!(await this.budget.remove(id, tripId))) { throw new HttpException({ error: 'Budget item not found' }, 404); } this.budget.broadcast(tripId, 'budget:deleted', { itemId: Number(id) }, socketId); diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 2969544de..5cf02cc41 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -16,6 +16,7 @@ import { BudgetService } from './budget.service'; import { ExchangeRatesService } from './exchange-rates.service'; import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; +import { UnitOfWork } from '../database/unit-of-work'; /** Legacy registrar gate: the whole budget surface rides the budget addon. */ const budgetAddonOn = addonGate(ADDON_IDS.BUDGET); @@ -84,11 +85,12 @@ export class BudgetMcp { private readonly membership: TripMembershipService, readonly addons: AddonsService, private readonly guards: McpToolGuardsService, + private readonly uow: UnitOfWork, ) {} /** The AuthService.isDemoUser check without the auth graph (demo-write.ts). */ - private isDemoUser(userId: number): boolean { - return isDemoUserId(this.env, this.db, userId); + private async isDemoUser(userId: number): Promise { + return await isDemoUserId(this.env, this.db, userId); } /** @@ -111,12 +113,12 @@ export class BudgetMcp { * (the write path overwrites total_price with their sum), so the stated total * is not necessarily the figure that ends up in the row. */ - private settledTotalCents( + private async settledTotalCents( tripId: number, payers: { user_id: number; amount: number }[] | undefined, total_price: number | undefined, fallbackCents: number, - ): number { + ): Promise { // Once payers are sent at all, the write path derives the total from them and // an empty list therefore means zero, not "no opinion". Reading total_price // here instead would certify a split against a figure the row never receives. @@ -138,12 +140,12 @@ export class BudgetMcp { * user_id is refused for the same reason: the write path drops both silently, * which would unbalance a split that had just been checked. */ - private splitRefusal( + private async splitRefusal( tripId: number, members: { user_id: number; amount: number }[], totalCents: number, payers?: { user_id: number; amount: number }[], - ): string | null { + ): Promise { const roster = this.db.rosterUserIds(tripId); const strangers = members.filter(m => !roster.has(m.user_id)).map(m => m.user_id); if (strangers.length > 0) { @@ -169,7 +171,7 @@ export class BudgetMcp { } /** The sibling tools' foreign-id rule: a linked id must live on the same trip. */ - private placeOnTrip(tripId: number, placeId: number): boolean { + private async placeOnTrip(tripId: number, placeId: number): Promise { return !!this.db.get('SELECT id FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); } @@ -203,13 +205,13 @@ export class BudgetMcp { }, ctx: McpContext, ) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); + if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess(); 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 (place_id != null && !(await this.placeOnTrip(tripId, place_id))) return errorResult('place_id does not belong to this trip.'); if (members !== undefined) { - const refusal = this.splitRefusal(tripId, members, this.settledTotalCents(tripId, payers, total_price, toCents(total_price)), payers); + const refusal = await this.splitRefusal(tripId, members, await this.settledTotalCents(tripId, payers, total_price, toCents(total_price)), payers); if (refusal) return errorResult(refusal); } // The split participants are the members of an uneven split; the equal-split @@ -219,7 +221,7 @@ export class BudgetMcp { // 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. await this.budget.freezeForeignRate(tripId, itemData); - const item = this.budget.createBudgetItem(tripId, itemData); + const item = await this.budget.createBudgetItem(tripId, itemData); this.guards.safeBroadcast(tripId, 'budget:created', { item }); return ok({ item }); } @@ -236,10 +238,10 @@ export class BudgetMcp { access: { group: 'budget', mode: 'write' }, }) 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 (await this.isDemoUser(ctx.userId)) return demoDenied(); + if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied(); - const deleted = this.budget.deleteBudgetItem(itemId, tripId); + const deleted = await this.budget.deleteBudgetItem(itemId, tripId); if (!deleted) return errorResult('Budget item not found.'); this.guards.safeBroadcast(tripId, 'budget:deleted', { itemId }); return ok({ success: true }); @@ -278,16 +280,16 @@ export class BudgetMcp { }, ctx: McpContext, ) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); + if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess(); 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 // the stored figure stands in when the call does not restate one. - const existing = this.budget.getBudgetItem(itemId, tripId); + const existing = await this.budget.getBudgetItem(itemId, tripId); if (!existing) return errorResult('Budget item not found.'); - const refusal = this.splitRefusal(tripId, members, this.settledTotalCents(tripId, payers, total_price, toCents(existing.total_price)), payers); + const refusal = await this.splitRefusal(tripId, members, await this.settledTotalCents(tripId, payers, total_price, toCents(existing.total_price)), payers); if (refusal) return errorResult(refusal); } // Freeze-then-write composite: a currency change re-freezes the rate at entry @@ -322,16 +324,16 @@ export class BudgetMcp { }, ctx: McpContext, ) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); + if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess(); 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.'); + if (place_id != null && !(await 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 : 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 }); - return this.budget.getBudgetItem(created.id, tripId)!; + const item = await this.uow.transactional(async () => { + const created = await this.budget.createBudgetItem(tripId, { category, name, total_price, note, member_ids: members, place_id }); + return (await this.budget.getBudgetItem(created.id, tripId))!; }); this.guards.safeBroadcast(tripId, 'budget:created', { item }); if (members && members.length > 0) this.guards.safeBroadcast(tripId, 'budget:members-updated', { itemId: item.id, members: item.members, persons: item.persons }); @@ -354,12 +356,12 @@ export class BudgetMcp { access: { group: 'budget', mode: 'write' }, }) 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 (await this.isDemoUser(ctx.userId)) return demoDenied(); + if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied(); - const result = this.budget.updateMembers(itemId, tripId, userIds); + const result = await this.budget.updateMembers(itemId, tripId, userIds); if (!result) return errorResult('Budget item not found.'); - const item = this.budget.getBudgetItem(itemId, tripId); + const item = await this.budget.getBudgetItem(itemId, tripId); this.guards.safeBroadcast(tripId, 'budget:members-updated', { itemId, members: result.members, persons: result.item.persons }); return ok({ item }); } @@ -378,10 +380,10 @@ export class BudgetMcp { access: { group: 'budget', mode: 'write' }, }) 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 (await this.isDemoUser(ctx.userId)) return demoDenied(); + if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied(); - const member = this.budget.toggleMemberPaid(itemId, tripId, memberId, paid); + const member = await 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 }); } @@ -400,12 +402,12 @@ export class BudgetMcp { access: { group: 'budget', mode: 'read' }, }) async getSettlementSummary({ tripId, base }: { tripId: number; base?: string }, ctx: McpContext) { - if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess(); const trip = this.db.get<{ currency?: string }>('SELECT currency FROM trips WHERE id = ?', tripId); const tripCurrency = trip?.currency || 'EUR'; const effectiveBase = (base || tripCurrency).toUpperCase(); const rates = await this.exchangeRates.getRates(effectiveBase); - const summary = this.budget.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency }); + const summary = await this.budget.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency }); return ok({ summary }); } @@ -420,8 +422,8 @@ export class BudgetMcp { access: { group: 'budget', mode: 'read' }, }) async listSettlements({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - return ok({ settlements: this.budget.listSettlements(tripId) }); + if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess(); + return ok({ settlements: await this.budget.listSettlements(tripId) }); } @Tool({ @@ -443,8 +445,8 @@ export class BudgetMcp { { tripId, from_user_id, to_user_id, amount, currency, settled_at }: { tripId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }, ctx: McpContext, ) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); + if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess(); 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). @@ -474,8 +476,8 @@ export class BudgetMcp { { tripId, settlementId, from_user_id, to_user_id, amount, currency, settled_at }: { tripId: number; settlementId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }, ctx: McpContext, ) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); + if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess(); 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. @@ -497,10 +499,10 @@ export class BudgetMcp { access: { group: 'budget', mode: 'write' }, }) 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 (await this.isDemoUser(ctx.userId)) return demoDenied(); + if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied(); - const deleted = this.budget.deleteSettlement(settlementId, tripId); + const deleted = await this.budget.deleteSettlement(settlementId, tripId); if (!deleted) return errorResult('Settlement not found.'); this.guards.safeBroadcast(tripId, 'budget:settlement-deleted', { settlementId }); return ok({ success: true }); @@ -518,7 +520,7 @@ export class BudgetMcp { }) async tripBudgetResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { const id = parseId(tripId); - if (id === null || !this.budget.verifyTripAccess(id, ctx.userId)) { + if (id === null || !(await this.budget.verifyTripAccess(id, ctx.userId))) { return { contents: [{ uri: uri.href, @@ -527,7 +529,7 @@ export class BudgetMcp { }], }; } - const items = this.budget.listBudgetItems(id); + const items = await this.budget.listBudgetItems(id); return { contents: [{ uri: uri.href, @@ -547,7 +549,7 @@ export class BudgetMcp { }) async tripBudgetPerPersonResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { const id = parseId(tripId); - if (id === null || !this.budget.verifyTripAccess(id, ctx.userId)) { + if (id === null || !(await this.budget.verifyTripAccess(id, ctx.userId))) { return { contents: [{ uri: uri.href, @@ -556,7 +558,7 @@ export class BudgetMcp { }], }; } - const summary = this.budget.getPerPersonSummary(id); + const summary = await this.budget.getPerPersonSummary(id); return { contents: [{ uri: uri.href, @@ -576,7 +578,7 @@ export class BudgetMcp { }) async tripBudgetSettlementResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { const id = parseId(tripId); - if (id === null || !this.budget.verifyTripAccess(id, ctx.userId)) { + if (id === null || !(await this.budget.verifyTripAccess(id, ctx.userId))) { return { contents: [{ uri: uri.href, @@ -592,7 +594,7 @@ export class BudgetMcp { const tripCurrency = trip?.currency || 'EUR'; const effectiveBase = tripCurrency.toUpperCase(); const rates = await this.exchangeRates.getRates(effectiveBase); - const settlement = this.budget.calculateSettlement(id, { base: effectiveBase, rates, tripCurrency }); + const settlement = await this.budget.calculateSettlement(id, { base: effectiveBase, rates, tripCurrency }); return { contents: [{ uri: uri.href, diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index d3eb21804..6de87b21e 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -6,6 +6,7 @@ import { PermissionsService } from '../permissions/permissions.service'; import { avatarUrl } from '../common/avatarUrl'; import type { User, BudgetItem, BudgetItemMember, BudgetItemPayer, BudgetItemReceipt } from '../../types'; import { ExchangeRatesService } from './exchange-rates.service'; +import { UnitOfWork } from '../database/unit-of-work'; type Trip = TripAccess; @@ -98,7 +99,7 @@ function allocateDisplayCents(cents: number[], factor: number, total = Math.roun * UserCleanupService, injects this class now.) * * Quirk fixes on top of the relocated legacy behavior: every multi-statement - * write now runs in db.transaction() ("transactions are not optional"), + * write now runs in uow.transactional() ("transactions are not optional"), * linkBudgetItemToReservation passes reservation_id through the insert instead * of a redundant second UPDATE, settlement re-selects are targeted single-row * queries instead of a full listSettlements() scan, settlement usernames use @@ -112,9 +113,10 @@ export class BudgetService { private readonly permissions: PermissionsService, private readonly exchangeRates: ExchangeRatesService, private readonly realtime: RealtimeService, + private readonly uow: UnitOfWork, ) {} - verifyTripAccess(tripId: string | number, userId: number) { + async verifyTripAccess(tripId: string | number, userId: number) { return this.db.canAccessTrip(tripId, userId); } @@ -130,7 +132,7 @@ export class BudgetService { // Helpers // ------------------------------------------------------------------------- - private loadItemMembers(itemId: number | string) { + private async loadItemMembers(itemId: number | string) { const rows = this.db.all(` SELECT bm.user_id, bm.paid, bm.amount, COALESCE(u.display_name, u.username) AS username, u.avatar FROM budget_item_members bm @@ -140,7 +142,7 @@ export class BudgetService { return rows.map(m => ({ ...m, avatar_url: avatarUrl(m) })); } - private loadItemPayers(itemId: number | string) { + private async loadItemPayers(itemId: number | string) { const rows = this.db.all(` SELECT bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar FROM budget_item_payers bp @@ -150,7 +152,7 @@ export class BudgetService { return rows.map(p => ({ ...p, avatar_url: avatarUrl(p) })); } - private loadItemReceipts(itemId: number | string): BudgetItemReceipt[] { + private async loadItemReceipts(itemId: number | string): Promise { const rows = this.db.all<{ id: number; filename: string; @@ -183,7 +185,7 @@ export class BudgetService { * name and an avatar attached. Off-roster ids drop silently, the way the * packing and reservations assignee paths handle them. */ - private rosterMemberIds(tripId: string | number, userIds: number[]): Set { + private async rosterMemberIds(tripId: string | number, userIds: number[]): Promise> { const unique = new Set(userIds); if (unique.size === 0) return new Set(); const roster = this.db.rosterUserIds(tripId); @@ -195,10 +197,10 @@ export class BudgetService { * A negative amount is a real payer — the recipient of a refund (#2176) — and * is stored as such; only a zero (or NaN) amount says nothing and is dropped. */ - private writeItemPayers(itemId: number | string, tripId: string | number, payers: { user_id: number; amount: number }[]) { + private async writeItemPayers(itemId: number | string, tripId: string | number, payers: { user_id: number; amount: number }[]) { this.db.run('DELETE FROM budget_item_payers WHERE budget_item_id = ?', itemId); const insert = this.db.prepare('INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)'); - const known = this.rosterMemberIds(tripId, payers.map(p => p.user_id)); + const known = await this.rosterMemberIds(tripId, payers.map(p => p.user_id)); const accepted: number[] = []; for (const p of payers) { if (!p.amount || !known.has(p.user_id)) continue; @@ -225,7 +227,7 @@ export class BudgetService { * nothing to do with the expense. A link whose file already sits in the trash * is left in place, so restoring that file brings it back attached. */ - private unlinkReceipts(budgetItemId: number | string, keep: ReadonlySet = new Set()) { + private async unlinkReceipts(budgetItemId: number | string, keep: ReadonlySet = new Set()) { const rows = this.db.all<{ id: number; file_id: number; reservation_id: number | null; assignment_id: number | null; place_id: number | null }>( `SELECT fl.id, fl.file_id, fl.reservation_id, fl.assignment_id, fl.place_id FROM file_links fl @@ -247,7 +249,7 @@ export class BudgetService { // CRUD // ------------------------------------------------------------------------- - listBudgetItems(tripId: string | number) { + async listBudgetItems(tripId: string | number) { const items = this.db.all(` SELECT bi.* FROM budget_items bi LEFT JOIN budget_category_order bco ON bco.trip_id = bi.trip_id AND bco.category = bi.category @@ -437,10 +439,10 @@ export class BudgetService { `, prev, tripId); }; - this.db.transaction(() => { rebase('budget_items'); rebase('budget_settlements'); pinPlaces(); }); + await this.uow.transactional(async () => { rebase('budget_items'); rebase('budget_settlements'); pinPlaces(); }); } - createBudgetItem( + async createBudgetItem( tripId: string | number, data: { category?: string; name: string; total_price?: number; @@ -454,7 +456,7 @@ export class BudgetService { receipt_file_ids?: number[]; }, ) { - return this.db.transaction(() => { + return await this.uow.transactional(async () => { const maxOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?', tripId)!; const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; @@ -474,9 +476,9 @@ export class BudgetService { const payerTotal = sumMoney((data.payers || []).filter(p => p.amount !== 0).map(p => p.amount)); const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0); - const knownMembers = data.members ? this.rosterMemberIds(tripId, data.members.map(m => m.user_id)) : null; + const knownMembers = data.members ? await this.rosterMemberIds(tripId, data.members.map(m => m.user_id)) : null; const members = data.members && knownMembers ? data.members.filter(m => knownMembers.has(m.user_id)) : undefined; - const knownIds = data.member_ids ? this.rosterMemberIds(tripId, data.member_ids) : null; + const knownIds = data.member_ids ? await this.rosterMemberIds(tripId, data.member_ids) : null; const memberIds = data.member_ids && knownIds ? data.member_ids.filter(uid => knownIds.has(uid)) : undefined; const { note, ticket } = splitLegacyTicketNote(data.note, data.ticket_json); @@ -500,7 +502,7 @@ export class BudgetService { ); const itemId = result.lastInsertRowid as number; - if (data.payers && data.payers.length > 0) this.writeItemPayers(itemId, tripId, data.payers); + if (data.payers && data.payers.length > 0) await this.writeItemPayers(itemId, tripId, data.payers); if (members && members.length > 0) { const insert = this.db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, ?)'); for (const m of members) insert.run(itemId, m.user_id, m.amount !== undefined && m.amount !== null ? m.amount : null); @@ -521,34 +523,34 @@ export class BudgetService { } const item = this.db.get('SELECT * FROM budget_items WHERE id = ?', itemId)!; - item.members = this.loadItemMembers(itemId); - item.payers = this.loadItemPayers(itemId); - item.receipts = this.loadItemReceipts(itemId); + item.members = await this.loadItemMembers(itemId); + item.payers = await this.loadItemPayers(itemId); + item.receipts = await this.loadItemReceipts(itemId); return item; }); } /** Fetch a single budget item hydrated with its members, payers, and receipts, scoped to the trip. */ - getBudgetItem(id: string | number, tripId: string | number): BudgetItem | null { + async getBudgetItem(id: string | number, tripId: string | number): Promise { const item = this.db.get('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId); if (!item) return null; - item.members = this.loadItemMembers(id); - item.payers = this.loadItemPayers(id); - item.receipts = this.loadItemReceipts(id); + item.members = await this.loadItemMembers(id); + item.payers = await this.loadItemPayers(id); + item.receipts = await this.loadItemReceipts(id); return item; } - linkBudgetItemToReservation( + async linkBudgetItemToReservation( tripId: string | number, reservationId: number, data: { name: string; category?: string; total_price: number }, ) { // createBudgetItem accepts reservation_id directly — the legacy separate // UPDATE after the insert was redundant (and non-atomic). - return this.createBudgetItem(tripId, { ...data, reservation_id: reservationId }); + return await this.createBudgetItem(tripId, { ...data, reservation_id: reservationId }); } - updateBudgetItem( + async updateBudgetItem( id: string | number, tripId: string | number, data: { @@ -561,7 +563,7 @@ export class BudgetService { receipt_file_ids?: number[]; }, ) { - return this.db.transaction(() => { + return await this.uow.transactional(async () => { const item = this.db.get('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId); if (!item) return null; @@ -602,7 +604,7 @@ export class BudgetService { // Optional inline payer/member replacement (the edit modal saves all at once). if (data.payers !== undefined) { - this.writeItemPayers(id, tripId, data.payers); + await this.writeItemPayers(id, tripId, data.payers); // writeItemPayers derives total_price from the payer sum (0 for no payers). // A "recorded total, nobody assigned" expense clears payers but still carries // an explicit total_price — re-apply it so it isn't clobbered to 0. @@ -611,14 +613,14 @@ export class BudgetService { } } if (data.members !== undefined) { - const known = this.rosterMemberIds(tripId, data.members.map(m => m.user_id)); + const known = await this.rosterMemberIds(tripId, data.members.map(m => m.user_id)); const members = data.members.filter(m => known.has(m.user_id)); this.db.run('DELETE FROM budget_item_members WHERE budget_item_id = ?', id); const insert = this.db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, ?)'); for (const m of members) insert.run(id, m.user_id, m.amount !== undefined && m.amount !== null ? m.amount : null); this.db.run('UPDATE budget_items SET persons = ? WHERE id = ?', members.length || null, id); } else if (data.member_ids !== undefined) { - const known = this.rosterMemberIds(tripId, data.member_ids); + const known = await this.rosterMemberIds(tripId, data.member_ids); const memberIds = data.member_ids.filter(uid => known.has(uid)); this.db.run('DELETE FROM budget_item_members WHERE budget_item_id = ?', id); const insert = this.db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, NULL)'); @@ -643,7 +645,7 @@ export class BudgetService { // second spare row into the pair the first one just wrote. const wanted = [...new Set(data.receipt_file_ids.map(Number))]; const keep = new Set(wanted); - this.unlinkReceipts(id, keep); + await this.unlinkReceipts(id, keep); if (wanted.length > 0) { const insertLink = this.db.prepare('INSERT OR IGNORE INTO file_links (file_id, budget_item_id) VALUES (?, ?)'); const adopt = this.db.prepare('UPDATE file_links SET budget_item_id = ? WHERE id = ?'); @@ -671,9 +673,9 @@ export class BudgetService { } const updated = this.db.get('SELECT * FROM budget_items WHERE id = ?', id)!; - updated.members = this.loadItemMembers(id); - updated.payers = this.loadItemPayers(id); - updated.receipts = this.loadItemReceipts(id); + updated.members = await this.loadItemMembers(id); + updated.payers = await this.loadItemPayers(id); + updated.receipts = await this.loadItemReceipts(id); return updated; }); } @@ -682,38 +684,38 @@ export class BudgetService { // Payers // ------------------------------------------------------------------------- - setItemPayers(id: string | number, tripId: string | number, payers: { user_id: number; amount: number }[]) { - return this.db.transaction(() => { + async setItemPayers(id: string | number, tripId: string | number, payers: { user_id: number; amount: number }[]) { + return await this.uow.transactional(async () => { const item = this.db.get('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId); if (!item) return null; - this.writeItemPayers(id, tripId, payers); + await this.writeItemPayers(id, tripId, payers); const updated = this.db.get('SELECT * FROM budget_items WHERE id = ?', id)!; - updated.members = this.loadItemMembers(id); - updated.payers = this.loadItemPayers(id); + updated.members = await this.loadItemMembers(id); + updated.payers = await this.loadItemPayers(id); return updated; }); } - deleteBudgetItem(id: string | number, tripId: string | number): boolean { + async deleteBudgetItem(id: string | number, tripId: string | number): Promise { const item = this.db.get<{ id: number; reservation_id: number | null }>( 'SELECT id, reservation_id FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId, ); if (!item) return false; - return this.db.transaction(() => { + return await this.uow.transactional(async () => { // Find all receipts attached to this item before deleting it // The receipts stay; only their tie to this expense goes. Rows that // carry nothing else are removed, rows that also point at a place or a // booking keep those. A link on a file already in the trash is left for // the SET NULL on the foreign key, so restoring the file does not bring // back a pointer to an expense that no longer exists. - this.unlinkReceipts(id); + await this.unlinkReceipts(id); this.db.run('DELETE FROM budget_items WHERE id = ?', id); // The booking keeps a copy of this expense's total in its metadata, and // the reservation update path preserves that copy across edits. With the // expense gone there is nothing left to mirror, so drop it here rather // than leave a price on the card that no longer has anything behind it. - if (item.reservation_id) this.clearReservationPrice(tripId, item.reservation_id); + if (item.reservation_id) await this.clearReservationPrice(tripId, item.reservation_id); return true; }); } @@ -722,7 +724,7 @@ export class BudgetService { * The counterpart to syncReservationPrice: take the mirrored total back off * the booking. Non-fatal, exactly like the writer. */ - private clearReservationPrice(tripId: string | number, reservationId: number): void { + private async clearReservationPrice(tripId: string | number, reservationId: number): Promise { try { const reservation = this.db.get<{ id: number; metadata: string | null }>( 'SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?', @@ -745,8 +747,8 @@ export class BudgetService { // Members // ------------------------------------------------------------------------- - updateMembers(id: string | number, tripId: string | number, userIds: number[]) { - return this.db.transaction(() => { + async updateMembers(id: string | number, tripId: string | number, userIds: number[]) { + return await this.uow.transactional(async () => { const item = this.db.get('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId); if (!item) return null; @@ -756,7 +758,7 @@ export class BudgetService { this.db.run('DELETE FROM budget_item_members WHERE budget_item_id = ?', id); - const known = this.rosterMemberIds(tripId, userIds); + const known = await this.rosterMemberIds(tripId, userIds); const memberIds = userIds.filter(uid => known.has(uid)); if (memberIds.length > 0) { const insert = this.db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, ?)'); @@ -767,14 +769,14 @@ export class BudgetService { } // loadItemMembers already applies avatar_url — the legacy second .map was redundant. - const members = this.loadItemMembers(id); + const members = await this.loadItemMembers(id); const updated = this.db.get('SELECT * FROM budget_items WHERE id = ?', id)!; return { members, item: updated }; }); } - removeUserFromBudgetItems(userId: number): void { - this.db.transaction(() => { + async removeUserFromBudgetItems(userId: number): Promise { + await this.uow.transactional(async () => { const itemIds = this.db.all<{ budget_item_id: number }>( 'SELECT DISTINCT budget_item_id FROM budget_item_members WHERE user_id = ?', userId, @@ -794,7 +796,7 @@ export class BudgetService { }); } - toggleMemberPaid(id: string | number, tripId: string | number, userId: string | number, paid: boolean) { + async toggleMemberPaid(id: string | number, tripId: string | number, userId: string | number, paid: boolean) { // Resolve the item within the caller's trip before updating. const item = this.db.get('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId); if (!item) return null; @@ -814,7 +816,7 @@ export class BudgetService { // Per-person summary // ------------------------------------------------------------------------- - getPerPersonSummary(tripId: string | number) { + async getPerPersonSummary(tripId: string | number) { const summary = this.db.all<{ user_id: number; username: string; avatar: string | null; total_assigned: number; total_paid: number; items_count: number }>(` SELECT bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, SUM(COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id))) as total_assigned, @@ -880,7 +882,7 @@ export class BudgetService { * expense list converted again on the client with today's rate would not add up * to a figure that was booked at the rate frozen on entry. */ - calculateSettlement( + async calculateSettlement( tripId: string | number, opts: { base?: string; rates?: Record | null; tripCurrency?: string } = {}, ) { @@ -1035,7 +1037,7 @@ export class BudgetService { // counts even when neither user has an expense-derived balance yet — a manual // payment, or one left behind after its expense was deleted, then correctly // surfaces as an amount still to square up instead of silently vanishing. - const settlements = this.listSettlements(tripId); + const settlements = await this.listSettlements(tripId); const ensureSettled = (id: number, username: string | undefined, avatar_url: string | null | undefined) => { if (!balances[id]) balances[id] = { user_id: id, username: username || '', avatar_url: avatar_url ?? null, cents: 0 }; return balances[id]; @@ -1162,7 +1164,7 @@ export class BudgetService { }; } - listSettlements(tripId: string | number) { + async listSettlements(tripId: string | number) { const rows = this.db.all( `${BudgetService.SETTLEMENT_SELECT} WHERE s.trip_id = ? @@ -1172,7 +1174,7 @@ export class BudgetService { } /** Targeted single-row read (the legacy re-select was a full listSettlements scan). */ - getSettlement(id: string | number, tripId: string | number) { + async getSettlement(id: string | number, tripId: string | number) { const row = this.db.get( `${BudgetService.SETTLEMENT_SELECT} WHERE s.trip_id = ? AND s.id = ? @@ -1181,7 +1183,7 @@ export class BudgetService { } /** Raw settlement insert (no FX freeze) — the REST path wraps it in createSettlement. */ - insertSettlement( + async insertSettlement( tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number; settled_at?: string | null }, createdByUserId?: number, @@ -1194,11 +1196,11 @@ export class BudgetService { data.settled_at || null, createdByUserId ?? null, ); - return this.getSettlement(Number(result.lastInsertRowid), tripId); + return await this.getSettlement(Number(result.lastInsertRowid), tripId); } /** Raw settlement update (no FX freeze) — the REST path wraps it in updateSettlement. */ - applySettlementUpdate( + async applySettlementUpdate( id: string | number, tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number; settled_at?: string | null }, @@ -1219,10 +1221,10 @@ export class BudgetService { data.settled_at !== undefined ? 1 : 0, data.settled_at || null, id, ); - return this.getSettlement(id, tripId); + return await this.getSettlement(id, tripId); } - deleteSettlement(id: string | number, tripId: string | number): boolean { + async deleteSettlement(id: string | number, tripId: string | number): Promise { const row = this.db.get('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?', id, tripId); if (!row) return false; this.db.run('DELETE FROM budget_settlements WHERE id = ?', id); @@ -1233,36 +1235,36 @@ export class BudgetService { // Controller-facing surface (unchanged from the pre-fold wrapper) // ------------------------------------------------------------------------- - list(tripId: string) { - return this.listBudgetItems(tripId); + async list(tripId: string) { + return await this.listBudgetItems(tripId); } - perPersonSummary(tripId: string) { - return this.getPerPersonSummary(tripId); + async perPersonSummary(tripId: string) { + return await this.getPerPersonSummary(tripId); } async settlement(tripId: string, base: string | undefined, tripCurrency: string) { const effectiveBase = (base || tripCurrency || 'EUR').toUpperCase(); const rates = await this.exchangeRates.getRates(effectiveBase); - return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency }); + return await this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency }); } async create(tripId: string, data: Parameters[1]) { await this.freezeForeignRate(tripId, data); - return this.createBudgetItem(tripId, data); + return await this.createBudgetItem(tripId, data); } async update(id: string | number, tripId: string | number, data: Parameters[2]) { await this.freezeForeignRate(tripId, data, id); - return this.updateBudgetItem(id, tripId, data); + return await this.updateBudgetItem(id, tripId, data); } - remove(id: string, tripId: string): boolean { - return this.deleteBudgetItem(id, tripId); + async remove(id: string, tripId: string): Promise { + return await this.deleteBudgetItem(id, tripId); } - setPayers(id: string, tripId: string, payers: { user_id: number; amount: number }[]) { - return this.setItemPayers(id, tripId, payers); + async setPayers(id: string, tripId: string, payers: { user_id: number; amount: number }[]) { + return await this.setItemPayers(id, tripId, payers); } /** @@ -1273,41 +1275,41 @@ export class BudgetService { * found" 404, which is also what keeps the endpoint from confirming whether * an id it rejected exists at all. */ - private settlementPartiesOnTrip(tripId: string | number, data: { from_user_id: number; to_user_id: number }): boolean { + private async settlementPartiesOnTrip(tripId: string | number, data: { from_user_id: number; to_user_id: number }): Promise { const roster = this.db.rosterUserIds(tripId); return roster.has(data.from_user_id) && roster.has(data.to_user_id); } async createSettlement(tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }, userId: number) { - if (!this.settlementPartiesOnTrip(tripId, data)) return null; + if (!(await this.settlementPartiesOnTrip(tripId, data))) return null; // Freeze the FX rate for the display currency the amount was entered in so the // transfer keeps cancelling its expense when live rates drift (#1445). await this.freezeForeignRate(tripId, data); - return this.insertSettlement(tripId, data, userId); + return await this.insertSettlement(tripId, data, userId); } async updateSettlement(id: string | number, tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }) { // Pass the settlement's stored currency so an edit that doesn't change it keeps // the already-frozen rate (#1445) — otherwise a live-rate drift would re-open a // settled position on an unrelated edit. - if (!this.settlementPartiesOnTrip(tripId, data)) return null; - const existing = this.getSettlement(id, tripId); + if (!(await this.settlementPartiesOnTrip(tripId, data))) return null; + const existing = await this.getSettlement(id, tripId); await this.freezeForeignRate(tripId, data, undefined, existing?.currency ?? null); - return this.applySettlementUpdate(id, tripId, data); + return await this.applySettlementUpdate(id, tripId, data); } - reorderItems(tripId: string, orderedIds: number[]): void { + async reorderItems(tripId: string, orderedIds: number[]): Promise { const update = this.db.prepare('UPDATE budget_items SET sort_order = ? WHERE id = ? AND trip_id = ?'); - this.db.transaction(() => { + await this.uow.transactional(async () => { orderedIds.forEach((id, index) => update.run(index, id, tripId)); }); } - reorderCategories(tripId: string, orderedCategories: string[]): void { + async reorderCategories(tripId: string, orderedCategories: string[]): Promise { const upsert = this.db.prepare( 'INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?) ON CONFLICT(trip_id, category) DO UPDATE SET sort_order = excluded.sort_order' ); - this.db.transaction(() => { + await this.uow.transactional(async () => { orderedCategories.forEach((cat, index) => upsert.run(tripId, cat, index)); }); } @@ -1317,7 +1319,7 @@ export class BudgetService { * total_price changes, write it into the reservation's metadata and broadcast * reservation:updated. Non-fatal — a failure here never breaks the budget update. */ - syncReservationPrice(tripId: string, reservationId: number, totalPrice: number, socketId: string | undefined): void { + async syncReservationPrice(tripId: string, reservationId: number, totalPrice: number, socketId: string | undefined): Promise { try { const reservation = this.db.get<{ id: number; metadata: string | null }>( 'SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?', diff --git a/server/src/nest/budget/costs.rpc.ts b/server/src/nest/budget/costs.rpc.ts index a853233aa..a7a3cf97d 100644 --- a/server/src/nest/budget/costs.rpc.ts +++ b/server/src/nest/budget/costs.rpc.ts @@ -37,10 +37,10 @@ export class CostsRpc { ) {} @PluginMethod('costs.getByTrip', { permission: 'db:read:costs' }) - getByTrip(params: Record, ctx: PluginRpcContext): unknown { - return this.guards.tripRead(params, ctx, async () => { + async getByTrip(params: Record, ctx: PluginRpcContext): Promise { + return await this.guards.tripRead(params, ctx, async () => { await this.requireBudgetAddon(); - return this.budget.listBudgetItems(num(params.tripId, 'tripId')); + return await this.budget.listBudgetItems(num(params.tripId, 'tripId')); }); } @@ -54,7 +54,11 @@ export class CostsRpc { // one, so injecting TripsService here would close a cycle. Same id set, // same newest-first order. const tripIds = await this.membership.listAccessibleTripIds(ctx.actingUserId); - return tripIds.flatMap((id) => this.budget.listBudgetItems(id)); + // Sequential, not Promise.all: the legacy flatMap read each trip's items one + // after the other, in id order, and the output order is the contract here. + const items: unknown[] = []; + for (const id of tripIds) items.push(...(await this.budget.listBudgetItems(id))); + return items; } @PluginMethod('costs.create', { permission: 'db:write:costs' }) @@ -95,7 +99,7 @@ export class CostsRpc { const actor = this.requireCostActor(ctx); await this.requireBudgetAddon(); await this.requireCostEdit(tripId, actor); - if (!this.budget.remove(String(itemId), String(tripId))) { + if (!(await this.budget.remove(String(itemId), String(tripId)))) { throw new ForbiddenResource(`no cost ${itemId} on trip ${tripId}`); } this.realtime.broadcast(tripId, 'budget:deleted', { itemId }); diff --git a/server/src/nest/categories/categories.mcp.ts b/server/src/nest/categories/categories.mcp.ts index 65e803f84..a8e360438 100644 --- a/server/src/nest/categories/categories.mcp.ts +++ b/server/src/nest/categories/categories.mcp.ts @@ -39,8 +39,8 @@ export class CategoriesMcp { ) {} /** The AuthService.isDemoUser check without the auth graph (demo-write.ts). */ - private isDemoUser(userId: number): boolean { - return isDemoUserId(this.env, this.db, userId); + private async isDemoUser(userId: number): Promise { + return await isDemoUserId(this.env, this.db, userId); } @Tool({ @@ -67,7 +67,7 @@ export class CategoriesMcp { access: { group: 'places', mode: 'write' }, }) async createCategory({ name, color, icon }: { name: string; color?: string; icon?: string }, ctx: McpContext) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); + if (await 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 = await this.categories.create(ctx.userId, name, color, icon); @@ -87,7 +87,7 @@ export class CategoriesMcp { access: { group: 'places', mode: 'write' }, }) async updateCategory({ categoryId, name, color, icon }: { categoryId: number; name?: string; color?: string; icon?: string }, ctx: McpContext) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); if (!this.guards.isAdminUser(ctx.userId)) return adminRequired(); if (!(await this.categories.getById(categoryId))) return errorResult('Category not found'); const category = await this.categories.update(categoryId, name, color, icon); @@ -104,7 +104,7 @@ export class CategoriesMcp { access: { group: 'places', mode: 'write' }, }) async deleteCategory({ categoryId }: { categoryId: number }, ctx: McpContext) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); if (!this.guards.isAdminUser(ctx.userId)) return adminRequired(); if (!(await this.categories.getById(categoryId))) return errorResult('Category not found'); await this.categories.remove(categoryId); diff --git a/server/src/nest/common/demo-write.ts b/server/src/nest/common/demo-write.ts index 28360717e..537eb8811 100644 --- a/server/src/nest/common/demo-write.ts +++ b/server/src/nest/common/demo-write.ts @@ -29,7 +29,7 @@ export function isDemoWriteBlocked(env: RuntimeEnvService, email: string | null * relocated so surfaces that hold only a user id (the *.mcp.ts demo guards) * don't need the whole auth graph for one check. */ -export function isDemoUserId(env: RuntimeEnvService, db: DatabaseService, userId: number): boolean { +export async function isDemoUserId(env: RuntimeEnvService, db: DatabaseService, userId: number): Promise { if (!env.isDemoMode()) return false; const user = db.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId); return isDemoEmail(user?.email); diff --git a/server/src/nest/common/idempotency-cleanup.job.ts b/server/src/nest/common/idempotency-cleanup.job.ts index 35274d604..612098d27 100644 --- a/server/src/nest/common/idempotency-cleanup.job.ts +++ b/server/src/nest/common/idempotency-cleanup.job.ts @@ -21,9 +21,9 @@ export class IdempotencyCleanupJob implements OnApplicationBootstrap { this.registrar.register('idempotency-cleanup', '0 3 * * *', () => this.tick()); } - tick(): void { + async tick(): Promise { try { - const removed = purgeExpiredIdempotencyKeys(undefined, undefined, this.db); + const removed = await purgeExpiredIdempotencyKeys(undefined, undefined, this.db); if (removed > 0) { logInfo(`Idempotency cleanup: removed ${removed} expired key(s)`); } diff --git a/server/src/nest/common/idempotency-cleanup.ts b/server/src/nest/common/idempotency-cleanup.ts index efd4db4e4..507d58636 100644 --- a/server/src/nest/common/idempotency-cleanup.ts +++ b/server/src/nest/common/idempotency-cleanup.ts @@ -18,11 +18,11 @@ export interface PurgeDb { /** Delete idempotency keys older than the configured TTL. Returns rows removed. * The db is a required parameter now (DatabaseService satisfies PurgeDb * structurally) — the old lazy-require default died with the scheduler. */ -export function purgeExpiredIdempotencyKeys( +export async function purgeExpiredIdempotencyKeys( now: number = Date.now(), ttlSeconds: number = readEnv().session.idempotencyTtlSeconds, database: PurgeDb, -): number { +): Promise { const cutoff = Math.floor(now / 1000) - ttlSeconds; const result = database.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff); return result.changes; diff --git a/server/src/nest/common/idempotency.interceptor.ts b/server/src/nest/common/idempotency.interceptor.ts index ff0f0fa40..40b9ebedc 100644 --- a/server/src/nest/common/idempotency.interceptor.ts +++ b/server/src/nest/common/idempotency.interceptor.ts @@ -1,7 +1,7 @@ import { CallHandler, ExecutionContext, HttpException, Injectable, NestInterceptor } from '@nestjs/common'; import type { Request, Response } from 'express'; import { Observable, from, of } from 'rxjs'; -import { finalize, switchMap } from 'rxjs/operators'; +import { finalize, mergeAll, switchMap } from 'rxjs/operators'; import { DatabaseService } from '../database/database.service'; /** @@ -53,7 +53,7 @@ interface IdempotencyRow { export class IdempotencyInterceptor implements NestInterceptor { constructor(private readonly database: DatabaseService) {} - intercept(context: ExecutionContext, next: CallHandler): Observable { + async intercept(context: ExecutionContext, next: CallHandler): Promise> { const req = context.switchToHttp().getRequest(); const res = context.switchToHttp().getResponse(); @@ -71,32 +71,48 @@ export class IdempotencyInterceptor implements NestInterceptor { throw new HttpException({ error: 'X-Idempotency-Key exceeds maximum length of 128 characters' }, 400); } - const existing = this.lookup(key, userId, req); + const existing = await this.lookup(key, userId, req); if (existing) return this.replay(existing, res); const signature = `${userId}|${req.method}|${req.path}|${key}`; const pending = inFlight.get(signature); if (pending !== undefined) { + // The wait stays inside the stream, where it was: `intercept` answers at + // once with an Observable that only produces when the first request has. + // `afterPending` is async now (the store read is), so what switchMap emits + // is itself an Observable and needs the extra mergeAll to be flattened. return from(pending).pipe( - switchMap(() => { - const stored = this.lookup(key, userId, req); - if (stored) return this.replay(stored, res); - // The first request answered without caching anything (it failed, or - // it never went through res.json). Run this one normally rather than - // inventing a response for it. - return this.run(signature, key, userId, req, res, next); - }), + switchMap(() => this.afterPending(signature, key, userId, req, res, next)), + mergeAll(), ); } - return this.run(signature, key, userId, req, res, next); + return await this.run(signature, key, userId, req, res, next); + } + + /** + * What a waiter does once the request holding its key has answered: replay the + * response that request stored, or — when it stored nothing (it failed, or it + * never went through res.json) — run normally rather than inventing one. + */ + private async afterPending( + signature: string, + key: string, + userId: number, + req: Request, + res: Response, + next: CallHandler, + ): Promise> { + const stored = await this.lookup(key, userId, req); + if (stored) return this.replay(stored, res); + return await this.run(signature, key, userId, req, res, next); } /** * Scope the lookup by method + path as well as user, so the same key replayed * against a different endpoint can't return an unrelated cached body. */ - private lookup(key: string, userId: number, req: Request): IdempotencyRow | undefined { + private async lookup(key: string, userId: number, req: Request): Promise { return this.database.get( 'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?', key, userId, req.method, req.path, @@ -108,14 +124,14 @@ export class IdempotencyInterceptor implements NestInterceptor { return of(JSON.parse(row.response_body)); } - private run( + private async run( signature: string, key: string, userId: number, req: Request, res: Response, next: CallHandler, - ): Observable { + ): Promise> { const originalJson = res.json.bind(res); const database = this.database; diff --git a/server/src/nest/day-notes/day-notes.controller.ts b/server/src/nest/day-notes/day-notes.controller.ts index de9d13e79..13844d26d 100644 --- a/server/src/nest/day-notes/day-notes.controller.ts +++ b/server/src/nest/day-notes/day-notes.controller.ts @@ -38,33 +38,33 @@ export class DayNotesController { @Get() - list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) { - return { notes: this.notes.list(dayId, tripId) }; + async list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) { + return { notes: await this.notes.list(dayId, tripId) }; } @RequirePermission('day_edit') @Post() - create( + async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string, @Body() body: DayNoteCreateDto, @Headers('x-socket-id') socketId?: string, ) { - if (!this.notes.dayExists(dayId, tripId)) { + if (!(await this.notes.dayExists(dayId, tripId))) { throw new HttpException({ error: 'Day not found' }, 404); } if (!body.text?.trim()) { throw new HttpException({ error: 'Text required' }, 400); } - const note = this.notes.create(dayId, tripId, body.text, body.time, body.icon, body.sort_order, body.color); + const note = await this.notes.create(dayId, tripId, body.text, body.time, body.icon, body.sort_order, body.color); this.notes.broadcast(tripId, 'dayNote:created', { dayId: Number(dayId), note }, socketId); return { note }; } @RequirePermission('day_edit') @Put(':id') - update( + async update( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string, @@ -72,28 +72,28 @@ export class DayNotesController { @Body() body: DayNoteUpdateDto, @Headers('x-socket-id') socketId?: string, ) { - const current = this.notes.getNote(id, dayId, tripId); + const current = await this.notes.getNote(id, dayId, tripId); if (!current) { throw new HttpException({ error: 'Note not found' }, 404); } - const note = this.notes.update(id, current as never, { text: body.text, time: body.time, icon: body.icon, sort_order: body.sort_order, color: body.color }); + const note = await this.notes.update(id, current as never, { text: body.text, time: body.time, icon: body.icon, sort_order: body.sort_order, color: body.color }); this.notes.broadcast(tripId, 'dayNote:updated', { dayId: Number(dayId), note }, socketId); return { note }; } @RequirePermission('day_edit') @Delete(':id') - remove( + async remove( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { - if (!this.notes.getNote(id, dayId, tripId)) { + if (!(await this.notes.getNote(id, dayId, tripId))) { throw new HttpException({ error: 'Note not found' }, 404); } - this.notes.remove(id); + await this.notes.remove(id); this.notes.broadcast(tripId, 'dayNote:deleted', { noteId: Number(id), dayId: Number(dayId) }, socketId); return { success: true }; } diff --git a/server/src/nest/day-notes/day-notes.mcp.ts b/server/src/nest/day-notes/day-notes.mcp.ts index bba8bc9c9..4427b794e 100644 --- a/server/src/nest/day-notes/day-notes.mcp.ts +++ b/server/src/nest/day-notes/day-notes.mcp.ts @@ -68,10 +68,10 @@ export class DayNotesMcp { ctx: McpContext, ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess(); 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); + if (!(await this.notes.dayExists(dayId, tripId))) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; + const note = await this.notes.create(dayId, tripId, text, time, icon, sort_order, color); this.guards.safeBroadcast(tripId, 'dayNote:created', { dayId, note }); return ok({ note }); } @@ -99,11 +99,11 @@ export class DayNotesMcp { ctx: McpContext, ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); - const existing = this.notes.getNote(noteId, dayId, tripId); + const existing = await 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 }); + const note = await this.notes.update(noteId, existing, { text, time: time !== undefined ? time : undefined, icon, color, sort_order }); this.guards.safeBroadcast(tripId, 'dayNote:updated', { dayId, note }); return ok({ note }); } @@ -121,11 +121,11 @@ 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 (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); - const note = this.notes.getNote(noteId, dayId, tripId); + const note = await this.notes.getNote(noteId, dayId, tripId); if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; - this.notes.remove(noteId); + await this.notes.remove(noteId); this.guards.safeBroadcast(tripId, 'dayNote:deleted', { noteId, dayId }); return ok({ success: true }); } @@ -140,7 +140,7 @@ export class DayNotesMcp { async dayNotesResource(uri: URL, { tripId, dayId }: { tripId: string | string[]; dayId: string | string[] }, ctx: McpContext) { const tId = parseId(tripId); const dId = parseId(dayId); - if (tId === null || dId === null || !this.notes.verifyTripAccess(tId, ctx.userId)) { + if (tId === null || dId === null || !(await this.notes.verifyTripAccess(tId, ctx.userId))) { return { contents: [{ uri: uri.href, @@ -149,7 +149,7 @@ export class DayNotesMcp { }], }; } - const notes = this.notes.list(dId, tId); + const notes = await this.notes.list(dId, tId); return { contents: [{ uri: uri.href, diff --git a/server/src/nest/day-notes/day-notes.rpc.ts b/server/src/nest/day-notes/day-notes.rpc.ts index fa522aa86..71cbd08d3 100644 --- a/server/src/nest/day-notes/day-notes.rpc.ts +++ b/server/src/nest/day-notes/day-notes.rpc.ts @@ -28,9 +28,9 @@ export class DayNotesRpc { ) {} @PluginMethod('daynotes.list', { permission: 'db:read:daynotes' }) - list(params: Record, ctx: PluginRpcContext): unknown { - return this.guards.tripRead(params, ctx, () => - this.dayNotes.list(num(params.dayId, 'dayId'), num(params.tripId, 'tripId')), + async list(params: Record, ctx: PluginRpcContext): Promise { + return await this.guards.tripRead(params, ctx, async () => + await this.dayNotes.list(num(params.dayId, 'dayId'), num(params.tripId, 'tripId')), ); } @@ -42,9 +42,9 @@ export class DayNotesRpc { const input = asPayload(params.input); if (typeof input.text !== 'string' || input.text.trim() === '') throw new BadParams('note text is required'); 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}`); + if (!(await 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); + const note = await this.dayNotes.create(dayId, tripId, i.text ?? '', i.time, i.icon, i.sort_order); this.realtime.broadcast(tripId, 'dayNote:created', { dayId, note }, undefined); return note; } @@ -56,9 +56,9 @@ export class DayNotesRpc { const noteId = num(params.noteId, 'noteId'); const actor = this.guards.requireActor(ctx, 'day note'); await this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION); - const current = this.dayNotes.getNote(noteId, dayId, tripId); + const current = await 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); + const note = await this.dayNotes.update(noteId, current as never, asPayload(params.input) as DayNoteInput); this.realtime.broadcast(tripId, 'dayNote:updated', { dayId, note }, undefined); return note; } @@ -70,9 +70,9 @@ export class DayNotesRpc { const noteId = num(params.noteId, 'noteId'); const actor = this.guards.requireActor(ctx, 'day note'); await this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION); - const current = this.dayNotes.getNote(noteId, dayId, tripId); + const current = await this.dayNotes.getNote(noteId, dayId, tripId); if (!current) throw new ForbiddenResource(`no note ${noteId} on day ${dayId}`); - this.dayNotes.remove(noteId); + await this.dayNotes.remove(noteId); this.realtime.broadcast(tripId, 'dayNote:deleted', { noteId, dayId }, undefined); return { deleted: true }; } diff --git a/server/src/nest/day-notes/day-notes.service.ts b/server/src/nest/day-notes/day-notes.service.ts index 177c3038e..4e174f340 100644 --- a/server/src/nest/day-notes/day-notes.service.ts +++ b/server/src/nest/day-notes/day-notes.service.ts @@ -33,7 +33,7 @@ export class DayNotesService { private readonly realtime: RealtimeService, ) {} - verifyTripAccess(tripId: string | number, userId: number): TripAccess | undefined { + async verifyTripAccess(tripId: string | number, userId: number): Promise { return this.dbs.canAccessTrip(tripId, userId); } @@ -45,22 +45,22 @@ export class DayNotesService { this.realtime.broadcast(tripId, event, payload, socketId); } - list(dayId: string | number, tripId: string | number) { + async list(dayId: string | number, tripId: string | number) { return this.dbs.all( 'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC', dayId, tripId, ); } - dayExists(dayId: string | number, tripId: string | number) { + async dayExists(dayId: string | number, tripId: string | number) { return this.dbs.get('SELECT id FROM days WHERE id = ? AND trip_id = ?', dayId, tripId); } - getNote(id: string | number, dayId: string | number, tripId: string | number) { + async getNote(id: string | number, dayId: string | number, tripId: string | number) { return this.dbs.get('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?', id, dayId, tripId); } - create(dayId: string | number, tripId: string | number, text: string, time?: string | null, icon?: string | null, sortOrder?: number, color?: string | null) { + async create(dayId: string | number, tripId: string | number, text: string, time?: string | null, icon?: string | null, sortOrder?: number, color?: string | null) { const result = this.dbs.run( 'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order, color) VALUES (?, ?, ?, ?, ?, ?, ?)', dayId, tripId, text.trim(), time || null, icon || '📝', sortOrder ?? 9999, normalizeNoteColor(color), @@ -68,7 +68,7 @@ export class DayNotesService { return this.dbs.get('SELECT * FROM day_notes WHERE id = ?', result.lastInsertRowid); } - update(id: string | number, current: DayNote, fields: { text?: string; time?: string | null; icon?: string | null; sort_order?: number; color?: string | null }) { + async update(id: string | number, current: DayNote, fields: { text?: string; time?: string | null; icon?: string | null; sort_order?: number; color?: string | null }) { this.dbs.run( 'UPDATE day_notes SET text = ?, time = ?, icon = ?, sort_order = ?, color = ? WHERE id = ?', fields.text !== undefined ? fields.text.trim() : current.text, @@ -81,7 +81,7 @@ export class DayNotesService { return this.dbs.get('SELECT * FROM day_notes WHERE id = ?', id); } - remove(id: string | number): void { + async remove(id: string | number): Promise { this.dbs.run('DELETE FROM day_notes WHERE id = ?', id); } } diff --git a/server/src/nest/days/days.controller.ts b/server/src/nest/days/days.controller.ts index 94aa81651..05147b06e 100644 --- a/server/src/nest/days/days.controller.ts +++ b/server/src/nest/days/days.controller.ts @@ -35,13 +35,13 @@ export class DaysController { constructor(private readonly days: DaysService) {} @Get() - list(@CurrentUser() user: User, @Param('tripId') tripId: string) { - return this.days.list(tripId); + async list(@CurrentUser() user: User, @Param('tripId') tripId: string) { + return await this.days.list(tripId); } @RequirePermission('day_edit') @Post() - create( + async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: DayCreateDto, @@ -50,8 +50,8 @@ export class DaysController { // A `position` means "insert a new empty day here" (which on a dated trip // extends the trip and re-pins dates); without it, the legacy append. const day = body.position !== undefined - ? this.days.insert(tripId, body.position) - : this.days.create(tripId, body.date, body.notes); + ? await this.days.insert(tripId, body.position) + : await this.days.create(tripId, body.date, body.notes); // An insert can shuffle dates/positions of other days, so collaborators // refetch the whole list; a plain append only needs the new day. const event = body.position !== undefined ? 'day:reordered' : 'day:created'; @@ -91,7 +91,7 @@ export class DaysController { @Body() body: DayUpdateDto, @Headers('x-socket-id') socketId?: string, ) { - const current = this.days.getDay(id, tripId); + const current = await this.days.getDay(id, tripId); if (!current) { throw new HttpException({ error: 'Day not found' }, 404); } @@ -112,7 +112,7 @@ export class DaysController { @Body() body: DayTransportDto, @Headers('x-socket-id') socketId?: string, ) { - if (!this.days.getDay(id, tripId)) { + if (!(await this.days.getDay(id, tripId))) { throw new HttpException({ error: 'Day not found' }, 404); } const day = await this.days.setDefaultTransportMode(id, body.transport_mode ?? null); @@ -122,16 +122,16 @@ export class DaysController { @RequirePermission('day_edit') @Delete(':id') - remove( + async remove( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { - if (!this.days.getDay(id, tripId)) { + if (!(await this.days.getDay(id, tripId))) { throw new HttpException({ error: 'Day not found' }, 404); } - this.days.remove(id); + await this.days.remove(id); this.days.broadcast(tripId, 'day:deleted', { dayId: Number(id) }, socketId); return { success: true }; } diff --git a/server/src/nest/days/days.mcp.ts b/server/src/nest/days/days.mcp.ts index bcf7301ce..d65157de5 100644 --- a/server/src/nest/days/days.mcp.ts +++ b/server/src/nest/days/days.mcp.ts @@ -63,9 +63,9 @@ export class DaysMcp { ctx: McpContext, ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); - const current = this.days.getDay(dayId, tripId); + const current = await 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 @@ -92,15 +92,15 @@ export class DaysMcp { ctx: McpContext, ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (position === undefined) { - const day = this.days.create(tripId, date, notes); + const day = await this.days.create(tripId, date, notes); this.guards.safeBroadcast(tripId, 'day:created', { day }); return ok({ day }); } try { - const day = this.days.insert(tripId, position); + const day = await this.days.insert(tripId, position); // An insert renumbers and re-dates every later day, so collaborators get // the list-wide event and refetch, the same one the REST create route sends. this.guards.safeBroadcast(tripId, 'day:reordered', { day }); @@ -127,7 +127,7 @@ export class DaysMcp { ctx: McpContext, ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); try { await this.days.reorder(tripId, orderedIds); @@ -154,10 +154,10 @@ 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 (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess(); 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); + if (!(await this.days.getDay(dayId, tripId))) return errorResult('Day not found.'); + await this.days.remove(dayId); // REST parity shape ({ dayId }) — the client reads payload.dayId, so the { id } // variant never removed the day from collaborator screens. this.guards.safeBroadcast(tripId, 'day:deleted', { dayId }); @@ -180,9 +180,9 @@ export class DaysMcp { ctx: McpContext, ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); - if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.'); + if (!(await this.days.getDay(dayId, tripId))) return errorResult('Day not found.'); const day = await this.days.setDefaultTransportMode(dayId, transport_mode ?? null); this.guards.safeBroadcast(tripId, 'day:updated', { day }); return ok({ day }); @@ -197,7 +197,7 @@ export class DaysMcp { }) async tripDaysResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { const id = parseId(tripId); - if (id === null || !this.days.verifyTripAccess(id, ctx.userId)) { + if (id === null || !(await this.days.verifyTripAccess(id, ctx.userId))) { return { contents: [{ uri: uri.href, diff --git a/server/src/nest/days/days.rpc.ts b/server/src/nest/days/days.rpc.ts index 7e2feece6..e97e8d8b5 100644 --- a/server/src/nest/days/days.rpc.ts +++ b/server/src/nest/days/days.rpc.ts @@ -31,7 +31,7 @@ export class DaysRpc { if (!parsed.success) throw new BadParams(`invalid day: ${schemaMessage(parsed.error)}`); 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); + const day = await this.days.create(tripId, input.date, input.notes); this.realtime.broadcast(tripId, 'day:created', { day }); return day; } @@ -45,7 +45,7 @@ export class DaysRpc { if (!parsed.success) throw new BadParams(`invalid day: ${schemaMessage(parsed.error)}`); 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); + const current = await this.days.getDay(dayId, tripId); if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`); const day = await this.days.update(dayId, current, parsed.data as { notes?: string; title?: string | null }); this.realtime.broadcast(tripId, 'day:updated', { day }); @@ -58,8 +58,8 @@ export class DaysRpc { const dayId = num(params.dayId, 'dayId'); const actor = this.guards.requireActor(ctx, 'day'); 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); + if (!(await this.days.getDay(dayId, tripId))) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`); + await this.days.remove(dayId); this.realtime.broadcast(tripId, 'day:deleted', { dayId }); return { deleted: true }; } diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index 5deb1a733..584707637 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -6,6 +6,7 @@ import { PermissionsService } from '../permissions/permissions.service'; import { QueryHelpersService } from '../query-helpers/query-helpers.service'; import { formatAssignmentWithPlace } from '../common/rowShape'; import type { AssignmentRow, Day, DayNote, User } from '../../types'; +import { UnitOfWork } from '../database/unit-of-work'; type Trip = TripAccess; @@ -47,7 +48,7 @@ export class DayReorderError extends Error {} * legacy services/dayService.ts: identical statements, the `||` falsy-coercion * defaults, the post-write re-selects, the two-phase negative-day_number * renumber and the reservation re-stamping). The legacy hand-rolled - * BEGIN/COMMIT blocks in reorder/insert became db.transaction() (same + * BEGIN/COMMIT blocks in reorder/insert became uow.transactional() (same * rollback-on-throw semantics, savepoint-safe when nested). Verified defects * were fixed after the port (2026-07): update() now presence-sentinels BOTH * columns (the legacy always-write wiped notes when only a title was sent), @@ -66,9 +67,10 @@ export class DaysService { private readonly permissions: PermissionsService, private readonly realtime: RealtimeService, private readonly queryHelpers: QueryHelpersService, + private readonly uow: UnitOfWork, ) {} - verifyTripAccess(tripId: string | number, userId: number) { + async verifyTripAccess(tripId: string | number, userId: number) { return this.db.canAccessTrip(Number(tripId), userId); } @@ -216,7 +218,7 @@ export class DaysService { return { days: daysWithAssignments }; } - create(tripId: string | number, date?: string, notes?: string) { + async create(tripId: string | number, date?: string, notes?: string) { const maxDay = this.db.get<{ max: number | null }>('SELECT MAX(day_number) as max FROM days WHERE trip_id = ?', tripId)!; const dayNumber = (maxDay.max || 0) + 1; @@ -229,7 +231,7 @@ export class DaysService { return { ...day, assignments: [] }; } - getDay(id: string | number, tripId: string | number) { + async getDay(id: string | number, tripId: string | number) { return this.db.get('SELECT * FROM days WHERE id = ? AND trip_id = ?', id, tripId); } @@ -258,7 +260,7 @@ export class DaysService { return { ...updatedDay, assignments: await this.getAssignmentsForDay(id) }; } - remove(id: string | number): void { + async remove(id: string | number): Promise { this.db.run('DELETE FROM days WHERE id = ?', id); } @@ -281,11 +283,11 @@ export class DaysService { * date (time-of-day preserved). Transport endpoints (flight legs) shift by the * same per-booking day delta so multi-leg timing stays internally consistent. */ - restampReservationDates( + async restampReservationDates( tripId: string | number, oldDateById: Map, newDateById: Map, - ): void { + ): Promise { const reservations = this.db.all<{ id: number; day_id: number | null; end_day_id: number | null; reservation_time: string | null; reservation_end_time: string | null; @@ -325,7 +327,7 @@ export class DaysService { } /** A stay must not end before it begins after a reorder/insert. */ - private assertNoInvertedAccommodation(tripId: string | number): void { + private async assertNoInvertedAccommodation(tripId: string | number): Promise { const spans = this.db.all<{ id: number; start_no: number; end_no: number }>(` SELECT a.id, s.day_number AS start_no, e.day_number AS end_no FROM day_accommodations a @@ -350,10 +352,10 @@ export class DaysService { * whole trip still shifts everything together. The linked hotel reservation follows * its accommodation's start day in both branches. */ - resyncAccommodationDays( + async resyncAccommodationDays( tripId: string | number, prevDateByDayId: Map, - ): void { + ): Promise { const stays = this.db.all<{ id: number; start_day_id: number; end_day_id: number }>( 'SELECT id, start_day_id, end_day_id FROM day_accommodations WHERE trip_id = ?', tripId @@ -405,7 +407,7 @@ export class DaysService { * Reorder whole days. `orderedIds` is the desired full sequence of this trip's * day ids (a permutation of the current ids). */ - reorder(tripId: string | number, orderedIds: number[]) { + async reorder(tripId: string | number, orderedIds: number[]) { const rows = this.db.all<{ id: number; day_number: number; date: string | null }>( 'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number', tripId @@ -424,7 +426,7 @@ export class DaysService { const setDayNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); const setDayNumberAndDate = this.db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?'); - this.db.transaction(() => { + await this.uow.transactional(async () => { // Two-phase renumber to dodge UNIQUE(trip_id, day_number) collisions. orderedIds.forEach((id, i) => setDayNumber.run(-(i + 1), id)); const newDateById = new Map(); @@ -434,11 +436,11 @@ export class DaysService { newDateById.set(id, date); }); - if (isDated) this.restampReservationDates(tripId, oldDateById, newDateById); - this.assertNoInvertedAccommodation(tripId); + if (isDated) await this.restampReservationDates(tripId, oldDateById, newDateById); + await this.assertNoInvertedAccommodation(tripId); }); - return this.list(tripId); + return await this.list(tripId); } /** @@ -447,7 +449,7 @@ export class DaysService { * stay contiguous, the trip's end_date extends by one day, and bookings on * shifted days have their dates re-stamped (same rules as reorder). */ - insert(tripId: string | number, position?: number) { + async insert(tripId: string | number, position?: number) { const rows = this.db.all<{ id: number; day_number: number; date: string | null }>( 'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number', tripId @@ -460,7 +462,7 @@ export class DaysService { const setDayNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); if (!isDated) { - const newRowid = this.db.transaction(() => { + const newRowid = await this.uow.transactional(async () => { const toShift = rows.filter(r => r.day_number >= pos); toShift.forEach(r => setDayNumber.run(-r.day_number, r.id)); const result = this.db.run('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)', tripId, pos); @@ -477,7 +479,7 @@ export class DaysService { const oldDateById = new Map(rows.map(r => [r.id, r.date])); const setDayNumberAndDate = this.db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?'); - const newId = this.db.transaction(() => { + const newId = await this.uow.transactional(async () => { rows.forEach((r, i) => setDayNumber.run(-(i + 1), r.id)); const result = this.db.run('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)', tripId, pos, dates[pos - 1]); const insertedId = Number(result.lastInsertRowid); @@ -490,8 +492,8 @@ export class DaysService { newDateById.set(id, dates[i]); }); - this.restampReservationDates(tripId, oldDateById, newDateById); - this.assertNoInvertedAccommodation(tripId); + await this.restampReservationDates(tripId, oldDateById, newDateById); + await this.assertNoInvertedAccommodation(tripId); this.db.run('UPDATE trips SET end_date = ? WHERE id = ?', dates[dates.length - 1], tripId); return insertedId; diff --git a/server/src/nest/feeds/feeds.mcp.ts b/server/src/nest/feeds/feeds.mcp.ts index b8852530c..65a462a34 100644 --- a/server/src/nest/feeds/feeds.mcp.ts +++ b/server/src/nest/feeds/feeds.mcp.ts @@ -41,8 +41,8 @@ export class FeedsMcp { ) {} /** The AuthService.isDemoUser check without the auth graph (demo-write.ts). */ - private isDemoUser(userId: number): boolean { - return isDemoUserId(this.env, this.db, userId); + private async isDemoUser(userId: number): Promise { + return await isDemoUserId(this.env, this.db, userId); } /** @@ -90,7 +90,7 @@ export class FeedsMcp { access: { group: 'trips', mode: 'share' }, }) async enableTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); const denied = await this.denyTripFeed(tripId, ctx.userId); if ((await denied)) return denied; return ok(this.feeds.generateTripToken(String(tripId), ctx.userId, this.base())); @@ -106,7 +106,7 @@ export class FeedsMcp { access: { group: 'trips', mode: 'share' }, }) async rotateTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); const denied = await this.denyTripFeed(tripId, ctx.userId); if ((await denied)) return denied; return ok(this.feeds.rotateTripToken(String(tripId), ctx.userId, this.base())); @@ -122,7 +122,7 @@ export class FeedsMcp { access: { group: 'trips', mode: 'share' }, }) async disableTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); const denied = await this.denyTripFeed(tripId, ctx.userId); if ((await denied)) return denied; this.feeds.disableTripToken(String(tripId), ctx.userId); @@ -153,7 +153,7 @@ export class FeedsMcp { access: { group: 'trips', mode: 'share' }, }) async enableAllTripsCalendarFeed(_input: Record, ctx: McpContext) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); return ok(this.feeds.generateUserToken(ctx.userId, this.base())); } @@ -165,7 +165,7 @@ export class FeedsMcp { access: { group: 'trips', mode: 'share' }, }) async rotateAllTripsCalendarFeed(_input: Record, ctx: McpContext) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); return ok(this.feeds.rotateUserToken(ctx.userId, this.base())); } @@ -177,7 +177,7 @@ export class FeedsMcp { access: { group: 'trips', mode: 'share' }, }) async disableAllTripsCalendarFeed(_input: Record, ctx: McpContext) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); this.feeds.disableUserToken(ctx.userId); return ok({ feed_url: null }); } diff --git a/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts b/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts index 72a143f11..3538fdd43 100644 --- a/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts +++ b/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts @@ -157,7 +157,7 @@ export class PluginMcpToolsService implements OnApplicationBootstrap, OnModuleDe if (!pluginsEnabled()) return errorResult('Plugins are disabled on this server.'); // The plugins domain's first demo gate. The child has none of its own, and // the ~40 isDemoUser checks elsewhere are per-handler, so it belongs here. - if (isDemoUserId(this.env, this.dbs, ctx.userId)) return demoDenied(); + if (await isDemoUserId(this.env, this.dbs, ctx.userId)) return demoDenied(); try { const raw = await this.hooks.callMcpTool(pluginId, { name, args: args ?? {} }, ctx.userId); diff --git a/server/src/nest/reservations/reservations.controller.ts b/server/src/nest/reservations/reservations.controller.ts index 482f83fa2..439889955 100644 --- a/server/src/nest/reservations/reservations.controller.ts +++ b/server/src/nest/reservations/reservations.controller.ts @@ -73,7 +73,7 @@ export class ReservationsController { if (accommodationCreated) { this.reservations.broadcast(tripId, 'accommodation:created', {}, socketId); } - this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, body.create_budget_entry, socketId); + await this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, body.create_budget_entry, socketId); this.reservations.broadcast(tripId, 'reservation:created', { reservation }, socketId); this.reservations.notifyBookingChange(tripId, user.id, body.title, body.type ?? ''); return { reservation }; @@ -114,7 +114,7 @@ export class ReservationsController { this.reservations.broadcast(tripId, 'accommodation:updated', {}, socketId); } const cur = current as { title: string; type?: string }; - this.reservations.syncBudgetOnUpdate(tripId, id, body.title ?? '', body.type, cur.title, cur.type, body.create_budget_entry, socketId); + await this.reservations.syncBudgetOnUpdate(tripId, id, body.title ?? '', body.type, cur.title, cur.type, body.create_budget_entry, socketId); this.reservations.broadcast(tripId, 'reservation:updated', { reservation }, socketId); // Push a locally-edited AirTrail flight back to AirTrail (fire-and-forget, // under the importer's credentials — see airtrailSync). #214 diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index 3c59365e4..49fe12390 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -156,7 +156,7 @@ interface LegPlan { reservationEndTime?: string; // A day row carries an optional date, so the caller cannot promise one. Every // read below treats a dateless day as "no date known" rather than stamping undefined. - lookupDay: (id: number) => { date?: string | null } | undefined; + lookupDay: (id: number) => Promise<{ date?: string | null } | undefined>; } interface LegOutcome { @@ -174,7 +174,7 @@ interface LegOutcome { * Validate the legs against the endpoints and fold them into the metadata. * Returns the values the write should use, or the first error as text. */ -function applyLegs(plan: LegPlan): LegOutcome | { error: string } { +async function applyLegs(plan: LegPlan): Promise { const { legs, lookupDay } = plan; const last = legs.length - 1; @@ -190,7 +190,7 @@ function applyLegs(plan: LegPlan): LegOutcome | { error: string } { for (let i = 0; i < legs.length; i++) { for (const field of ['dep_day_id', 'arr_day_id'] as const) { const dayId = legs[i][field]; - if (dayId != null && !lookupDay(dayId)) + if (dayId != null && !(await lookupDay(dayId))) return { error: `legs[${i}].${field} does not belong to this trip.` }; } } @@ -204,10 +204,10 @@ function applyLegs(plan: LegPlan): LegOutcome | { error: string } { const endpoints = plan.endpoints.map(e => ({ ...e })); let endpointsChanged = false; - const syncEndpoint = (index: number, time: string | null | undefined, dayId: number | null | undefined, label: string): string | null => { + const syncEndpoint = async (index: number, time: string | null | undefined, dayId: number | null | undefined, label: string): Promise => { const ep = endpoints[index]; if (dayId != null && !ep.local_date) { - const day = lookupDay(dayId); + const day = await lookupDay(dayId); if (day?.date) { ep.local_date = day.date; endpointsChanged = true; } } if (!time) return null; @@ -229,7 +229,7 @@ function applyLegs(plan: LegPlan): LegOutcome | { error: string } { if (leg.to && arrEp.code && leg.to.toUpperCase() !== arrEp.code.toUpperCase()) return { error: `legs[${i}].to (${leg.to}) does not match endpoints[${i + 1}] (${arrEp.code}).` }; - const depConflict = syncEndpoint(i, leg.dep_time, leg.dep_day_id, `legs[${i}].dep_time`); + const depConflict = await syncEndpoint(i, leg.dep_time, leg.dep_day_id, `legs[${i}].dep_time`); if (depConflict) return { error: depConflict }; const entry: MetaRecord = { @@ -251,7 +251,7 @@ function applyLegs(plan: LegPlan): LegOutcome | { error: string } { merged.push(entry); } - const arrConflict = syncEndpoint(legs.length, legs[last].arr_time, lastArrDay, `legs[${last}].arr_time`); + const arrConflict = await syncEndpoint(legs.length, legs[last].arr_time, lastArrDay, `legs[${last}].arr_time`); if (arrConflict) return { error: arrConflict }; const metadata: MetaRecord = { ...plan.baseMetadata, legs: merged }; @@ -278,9 +278,9 @@ function applyLegs(plan: LegPlan): LegOutcome | { error: string } { const endDayId = plan.endDayId ?? lastArrDay ?? undefined; // Same fallback as the form's buildTime: date the time when the day is known, // otherwise keep the bare 'HH:mm' rather than dropping it. - const stamp = (day: number | undefined, time: string | null | undefined) => { + const stamp = async (day: number | undefined, time: string | null | undefined) => { if (!time) return undefined; - const row = day === undefined ? undefined : lookupDay(day); + const row = day === undefined ? undefined : await lookupDay(day); return row?.date ? `${row.date}T${time}` : time; }; @@ -290,8 +290,8 @@ function applyLegs(plan: LegPlan): LegOutcome | { error: string } { endpointsChanged, day_id: dayId, end_day_id: endDayId, - reservation_time: plan.reservationTime ?? stamp(dayId, legs[0].dep_time), - reservation_end_time: plan.reservationEndTime ?? stamp(endDayId, legs[last].arr_time), + reservation_time: plan.reservationTime ?? await stamp(dayId, legs[0].dep_time), + reservation_end_time: plan.reservationEndTime ?? await stamp(endDayId, legs[last].arr_time), }; } @@ -366,13 +366,13 @@ export class ReservationsMcp { 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)) + if (day_id && !(await this.days.getDay(day_id, tripId))) return errorResult('day_id does not belong to this trip.'); if (place_id && !this.assignments.placeExists(place_id, tripId)) return errorResult('place_id does not belong to this trip.'); - if (start_day_id && !this.days.getDay(start_day_id, tripId)) + if (start_day_id && !(await this.days.getDay(start_day_id, tripId))) return errorResult('start_day_id does not belong to this trip.'); - if (end_day_id && !this.days.getDay(end_day_id, tripId)) + if (end_day_id && !(await this.days.getDay(end_day_id, tripId))) return errorResult('end_day_id does not belong to this trip.'); if (assignment_id && !this.assignments.getAssignmentForTrip(assignment_id, tripId)) return errorResult('assignment_id does not belong to this trip.'); @@ -395,7 +395,7 @@ export class ReservationsMcp { } if (price != null && price > 0) { - const item = this.budget.linkBudgetItemToReservation(tripId, reservation.id, { + const item = await this.budget.linkBudgetItemToReservation(tripId, reservation.id, { name: title, category: budget_category || type, total_price: price, @@ -540,7 +540,7 @@ export class ReservationsMcp { // 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 // nothing, the way the sibling tools above do. - if (dayId && !this.days.getDay(dayId, tripId)) + if (dayId && !(await this.days.getDay(dayId, tripId))) return errorResult('dayId does not belong to this trip.'); this.reservations.updatePositions(tripId, positions, dayId); @@ -579,9 +579,9 @@ export class ReservationsMcp { if (!this.assignments.placeExists(place_id, tripId)) return errorResult('place_id does not belong to this trip.'); - if (!this.days.getDay(start_day_id, tripId)) + if (!(await this.days.getDay(start_day_id, tripId))) return errorResult('start_day_id does not belong to this trip.'); - if (!this.days.getDay(end_day_id, tripId)) + if (!(await this.days.getDay(end_day_id, tripId))) return errorResult('end_day_id does not belong to this trip.'); const isNewAccommodation = !current.accommodation_id; @@ -700,9 +700,9 @@ export class ReservationsMcp { if (metadata && 'legs' in metadata) return errorResult(LEGS_IN_METADATA_ERROR); - if (start_day_id && !this.days.getDay(start_day_id, tripId)) + if (start_day_id && !(await this.days.getDay(start_day_id, tripId))) return errorResult('start_day_id does not belong to this trip.'); - if (end_day_id && !this.days.getDay(end_day_id, tripId)) + if (end_day_id && !(await this.days.getDay(end_day_id, tripId))) return errorResult('end_day_id does not belong to this trip.'); const resolved = resolveEndpointCoords(endpoints); @@ -716,7 +716,7 @@ export class ReservationsMcp { let arrivalTime = reservation_end_time; if (legs !== undefined) { - const applied = applyLegs({ + const applied = await applyLegs({ legs, type, endpoints: resolved.endpoints, @@ -757,7 +757,7 @@ export class ReservationsMcp { }); if (price != null && price > 0) { - const item = this.budget.linkBudgetItemToReservation(tripId, reservation.id, { + const item = await this.budget.linkBudgetItemToReservation(tripId, reservation.id, { name: title, category: budget_category || type, total_price: price, @@ -815,9 +815,9 @@ export class ReservationsMcp { if (!(TRANSPORT_TYPES as readonly string[]).includes(resolvedType)) return errorResult('Reservation is not a transport type. Use update_reservation instead.'); - if (start_day_id && !this.days.getDay(start_day_id, tripId)) + if (start_day_id && !(await this.days.getDay(start_day_id, tripId))) return errorResult('start_day_id does not belong to this trip.'); - if (end_day_id && !this.days.getDay(end_day_id, tripId)) + if (end_day_id && !(await this.days.getDay(end_day_id, tripId))) return errorResult('end_day_id does not belong to this trip.'); // Only resolve when endpoints are explicitly provided; undefined leaves them untouched. @@ -836,7 +836,7 @@ export class ReservationsMcp { if (legs !== undefined) { const stored = parseStoredMetadata(existing.metadata); - const applied = applyLegs({ + const applied = await applyLegs({ legs, type: resolvedType, // Endpoints the caller did not replace stay the geometry the legs run diff --git a/server/src/nest/reservations/reservations.rpc.ts b/server/src/nest/reservations/reservations.rpc.ts index e9f7f50e1..687d9a15c 100644 --- a/server/src/nest/reservations/reservations.rpc.ts +++ b/server/src/nest/reservations/reservations.rpc.ts @@ -42,7 +42,7 @@ export class ReservationsRpc { 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); + await this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, i.create_budget_entry as never, undefined); this.realtime.broadcast(tripId, 'reservation:created', { reservation }, undefined); this.notifyBooking(actor, tripId, i.title ?? '', i.type ?? ''); return reservation; @@ -65,7 +65,7 @@ export class ReservationsRpc { 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 }; - this.reservations.syncBudgetOnUpdate(String(tripId), String(reservationId), i.title ?? '', i.type, cur.title, cur.type, i.create_budget_entry as never, undefined); + await this.reservations.syncBudgetOnUpdate(String(tripId), String(reservationId), i.title ?? '', i.type, cur.title, cur.type, i.create_budget_entry as never, undefined); this.realtime.broadcast(tripId, 'reservation:updated', { reservation }, undefined); this.notifyBooking(actor, tripId, i.title || cur.title, i.type || cur.type || ''); return reservation; diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index 4752e03d0..f4c2cf288 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -1031,10 +1031,10 @@ export class ReservationsService { } /** POST side effect: auto-create a linked budget item when a price is provided. */ - syncBudgetOnCreate(tripId: string, reservationId: number, title: string, type: string | undefined, entry: BudgetEntry, socketId: string | undefined): void { + async syncBudgetOnCreate(tripId: string, reservationId: number, title: string, type: string | undefined, entry: BudgetEntry, socketId: string | undefined): Promise { if (!entry || !(Number(entry.total_price) > 0)) return; try { - const item = this.budget.linkBudgetItemToReservation(tripId, reservationId, { + const item = await this.budget.linkBudgetItemToReservation(tripId, reservationId, { name: title, category: entry.category || type || 'Other', total_price: entry.total_price!, @@ -1046,7 +1046,7 @@ export class ReservationsService { } /** PUT side effect: drop the linked budget item when the price is cleared, else create/update it. */ - syncBudgetOnUpdate(tripId: string, id: string, title: string, type: string | undefined, currentTitle: string, currentType: string | undefined, entry: BudgetEntry, socketId: string | undefined): void { + async syncBudgetOnUpdate(tripId: string, id: string, title: string, type: string | undefined, currentTitle: string, currentType: string | undefined, entry: BudgetEntry, socketId: string | undefined): Promise { // When the booking type changes, keep a linked expense's category in sync — // but only if it still carries the auto-derived category (so a manual pick in // the Costs editor is preserved). Runs regardless of create_budget_entry. @@ -1056,7 +1056,7 @@ export class ReservationsService { const oldCat = typeToCostCategory(currentType); const newCat = typeToCostCategory(type); if (oldCat !== newCat && linked.category === oldCat) { - const updated = this.budget.updateBudgetItem(linked.id, tripId, { category: newCat }); + const updated = await this.budget.updateBudgetItem(linked.id, tripId, { category: newCat }); this.realtime.broadcast(tripId, 'budget:updated', { item: updated }, socketId); } } @@ -1071,7 +1071,7 @@ export class ReservationsService { // Explicit clear (total_price 0/empty) — drop the linked item. const linked = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id); if (linked) { - this.budget.deleteBudgetItem(linked.id, tripId); + await this.budget.deleteBudgetItem(linked.id, tripId); this.realtime.broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId); } return; @@ -1082,10 +1082,10 @@ export class ReservationsService { const category = entry.category || type || currentType || 'Other'; const existing = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id); if (existing) { - const updated = this.budget.updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price }); + const updated = await this.budget.updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price }); this.realtime.broadcast(tripId, 'budget:updated', { item: updated }, socketId); } else { - const item = this.budget.createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price }); + const item = await this.budget.createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price }); this.db.run('UPDATE budget_items SET reservation_id = ? WHERE id = ?', id, item.id); item.reservation_id = Number(id); this.realtime.broadcast(tripId, 'budget:created', { item }, socketId); diff --git a/server/src/nest/transit/transit.mcp.ts b/server/src/nest/transit/transit.mcp.ts index 55e52fc28..4ba265176 100644 --- a/server/src/nest/transit/transit.mcp.ts +++ b/server/src/nest/transit/transit.mcp.ts @@ -186,7 +186,7 @@ export class TransitMcp { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); - const day = this.days.getDay(dayId, tripId); + const day = await this.days.getDay(dayId, tripId); if (!day) { return { content: [{ type: 'text' as const, text: 'dayId does not belong to this trip.' }], isError: true }; } diff --git a/server/src/nest/trip-invite/trip-invite.mcp.ts b/server/src/nest/trip-invite/trip-invite.mcp.ts index 91d67c983..497f8bb31 100644 --- a/server/src/nest/trip-invite/trip-invite.mcp.ts +++ b/server/src/nest/trip-invite/trip-invite.mcp.ts @@ -41,8 +41,8 @@ export class TripInviteMcp { ) {} /** The AuthService.isDemoUser check without the auth graph (demo-write.ts). */ - private isDemoUser(userId: number): boolean { - return isDemoUserId(this.env, this.db, userId); + private async isDemoUser(userId: number): Promise { + return await isDemoUserId(this.env, this.db, userId); } /** Trip access first (404-equivalent), then share_manage, which is requireManage() in the controller. */ @@ -88,7 +88,7 @@ export class TripInviteMcp { { tripId, expires_in_days }: { tripId: number } & TripInviteLinkCreateRequest, ctx: McpContext, ) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); 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 @@ -122,7 +122,7 @@ export class TripInviteMcp { access: (ctx) => canShareTrips(ctx.scopes) && canWrite(ctx.scopes, 'trips'), }) async deleteTripInviteLink({ tripId }: { tripId: number }, ctx: McpContext) { - if (this.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.isDemoUser(ctx.userId)) return demoDenied(); const denied = await this.denyManage(tripId, ctx.userId); if ((await denied)) return denied; this.invites.remove(tripId); diff --git a/server/src/nest/trip-members/trip-members.controller.ts b/server/src/nest/trip-members/trip-members.controller.ts index 107338953..5298d5f84 100644 --- a/server/src/nest/trip-members/trip-members.controller.ts +++ b/server/src/nest/trip-members/trip-members.controller.ts @@ -153,8 +153,8 @@ export class TripMembersController { @Delete(':id/guests/:userId') @UseGuards(TripOwnerGuard) @RequireTripOwner('Only the owner can manage guests', { param: 'id' }) - deleteGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) { - if (!this.roster.deleteGuest(id, Number.parseInt(userId))) { + async deleteGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) { + if (!(await this.roster.deleteGuest(id, Number.parseInt(userId)))) { throw new HttpException({ error: 'Guest not found' }, 404); } return { success: true }; diff --git a/server/src/nest/trip-members/trip-members.service.ts b/server/src/nest/trip-members/trip-members.service.ts index c6d972875..45673910b 100644 --- a/server/src/nest/trip-members/trip-members.service.ts +++ b/server/src/nest/trip-members/trip-members.service.ts @@ -4,6 +4,7 @@ import { DatabaseService } from '../database/database.service'; import type { User } from '../../types'; import { avatarUrl } from '../common/avatarUrl'; import { UserCleanupService } from '../auth/user-cleanup.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { BudgetService } from '../budget/budget.service'; import { PermissionsService } from '../permissions/permissions.service'; import { RealtimeService } from '../realtime/realtime.service'; @@ -66,6 +67,7 @@ export class TripMembersService { private readonly permissions: PermissionsService, private readonly realtime: RealtimeService, private readonly notifications: NotificationsService, + private readonly uow: UnitOfWork, ) {} private get db() { @@ -249,7 +251,7 @@ export class TripMembersService { return true; } - deleteGuest(tripId: string | number, guestUserId: number): boolean { + async deleteGuest(tripId: string | number, guestUserId: number): Promise { if (!this.guestOfTrip(tripId, guestUserId)) return false; // A guest is still a user id a plugin may hold data for, so erase that too — the // host-side per-user tables + a durable own-db erasure per granted plugin — exactly @@ -259,14 +261,14 @@ export class TripMembersService { // run in one transaction, so a failure mid-flow can't leave the expense // divisors re-derived for a guest that still exists (or vice versa). The // plugin-side erasure/notification keep their order around it. - this.db.transaction(() => { + await this.uow.transactional(async () => { // Re-split the expenses they were part of before the cascade takes their member // rows away — the divisor is denormalized and cannot follow a foreign key (#1553). - this.budget.removeUserFromBudgetItems(guestUserId); + await this.budget.removeUserFromBudgetItems(guestUserId); // Deleting the guest's users row cascades its membership and every assignment join // (trip_members, budget/packing/assignment links) via the ON DELETE foreign keys. this.db.prepare('DELETE FROM users WHERE id = ? AND is_guest = 1').run(guestUserId); - })(); + }); emitUserDeleted(guestUserId); // deliver the erasure to any active plugin now return true; } 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 289c868dd..6378bbbbc 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 @@ -62,7 +62,7 @@ export class TripReadModelService { const accommodations = this.accommodations.list(tripId); - const budgetItems = this.budget.listBudgetItems(tripId); + const budgetItems = await this.budget.listBudgetItems(tripId); const budget = { items: budgetItems, item_count: budgetItems.length, @@ -108,7 +108,7 @@ export class TripReadModelService { // (#858) never land in this viewer's offline cache. packingItems: this.packing.listItems(tripId, viewerId), todoItems: this.todo.listItems(tripId), - budgetItems: this.budget.listBudgetItems(tripId), + budgetItems: await this.budget.listBudgetItems(tripId), reservations: this.reservations.list(tripId), files: this.files.listFiles(tripId, false), accommodations: this.accommodations.list(tripId), diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index 63e65a680..ae5bcf493 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -493,7 +493,7 @@ export class TripsMcp { const ownerRow = this.trips.getOwner(tripId); if (!ownerRow || ownerRow.user_id !== ctx.userId) return { content: [{ type: 'text' as const, text: 'Only the trip owner can manage guests.' }], isError: true }; - if (!this.members.deleteGuest(tripId, guestId)) + if (!(await this.members.deleteGuest(tripId, guestId))) return { content: [{ type: 'text' as const, text: 'Guest not found.' }], isError: true }; this.guards.safeBroadcast(tripId, 'member:removed', { userId: guestId }); return ok({ success: true }); diff --git a/server/src/nest/trips/trips.rpc.ts b/server/src/nest/trips/trips.rpc.ts index 82844444c..b53b20431 100644 --- a/server/src/nest/trips/trips.rpc.ts +++ b/server/src/nest/trips/trips.rpc.ts @@ -133,7 +133,7 @@ export class TripsRpc { try { // The no-rebase core, parity with the legacy host path, which never // re-anchored the budget currency. - const result = this.trips.updateTrip(tripId, actor, input as Parameters[2], user?.role ?? 'user'); + const result = await this.trips.updateTrip(tripId, actor, input as Parameters[2], user?.role ?? 'user'); this.realtime.broadcast(tripId, 'trip:updated', { trip: result.updatedTrip }); return result.updatedTrip; } catch (e) { diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index e5b05a038..04a06cf12 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -12,6 +12,7 @@ import { VacayService } from '../vacay/vacay.service'; import { UnsplashService } from '../unsplash/unsplash.service'; import { StorageService } from '../storage/storage.service'; import { NotFoundError, ValidationError } from '../common/domain-errors'; +import { UnitOfWork } from '../database/unit-of-work'; export const MS_PER_DAY = 86400000; @@ -159,6 +160,7 @@ export class TripsService { private readonly realtime: RealtimeService, private readonly unsplash: UnsplashService, private readonly storage: StorageService, + private readonly uow: UnitOfWork, ) {} private get db() { @@ -391,7 +393,7 @@ export class TripsService { * through update() below; the plugin RPC host calls this directly (parity: * the legacy host path never rebased). */ - updateTrip(tripId: string | number, userId: number, data: UpdateTripData, userRole: string): UpdateTripResult { + async updateTrip(tripId: string | number, userId: number, data: UpdateTripData, userRole: string): Promise { const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip & { reminder_days?: number } | undefined; if (!trip) throw new NotFoundError('Trip not found'); @@ -418,7 +420,7 @@ export class TripsService { this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart); if (regenerate) { - this.db.transaction(() => { + await this.uow.transactional(async () => { // Accommodations have no absolute date columns, so their pre-change dates must be // snapshotted before generateDays re-dates the day rows in place. const prevDateByDayId = new Map( @@ -433,15 +435,15 @@ export class TripsService { (this.db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[]) .map(d => [d.id, d.date]), ); - this.days.restampReservationDates(tripId, prevDateByDayId, newDateByDayId); + await this.days.restampReservationDates(tripId, prevDateByDayId, newDateByDayId); } else { // Default: generateDays re-dates day rows positionally; re-anchor dated bookings to // the day matching their absolute reservation_time, and accommodations (+ their // linked hotel reservations) to the days now holding their pre-change dates (#1288). this.reservations.resyncReservationDays(tripId); - this.days.resyncAccommodationDays(tripId, prevDateByDayId); + await this.days.resyncAccommodationDays(tripId, prevDateByDayId); } - })(); + }); } const changes: Record = {}; @@ -491,7 +493,7 @@ export class TripsService { // otherwise the frozen FX rates and the currency-less expenses that inherit the // trip's base are left pointing at a currency that no longer exists (#1543). await this.budget.rebaseTripCurrency(tripId, body.currency); - return this.updateTrip(tripId, userId, body, role); + return await this.updateTrip(tripId, userId, body, role); } // ── Delete ───────────────────────────────────────────────────────────────── diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index da10bd23b..f1c85f5fa 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -153,7 +153,7 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => { seedUser(db as never, { id: 1 }); app = await build(); checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); - vi.spyOn(app.get(BudgetService), 'listBudgetItems').mockReturnValue([]); + vi.spyOn(app.get(BudgetService), 'listBudgetItems').mockResolvedValue([]); vi.spyOn(app.get(BudgetService), 'rebaseTripCurrency').mockResolvedValue(); server = app.getHttpServer(); }); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 68aecd140..4fe736f37 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -136,19 +136,19 @@ export async function createMcpTestRegistry(): Promise { const realtimeService = new RealtimeService(); const guards = new McpToolGuardsService(dbService, permissionsService, realtimeService); const exchangeRatesService = new ExchangeRatesService(); - const budgetService = new BudgetService(dbService, permissionsService, exchangeRatesService, realtimeService); + const budgetService = new BudgetService(dbService, permissionsService, exchangeRatesService, realtimeService, await createTestUnitOfWork(dbService.connection)); const authService = new AuthService( dbService, permissionsService, new TripMembershipService(dbService), new WebauthnConfigService(dbService), - new UserCleanupService(dbService, budgetService), + new UserCleanupService(dbService, budgetService, await createTestUnitOfWork(dbService.connection)), new MailerService(dbService), new EphemeralTokenService(), new AllowedFileTypesService(dbService), await createTestUnitOfWork(dbService.connection), ); const queryHelpersService = new QueryHelpersService(dbService); - const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService); + const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService, await createTestUnitOfWork(dbService.connection)); const todoService = new TodoService(dbService, permissionsService, realtimeService); const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub()); const collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage, new RateLimitService()); @@ -177,7 +177,7 @@ export async function createMcpTestRegistry(): Promise { ); // 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, await createTestUnitOfWork(dbService.connection)); - const membersService = new TripMembersService(dbService, budgetService, new UserCleanupService(dbService, budgetService), permissionsService, realtimeService, notificationsStub()); + const membersService = new TripMembersService(dbService, budgetService, new UserCleanupService(dbService, budgetService, await createTestUnitOfWork(dbService.connection)), permissionsService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection)); const tripsService = new TripsService( dbService, reservationsService, @@ -188,6 +188,7 @@ export async function createMcpTestRegistry(): Promise { realtimeService, new UnsplashService(dbService, new RuntimeEnvService(), generalStorage), generalStorage, + await createTestUnitOfWork(dbService.connection), ); const readModelService = new TripReadModelService( dbService, membersService, daysService, accommodationsService, budgetService, @@ -219,7 +220,7 @@ export async function createMcpTestRegistry(): Promise { new AuthMcp(), new TodoMcp(todoService, authService, addonsService, guards), new PackingMcp(packingService, authService, addonsService, guards), - new BudgetMcp(budgetService, exchangeRatesService, dbService, new RuntimeEnvService(), new TripMembershipService(dbService), addonsService, guards), + new BudgetMcp(budgetService, exchangeRatesService, dbService, new RuntimeEnvService(), new TripMembershipService(dbService), addonsService, guards, await createTestUnitOfWork(dbService.connection)), new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService, guards), new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService, guards), new DaysMcp(daysService, authService, guards), diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 653d98082..278727644 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -90,7 +90,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise< 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); + const budget = new BudgetService(dbs, permissions, exchangeRates, realtime, await createTestUnitOfWork(dbs.connection)); const addons = new AddonsService(dbs); const queryHelpers = new QueryHelpersService(dbs); const todos = new TodoService(dbs, permissions, realtime); @@ -98,7 +98,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise< const files = new FilesService(dbs, permissions, realtime, new EphemeralTokenService(), generalStorage); const collab = new CollabService(dbs, permissions, realtime, notificationsStub(), generalStorage, new RateLimitService()); const vacay = new VacayService(dbs, realtime, notificationsStub()); - const days = new DaysService(dbs, permissions, realtime, queryHelpers); + const days = new DaysService(dbs, permissions, realtime, queryHelpers, await createTestUnitOfWork(dbs.connection)); const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/google/').storage); const unsplash = new UnsplashService(dbs, new RuntimeEnvService(), generalStorage); const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)); @@ -115,8 +115,8 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise< 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, 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 trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash, generalStorage, await createTestUnitOfWork(dbs.connection)); + const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs, budget, await createTestUnitOfWork(dbs.connection)), permissions, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection)); const guards = new PluginGuards(dbs, permissions, addons); const registry = createTestPluginRegistry([ diff --git a/server/tests/integration/dayReorder.test.ts b/server/tests/integration/dayReorder.test.ts index c59006998..9ff0816cf 100644 --- a/server/tests/integration/dayReorder.test.ts +++ b/server/tests/integration/dayReorder.test.ts @@ -30,10 +30,10 @@ import { UnitOfWork } from '../../src/nest/database/unit-of-work'; 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))); + svc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), new QueryHelpersService(new DatabaseService(testDb)), await createTestUnitOfWork(testDb)); }); -const reorderDays = (tripId: number, orderedIds: number[]) => svc.reorder(tripId, orderedIds); -const insertDay = (tripId: number, position?: number) => svc.insert(tripId, position); +const reorderDays = async (tripId: number, orderedIds: number[]) => await svc.reorder(tripId, orderedIds); +const insertDay = async (tripId: number, position?: number) => await svc.insert(tripId, position); let userId: number; @@ -79,20 +79,20 @@ describe('reorderDays', () => { expect(r.reservation_time).toBe('2026-03-01T19:00'); }); - it('rejects an orderedIds list that is not a permutation of the trip days', () => { + it('rejects an orderedIds list that is not a permutation of the trip days', async () => { const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' }); const [d1, d2] = orderedDays(trip.id); - expect(() => reorderDays(trip.id, [d1.id, d2.id])).toThrow(DayReorderError); + await expect(reorderDays(trip.id, [d1.id, d2.id])).rejects.toThrow(DayReorderError); }); - it('blocks a move that would make an accommodation end before it starts, and rolls back', () => { + it('blocks a move that would make an accommodation end before it starts, and rolls back', 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); createDayAccommodation(testDb, trip.id, place.id, d1.id, d2.id); // stay spans day 1 -> day 2 // Put the start day (d1) after the end day (d2): [d2, d3, d1] - expect(() => reorderDays(trip.id, [d2.id, d3.id, d1.id])).toThrow(DayReorderError); + await expect(reorderDays(trip.id, [d2.id, d3.id, d1.id])).rejects.toThrow(DayReorderError); // Transaction rolled back: original order intact expect(orderedDays(trip.id).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]); @@ -100,13 +100,13 @@ describe('reorderDays', () => { }); describe('insertDay', () => { - it('inserts an empty day at a position on a dateless trip and shifts the rest', () => { + it('inserts an empty day at a position on a dateless trip and shifts the rest', async () => { const trip = createTrip(testDb, userId); const d1 = createDay(testDb, trip.id); const d2 = createDay(testDb, trip.id); const d3 = createDay(testDb, trip.id); - const created = insertDay(trip.id, 1); + const created = await insertDay(trip.id, 1); const after = orderedDays(trip.id); expect(after).toHaveLength(4); @@ -115,11 +115,11 @@ describe('insertDay', () => { expect(after.slice(1).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]); }); - it('inserts at the front of a dated trip: dates stay contiguous and the trip extends', () => { + it('inserts at the front of a dated trip: dates stay contiguous and the trip extends', async () => { const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' }); const [d1, d2, d3] = orderedDays(trip.id); - const created = insertDay(trip.id, 1); + const created = await insertDay(trip.id, 1); const after = orderedDays(trip.id); expect(after).toHaveLength(4); diff --git a/server/tests/unit/idempotency-cleanup.test.ts b/server/tests/unit/idempotency-cleanup.test.ts index 4f33be11c..d51d12889 100644 --- a/server/tests/unit/idempotency-cleanup.test.ts +++ b/server/tests/unit/idempotency-cleanup.test.ts @@ -36,27 +36,27 @@ afterEach(() => { }); describe('purgeExpiredIdempotencyKeys', () => { - it('removes keys older than the 30-day default, keeps recent ones', () => { + it('removes keys older than the 30-day default, keeps recent ones', async () => { insertKey('old', 31 * DAY); insertKey('fresh', 5 * DAY); - const removed = purgeExpiredIdempotencyKeys(NOW, undefined, db); + const removed = await purgeExpiredIdempotencyKeys(NOW, undefined, db); expect(removed).toBe(1); const keys = db.prepare('SELECT key FROM idempotency_keys').all().map((r: { key: string }) => r.key); expect(keys).toEqual(['fresh']); }); - it('keeps a 25-day-old key that the old 24h TTL would have dropped', () => { + it('keeps a 25-day-old key that the old 24h TTL would have dropped', async () => { insertKey('offline-trip', 25 * DAY); - expect(purgeExpiredIdempotencyKeys(NOW, undefined, db)).toBe(0); + expect(await purgeExpiredIdempotencyKeys(NOW, undefined, db)).toBe(0); expect(db.prepare('SELECT COUNT(*) c FROM idempotency_keys').get()).toMatchObject({ c: 1 }); }); - it('respects the IDEMPOTENCY_TTL_SECONDS override', () => { + it('respects the IDEMPOTENCY_TTL_SECONDS override', async () => { process.env.IDEMPOTENCY_TTL_SECONDS = String(DAY); insertKey('twoDays', 2 * DAY); - expect(purgeExpiredIdempotencyKeys(NOW, undefined, db)).toBe(1); + expect(await purgeExpiredIdempotencyKeys(NOW, undefined, db)).toBe(1); }); }); @@ -89,7 +89,7 @@ describe('IdempotencyCleanupJob', () => { expect(off.registrar.register).not.toHaveBeenCalled(); }); - it('the tick purges through the injected DatabaseService', () => { + it('the tick purges through the injected DatabaseService', async () => { // The tick uses the live clock, so these fixtures age against Date.now() // (the pure-function cases above pin their own fixed NOW instead). const liveNowSec = Math.floor(Date.now() / 1000); @@ -102,7 +102,7 @@ describe('IdempotencyCleanupJob', () => { liveInsert('fresh', 5 * DAY); const { job } = makeJob(); - job.tick(); + await job.tick(); const keys = db.prepare('SELECT key FROM idempotency_keys').all().map((r: { key: string }) => r.key); expect(keys).toEqual(['fresh']); }); diff --git a/server/tests/unit/mcp/tools-budget-advanced.test.ts b/server/tests/unit/mcp/tools-budget-advanced.test.ts index 7175a8295..cc36bb93b 100644 --- a/server/tests/unit/mcp/tools-budget-advanced.test.ts +++ b/server/tests/unit/mcp/tools-budget-advanced.test.ts @@ -358,7 +358,7 @@ describe('Settlement tools', () => { const dbService = new DatabaseService(testDb); const controller = new BudgetController( - new BudgetService(dbService, new PermissionsService(dbService, await createTestUnitOfWork(dbService.connection)), new ExchangeRatesService(), new RealtimeService()), + new BudgetService(dbService, new PermissionsService(dbService, await createTestUnitOfWork(dbService.connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbService.connection)), ); const rest = await controller.settlement( { id: user.id } as User, diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index f7f4a63cc..4236c1e25 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -136,13 +136,14 @@ beforeAll(async () => { 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 BudgetService(promptDbs(), new PermissionsService(promptDbs(), await createTestUnitOfWork(promptDbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(promptDbs().connection)), new ExchangeRatesService(), promptDbs(), new RuntimeEnvService(), new TripMembershipService(promptDbs()), addonsStub, promptGuards, + await createTestUnitOfWork(promptDbs().connection), ); tripPromptsMcp = new TripPromptsMcp(tripsStub, readModelStub, promptPackingService, addonsStub); }); diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index 5db9e7d75..a5d008dab 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -101,7 +101,7 @@ 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)); + userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection)), await createTestUnitOfWork(dbs.connection)); 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, @@ -247,22 +247,22 @@ describe('updateUser', () => { // ── deleteUser ──────────────────────────────────────────────────────────────── describe('deleteUser', () => { - it('ADMIN-SVC-015 — deletes user successfully', () => { + it('ADMIN-SVC-015 — deletes user successfully', async () => { const { user: admin } = createAdmin(testDb); const { user } = createUser(testDb); - const result = deleteUser(String(user.id), admin.id) as any; + const result = await deleteUser(String(user.id), admin.id) as any; expect(result.email).toBe(user.email); }); - it('ADMIN-SVC-016 — returns 400 when deleting own account', () => { + it('ADMIN-SVC-016 — returns 400 when deleting own account', async () => { const { user: admin } = createAdmin(testDb); - const result = deleteUser(String(admin.id), admin.id) as any; + const result = await deleteUser(String(admin.id), admin.id) as any; expect(result.status).toBe(400); }); - it('ADMIN-SVC-017 — returns 404 for non-existent user', () => { + it('ADMIN-SVC-017 — returns 404 for non-existent user', async () => { const { user: admin } = createAdmin(testDb); - const result = deleteUser('99999', admin.id) as any; + const result = await deleteUser('99999', admin.id) as any; expect(result.status).toBe(404); }); }); diff --git a/server/tests/unit/nest/admin.version-notification.test.ts b/server/tests/unit/nest/admin.version-notification.test.ts index 967d808e6..201a5dada 100644 --- a/server/tests/unit/nest/admin.version-notification.test.ts +++ b/server/tests/unit/nest/admin.version-notification.test.ts @@ -79,7 +79,7 @@ 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)); + userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection)), await createTestUnitOfWork(dbs.connection)); 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, diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index 62305620c..356d6024d 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -106,7 +106,7 @@ beforeAll(async () => { 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), await createTestUnitOfWork(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(), await createTestUnitOfWork(testDb)), await createTestUnitOfWork(testDb)), mailerStub, new EphemeralTokenService(), new AllowedFileTypesService(new DatabaseService(testDb)), await createTestUnitOfWork(testDb), @@ -780,22 +780,22 @@ describe('getCurrentUser', () => { }); describe('deleteAccount', () => { - it('AUTH-DB-065: refuses to delete the last admin', () => { + it('AUTH-DB-065: refuses to delete the last admin', async () => { const { user } = createAdmin(testDb); - expect(svc.deleteAccount(user.id, user.email, 'admin')) + expect(await svc.deleteAccount(user.id, user.email, 'admin')) .toEqual({ error: 'Cannot delete the last admin account', status: 400 }); }); - it('AUTH-DB-066: demo mode blocks deletion', () => { + it('AUTH-DB-066: demo mode blocks deletion', async () => { vi.stubEnv('DEMO_MODE', 'true'); - expect(svc.deleteAccount(1, 'demo@nomad.app', 'user')) + expect(await svc.deleteAccount(1, 'demo@nomad.app', 'user')) .toEqual({ error: 'Account deletion is disabled in demo mode.', status: 403 }); vi.unstubAllEnvs(); }); - it('AUTH-DB-067: deletes a regular user row', () => { + it('AUTH-DB-067: deletes a regular user row', async () => { const { user } = createUser(testDb); - expect(svc.deleteAccount(user.id, user.email, 'user')).toEqual({ success: true }); + expect(await svc.deleteAccount(user.id, user.email, 'user')).toEqual({ success: true }); expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(user.id)).toBeUndefined(); }); }); diff --git a/server/tests/unit/nest/budget.controller.test.ts b/server/tests/unit/nest/budget.controller.test.ts index 6764265b2..85495f721 100644 --- a/server/tests/unit/nest/budget.controller.test.ts +++ b/server/tests/unit/nest/budget.controller.test.ts @@ -44,20 +44,20 @@ const tripRow = { id: 5, user_id: 42, currency: 'USD' } as never; describe('BudgetController (parity with the legacy /api/trips/:tripId/budget route)', () => { - it('GET / returns items', () => { - const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); - expect(new BudgetController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] }); + it('GET / returns items', async () => { + const svc = makeService({ list: vi.fn().mockResolvedValue([{ id: 1 }]) } as Partial); + expect(await new BudgetController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] }); }); - it('GET /summary/per-person + /settlement delegate', () => { - const settlement = vi.fn().mockReturnValue({ transfers: [] }); + it('GET /summary/per-person + /settlement delegate', async () => { + const settlement = vi.fn().mockResolvedValue({ transfers: [] }); const svc = makeService({ - perPersonSummary: vi.fn().mockReturnValue([{ userId: 1, owes: 10 }]), + perPersonSummary: vi.fn().mockResolvedValue([{ userId: 1, owes: 10 }]), settlement, } as Partial); - expect(new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] }); + expect(await new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] }); // A trip with no currency set falls back to EUR rather than passing undefined on. - expect(new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5')).toEqual({ transfers: [] }); + expect(await new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5')).toEqual({ transfers: [] }); expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR'); }); @@ -72,9 +72,9 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou }); describe('settlements ledger', () => { - it('GET /settlements lists', () => { - const svc = makeService({ listSettlements: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); - expect(new BudgetController(svc).listSettlements(user, '5')).toEqual({ settlements: [{ id: 1 }] }); + it('GET /settlements lists', async () => { + const svc = makeService({ listSettlements: vi.fn().mockResolvedValue([{ id: 1 }]) } as Partial); + expect(await new BudgetController(svc).listSettlements(user, '5')).toEqual({ settlements: [{ id: 1 }] }); }); @@ -92,17 +92,17 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-created', { settlement: { id: 3, amount: 0 } }, 'sock'); }); - it('DELETE /settlements/:id 404 when missing', () => { - const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(false) } as Partial); - expect(thrown(() => new BudgetController(svc).deleteSettlement(user, '5', '7'))).toEqual({ + it('DELETE /settlements/:id 404 when missing', async () => { + const svc = makeService({ deleteSettlement: vi.fn().mockResolvedValue(false) } as Partial); + expect(await thrownAsync(() => new BudgetController(svc).deleteSettlement(user, '5', '7'))).toEqual({ status: 404, body: { error: 'Settlement not found' }, }); }); - it('DELETE /settlements/:id success broadcasts the numeric id', () => { + it('DELETE /settlements/:id success broadcasts the numeric id', async () => { const broadcast = vi.fn(); - const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(true), broadcast } as Partial); - expect(new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true }); + const svc = makeService({ deleteSettlement: vi.fn().mockResolvedValue(true), broadcast } as Partial); + expect(await new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true }); expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-deleted', { settlementId: 7 }, 'sock'); }); @@ -171,18 +171,18 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou // The legacy 'user_ids must be an array' 400 is now produced by the global // ZodValidationPipe (budgetUpdateMembersRequestSchema) before the handler runs. - it('404 when the item is missing', () => { - const svc = makeService({ updateMembers: vi.fn().mockReturnValue(null) } as Partial); - expect(thrown(() => new BudgetController(svc).updateMembers(user, '5', '9', { user_ids: [2, 3] }))).toEqual({ + it('404 when the item is missing', async () => { + const svc = makeService({ updateMembers: vi.fn().mockResolvedValue(null) } as Partial); + expect(await thrownAsync(() => new BudgetController(svc).updateMembers(user, '5', '9', { user_ids: [2, 3] }))).toEqual({ status: 404, body: { error: 'Budget item not found' }, }); }); - it('updates members and broadcasts persons count', () => { - const updateMembers = vi.fn().mockReturnValue({ members: [{ user_id: 2 }], item: { persons: 1 } }); + it('updates members and broadcasts persons count', async () => { + const updateMembers = vi.fn().mockResolvedValue({ members: [{ user_id: 2 }], item: { persons: 1 } }); const broadcast = vi.fn(); const svc = makeService({ updateMembers, broadcast } as Partial); - const res = new BudgetController(svc).updateMembers(user, '5', '9', { user_ids: [2] }, 'sock'); + const res = await new BudgetController(svc).updateMembers(user, '5', '9', { user_ids: [2] }, 'sock'); expect(res).toEqual({ members: [{ user_id: 2 }], item: { persons: 1 } }); expect(updateMembers).toHaveBeenCalledWith('9', '5', [2]); expect(broadcast).toHaveBeenCalledWith('5', 'budget:members-updated', { itemId: 9, members: [{ user_id: 2 }], persons: 1 }, 'sock'); @@ -193,55 +193,55 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou // The legacy 'payers must be an array' 400 is now produced by the global // ZodValidationPipe (budgetUpdatePayersRequestSchema) before the handler runs. - it('404 when the item is missing', () => { - const svc = makeService({ setPayers: vi.fn().mockReturnValue(null) } as Partial); - expect(thrown(() => new BudgetController(svc).setPayers(user, '5', '9', { payers: [{ user_id: 2, amount: 10 }] }))).toEqual({ + it('404 when the item is missing', async () => { + const svc = makeService({ setPayers: vi.fn().mockResolvedValue(null) } as Partial); + expect(await thrownAsync(() => new BudgetController(svc).setPayers(user, '5', '9', { payers: [{ user_id: 2, amount: 10 }] }))).toEqual({ status: 404, body: { error: 'Budget item not found' }, }); }); - it('sets payers and broadcasts budget:updated', () => { - const setPayers = vi.fn().mockReturnValue({ id: 9, payers: [{ user_id: 2, amount: 10 }] }); + it('sets payers and broadcasts budget:updated', async () => { + const setPayers = vi.fn().mockResolvedValue({ id: 9, payers: [{ user_id: 2, amount: 10 }] }); const broadcast = vi.fn(); const svc = makeService({ setPayers, broadcast } as Partial); - const res = new BudgetController(svc).setPayers(user, '5', '9', { payers: [{ user_id: 2, amount: 10 }] }, 'sock'); + const res = await new BudgetController(svc).setPayers(user, '5', '9', { payers: [{ user_id: 2, amount: 10 }] }, 'sock'); expect(res).toEqual({ item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } }); expect(setPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]); expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } }, 'sock'); }); }); - it('PUT /:id/members/:userId/paid toggles + broadcasts normalised paid flag', () => { - const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 1 }); + it('PUT /:id/members/:userId/paid toggles + broadcasts normalised paid flag', async () => { + const toggleMemberPaid = vi.fn().mockResolvedValue({ user_id: 2, paid: 1 }); const broadcast = vi.fn(); const svc = makeService({ toggleMemberPaid, broadcast } as Partial); - expect(new BudgetController(svc).toggleMemberPaid(user, '5', '9', '2', { paid: true }, 'sock')).toEqual({ member: { user_id: 2, paid: 1 } }); + expect(await new BudgetController(svc).toggleMemberPaid(user, '5', '9', '2', { paid: true }, 'sock')).toEqual({ member: { user_id: 2, paid: 1 } }); expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 1 }, 'sock'); }); - it('PUT /:id/members/:userId/paid broadcasts paid: 0 when toggled off', () => { + it('PUT /:id/members/:userId/paid broadcasts paid: 0 when toggled off', async () => { const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 0 }); const broadcast = vi.fn(); const svc = makeService({ toggleMemberPaid, broadcast } as Partial); - new BudgetController(svc).toggleMemberPaid(user, '5', '9', '2', { paid: false }, 'sock'); + await new BudgetController(svc).toggleMemberPaid(user, '5', '9', '2', { paid: false }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 0 }, 'sock'); }); - it('DELETE /:id 404 when missing, success otherwise', () => { - const missing = makeService({ remove: vi.fn().mockReturnValue(false) } as Partial); - expect(thrown(() => new BudgetController(missing).remove(user, '5', '9'))).toEqual({ + it('DELETE /:id 404 when missing, success otherwise', async () => { + const missing = makeService({ remove: vi.fn().mockResolvedValue(false) } as Partial); + expect(await thrownAsync(() => new BudgetController(missing).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Budget item not found' }, }); - const ok = makeService({ remove: vi.fn().mockReturnValue(true), broadcast: vi.fn() } as Partial); - expect(new BudgetController(ok).remove(user, '5', '9')).toEqual({ success: true }); + const ok = makeService({ remove: vi.fn().mockResolvedValue(true), broadcast: vi.fn() } as Partial); + expect(await new BudgetController(ok).remove(user, '5', '9')).toEqual({ success: true }); }); - it('PUT /reorder/items + /reorder/categories broadcast budget:reordered', () => { + it('PUT /reorder/items + /reorder/categories broadcast budget:reordered', async () => { const reorderItems = vi.fn(); const reorderCategories = vi.fn(); const broadcast = vi.fn(); const svc = makeService({ reorderItems, reorderCategories, broadcast } as Partial); - expect(new BudgetController(svc).reorderItems(user, '5', { orderedIds: [3, 1] }, 'sock')).toEqual({ success: true }); + expect(await new BudgetController(svc).reorderItems(user, '5', { orderedIds: [3, 1] }, 'sock')).toEqual({ success: true }); expect(reorderItems).toHaveBeenCalledWith('5', [3, 1]); - expect(new BudgetController(svc).reorderCategories(user, '5', { orderedCategories: ['food', 'fun'] }, 'sock')).toEqual({ success: true }); + expect(await new BudgetController(svc).reorderCategories(user, '5', { orderedCategories: ['food', 'fun'] }, 'sock')).toEqual({ success: true }); expect(reorderCategories).toHaveBeenCalledWith('5', ['food', 'fun']); }); }); diff --git a/server/tests/unit/nest/budget.service.calc.test.ts b/server/tests/unit/nest/budget.service.calc.test.ts index 9fe0398e2..052c8934b 100644 --- a/server/tests/unit/nest/budget.service.calc.test.ts +++ b/server/tests/unit/nest/budget.service.calc.test.ts @@ -47,6 +47,14 @@ import type { ExchangeRatesService } from '../../../src/nest/budget/exchange-rat import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src/types'; import type Database from 'better-sqlite3'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; + +/** + * There is no database behind this suite — every read is served by the + * prepare-stub above — so the UnitOfWork is a pass-through: it runs the callback + * as MikroORM would, without a transaction there is nothing to open on. + */ +const uowStub = { transactional: (fn: () => Promise) => fn() } as unknown as UnitOfWork; /** * A prepared-statement stub. @@ -65,6 +73,7 @@ const budget = new BudgetService( permissionsStub, mockRates as unknown as ExchangeRatesService, new RealtimeService(), + uowStub, ); // ── Helpers ────────────────────────────────────────────────────────────────── @@ -134,40 +143,40 @@ beforeEach(() => { // ── calculateSettlement ────────────────────────────────────────────────────── describe('calculateSettlement', () => { - it('returns empty balances and flows when trip has no items', () => { + it('returns empty balances and flows when trip has no items', async () => { setupDb([], [], []); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(result.balances).toEqual([]); expect(result.flows).toEqual([]); }); - it('returns no flows when there are items but no members', () => { + it('returns no flows when there are items but no members', async () => { setupDb([makeItem(1, 100)], [], [makePayer(1, 1, 100, 'alice')]); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(result.flows).toEqual([]); }); - it('returns no flows when no one has paid', () => { + it('returns no flows when no one has paid', async () => { setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(result.flows).toEqual([]); // "No flows" on its own said nothing about the balances behind them: they // were -50/-50 here until #2225, an offer of nothing next to money owed. expect(centSum(result.balances.map(b => b.balance))).toBe(0); }); - it('2 members, 1 payer: payer is owed half, non-payer owes half', () => { + it('2 members, 1 payer: payer is owed half, non-payer owes half', async () => { // Item: $100. Alice paid all, [Alice, Bob] split. Each owes $50. Alice net: +$50. Bob: -$50. setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const alice = result.balances.find(b => b.user_id === 1)!; const bob = result.balances.find(b => b.user_id === 2)!; expect(alice.balance).toBe(50); @@ -178,14 +187,14 @@ describe('calculateSettlement', () => { expect(result.flows[0].amount).toBe(50); }); - it('3 members, 1 payer: correct 3-way split', () => { + it('3 members, 1 payer: correct 3-way split', async () => { // Item: $90. Alice paid. Each of 3 owes $30. Alice net: +$60. Bob: -$30. Carol: -$30. setupDb( [makeItem(1, 90)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 90, 'alice')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const alice = result.balances.find(b => b.user_id === 1)!; const bob = result.balances.find(b => b.user_id === 2)!; const carol = result.balances.find(b => b.user_id === 3)!; @@ -195,14 +204,14 @@ describe('calculateSettlement', () => { expect(result.flows).toHaveLength(2); }); - it('all paid equally: all balances are zero, no flows', () => { + it('all paid equally: all balances are zero, no flows', async () => { // Item: $60. 3 members, each paid $20 and owes $20. Net: 0 for everyone. setupDb( [makeItem(1, 60)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 20, 'alice'), makePayer(1, 2, 20, 'bob'), makePayer(1, 3, 20, 'carol')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); // Exactly zero, not "within a cent": a tolerance here is what let #1382's // stranded cents through unnoticed. for (const b of result.balances) { @@ -211,27 +220,27 @@ describe('calculateSettlement', () => { expect(result.flows).toHaveLength(0); }); - it('flow direction: from is debtor (owes), to is creditor (is owed)', () => { + it('flow direction: from is debtor (owes), to is creditor (is owed)', async () => { // Alice paid $100 for 2 people. Bob owes Alice $50. setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const flow = result.flows[0]; expect(flow.from.username).toBe('bob'); // debtor expect(flow.to.username).toBe('alice'); // creditor }); - it('amounts are rounded to 2 decimal places', () => { + it('amounts are rounded to 2 decimal places', async () => { // Item: $10. 3 members, 1 payer. Share = 3.333... Each rounded to 3.33. setupDb( [makeItem(1, 10)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 10, 'alice')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); for (const b of result.balances) { const str = b.balance.toString(); const decimals = str.includes('.') ? str.split('.')[1].length : 0; @@ -244,7 +253,7 @@ describe('calculateSettlement', () => { } }); - it('2 items with different payers: aggregates balances correctly', () => { + it('2 items with different payers: aggregates balances correctly', async () => { // Item 1: $100, Alice paid, [Alice, Bob] (Alice net: +50, Bob: -50) // Item 2: $60, Bob paid, [Alice, Bob] (Bob net: +30, Alice: -30) // Final: Alice: +50 - 30 = +20, Bob: -50 + 30 = -20 @@ -256,7 +265,7 @@ describe('calculateSettlement', () => { ], [makePayer(1, 1, 100, 'alice'), makePayer(2, 2, 60, 'bob')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const alice = result.balances.find(b => b.user_id === 1)!; const bob = result.balances.find(b => b.user_id === 2)!; expect(alice.balance).toBe(20); @@ -265,7 +274,7 @@ describe('calculateSettlement', () => { expect(result.flows[0].amount).toBe(20); }); - it('counts a settlement with no matching expense as an amount still to square up', () => { + it('counts a settlement with no matching expense as an amount still to square up', async () => { // bob paid alice 30 but every expense behind it was deleted: alice now owes bob. mockDb.db.prepare.mockImplementation((sql: string) => { if (sql.includes('FROM budget_settlements')) { @@ -275,7 +284,7 @@ describe('calculateSettlement', () => { } return stmt({ all: vi.fn(() => []), get: vi.fn(), run: vi.fn() }); }); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const alice = result.balances.find(b => b.user_id === 1)!; const bob = result.balances.find(b => b.user_id === 2)!; expect(bob.balance).toBe(30); @@ -285,7 +294,7 @@ describe('calculateSettlement', () => { ]); }); - it('#1335 converts a foreign expense with the frozen exchange_rate, not live rates', () => { + it('#1335 converts a foreign expense with the frozen exchange_rate, not live rates', async () => { // $110 booked at a frozen rate of 1.1 (USD per 1 EUR) = 100 EUR. Live rates have since // drifted to 1.2, but the converted amount must stay on the frozen rate so an already // settled position isn't re-opened with a residual. @@ -294,25 +303,25 @@ describe('calculateSettlement', () => { [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 110, 'alice')], ); - const result = budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } }); + const result = await budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } }); const bob = result.balances.find(b => b.user_id === 2)!; // 110 / 1.1 = 100 EUR; Bob owes half = 50 (frozen). With the live 1.2 it would be ~45.83. expect(bob.balance).toBeCloseTo(-50, 2); }); - it('#1335 a legacy row (exchange_rate = 1) still converts with live rates', () => { + it('#1335 a legacy row (exchange_rate = 1) still converts with live rates', async () => { setupDb( [{ ...makeItem(1, 120), currency: 'USD', exchange_rate: 1 } as BudgetItem], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 120, 'alice')], ); - const result = budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } }); + const result = await budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } }); const bob = result.balances.find(b => b.user_id === 2)!; // 120 / 1.2 (live) = 100 EUR; Bob owes 50 — unchanged behaviour for pre-#1335 rows. expect(bob.balance).toBeCloseTo(-50, 2); }); - it('#1445 a settle-up transfer with a frozen currency+rate keeps the position balanced when rates drift', () => { + it('#1445 a settle-up transfer with a frozen currency+rate keeps the position balanced when rates drift', async () => { // Trip currency EUR; Bob viewed in USD (display) and owes Alice 50 EUR = 62.50 USD // at the settle-time rate of 1.25 USD/EUR. He records that 62.50 USD transfer, which // freezes currency=USD, exchange_rate=1.25. Live rates have since drifted (now the @@ -323,12 +332,12 @@ describe('calculateSettlement', () => { [makePayer(1, 1, 100, 'alice')], [makeSettlementRow(1, 2, 1, 62.5, 'USD', 1.25)], ); - const result = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: 0.5 } }); + const result = await budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: 0.5 } }); const bob = result.balances.find(b => b.user_id === 2)!; expect(bob.balance).toBeCloseTo(0, 2); // settled — no residual re-opens }); - it('#1445 a legacy settle-up transfer (currency NULL) still converts with live rates', () => { + it('#1445 a legacy settle-up transfer (currency NULL) still converts with live rates', async () => { // Same shape, but the transfer predates the fix (currency NULL). It is re-converted // to trip currency with live rates, so a drift re-opens the position — unchanged // legacy behaviour that normalises once the row is re-edited. @@ -338,7 +347,7 @@ describe('calculateSettlement', () => { [makePayer(1, 1, 100, 'alice')], [makeSettlementRow(1, 2, 1, 62.5, null, 1)], ); - const result = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: 0.5 } }); + const result = await budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: 0.5 } }); const bob = result.balances.find(b => b.user_id === 2)!; // settleToTrip(62.5) = 62.5 * 0.5 = 31.25 EUR; balance -50 + 31.25 = -18.75 EUR → reopens. expect(Math.abs(bob.balance)).toBeGreaterThan(1); @@ -348,7 +357,7 @@ describe('calculateSettlement', () => { // The UI could only send one payer between 3.2.0 and this fix, but the ledger // has credited each payer individually since 3.1.0. These pin that. - it('2 payers, 3 members: each payer is credited what they actually paid', () => { + it('2 payers, 3 members: each payer is credited what they actually paid', async () => { // $90 bill split 3 ways ($30 each). Alice and Bob each fronted $45. // Alice: +45 - 30 = +15. Bob: +45 - 30 = +15. Carol: -30. setupDb( @@ -356,7 +365,7 @@ describe('calculateSettlement', () => { [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 45, 'alice'), makePayer(1, 2, 45, 'bob')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; expect(balance(1)).toBeCloseTo(15, 2); @@ -364,7 +373,7 @@ describe('calculateSettlement', () => { expect(balance(3)).toBeCloseTo(-30, 2); }); - it('2 payers with unequal amounts: credits follow the actual amounts paid', () => { + it('2 payers with unequal amounts: credits follow the actual amounts paid', async () => { // $100 bill split 2 ways ($50 each). Alice fronted $70, Bob fronted $30. // Alice: +70 - 50 = +20. Bob: +30 - 50 = -20. Bob owes Alice $20. setupDb( @@ -372,7 +381,7 @@ describe('calculateSettlement', () => { [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 70, 'alice'), makePayer(1, 2, 30, 'bob')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; expect(balance(1)).toBeCloseTo(20, 2); @@ -383,7 +392,7 @@ describe('calculateSettlement', () => { expect(result.flows[0].amount).toBeCloseTo(20, 2); }); - it('multi-payer balances sum to zero (no money invented or destroyed)', () => { + it('multi-payer balances sum to zero (no money invented or destroyed)', async () => { // The invariant that makes settle-up trustworthy: credits == debits. setupDb( [makeItem(1, 90), makeItem(2, 55)], @@ -396,12 +405,12 @@ describe('calculateSettlement', () => { makePayer(2, 2, 25, 'bob'), makePayer(2, 3, 30, 'carol'), ], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(centSum(result.balances.map(b => b.balance))).toBe(0); }); - it('3 payers on one bill: an odd total still splits to the cent', () => { + it('3 payers on one bill: an odd total still splits to the cent', async () => { // $100.01 split 3 ways. splitEqualShares distributes the remainder cent, // so debits must still exactly cancel the $100.01 of credits. setupDb( @@ -409,7 +418,7 @@ describe('calculateSettlement', () => { [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 33.34, 'alice'), makePayer(1, 2, 33.34, 'bob'), makePayer(1, 3, 33.33, 'carol')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(centSum(result.balances.map(b => b.balance))).toBe(0); }); @@ -418,7 +427,7 @@ describe('calculateSettlement', () => { // ── Refunds: negative amounts are first-class expenses (#2176) ─────────────── describe('calculateSettlement — negative amounts (#2176)', () => { - it('a refund credits the members and debits its recipient', () => { + it('a refund credits the members and debits its recipient', async () => { // A 30 € refund lands with Alice; all three had shared the original cost. // Alice received 30 but only 10 of it was hers, so she owes 20. setupDb( @@ -426,7 +435,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => { [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, -30, 'alice')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; expect(balance(1)).toBe(-20); @@ -435,7 +444,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => { expect(centSum(result.balances.map(b => b.balance))).toBe(0); }); - it('a refund reduces the debt its expense created', () => { + it('a refund reduces the debt its expense created', async () => { // 90 € dinner fronted by Alice, then a 30 € partial reimbursement she keeps — // both split three ways. Bob's debt drops from 30 to 20. setupDb( @@ -446,7 +455,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => { ], [makePayer(1, 1, 90, 'alice'), makePayer(2, 1, -30, 'alice')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; expect(balance(1)).toBe(40); @@ -457,18 +466,18 @@ describe('calculateSettlement — negative amounts (#2176)', () => { ])); }); - it('an odd negative total still nets to exactly zero', () => { + it('an odd negative total still nets to exactly zero', async () => { setupDb( [makeItem(1, -100.01)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, -100.01, 'alice')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(centSum(result.balances.map(b => b.balance))).toBe(0); }); - it('a negative custom split settles by the custom amounts', () => { + it('a negative custom split settles by the custom amounts', async () => { // 100 € refund to Alice; by agreement Bob is owed 70 of it, Carol 30. setupDb( [makeItem(1, -100)], @@ -478,7 +487,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => { ], [makePayer(1, 1, -100, 'alice')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; expect(balance(1)).toBe(-100); @@ -487,7 +496,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => { expect(centSum(result.balances.map(b => b.balance))).toBe(0); }); - it('a payer-less refund owes nobody anything until its recipient is named (#2225)', () => { + it('a payer-less refund owes nobody anything until its recipient is named (#2225)', async () => { // Nobody is recorded as having received the refund, so there is no credit to // hand back: the row is outstanding, not a debt the trip owes its members. // It used to credit all three 30 € out of thin air. @@ -496,7 +505,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => { [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(result.balances).toEqual([]); expect(result.flows).toEqual([]); @@ -536,7 +545,7 @@ describe('calculateSettlement — finalBudgets', () => { } }; - it('charges each participant their share of what the payer fronted', () => { + it('charges each participant their share of what the payer fronted', async () => { // Alice fronts 100 for the two of them. Nothing has been paid back yet, so the // trip costs each of them 50: Alice is out 100 with 50 still coming, Bob is out // nothing with 50 still to pay. @@ -545,7 +554,7 @@ describe('calculateSettlement — finalBudgets', () => { [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const alice = result.finalBudgets.find(f => f.user_id === 1)!; const bob = result.finalBudgets.find(f => f.user_id === 2)!; @@ -554,7 +563,7 @@ describe('calculateSettlement — finalBudgets', () => { checkIdentity(result.finalBudgets); }); - it('a recorded transfer moves out of pending and into reimbursed, leaving the final alone', () => { + it('a recorded transfer moves out of pending and into reimbursed, leaving the final alone', async () => { // Bob pays his 50 back. What the trip costs either of them cannot change — only // which of the two lines under it the 50 now sits on. setupDb( @@ -563,7 +572,7 @@ describe('calculateSettlement — finalBudgets', () => { [makePayer(1, 1, 100, 'alice')], [makeSettlementRow(1, 2, 1, 50)], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const alice = result.finalBudgets.find(f => f.user_id === 1)!; const bob = result.finalBudgets.find(f => f.user_id === 2)!; @@ -572,7 +581,7 @@ describe('calculateSettlement — finalBudgets', () => { checkIdentity(result.finalBudgets); }); - it('sums the finals to what the trip actually spent', () => { + it('sums the finals to what the trip actually spent', async () => { // 90 + 60 across three people, fronted by two of them. However the debts are // arranged, the trip cost the group exactly what it spent. setupDb( @@ -583,7 +592,7 @@ describe('calculateSettlement — finalBudgets', () => { ], [makePayer(1, 1, 90, 'alice'), makePayer(2, 2, 60, 'bob')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(centSum(result.finalBudgets.map(f => f.final))).toBe(15000); expect(result.finalBudgets.find(f => f.user_id === 1)!.final).toBe(30); @@ -592,7 +601,7 @@ describe('calculateSettlement — finalBudgets', () => { checkIdentity(result.finalBudgets); }); - it('follows a custom split rather than an equal one', () => { + it('follows a custom split rather than an equal one', async () => { // Alice fronts 100 but only owes 20 of it — the split says so. setupDb( [makeItem(1, 100)], @@ -602,26 +611,26 @@ describe('calculateSettlement — finalBudgets', () => { ], [makePayer(1, 1, 100, 'alice')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(result.finalBudgets.find(f => f.user_id === 1)!.final).toBe(20); expect(result.finalBudgets.find(f => f.user_id === 2)!.final).toBe(80); checkIdentity(result.finalBudgets); }); - it('leaves an expense nobody paid out of the final budget (#2225)', () => { + it('leaves an expense nobody paid out of the final budget (#2225)', async () => { // The unpaid row stays out of the ledger, so it cannot charge anybody either. setupDb( [makeItem(1, 90)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(result.finalBudgets).toEqual([]); }); - it('gives a refund back to whoever was charged for it (#2176)', () => { + it('gives a refund back to whoever was charged for it (#2176)', async () => { // A 30 refund Alice received, split between the two of them: each is 15 better // off, so the trip costs them -15 on this row alone. setupDb( @@ -629,14 +638,14 @@ describe('calculateSettlement — finalBudgets', () => { [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, -30, 'alice')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(result.finalBudgets.find(f => f.user_id === 1)!).toMatchObject({ expenses: -30, final: -15 }); expect(result.finalBudgets.find(f => f.user_id === 2)!.final).toBe(-15); checkIdentity(result.finalBudgets); }); - it('keeps the breakdown adding up in a display currency of its own', () => { + it('keeps the breakdown adding up in a display currency of its own', async () => { // The three lines are converted as their own sets, like the balances are, and // the final is subtracted afterwards — so what the breakdown prints adds up in // whatever currency the viewer picked, at whatever the live rate happens to be. @@ -647,7 +656,7 @@ describe('calculateSettlement — finalBudgets', () => { [makeSettlementRow(1, 2, 1, 33.33)], ); for (const eurPerUsd of [0.855, 0.9312, 0.94]) { - const result = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: eurPerUsd } }); + const result = await budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: eurPerUsd } }); checkIdentity(result.finalBudgets); // And the pending line is the balance itself, not a second opinion on it. @@ -657,19 +666,19 @@ describe('calculateSettlement — finalBudgets', () => { } }); - it('costs nobody anything when a transfer has no expense behind it', () => { + it('costs nobody anything when a transfer has no expense behind it', async () => { // Bob handed Alice 40 with no expense on the trip to justify it. Alice has the // 40 but owes it straight back, so the trip has cost neither of them anything — // the transfer shows up as reimbursed on one line and outstanding on the next. setupDb([], [], [], [makeSettlementRow(1, 2, 1, 40)]); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(result.finalBudgets.find(f => f.user_id === 1)!).toMatchObject({ expenses: 0, reimbursed: 40, pending: -40, final: 0 }); expect(result.finalBudgets.find(f => f.user_id === 2)!).toMatchObject({ expenses: 0, reimbursed: -40, pending: 40, final: 0 }); checkIdentity(result.finalBudgets); }); - it('lists the rows each figure is made of, signed the way the figure is', () => { + it('lists the rows each figure is made of, signed the way the figure is', async () => { // The first case read row by row, with 20 of Bob's 50 already sent: Alice // fronted the one expense, received the 20, and the open flow is the 30 coming // to her; on Bob's side the same transfer and flow are going out. @@ -679,7 +688,7 @@ describe('calculateSettlement — finalBudgets', () => { [makePayer(1, 1, 100, 'alice')], [makeSettlementRow(1, 2, 1, 20)], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(result.finalBudgets.find(f => f.user_id === 1)!.sources).toEqual({ fronted: [{ item_id: 1, cents: 10000 }], @@ -693,7 +702,7 @@ describe('calculateSettlement — finalBudgets', () => { }); }); - it('keeps an expense with no split members out of the rows, as it is out of the ledger', () => { + it('keeps an expense with no split members out of the rows, as it is out of the ledger', async () => { // Item 1 has a payer but nobody to split it with: a planning-only entry that // charges nobody, so it cannot be listed as something Alice fronted either. setupDb( @@ -701,14 +710,14 @@ describe('calculateSettlement — finalBudgets', () => { [makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')], [makePayer(1, 1, 100, 'alice'), makePayer(2, 1, 40, 'alice')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const alice = result.finalBudgets.find(f => f.user_id === 1)!; expect(alice.expenses).toBe(40); expect(alice.sources.fronted).toEqual([{ item_id: 2, cents: 4000 }]); }); - it('spreads a foreign-currency figure over its rows so they still add up in the display currency', () => { + it('spreads a foreign-currency figure over its rows so they still add up in the display currency', async () => { // Two USD expenses booked at their own frozen rates and one in the trip's euros, // viewed in pounds: every row goes through the conversion its figure went // through, and the cents lost to rounding land on rows instead of between them. @@ -728,7 +737,7 @@ describe('calculateSettlement — finalBudgets', () => { [makeSettlementRow(1, 3, 1, 20, 'GBP', 0.8547), makeSettlementRow(2, 3, 2, 7.77, 'GBP', 0.8547)], ); for (const eurPerGbp of [1.17, 1.1523, 1.2]) { - const result = budget.calculateSettlement(1, { base: 'GBP', tripCurrency: 'EUR', rates: { GBP: 1, EUR: eurPerGbp } }); + const result = await budget.calculateSettlement(1, { base: 'GBP', tripCurrency: 'EUR', rates: { GBP: 1, EUR: eurPerGbp } }); checkIdentity(result.finalBudgets); for (const f of result.finalBudgets) { @@ -771,7 +780,7 @@ describe('splitEqualShares — client parity (#2176)', () => { // be smoothed over with a 0.01 tolerance is now exact. describe('calculateSettlement — cent-exact settle-up (#1382)', () => { - it('#1382 offers the last cent as a flow instead of stranding it', () => { + it('#1382 offers the last cent as a flow instead of stranding it', async () => { // 30.00 fronted by Alice, split three ways. Bob transfers 9.99 instead of his // 10.00 and Carol pays exactly, so one cent is still open. The reporter's // symptom was that the balances showed that cent while "Settle up" offered @@ -782,7 +791,7 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => { [makePayer(1, 1, 30, 'alice')], [makeSettlementRow(1, 2, 1, 9.99), makeSettlementRow(2, 3, 1, 10)], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; expect(balance(1)).toBe(0.01); @@ -793,7 +802,7 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => { ]); }); - it('#1382 booking every offered flow leaves all balances at exactly zero', () => { + it('#1382 booking every offered flow leaves all balances at exactly zero', async () => { // 10.00 three ways is 3.34/3.33/3.33 — the case where the old greedy walked // away from the rounding remainder. Recording the flows it offers must square // the trip up completely, or the leftover cent is there forever. @@ -802,18 +811,18 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => { const payers = [makePayer(1, 1, 10, 'alice')]; setupDb(items, members, payers); - const first = budget.calculateSettlement(1); + const first = await budget.calculateSettlement(1); expect(first.flows.length).toBeGreaterThan(0); const booked = first.flows.map((f, i) => makeSettlementRow(i + 1, f.from.user_id, f.to.user_id, f.amount)); setupDb(items, members, payers, booked); - const after = budget.calculateSettlement(1); + const after = await budget.calculateSettlement(1); expect(after.balances.map(b => b.balance)).toEqual([0, 0, 0]); expect(after.flows).toEqual([]); }); - it('#1382 a foreign-currency expense nets to exactly zero, not to a sub-cent residual', () => { + it('#1382 a foreign-currency expense nets to exactly zero, not to a sub-cent residual', async () => { // 100 USD frozen at 1.1 is 90.909090… EUR: no split of it lands on whole cents. // The equal split divides the credited cents rather than the foreign total, so // the debits cancel the credits exactly instead of leaving dust behind. @@ -822,14 +831,14 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => { [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 100, 'alice')], ); - const result = budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.1 } }); + const result = await budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.1 } }); expect(centSum(result.balances.map(b => b.balance))).toBe(0); expect(centSum(result.flows.map(f => f.amount))) .toBe(Math.round(result.balances.find(b => b.user_id === 1)!.balance * 100)); }); - it('#1382 a display currency of its own neither invents nor loses a cent', () => { + it('#1382 a display currency of its own neither invents nor loses a cent', async () => { // Nothing here is foreign: an all-EUR trip, but the viewer's preferred currency // is USD. Rounding each balance into that currency on its own used to leave the // set adding up to a cent that no flow could ever clear, and the drift moved @@ -840,7 +849,7 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => { [makePayer(1, 1, 100, 'alice')], ); for (const eurPerUsd of [0.855, 0.9312, 0.94]) { - const result = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: eurPerUsd } }); + const result = await budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: eurPerUsd } }); expect(centSum(result.balances.map(b => b.balance))).toBe(0); for (const b of result.balances) { @@ -852,7 +861,7 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => { } }); - it('#2225 an unpaid bill owes nobody anything', () => { + it('#2225 an unpaid bill owes nobody anything', async () => { // Nobody is down as a payer, so nobody is out of pocket and there is nothing // to pay back. It used to debit all three 30 € against no credit at all, // leaving Σ(balances) at -90 with no flow able to clear it. @@ -861,7 +870,7 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => { [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(result.balances).toEqual([]); expect(result.flows).toEqual([]); @@ -871,7 +880,7 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => { // ── Unpaid expenses stay out of the ledger (#2225) ──────────────────────── describe('calculateSettlement: unpaid expenses (#2225)', () => { - it('an unpaid expense does not turn its members into debtors of an unrelated payer', () => { + it('an unpaid expense does not turn its members into debtors of an unrelated payer', async () => { // The shape from the issue: a 300 € bill Alice fronted for all four, plus a // 60 € row nobody has paid that only Bob and Carol are on. The 60 € used to // be debited with no credit behind it, and the simplifier (which knows @@ -885,7 +894,7 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => { ], [makePayer(1, 1, 300, 'alice')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; expect(centSum(result.balances.map(b => b.balance))).toBe(0); @@ -897,7 +906,7 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => { for (const f of result.flows) expect(f.to.user_id).toBe(1); }); - it('an unpaid expense with a custom split is skipped too', () => { + it('an unpaid expense with a custom split is skipped too', async () => { // The custom branch had the same hole and no coverage: 100 € split 70/30 by // agreement, with nobody recorded as having paid it. setupDb( @@ -909,7 +918,7 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => { ], [makePayer(1, 1, 90, 'alice')], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; expect(balance(1)).toBe(60); @@ -918,7 +927,7 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => { expect(centSum(result.balances.map(b => b.balance))).toBe(0); }); - it('a zero-total item with no payer contributes nothing and no rows', () => { + it('a zero-total item with no payer contributes nothing and no rows', async () => { // Payer-less is payer-less regardless of the total: it contributes 0 either // way, and both panels synthesise a missing member's 0.00 row from the trip // roster (CostsPanel.tsx:853, MCostsTab.tsx:273), so dropping the row costs @@ -928,13 +937,13 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => { [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(result.balances).toEqual([]); expect(result.flows).toEqual([]); }); - it('a custom split left with no payer and a zeroed total creates no debt', () => { + it('a custom split left with no payer and a zeroed total creates no debt', async () => { // PUT /budget/:id/payers with an empty array zeroes total_price via // writeItemPayers and, unlike updateBudgetItem, never re-applies it, so the // row survives as custom per-member amounts with no credit behind them. A @@ -948,7 +957,7 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => { ], [], ); - const result = budget.calculateSettlement(1); + const result = await budget.calculateSettlement(1); expect(result.balances).toEqual([]); expect(result.flows).toEqual([]); @@ -1033,17 +1042,17 @@ describe('freezeForeignRate', () => { // ── applySettlementUpdate (the raw no-freeze write behind updateSettlement) ── describe('applySettlementUpdate', () => { - it('returns null when the settlement is not in the trip', () => { + it('returns null when the settlement is not in the trip', async () => { mockDb.db.prepare.mockImplementation((sql: string) => { if (sql.includes('SELECT id FROM budget_settlements')) { return stmt({ get: vi.fn(() => undefined), all: vi.fn(), run: vi.fn() }); } return stmt({ get: vi.fn(), all: vi.fn(() => []), run: vi.fn() }); }); - expect(budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10 })).toBeNull(); + expect(await budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10 })).toBeNull(); }); - it('updates the row (rounded to cents) and returns the refreshed settlement', () => { + it('updates the row (rounded to cents) and returns the refreshed settlement', async () => { const run = vi.fn(); mockDb.db.prepare.mockImplementation((sql: string) => { if (sql.includes('SELECT id FROM budget_settlements')) { @@ -1062,7 +1071,7 @@ describe('applySettlementUpdate', () => { return stmt({ get: vi.fn(), all: vi.fn(() => []), run: vi.fn() }); }); - const res = budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 }); + const res = await budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 }); // from, to, rounded amount, currency-flag(0)/value(null), rate-flag(null)/value(1), // settled_at-flag(0)/value(null), id. // No currency/exchange_rate/settled_at passed → all three CASE guards keep the existing columns. diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 9bd4a861d..2ac3bb2a3 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -94,17 +94,19 @@ beforeAll(async () => { new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), + await createTestUnitOfWork(testDb), ); membersSvc = new TripMembersService( dbs(), budget, - new UserCleanupService(dbs(), budget), + new UserCleanupService(dbs(), budget, await createTestUnitOfWork(testDb)), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), + await createTestUnitOfWork(testDb), ); - createGuest = membersSvc.createGuest.bind(membersSvc); - deleteGuest = membersSvc.deleteGuest.bind(membersSvc); + createGuest = (...args) => membersSvc.createGuest(...args); + deleteGuest = (...args) => membersSvc.deleteGuest(...args); }); beforeAll(() => { @@ -136,18 +138,18 @@ describe('deleting a member re-splits their expenses (#1553)', () => { .get(itemId) as { count: number }).count; } - it('BUDGET-SVC-DB-010: re-derives the persons divisor when a guest in the split is deleted', () => { + it('BUDGET-SVC-DB-010: re-derives the persons divisor when a guest in the split is deleted', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const guests = ['G1', 'G2', 'G3'].map(n => createGuest(trip.id, n, owner.id).member); - const item = budget.createBudgetItem(trip.id, { + const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 400, member_ids: [owner.id, ...guests.map(g => g.id)], }); expect(personsOf(item.id)).toBe(4); - deleteGuest(trip.id, guests[0].id); - deleteGuest(trip.id, guests[1].id); + await deleteGuest(trip.id, guests[0].id); + await deleteGuest(trip.id, guests[1].id); // The member rows cascade with the users row; `persons` is denormalized and has to // be re-derived, or the per-person column keeps dividing by the departed. @@ -155,53 +157,53 @@ describe('deleting a member re-splits their expenses (#1553)', () => { expect(personsOf(item.id)).toBe(2); }); - it('BUDGET-SVC-DB-011: leaves a manually entered persons count alone', () => { + it('BUDGET-SVC-DB-011: leaves a manually entered persons count alone', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const guest = createGuest(trip.id, 'G1', owner.id).member; // No member rows — `persons` is just a number someone typed. - const item = budget.createBudgetItem(trip.id, { name: 'Rental', total_price: 300, persons: 6 }); + const item = await budget.createBudgetItem(trip.id, { name: 'Rental', total_price: 300, persons: 6 }); - deleteGuest(trip.id, guest.id); + await deleteGuest(trip.id, guest.id); expect(personsOf(item.id)).toBe(6); }); - it('BUDGET-SVC-DB-012: drops the last member to a null divisor rather than zero', () => { + it('BUDGET-SVC-DB-012: drops the last member to a null divisor rather than zero', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const guest = createGuest(trip.id, 'G1', owner.id).member; - const item = budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 50, member_ids: [guest.id] }); + const item = await budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 50, member_ids: [guest.id] }); - deleteGuest(trip.id, guest.id); + await deleteGuest(trip.id, guest.id); expect(memberCount(item.id)).toBe(0); expect(personsOf(item.id)).toBeNull(); }); - it('BUDGET-SVC-DB-013: saves a split from a stale client instead of failing on the users FK', () => { + it('BUDGET-SVC-DB-013: saves a split from a stale client instead of failing on the users FK', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const guest = createGuest(trip.id, 'G1', owner.id).member; - const item = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 200, member_ids: [owner.id, guest.id] }); + const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 200, member_ids: [owner.id, guest.id] }); - deleteGuest(trip.id, guest.id); + await deleteGuest(trip.id, guest.id); // A client that loaded before the deletion still sends the guest back (#1553). - const updated = budget.updateBudgetItem(item.id, trip.id, { member_ids: [owner.id, guest.id] }); + const updated = await budget.updateBudgetItem(item.id, trip.id, { member_ids: [owner.id, guest.id] }); expect(updated!.members.map(m => m.user_id)).toEqual([owner.id]); expect(personsOf(item.id)).toBe(1); }); - it('BUDGET-SVC-DB-014: ignores a deleted member arriving through updateMembers', () => { + it('BUDGET-SVC-DB-014: ignores a deleted member arriving through updateMembers', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const guest = createGuest(trip.id, 'G1', owner.id).member; - const item = budget.createBudgetItem(trip.id, { name: 'Drinks', total_price: 60, member_ids: [owner.id, guest.id] }); + const item = await budget.createBudgetItem(trip.id, { name: 'Drinks', total_price: 60, member_ids: [owner.id, guest.id] }); - deleteGuest(trip.id, guest.id); - const result = budget.updateMembers(item.id, trip.id, [owner.id, guest.id]); + await deleteGuest(trip.id, guest.id); + const result = await budget.updateMembers(item.id, trip.id, [owner.id, guest.id]); expect(result!.members.map(m => m.user_id)).toEqual([owner.id]); expect(personsOf(item.id)).toBe(1); @@ -209,27 +211,27 @@ describe('deleting a member re-splits their expenses (#1553)', () => { }); describe('toggleMemberPaid trip-scoping', () => { - it('BUDGET-SVC-DB-001: toggles paid for an item that belongs to the given trip', () => { + it('BUDGET-SVC-DB-001: toggles paid for an item that belongs to the given trip', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip A' }); - const item = budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100 }); - budget.updateMembers(item.id, trip.id, [user.id]); + const item = await budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100 }); + await budget.updateMembers(item.id, trip.id, [user.id]); - const member = budget.toggleMemberPaid(item.id, trip.id, user.id, true); + const member = await budget.toggleMemberPaid(item.id, trip.id, user.id, true); expect(member).not.toBeNull(); expect(paidFlag(item.id, user.id)).toBe(1); }); - it('BUDGET-SVC-DB-002: refuses to toggle an item from a different trip (cross-trip IDOR)', () => { + it('BUDGET-SVC-DB-002: refuses to toggle an item from a different trip (cross-trip IDOR)', async () => { const { user } = createUser(testDb); const tripA = createTrip(testDb, user.id, { title: 'Trip A' }); const tripB = createTrip(testDb, user.id, { title: 'Trip B' }); - const itemB = budget.createBudgetItem(tripB.id, { name: 'Foreign expense', total_price: 50 }); - budget.updateMembers(itemB.id, tripB.id, [user.id]); + const itemB = await budget.createBudgetItem(tripB.id, { name: 'Foreign expense', total_price: 50 }); + await budget.updateMembers(itemB.id, tripB.id, [user.id]); // Caller passes a trip they can access (A) but the item lives in trip B. - const member = budget.toggleMemberPaid(itemB.id, tripA.id, user.id, true); + const member = await budget.toggleMemberPaid(itemB.id, tripA.id, user.id, true); expect(member).toBeNull(); expect(paidFlag(itemB.id, user.id)).toBe(0); // unchanged @@ -237,14 +239,14 @@ describe('toggleMemberPaid trip-scoping', () => { }); describe('calculateSettlement custom splits', () => { - it('BUDGET-SVC-DB-003: settles by the custom per-member amounts, not the equal split (#1458)', () => { + it('BUDGET-SVC-DB-003: settles by the custom per-member amounts, not the equal split (#1458)', async () => { const { user: alice } = createUser(testDb, { username: 'alice' }); const { user: bob } = createUser(testDb, { username: 'bob' }); const trip = createTrip(testDb, alice.id, { title: 'Trip' }); addTripMember(testDb, trip.id, bob.id); // 100 total, custom split: Alice owes 90, Bob owes 10. Alice paid the whole bill. - budget.createBudgetItem(trip.id, { + await budget.createBudgetItem(trip.id, { name: 'Dinner', payers: [{ user_id: alice.id, amount: 100 }], members: [ @@ -253,7 +255,7 @@ describe('calculateSettlement custom splits', () => { ], }); - const result = budget.calculateSettlement(trip.id); + const result = await budget.calculateSettlement(trip.id); // Alice paid 100 but owes 90 → net +10 (creditor); Bob owes 10 → net -10 (debtor). // With the equal-split bug both owe 50, so the flow would be 50 instead of 10. @@ -268,7 +270,7 @@ describe('calculateSettlement custom splits', () => { }); describe('calculateSettlement squares up to the cent (#1382)', () => { - it('BUDGET-SVC-DB-026: recording the offered flows clears the trip to exactly zero', () => { + it('BUDGET-SVC-DB-026: recording the offered flows clears the trip to exactly zero', async () => { const { user: alice } = createUser(testDb, { username: 'alice' }); const { user: bob } = createUser(testDb, { username: 'bob' }); const { user: carol } = createUser(testDb, { username: 'carol' }); @@ -279,30 +281,30 @@ describe('calculateSettlement squares up to the cent (#1382)', () => { // Totals that never divide by three, so every expense leaves a remainder cent // somewhere: 10.00, 100.01 and 0.01 (the smallest debt there is). - budget.createBudgetItem(trip.id, { name: 'Coffee', payers: [{ user_id: alice.id, amount: 10 }], members }); - budget.createBudgetItem(trip.id, { name: 'Dinner', payers: [{ user_id: bob.id, amount: 100.01 }], members }); - budget.createBudgetItem(trip.id, { name: 'Stamp', payers: [{ user_id: carol.id, amount: 0.01 }], members }); + await budget.createBudgetItem(trip.id, { name: 'Coffee', payers: [{ user_id: alice.id, amount: 10 }], members }); + await budget.createBudgetItem(trip.id, { name: 'Dinner', payers: [{ user_id: bob.id, amount: 100.01 }], members }); + await budget.createBudgetItem(trip.id, { name: 'Stamp', payers: [{ user_id: carol.id, amount: 0.01 }], members }); - const before = budget.calculateSettlement(trip.id); + const before = await budget.calculateSettlement(trip.id); expect(before.flows.length).toBeGreaterThan(0); for (const f of before.flows) { - budget.insertSettlement(trip.id, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount }, f.from.user_id); + await budget.insertSettlement(trip.id, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount }, f.from.user_id); } // Nothing left over, and nothing left to offer — the settle-up list and the // balances agree instead of the balances holding a cent nobody can pay. - const after = budget.calculateSettlement(trip.id); + const after = await budget.calculateSettlement(trip.id); expect(after.balances.map(b => b.balance)).toEqual([0, 0, 0]); expect(after.flows).toEqual([]); }); it('BUDGET-SVC-DB-027: the balances hold still while the live rate moves under them', async () => { - const { trip, me, danil, serega } = seedIssue1543Trip('RUB'); + const { trip, me, danil, serega } = await seedIssue1543Trip('RUB'); // Nothing about the trip changes between the two reads; only the rate cache // turned over. That was enough to shuffle cents between people (#1382). - const first = budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB }); - const second = budget.calculateSettlement(trip.id, { + const first = await budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB }); + const second = await budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: { RUB: 1, USD: 0.013042 * 1.03, EUR: 0.011412 * 0.97 }, }); @@ -315,7 +317,7 @@ describe('calculateSettlement squares up to the cent (#1382)', () => { }); /** The exact trip from #1543: RUB base, three members, one expense booked in USD. */ -function seedIssue1543Trip(tripCurrency: string) { +async function seedIssue1543Trip(tripCurrency: string) { const { user: me } = createUser(testDb, { username: 'me' }); const { user: danil } = createUser(testDb, { username: 'danil' }); const { user: serega } = createUser(testDb, { username: 'serega' }); @@ -328,9 +330,9 @@ function seedIssue1543Trip(tripCurrency: string) { // 9 000 ₽ nobody has paid yet (outstanding, settled by nobody, #2225), 9 000 ₽ paid // by me, and $100 paid by me. The USD row carries the rate frozen at entry time: // units of USD per 1 RUB. - budget.createBudgetItem(trip.id, { name: 'Проезд обратно', total_price: 9000, currency: 'RUB', members }); - budget.createBudgetItem(trip.id, { name: 'Проезд туда', currency: 'RUB', payers: [{ user_id: me.id, amount: 9000 }], members }); - budget.createBudgetItem(trip.id, { + await budget.createBudgetItem(trip.id, { name: 'Проезд обратно', total_price: 9000, currency: 'RUB', members }); + await budget.createBudgetItem(trip.id, { name: 'Проезд туда', currency: 'RUB', payers: [{ user_id: me.id, amount: 9000 }], members }); + await budget.createBudgetItem(trip.id, { name: 'test', currency: 'USD', exchange_rate: 0.013042, payers: [{ user_id: me.id, amount: 100 }], members, }); @@ -338,10 +340,10 @@ function seedIssue1543Trip(tripCurrency: string) { } describe('calculateSettlement with a foreign-currency expense (#1543)', () => { - it('BUDGET-SVC-DB-004: nets in the trip currency instead of inflating the foreign share ~27x', () => { - const { trip, me, danil, serega } = seedIssue1543Trip('RUB'); + it('BUDGET-SVC-DB-004: nets in the trip currency instead of inflating the foreign share ~27x', async () => { + const { trip, me, danil, serega } = await seedIssue1543Trip('RUB'); - const result = budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB }); + const result = await budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB }); const balanceOf = (id: number) => result.balances.find(b => b.user_id === id)!.balance; // What actually settles is 9 000 ₽ + $100 (≈7 668 ₽): the third expense is the @@ -360,11 +362,11 @@ describe('calculateSettlement with a foreign-currency expense (#1543)', () => { expect(result.balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0); }); - it('BUDGET-SVC-DB-005: reports the same balances when the display currency differs from the trip currency', () => { - const { trip, danil } = seedIssue1543Trip('RUB'); + it('BUDGET-SVC-DB-005: reports the same balances when the display currency differs from the trip currency', async () => { + const { trip, danil } = await seedIssue1543Trip('RUB'); // Same trip, viewed in EUR: every balance is the RUB one converted once, at the end. - const inEur = budget.calculateSettlement(trip.id, { base: 'EUR', tripCurrency: 'RUB', rates: RATES.EUR }); + const inEur = await budget.calculateSettlement(trip.id, { base: 'EUR', tripCurrency: 'RUB', rates: RATES.EUR }); const danilEur = inEur.balances.find(b => b.user_id === danil.id)!.balance; const shareRub = (9000 + 100 / RATES.RUB.USD) / 3; @@ -385,9 +387,9 @@ describe('rebaseTripCurrency', () => { // An expense that inherits the trip's base (currency NULL), one booked in USD, and // one already in the incoming currency. - const implicit = budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members }) as { id: number }; - const usd = budget.createBudgetItem(trip.id, { name: 'USD', total_price: 100, currency: 'USD', exchange_rate: 1.1429, members }) as { id: number }; - const rub = budget.createBudgetItem(trip.id, { name: 'RUB', total_price: 9000, currency: 'RUB', exchange_rate: 87.63, members }) as { id: number }; + const implicit = await budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members }) as { id: number }; + const usd = await budget.createBudgetItem(trip.id, { name: 'USD', total_price: 100, currency: 'USD', exchange_rate: 1.1429, members }) as { id: number }; + const rub = await budget.createBudgetItem(trip.id, { name: 'RUB', total_price: 9000, currency: 'RUB', exchange_rate: 87.63, members }) as { id: number }; await budget.rebaseTripCurrency(trip.id, 'RUB'); @@ -407,15 +409,15 @@ describe('rebaseTripCurrency', () => { testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id); const members = [{ user_id: alice.id }, { user_id: bob.id }]; - budget.createBudgetItem(trip.id, { name: 'Hotel', payers: [{ user_id: alice.id, amount: 100 }], members }); - budget.createBudgetItem(trip.id, { name: 'Dinner', currency: 'USD', exchange_rate: 1.1429, payers: [{ user_id: bob.id, amount: 60 }], members }); + await budget.createBudgetItem(trip.id, { name: 'Hotel', payers: [{ user_id: alice.id, amount: 100 }], members }); + await budget.createBudgetItem(trip.id, { name: 'Dinner', currency: 'USD', exchange_rate: 1.1429, payers: [{ user_id: bob.id, amount: 60 }], members }); - const before = budget.calculateSettlement(trip.id, { base: 'EUR', tripCurrency: 'EUR', rates: RATES.EUR }); + const before = await budget.calculateSettlement(trip.id, { base: 'EUR', tripCurrency: 'EUR', rates: RATES.EUR }); await budget.rebaseTripCurrency(trip.id, 'RUB'); testDb.prepare("UPDATE trips SET currency = 'RUB' WHERE id = ?").run(trip.id); - const after = budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB }); + const after = await budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB }); for (const b of before.balances) { const rub = after.balances.find(x => x.user_id === b.user_id)!.balance; @@ -457,7 +459,7 @@ describe('rebaseTripCurrency', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip' }); testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id); - const item = budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] }) as { id: number }; + const item = await budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] }) as { id: number }; await budget.rebaseTripCurrency(trip.id, 'EUR'); @@ -469,24 +471,24 @@ describe('composite service paths (ex budget.bridge delegation)', () => { // budget.bridge is deleted — UserCleanupService injects BudgetService now // that BudgetModule no longer imports AuthModule. 015-018 kept their IDs and // pin the same behavior directly on the service. - it('BUDGET-SVC-DB-015: listBudgetItems returns the hydrated list', () => { + it('BUDGET-SVC-DB-015: listBudgetItems returns the hydrated list', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100, member_ids: [user.id] }); + const item = await budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100, member_ids: [user.id] }); - const items = budget.listBudgetItems(trip.id); + const items = await budget.listBudgetItems(trip.id); expect(items.map(i => i.id)).toEqual([item.id]); expect(items[0].members.map(m => m.user_id)).toEqual([user.id]); }); - it('BUDGET-SVC-DB-016: removeUserFromBudgetItems re-derives persons', () => { + it('BUDGET-SVC-DB-016: removeUserFromBudgetItems re-derives persons', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb, { username: 'other' }); const trip = createTrip(testDb, owner.id); - const item = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, other.id] }); + const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, other.id] }); - budget.removeUserFromBudgetItems(other.id); + await budget.removeUserFromBudgetItems(other.id); const row = testDb.prepare('SELECT persons FROM budget_items WHERE id = ?').get(item.id) as { persons: number | null }; expect(row.persons).toBe(1); @@ -496,7 +498,7 @@ describe('composite service paths (ex budget.bridge delegation)', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id); - const item = budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] }); + const item = await budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] }); await budget.rebaseTripCurrency(trip.id, 'RUB'); @@ -504,28 +506,28 @@ describe('composite service paths (ex budget.bridge delegation)', () => { expect(row).toEqual({ currency: 'EUR', exchange_rate: RATES.RUB.EUR }); }); - it('BUDGET-SVC-DB-018: linkBudgetItemToReservation stamps the reservation id', () => { + it('BUDGET-SVC-DB-018: linkBudgetItemToReservation stamps the reservation id', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const reservationId = Number(testDb .prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')") .run(trip.id).lastInsertRowid); - const item = budget.linkBudgetItemToReservation(trip.id, reservationId, { name: 'Flight', total_price: 200 }); + const item = await budget.linkBudgetItemToReservation(trip.id, reservationId, { name: 'Flight', total_price: 200 }); expect(item.reservation_id).toBe(reservationId); const row = testDb.prepare('SELECT reservation_id FROM budget_items WHERE id = ?').get(item.id) as { reservation_id: number | null }; expect(row.reservation_id).toBe(reservationId); }); - it('BUDGET-SVC-DB-018b: an expense can be created against a place (#1298)', () => { + it('BUDGET-SVC-DB-018b: an expense can be created against a place (#1298)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const placeId = Number(testDb .prepare("INSERT INTO places (trip_id, name) VALUES (?, 'Louvre')") .run(trip.id).lastInsertRowid); - const item = budget.createBudgetItem(trip.id, { name: 'Louvre tickets', total_price: 34, place_id: placeId }); + const item = await budget.createBudgetItem(trip.id, { name: 'Louvre tickets', total_price: 34, place_id: placeId }); expect(item.place_id).toBe(placeId); expect(item.reservation_id).toBeNull(); @@ -533,11 +535,11 @@ describe('composite service paths (ex budget.bridge delegation)', () => { expect(row.place_id).toBe(placeId); }); - it('BUDGET-SVC-DB-018c: an expense without a link stores neither id', () => { + it('BUDGET-SVC-DB-018c: an expense without a link stores neither id', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = budget.createBudgetItem(trip.id, { name: 'Coffee', total_price: 3 }); + const item = await budget.createBudgetItem(trip.id, { name: 'Coffee', total_price: 3 }); expect(item.place_id).toBeNull(); expect(item.reservation_id).toBeNull(); @@ -557,7 +559,7 @@ describe('settlement parties are confined to the trip', () => { const created = await budget.createSettlement(trip.id, { from_user_id: outsider.id, to_user_id: alice.id, amount: 10 }, alice.id); expect(created).toBeNull(); - expect(budget.listSettlements(trip.id)).toEqual([]); + expect(await budget.listSettlements(trip.id)).toEqual([]); }); it('BUDGET-SVC-DB-029: answers a nonexistent user the same way, without throwing', async () => { @@ -579,12 +581,12 @@ describe('settlement parties are confined to the trip', () => { expect(created).toMatchObject({ from_user_id: bob.id, to_user_id: alice.id }); }); - it('BUDGET-SVC-DB-031: drops an off-trip payer from an item instead of refusing it', () => { + it('BUDGET-SVC-DB-031: drops an off-trip payer from an item instead of refusing it', async () => { const { user: alice } = createUser(testDb, { username: 'alice' }); const { user: outsider } = createUser(testDb, { username: 'outsider' }); const trip = createTrip(testDb, alice.id); - const item = budget.createBudgetItem(trip.id, { + const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', payers: [{ user_id: alice.id, amount: 40 }, { user_id: outsider.id, amount: 60 }], }) as { payers: { user_id: number }[]; total_price: number }; @@ -601,11 +603,11 @@ describe('settlement parties are confined to the trip', () => { // payer row. describe('negative amounts persist end-to-end (#2176)', () => { - it('BUDGET-SVC-DB-032: stores a negative payer row and the negative total', () => { + it('BUDGET-SVC-DB-032: stores a negative payer row and the negative total', async () => { const { user: alice } = createUser(testDb, { username: 'alice' }); const trip = createTrip(testDb, alice.id); - const item = budget.createBudgetItem(trip.id, { + const item = await budget.createBudgetItem(trip.id, { name: 'Hotel partial refund', total_price: -100, payers: [{ user_id: alice.id, amount: -100 }], @@ -617,16 +619,16 @@ describe('negative amounts persist end-to-end (#2176)', () => { expect(row.total_price).toBe(-100); }); - it('BUDGET-SVC-DB-033: an update keeps a negative payer instead of nulling the entry', () => { + it('BUDGET-SVC-DB-033: an update keeps a negative payer instead of nulling the entry', async () => { const { user: alice } = createUser(testDb, { username: 'alice' }); const { user: bob } = createUser(testDb, { username: 'bob' }); const trip = createTrip(testDb, alice.id); addTripMember(testDb, trip.id, bob.id); - const item = budget.createBudgetItem(trip.id, { + const item = await budget.createBudgetItem(trip.id, { name: 'Refund', total_price: -60, payers: [{ user_id: alice.id, amount: -60 }], }) as { id: number }; - const updated = budget.updateBudgetItem(item.id, trip.id, { + const updated = await budget.updateBudgetItem(item.id, trip.id, { payers: [{ user_id: alice.id, amount: -40 }, { user_id: bob.id, amount: -20 }], }) as { payers: { user_id: number; amount: number }[]; total_price: number }; @@ -635,14 +637,14 @@ describe('negative amounts persist end-to-end (#2176)', () => { expect(updated.payers.map(p => p.amount).sort((a, b) => a - b)).toEqual([-40, -20]); }); - it('BUDGET-SVC-DB-034: mixed-sign payers derive the netted total', () => { + it('BUDGET-SVC-DB-034: mixed-sign payers derive the netted total', async () => { // Alice fronted 100 but bob pocketed a 30 refund on the same receipt. const { user: alice } = createUser(testDb, { username: 'alice' }); const { user: bob } = createUser(testDb, { username: 'bob' }); const trip = createTrip(testDb, alice.id); addTripMember(testDb, trip.id, bob.id); - const item = budget.createBudgetItem(trip.id, { + const item = await budget.createBudgetItem(trip.id, { name: 'Tickets', payers: [{ user_id: alice.id, amount: 100 }, { user_id: bob.id, amount: -30 }], }) as { payers: { user_id: number }[]; total_price: number }; @@ -650,13 +652,13 @@ describe('negative amounts persist end-to-end (#2176)', () => { expect(item.total_price).toBe(70); }); - it('BUDGET-SVC-DB-035: a zero-amount payer is still dropped', () => { + it('BUDGET-SVC-DB-035: a zero-amount payer is still dropped', async () => { const { user: alice } = createUser(testDb, { username: 'alice' }); const { user: bob } = createUser(testDb, { username: 'bob' }); const trip = createTrip(testDb, alice.id); addTripMember(testDb, trip.id, bob.id); - const item = budget.createBudgetItem(trip.id, { + const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', payers: [{ user_id: alice.id, amount: 90 }, { user_id: bob.id, amount: 0 }], }) as { payers: { user_id: number }[]; total_price: number }; @@ -664,21 +666,21 @@ describe('negative amounts persist end-to-end (#2176)', () => { expect(item.total_price).toBe(90); }); - it('BUDGET-SVC-DB-036: a negative expense settles against real rows — Σ balances = 0', () => { + it('BUDGET-SVC-DB-036: a negative expense settles against real rows — Σ balances = 0', async () => { const { user: alice } = createUser(testDb, { username: 'alice' }); const { user: bob } = createUser(testDb, { username: 'bob' }); const trip = createTrip(testDb, alice.id); addTripMember(testDb, trip.id, bob.id); - budget.createBudgetItem(trip.id, { + await budget.createBudgetItem(trip.id, { name: 'Dinner', payers: [{ user_id: alice.id, amount: 90 }], member_ids: [alice.id, bob.id], }); - budget.createBudgetItem(trip.id, { + await budget.createBudgetItem(trip.id, { name: 'Refund', payers: [{ user_id: alice.id, amount: -30 }], member_ids: [alice.id, bob.id], }); - const result = budget.calculateSettlement(trip.id); + const result = await budget.calculateSettlement(trip.id); const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; // 90 out, 30 back, both split evenly: bob nets 45 - 15 = 30 owed to alice. @@ -692,7 +694,7 @@ describe('negative amounts persist end-to-end (#2176)', () => { }); describe('deleting an expense takes its price off the booking (#2233)', () => { - it('BUDGET-SVC-DB-040: the mirrored price and currency are cleared, the rest of the metadata stays', () => { + it('BUDGET-SVC-DB-040: the mirrored price and currency are cleared, the rest of the metadata stays', async () => { // The reservation update path keeps metadata.price across booking edits, so // the expense side has to remove it when the expense itself goes away. // Without this the card would show a price with nothing behind it, for good. @@ -703,27 +705,27 @@ describe('deleting an expense takes its price off the booking (#2233)', () => { ).run(trip.id, JSON.stringify({ airline: 'CZ', seat: '12A', price: '2040', priceCurrency: 'CNY' })); const reservationId = Number(res.lastInsertRowid); - const item = budget.createBudgetItem(trip.id, { name: 'Flight', total_price: 2040 }); + const item = await budget.createBudgetItem(trip.id, { name: 'Flight', total_price: 2040 }); testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservationId, item.id); - expect(budget.deleteBudgetItem(item.id, trip.id)).toBe(true); + expect(await budget.deleteBudgetItem(item.id, trip.id)).toBe(true); const after = testDb.prepare('SELECT metadata FROM reservations WHERE id = ?').get(reservationId) as { metadata: string }; expect(JSON.parse(after.metadata)).toEqual({ airline: 'CZ', seat: '12A' }); }); - it('BUDGET-SVC-DB-041: an expense that was never linked to a booking deletes as before', () => { + it('BUDGET-SVC-DB-041: an expense that was never linked to a booking deletes as before', async () => { const { user } = createUser(testDb, { username: 'owner' }); const trip = createTrip(testDb, user.id, { title: 'Trip' }); - const item = budget.createBudgetItem(trip.id, { name: 'Coffee', total_price: 4 }); + const item = await budget.createBudgetItem(trip.id, { name: 'Coffee', total_price: 4 }); - expect(budget.deleteBudgetItem(item.id, trip.id)).toBe(true); + expect(await budget.deleteBudgetItem(item.id, trip.id)).toBe(true); expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(item.id)).toBeUndefined(); }); }); describe('an expense nobody paid stays out of the ledger (#2225)', () => { - it('BUDGET-SVC-DB-037: an unpaid expense moves neither the balances nor the offered flows', () => { + it('BUDGET-SVC-DB-037: an unpaid expense moves neither the balances nor the offered flows', async () => { const { user: alice } = createUser(testDb, { username: 'alice' }); const { user: bob } = createUser(testDb, { username: 'bob' }); const { user: carol } = createUser(testDb, { username: 'carol' }); @@ -731,15 +733,15 @@ describe('an expense nobody paid stays out of the ledger (#2225)', () => { addTripMember(testDb, trip.id, bob.id); addTripMember(testDb, trip.id, carol.id); - budget.createBudgetItem(trip.id, { + await budget.createBudgetItem(trip.id, { name: 'Dinner', payers: [{ user_id: alice.id, amount: 120 }], member_ids: [alice.id, bob.id, carol.id], }); - const before = budget.calculateSettlement(trip.id); + const before = await budget.calculateSettlement(trip.id); // The row from the issue: a recorded total left on "No one paid yet", split // between the two people who were actually there. - const unpaid = budget.createBudgetItem(trip.id, { + const unpaid = await budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 161.57, payers: [], member_ids: [bob.id, carol.id], }) as { id: number; payers: unknown[]; total_price: number }; expect(unpaid.payers).toEqual([]); @@ -747,7 +749,7 @@ describe('an expense nobody paid stays out of the ledger (#2225)', () => { // Re-saving it from the edit modal (payers cleared, total re-sent) must not // let writeItemPayers derive the total back down to 0. - const resaved = budget.updateBudgetItem(unpaid.id, trip.id, { + const resaved = await budget.updateBudgetItem(unpaid.id, trip.id, { total_price: 161.57, payers: [], member_ids: [bob.id, carol.id], }) as { payers: unknown[]; total_price: number }; expect(resaved.payers).toEqual([]); @@ -759,7 +761,7 @@ describe('an expense nobody paid stays out of the ledger (#2225)', () => { // 161.57 with no credit behind it used to push bob and carol 80.79/80.78 further // into the red and hand the whole of it to alice, who never fronted a rouble of it. - const after = budget.calculateSettlement(trip.id); + const after = await budget.calculateSettlement(trip.id); expect(after.balances).toEqual(before.balances); expect(after.flows).toEqual(before.flows); expect(after.balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0); @@ -767,41 +769,41 @@ describe('an expense nobody paid stays out of the ledger (#2225)', () => { }); describe('post-fold quirk fixes', () => { - it('BUDGET-SVC-DB-019: settlements prefer display_name over username (quirk fix)', () => { + it('BUDGET-SVC-DB-019: settlements prefer display_name over username (quirk fix)', async () => { const { user: alice } = createUser(testDb, { username: 'alice' }); const { user: bob } = createUser(testDb, { username: 'bob' }); testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Alice Displayed', alice.id); const trip = createTrip(testDb, alice.id); - const created = budget.insertSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 10 }, alice.id); + const created = await budget.insertSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 10 }, alice.id); expect(created!.from_username).toBe('Alice Displayed'); expect(created!.to_username).toBe('bob'); - expect(budget.listSettlements(trip.id)[0].from_username).toBe('Alice Displayed'); + expect((await budget.listSettlements(trip.id))[0].from_username).toBe('Alice Displayed'); }); - it('BUDGET-SVC-DB-042: a settle-up payment carries its own settled_at, independent of created_at', () => { + it('BUDGET-SVC-DB-042: a settle-up payment carries its own settled_at, independent of created_at', async () => { const { user: alice } = createUser(testDb, { username: 'alice' }); const { user: bob } = createUser(testDb, { username: 'bob' }); const trip = createTrip(testDb, alice.id); - const noDate = budget.insertSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 10 }, alice.id); + const noDate = await budget.insertSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 10 }, alice.id); expect(noDate!.settled_at).toBeNull(); - const dated = budget.insertSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 20, settled_at: '2026-01-05' }, alice.id); + const dated = await budget.insertSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 20, settled_at: '2026-01-05' }, alice.id); expect(dated!.settled_at).toBe('2026-01-05'); - expect(budget.getSettlement(dated!.id, trip.id)!.settled_at).toBe('2026-01-05'); + expect((await budget.getSettlement(dated!.id, trip.id))!.settled_at).toBe('2026-01-05'); - const moved = budget.applySettlementUpdate(dated!.id, trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 20, settled_at: '2026-01-09' }); + const moved = await budget.applySettlementUpdate(dated!.id, trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 20, settled_at: '2026-01-09' }); expect(moved!.settled_at).toBe('2026-01-09'); // An update that omits settled_at (undefined) leaves the stored day alone, // the same CASE WHEN pattern currency/exchange_rate already follow. - const untouched = budget.applySettlementUpdate(dated!.id, trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 25 }); + const untouched = await budget.applySettlementUpdate(dated!.id, trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 25 }); expect(untouched!.settled_at).toBe('2026-01-09'); }); - it('BUDGET-SVC-DB-043: clearing settled_at stores NULL, whether it arrives as null or an empty string', () => { + it('BUDGET-SVC-DB-043: clearing settled_at stores NULL, whether it arrives as null or an empty string', async () => { const { user: alice } = createUser(testDb, { username: 'alice' }); const { user: bob } = createUser(testDb, { username: 'bob' }); const trip = createTrip(testDb, alice.id); @@ -809,25 +811,25 @@ describe('post-fold quirk fixes', () => { // The date picker's clear button sends '' and the contract allows null; both // mean "no day of its own", never a stored empty string. - const blank = budget.insertSettlement(trip.id, { ...parties, settled_at: '' }, alice.id); + const blank = await budget.insertSettlement(trip.id, { ...parties, settled_at: '' }, alice.id); expect(blank!.settled_at).toBeNull(); - const dated = budget.insertSettlement(trip.id, { ...parties, settled_at: '2026-01-05' }, alice.id); - expect(budget.applySettlementUpdate(dated!.id, trip.id, { ...parties, settled_at: null })!.settled_at).toBeNull(); + const dated = await budget.insertSettlement(trip.id, { ...parties, settled_at: '2026-01-05' }, alice.id); + expect((await budget.applySettlementUpdate(dated!.id, trip.id, { ...parties, settled_at: null }))!.settled_at).toBeNull(); - budget.applySettlementUpdate(dated!.id, trip.id, { ...parties, settled_at: '2026-01-05' }); - expect(budget.applySettlementUpdate(dated!.id, trip.id, { ...parties, settled_at: '' })!.settled_at).toBeNull(); + await budget.applySettlementUpdate(dated!.id, trip.id, { ...parties, settled_at: '2026-01-05' }); + expect((await budget.applySettlementUpdate(dated!.id, trip.id, { ...parties, settled_at: '' }))!.settled_at).toBeNull(); const row = testDb.prepare('SELECT settled_at FROM budget_settlements WHERE id = ?').get(dated!.id) as { settled_at: string | null }; expect(row.settled_at).toBeNull(); }); // ── Notes vs. itemized receipts (#1658) ──────────────────────────────────── - it('BUDGET-SVC-DB-021: a note and a receipt are stored in their own columns', () => { + it('BUDGET-SVC-DB-021: a note and a receipt are stored in their own columns', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = budget.createBudgetItem(trip.id, { + const item = await budget.createBudgetItem(trip.id, { name: 'Groceries', total_price: 40, note: 'Lisa pays half of this back', @@ -841,37 +843,37 @@ describe('post-fold quirk fixes', () => { expect(JSON.parse(row.ticket_json!).items).toHaveLength(1); }); - it('BUDGET-SVC-DB-022: an update that omits note leaves the stored one alone', () => { + it('BUDGET-SVC-DB-022: an update that omits note leaves the stored one alone', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 20, note: 'split with Ben' }); + const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 20, note: 'split with Ben' }); // What the mobile sheet sends: notes are written on desktop, so it never // speaks about the field. - budget.updateBudgetItem(item!.id, trip.id, { total_price: 25 }); + await budget.updateBudgetItem(item!.id, trip.id, { total_price: 25 }); const row = testDb.prepare('SELECT note FROM budget_items WHERE id = ?').get(item!.id) as { note: string | null }; expect(row.note).toBe('split with Ben'); }); - it('BUDGET-SVC-DB-023: an explicit null clears the note', () => { + it('BUDGET-SVC-DB-023: an explicit null clears the note', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 12, note: 'airport run' }); + const item = await budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 12, note: 'airport run' }); - budget.updateBudgetItem(item!.id, trip.id, { note: null }); + await budget.updateBudgetItem(item!.id, trip.id, { note: null }); const row = testDb.prepare('SELECT note FROM budget_items WHERE id = ?').get(item!.id) as { note: string | null }; expect(row.note).toBeNull(); }); - it('BUDGET-SVC-DB-024: a pre-#1658 client sending the receipt as a note cannot erase the note', () => { + it('BUDGET-SVC-DB-024: a pre-#1658 client sending the receipt as a note cannot erase the note', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = budget.createBudgetItem(trip.id, { name: 'Supermarket', total_price: 30, note: 'reimburse from the kitty' }); + const item = await budget.createBudgetItem(trip.id, { name: 'Supermarket', total_price: 30, note: 'reimburse from the kitty' }); // An old tab, still encoding the receipt into `note`. - budget.updateBudgetItem(item!.id, trip.id, { + await budget.updateBudgetItem(item!.id, trip.id, { note: 'TICKETJSON:{"items":[{"name":"Milk","price":"2","parts":[1]}]}', }); @@ -882,11 +884,11 @@ describe('post-fold quirk fixes', () => { expect(JSON.parse(row.ticket_json!).items[0].name).toBe('Milk'); }); - it('BUDGET-SVC-DB-025: the same legacy payload on create lands in the receipt column', () => { + it('BUDGET-SVC-DB-025: the same legacy payload on create lands in the receipt column', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = budget.createBudgetItem(trip.id, { + const item = await budget.createBudgetItem(trip.id, { name: 'Market', total_price: 9, note: 'TICKETJSON:{"items":[{"name":"Cheese","price":"9","parts":[1]}]}', @@ -899,13 +901,13 @@ describe('post-fold quirk fixes', () => { expect(JSON.parse(row.ticket_json!).items[0].name).toBe('Cheese'); }); - it('BUDGET-SVC-DB-020: a failing item insert rolls back the category-order side write (quirk fix)', () => { + it('BUDGET-SVC-DB-020: a failing item insert rolls back the category-order side write (quirk fix)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); // NOT NULL violation on name fires after the category-order upsert — the // legacy non-transactional create leaked the budget_category_order row. - expect(() => budget.createBudgetItem(trip.id, { name: null as unknown as string, category: 'atomic-test' })).toThrow(); + await expect(budget.createBudgetItem(trip.id, { name: null as unknown as string, category: 'atomic-test' })).rejects.toThrow(); const cat = testDb.prepare("SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = 'atomic-test'").get(trip.id); expect(cat).toBeUndefined(); @@ -928,13 +930,13 @@ describe('an expense whose split leaves a remainder', () => { const totalOf = (itemId: number) => (testDb.prepare('SELECT total_price FROM budget_items WHERE id = ?').get(itemId) as { total_price: number }).total_price; - it('stores the total the parts add up to, not the float they land on', () => { + it('stores the total the parts add up to, not the float they land on', async () => { const { user: alice } = createUser(testDb); const { user: bob } = createUser(testDb); const trip = createTrip(testDb, alice.id); addTripMember(testDb, trip.id, bob.id); - const item = budget.createBudgetItem(trip.id, { + const item = await budget.createBudgetItem(trip.id, { name: 'Flight', payers: [{ user_id: alice.id, amount: 81.61 }, { user_id: bob.id, amount: 81.60 }], members: [{ user_id: alice.id }, { user_id: bob.id }], @@ -945,14 +947,14 @@ describe('an expense whose split leaves a remainder', () => { expect(String(totalOf(item.id))).toBe('163.21'); }); - it('does the same when the payers are replaced on an update', () => { + it('does the same when the payers are replaced on an update', async () => { const { user: alice } = createUser(testDb); const { user: bob } = createUser(testDb); const trip = createTrip(testDb, alice.id); addTripMember(testDb, trip.id, bob.id); - const item = budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 10, members: [{ user_id: alice.id }] }); - budget.updateBudgetItem(item.id, trip.id, { + const item = await budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 10, members: [{ user_id: alice.id }] }); + await budget.updateBudgetItem(item.id, trip.id, { payers: [{ user_id: alice.id, amount: 81.61 }, { user_id: bob.id, amount: 81.60 }], }); @@ -963,7 +965,7 @@ describe('an expense whose split leaves a remainder', () => { * Three ways is the harder case: 100.00 becomes 33.34 + 33.33 + 33.33, and * two of those additions drift. */ - it('holds across a three-way split too', () => { + it('holds across a three-way split too', async () => { const { user: alice } = createUser(testDb); const { user: bob } = createUser(testDb); const { user: carol } = createUser(testDb); @@ -971,7 +973,7 @@ describe('an expense whose split leaves a remainder', () => { addTripMember(testDb, trip.id, bob.id); addTripMember(testDb, trip.id, carol.id); - const item = budget.createBudgetItem(trip.id, { + const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', payers: [ { user_id: alice.id, amount: 33.34 }, @@ -984,13 +986,13 @@ describe('an expense whose split leaves a remainder', () => { expect(totalOf(item.id)).toBe(100); }); - it('leaves a total that needs no cleaning exactly as it was', () => { + it('leaves a total that needs no cleaning exactly as it was', async () => { const { user: alice } = createUser(testDb); const { user: bob } = createUser(testDb); const trip = createTrip(testDb, alice.id); addTripMember(testDb, trip.id, bob.id); - const item = budget.createBudgetItem(trip.id, { + const item = await budget.createBudgetItem(trip.id, { name: 'Taxi', payers: [{ user_id: alice.id, amount: 12.5 }, { user_id: bob.id, amount: 12.5 }], members: [{ user_id: alice.id }, { user_id: bob.id }], @@ -999,7 +1001,7 @@ describe('an expense whose split leaves a remainder', () => { expect(totalOf(item.id)).toBe(25); }); - it('attaches receipts created with receipt_file_ids and lists them', () => { + it('attaches receipts created with receipt_file_ids and lists them', async () => { const { user: alice } = createUser(testDb); const trip = createTrip(testDb, alice.id); @@ -1009,7 +1011,7 @@ describe('an expense whose split leaves a remainder', () => { ); const fileId = Number(res.lastInsertRowid); - const item = budget.createBudgetItem(trip.id, { + const item = await budget.createBudgetItem(trip.id, { name: 'Restaurant with receipt', total_price: 45, receipt_file_ids: [fileId], @@ -1020,13 +1022,13 @@ describe('an expense whose split leaves a remainder', () => { expect(item.receipts![0].id).toBe(fileId); expect(item.receipts![0].original_name).toBe('receipt.jpg'); - const listed = budget.listBudgetItems(trip.id); + const listed = await budget.listBudgetItems(trip.id); const found = listed.find(i => i.id === item.id); expect(found?.receipts?.length).toBe(1); expect(found?.receipts?.[0].id).toBe(fileId); }); - it('updates receipts on updateBudgetItem without deleting any file', () => { + it('updates receipts on updateBudgetItem without deleting any file', async () => { const { user: alice } = createUser(testDb); const trip = createTrip(testDb, alice.id); @@ -1039,11 +1041,11 @@ describe('an expense whose split leaves a remainder', () => { // File 4: will be removed but is shared with another budget item -> should NOT be trashed const f4 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'f4.jpg', 'f4.jpg').lastInsertRowid); - budget.createBudgetItem(trip.id, { name: 'Other', receipt_file_ids: [f4] }); - const item = budget.createBudgetItem(trip.id, { name: 'Dinner', receipt_file_ids: [f1, f2, f4] }); + await budget.createBudgetItem(trip.id, { name: 'Other', receipt_file_ids: [f4] }); + const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', receipt_file_ids: [f1, f2, f4] }); // Update item: remove f1 and f4, keep f2, add f3 - const updated = budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [f2, f3] }); + const updated = await budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [f2, f3] }); expect(updated?.receipts?.map(r => r.id).sort()).toEqual([f2, f3].sort()); // Removing a receipt removes the link and nothing else. The file stays on @@ -1057,7 +1059,7 @@ describe('an expense whose split leaves a remainder', () => { expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(f4)).toEqual({ c: 1 }); }); - it('re-saving an expense whose receipt is also linked elsewhere twice does not 500', () => { + it('re-saving an expense whose receipt is also linked elsewhere twice does not 500', async () => { // A file may carry one link row per place and one per booking, and the receipt // link is written onto a spare one of those. On the second save the row kept // from last time is skipped, so the next spare used to be adopted into a @@ -1071,26 +1073,26 @@ describe('an expense whose split leaves a remainder', () => { testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(file, place); testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(file, reservation); - const item = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 40, receipt_file_ids: [file] }); - const again = budget.updateBudgetItem(item.id, trip.id, { total_price: 42, receipt_file_ids: [file] }); + const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 40, receipt_file_ids: [file] }); + const again = await budget.updateBudgetItem(item.id, trip.id, { total_price: 42, receipt_file_ids: [file] }); expect(again?.total_price).toBe(42); expect(again?.receipts?.map(r => r.id)).toEqual([file]); expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file, item.id)).toEqual({ c: 1 }); }); - it('a receipt named twice in one save is linked once', () => { + it('a receipt named twice in one save is linked once', async () => { const { user: alice } = createUser(testDb); const trip = createTrip(testDb, alice.id); const file = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'dup.jpg', 'dup.jpg').lastInsertRowid); - const item = budget.createBudgetItem(trip.id, { name: 'Taxi' }); + const item = await budget.createBudgetItem(trip.id, { name: 'Taxi' }); - const updated = budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [file, file] }); + const updated = await budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [file, file] }); expect(updated?.receipts?.map(r => r.id)).toEqual([file]); }); - it('unlinks receipts on deleteBudgetItem and leaves every file in place', () => { + it('unlinks receipts on deleteBudgetItem and leaves every file in place', async () => { const { user: alice } = createUser(testDb); const trip = createTrip(testDb, alice.id); @@ -1100,8 +1102,8 @@ describe('an expense whose split leaves a remainder', () => { const place = testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(trip.id, 'Hotel').lastInsertRowid; const f2 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name, place_id) VALUES (?, ?, ?, ?)').run(trip.id, 'del2.jpg', 'del2.jpg', place).lastInsertRowid); - const item = budget.createBudgetItem(trip.id, { name: 'Lunch', receipt_file_ids: [f1, f2] }); - const deleted = budget.deleteBudgetItem(item.id, trip.id); + const item = await budget.createBudgetItem(trip.id, { name: 'Lunch', receipt_file_ids: [f1, f2] }); + const deleted = await budget.deleteBudgetItem(item.id, trip.id); expect(deleted).toBe(true); // Neither file is touched; only the links to the deleted expense go. @@ -1113,29 +1115,29 @@ describe('an expense whose split leaves a remainder', () => { expect(linkRows).toHaveLength(0); }); - it('unlinks receipts without ever trashing the file itself', () => { + it('unlinks receipts without ever trashing the file itself', async () => { const { user: alice } = createUser(testDb); const trip = createTrip(testDb, alice.id); // budget_edit and file_delete are separate permissions and a receipt id is // any file on the trip, so the budget domain must never delete one. const plain = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'r.pdf', 'r.pdf').lastInsertRowid); - const item = budget.createBudgetItem(trip.id, { name: 'Dinner', receipt_file_ids: [plain] }); - budget.deleteBudgetItem(item.id, trip.id); + const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', receipt_file_ids: [plain] }); + await budget.deleteBudgetItem(item.id, trip.id); expect((testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(plain) as { deleted_at: string | null }).deleted_at).toBeNull(); // The link is gone, because it was all the row carried. expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(plain)).toEqual({ c: 0 }); }); - it('keeps a place link on a row that also carried the receipt link', () => { + it('keeps a place link on a row that also carried the receipt link', async () => { const { user: alice } = createUser(testDb); const trip = createTrip(testDb, alice.id); const place = Number(testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(trip.id, 'Cafe').lastInsertRowid); const file = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'menu.pdf', 'menu.pdf').lastInsertRowid); testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(file, place); - const item = budget.createBudgetItem(trip.id, { name: 'Lunch', receipt_file_ids: [file] }); - budget.deleteBudgetItem(item.id, trip.id); + const item = await budget.createBudgetItem(trip.id, { name: 'Lunch', receipt_file_ids: [file] }); + await budget.deleteBudgetItem(item.id, trip.id); const row = testDb.prepare('SELECT place_id, budget_item_id FROM file_links WHERE file_id = ?').get(file) as { place_id: number | null; budget_item_id: number | null }; expect(row.place_id).toBe(place); @@ -1143,26 +1145,26 @@ describe('an expense whose split leaves a remainder', () => { expect((testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(file) as { deleted_at: string | null }).deleted_at).toBeNull(); }); - it('leaves a receipt already in the trash linked, so restoring it comes back attached', () => { + it('leaves a receipt already in the trash linked, so restoring it comes back attached', async () => { const { user: alice } = createUser(testDb); const trip = createTrip(testDb, alice.id); const file = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'old.pdf', 'old.pdf').lastInsertRowid); - const item = budget.createBudgetItem(trip.id, { name: 'Taxi', receipt_file_ids: [file] }); + const item = await budget.createBudgetItem(trip.id, { name: 'Taxi', receipt_file_ids: [file] }); testDb.prepare('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?').run(file); // A save that no longer mentions the trashed receipt must not drop its link. - budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [] }); + await budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [] }); expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file, item.id)).toEqual({ c: 1 }); }); - it('an edit that keeps a receipt does not churn its link row', () => { + it('an edit that keeps a receipt does not churn its link row', async () => { const { user: alice } = createUser(testDb); const trip = createTrip(testDb, alice.id); const file = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'keep.pdf', 'keep.pdf').lastInsertRowid); - const item = budget.createBudgetItem(trip.id, { name: 'Hotel', receipt_file_ids: [file] }); + const item = await budget.createBudgetItem(trip.id, { name: 'Hotel', receipt_file_ids: [file] }); const before = testDb.prepare('SELECT id FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file, item.id) as { id: number }; - budget.updateBudgetItem(item.id, trip.id, { name: 'Hotel 2', receipt_file_ids: [file] }); + await budget.updateBudgetItem(item.id, trip.id, { name: 'Hotel 2', receipt_file_ids: [file] }); const after = testDb.prepare('SELECT id FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file, item.id) as { id: number }; expect(after.id).toBe(before.id); }); diff --git a/server/tests/unit/nest/budget.service.test.ts b/server/tests/unit/nest/budget.service.test.ts index e0bc89146..d134e0a12 100644 --- a/server/tests/unit/nest/budget.service.test.ts +++ b/server/tests/unit/nest/budget.service.test.ts @@ -39,9 +39,17 @@ const exchangeRatesStub = { getRates } as unknown as ExchangeRatesService; import { BudgetService } from '../../../src/nest/budget/budget.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; + +/** + * There is no database behind this suite — every statement is served by the + * prepare stub above — so the UnitOfWork is a pass-through: it runs the callback + * as MikroORM would, with no transaction to open on. + */ +const uowStub = { transactional: (fn: () => Promise) => fn() } as unknown as UnitOfWork; function svc() { - return new BudgetService(new DatabaseService(dbConn), permissionsStub, exchangeRatesStub, new RealtimeService()); + return new BudgetService(new DatabaseService(dbConn), permissionsStub, exchangeRatesStub, new RealtimeService(), uowStub); } beforeEach(() => { @@ -50,9 +58,9 @@ beforeEach(() => { }); describe('BudgetService', () => { - it('verifyTripAccess resolves through DatabaseService.canAccessTrip', () => { + it('verifyTripAccess resolves through DatabaseService.canAccessTrip', async () => { canAccessTrip.mockReturnValue({ id: 5, user_id: 2 }); - expect(svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 }); + expect(await svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 }); expect(canAccessTrip).toHaveBeenCalledWith('5', 2); }); @@ -73,20 +81,20 @@ describe('BudgetService', () => { expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 1 } }, 'sock'); }); - it('list / perPersonSummary resolve through the folded SQL methods', () => { + it('list / perPersonSummary resolve through the folded SQL methods', async () => { const s = svc(); - const listSpy = vi.spyOn(s, 'listBudgetItems').mockReturnValue([{ id: 1 }] as never); - expect(s.list('5')).toEqual([{ id: 1 }]); + const listSpy = vi.spyOn(s, 'listBudgetItems').mockResolvedValue([{ id: 1 }] as never); + expect(await s.list('5')).toEqual([{ id: 1 }]); expect(listSpy).toHaveBeenCalledWith('5'); - const summarySpy = vi.spyOn(s, 'getPerPersonSummary').mockReturnValue([{ userId: 1 }] as never); - expect(s.perPersonSummary('5')).toEqual([{ userId: 1 }]); + const summarySpy = vi.spyOn(s, 'getPerPersonSummary').mockResolvedValue([{ userId: 1 }] as never); + expect(await s.perPersonSummary('5')).toEqual([{ userId: 1 }]); expect(summarySpy).toHaveBeenCalledWith('5'); }); describe('settlement', () => { it('upper-cases the explicit base and forwards the rates', async () => { const s = svc(); - const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never); + const calcSpy = vi.spyOn(s, 'calculateSettlement').mockResolvedValue({ transfers: [] } as never); getRates.mockResolvedValue({ USD: 1.1 }); await s.settlement('5', 'usd', 'EUR'); expect(getRates).toHaveBeenCalledWith('USD'); @@ -95,7 +103,7 @@ describe('BudgetService', () => { it('falls back to the trip currency when no base is given', async () => { const s = svc(); - const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never); + const calcSpy = vi.spyOn(s, 'calculateSettlement').mockResolvedValue({ transfers: [] } as never); getRates.mockResolvedValue(null); await s.settlement('5', undefined, 'gbp'); expect(getRates).toHaveBeenCalledWith('GBP'); @@ -104,7 +112,7 @@ describe('BudgetService', () => { it('falls back to EUR when neither base nor trip currency is present', async () => { const s = svc(); - const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never); + const calcSpy = vi.spyOn(s, 'calculateSettlement').mockResolvedValue({ transfers: [] } as never); getRates.mockResolvedValue(null); await s.settlement('5', undefined, ''); expect(getRates).toHaveBeenCalledWith('EUR'); @@ -127,14 +135,14 @@ describe('BudgetService', () => { expect(updateSpy).toHaveBeenCalledWith('9', '5', { name: 'X' }); }); - it('remove / setPayers resolve through the folded SQL methods', () => { + it('remove / setPayers resolve through the folded SQL methods', async () => { const s = svc(); - const deleteSpy = vi.spyOn(s, 'deleteBudgetItem').mockReturnValue(true); - expect(s.remove('9', '5')).toBe(true); + const deleteSpy = vi.spyOn(s, 'deleteBudgetItem').mockResolvedValue(true); + expect(await s.remove('9', '5')).toBe(true); expect(deleteSpy).toHaveBeenCalledWith('9', '5'); - const payersSpy = vi.spyOn(s, 'setItemPayers').mockReturnValue({ id: 9 } as never); - s.setPayers('9', '5', [{ user_id: 2, amount: 10 }]); + const payersSpy = vi.spyOn(s, 'setItemPayers').mockResolvedValue({ id: 9 } as never); + await s.setPayers('9', '5', [{ user_id: 2, amount: 10 }]); expect(payersSpy).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]); }); @@ -183,33 +191,33 @@ describe('BudgetService', () => { }); describe('syncReservationPrice', () => { - it('returns early when the reservation is not found', () => { + it('returns early when the reservation is not found', async () => { dbMock._stmt.get.mockReturnValueOnce(undefined); - svc().syncReservationPrice('5', 42, 250, 'sock'); + await svc().syncReservationPrice('5', 42, 250, 'sock'); expect(dbMock._stmt.run).not.toHaveBeenCalled(); expect(broadcast).not.toHaveBeenCalled(); }); - it('merges into existing metadata and broadcasts reservation:updated', () => { + it('merges into existing metadata and broadcasts reservation:updated', async () => { dbMock._stmt.get .mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME"}' }) // lookup .mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME","price":"250"}' }); // reload - svc().syncReservationPrice('5', 42, 250, 'sock'); + await svc().syncReservationPrice('5', 42, 250, 'sock'); const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string); expect(writtenMeta).toEqual({ vendor: 'ACME', price: '250' }); expect(broadcast).toHaveBeenCalledWith('5', 'reservation:updated', { reservation: { id: 42, metadata: '{"vendor":"ACME","price":"250"}' } }, 'sock'); }); - it('starts from an empty object when the reservation has no metadata', () => { + it('starts from an empty object when the reservation has no metadata', async () => { dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: null }).mockReturnValueOnce({ id: 42 }); - svc().syncReservationPrice('5', 42, 99, undefined); + await svc().syncReservationPrice('5', 42, 99, undefined); const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string); expect(writtenMeta).toEqual({ price: '99' }); }); - it('swallows errors so a sync failure never breaks the budget update', () => { + it('swallows errors so a sync failure never breaks the budget update', async () => { dbMock.prepare.mockImplementationOnce(() => { throw new Error('db gone'); }); - expect(() => svc().syncReservationPrice('5', 42, 250, 'sock')).not.toThrow(); + await expect(svc().syncReservationPrice('5', 42, 250, 'sock')).resolves.toBeUndefined(); expect(broadcast).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index 311397dd6..4acb12ed1 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -67,7 +67,7 @@ const dbs = () => new DatabaseService(testDb); let budgetSvc: BudgetService; let svc: CalendarService; beforeAll(async () => { - budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService()); + budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbs().connection)); svc = new CalendarService( 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)), diff --git a/server/tests/unit/nest/day-notes.service.test.ts b/server/tests/unit/nest/day-notes.service.test.ts index 959c28490..eb62081f7 100644 --- a/server/tests/unit/nest/day-notes.service.test.ts +++ b/server/tests/unit/nest/day-notes.service.test.ts @@ -82,126 +82,126 @@ function seedTripAndDay() { // ── verifyTripAccess ────────────────────────────────────────────────────────── describe('verifyTripAccess', () => { - it('DAYNOTE-SVC-001: returns trip for owner', () => { + it('DAYNOTE-SVC-001: returns trip for owner', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const result = svc.verifyTripAccess(trip.id, user.id); + const result = await svc.verifyTripAccess(trip.id, user.id); expect(result).toBeDefined(); expect(result?.id).toBe(trip.id); }); - it('DAYNOTE-SVC-002: returns nothing for non-member', () => { + it('DAYNOTE-SVC-002: returns nothing for non-member', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); - expect(svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy(); + expect(await svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy(); }); - it('DAYNOTE-SVC-003: returns trip for member', () => { + it('DAYNOTE-SVC-003: returns trip for member', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); - expect(svc.verifyTripAccess(trip.id, member.id)).toBeDefined(); + expect(await svc.verifyTripAccess(trip.id, member.id)).toBeDefined(); }); }); // ── dayExists ───────────────────────────────────────────────────────────────── describe('dayExists', () => { - it('DAYNOTE-SVC-004: truthy only when the day belongs to the trip', () => { + it('DAYNOTE-SVC-004: truthy only when the day belongs to the trip', async () => { const { trip, day } = seedTripAndDay(); const { user: other } = createUser(testDb); const otherTrip = createTrip(testDb, other.id); - expect(svc.dayExists(day.id, trip.id)).toBeTruthy(); - expect(svc.dayExists(day.id, otherTrip.id)).toBeFalsy(); - expect(svc.dayExists(9999, trip.id)).toBeFalsy(); + expect(await svc.dayExists(day.id, trip.id)).toBeTruthy(); + expect(await svc.dayExists(day.id, otherTrip.id)).toBeFalsy(); + expect(await svc.dayExists(9999, trip.id)).toBeFalsy(); }); }); // ── create ──────────────────────────────────────────────────────────────────── describe('create', () => { - it('DAYNOTE-SVC-005: inserts and returns the re-selected row', () => { + it('DAYNOTE-SVC-005: inserts and returns the re-selected row', async () => { const { trip, day } = seedTripAndDay(); - const note = svc.create(day.id, trip.id, 'Lunch', '12:00', '🍜', 2) as DayNote; + const note = await svc.create(day.id, trip.id, 'Lunch', '12:00', '🍜', 2) as DayNote; expect(note).toMatchObject({ day_id: day.id, trip_id: trip.id, text: 'Lunch', time: '12:00', icon: '🍜', sort_order: 2 }); expect(note.id).toBeGreaterThan(0); const row = testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(note.id); expect(row).toEqual(note); }); - it('DAYNOTE-SVC-006: trims the text on insert', () => { + it('DAYNOTE-SVC-006: trims the text on insert', async () => { const { trip, day } = seedTripAndDay(); - const note = svc.create(day.id, trip.id, ' Lunch ') as DayNote; + const note = await svc.create(day.id, trip.id, ' Lunch ') as DayNote; expect(note.text).toBe('Lunch'); }); - it('DAYNOTE-SVC-007: empty-string time coerces to NULL (`||`, not `??`)', () => { + it('DAYNOTE-SVC-007: empty-string time coerces to NULL (`||`, not `??`)', async () => { const { trip, day } = seedTripAndDay(); - expect((svc.create(day.id, trip.id, 'a', '') as DayNote).time).toBeNull(); - expect((svc.create(day.id, trip.id, 'b', undefined) as DayNote).time).toBeNull(); - expect((svc.create(day.id, trip.id, 'c', null) as DayNote).time).toBeNull(); + expect((await svc.create(day.id, trip.id, 'a', '') as DayNote).time).toBeNull(); + expect((await svc.create(day.id, trip.id, 'b', undefined) as DayNote).time).toBeNull(); + expect((await svc.create(day.id, trip.id, 'c', null) as DayNote).time).toBeNull(); }); - it('DAYNOTE-SVC-008: empty/absent icon falls back to 📝 (`||`, not `??`)', () => { + it('DAYNOTE-SVC-008: empty/absent icon falls back to 📝 (`||`, not `??`)', async () => { const { trip, day } = seedTripAndDay(); - expect((svc.create(day.id, trip.id, 'a', undefined, '') as DayNote).icon).toBe('📝'); - expect((svc.create(day.id, trip.id, 'b') as DayNote).icon).toBe('📝'); - expect((svc.create(day.id, trip.id, 'c', undefined, null) as DayNote).icon).toBe('📝'); + expect((await svc.create(day.id, trip.id, 'a', undefined, '') as DayNote).icon).toBe('📝'); + expect((await svc.create(day.id, trip.id, 'b') as DayNote).icon).toBe('📝'); + expect((await svc.create(day.id, trip.id, 'c', undefined, null) as DayNote).icon).toBe('📝'); }); - it('DAYNOTE-SVC-009: sort_order 0 is preserved, only undefined defaults to 9999 (`??`)', () => { + it('DAYNOTE-SVC-009: sort_order 0 is preserved, only undefined defaults to 9999 (`??`)', async () => { const { trip, day } = seedTripAndDay(); - expect((svc.create(day.id, trip.id, 'a', undefined, undefined, 0) as DayNote).sort_order).toBe(0); - expect((svc.create(day.id, trip.id, 'b') as DayNote).sort_order).toBe(9999); + expect((await svc.create(day.id, trip.id, 'a', undefined, undefined, 0) as DayNote).sort_order).toBe(0); + expect((await svc.create(day.id, trip.id, 'b') as DayNote).sort_order).toBe(9999); }); }); // ── colour (#1629) ──────────────────────────────────────────────────────────── describe('note colours', () => { - it('DAYNOTE-SVC-017: a palette colour is stored as given', () => { + it('DAYNOTE-SVC-017: a palette colour is stored as given', async () => { const { trip, day } = seedTripAndDay(); - const note = svc.create(day.id, trip.id, 'Passport check', null, null, 0, '#dc2626') as DayNote; + const note = await svc.create(day.id, trip.id, 'Passport check', null, null, 0, '#dc2626') as DayNote; expect(note.color).toBe('#dc2626'); }); - it('DAYNOTE-SVC-018: no colour is the default, and stays NULL rather than empty string', () => { + it('DAYNOTE-SVC-018: no colour is the default, and stays NULL rather than empty string', async () => { const { trip, day } = seedTripAndDay(); - expect((svc.create(day.id, trip.id, 'a') as DayNote).color).toBeNull(); - expect((svc.create(day.id, trip.id, 'b', null, null, 0, null) as DayNote).color).toBeNull(); - expect((svc.create(day.id, trip.id, 'c', null, null, 0, '') as DayNote).color).toBeNull(); + expect((await svc.create(day.id, trip.id, 'a') as DayNote).color).toBeNull(); + expect((await svc.create(day.id, trip.id, 'b', null, null, 0, null) as DayNote).color).toBeNull(); + expect((await svc.create(day.id, trip.id, 'c', null, null, 0, '') as DayNote).color).toBeNull(); }); - it('DAYNOTE-SVC-019: a colour outside the palette is dropped, not stored', () => { + it('DAYNOTE-SVC-019: a colour outside the palette is dropped, not stored', async () => { const { trip, day } = seedTripAndDay(); // Anything can reach this: the Zod contract can only say "a short string". - expect((svc.create(day.id, trip.id, 'a', null, null, 0, '#123456') as DayNote).color).toBeNull(); - expect((svc.create(day.id, trip.id, 'b', null, null, 0, 'red') as DayNote).color).toBeNull(); - expect((svc.create(day.id, trip.id, 'c', null, null, 0, 'javascript:x') as DayNote).color).toBeNull(); + expect((await svc.create(day.id, trip.id, 'a', null, null, 0, '#123456') as DayNote).color).toBeNull(); + expect((await svc.create(day.id, trip.id, 'b', null, null, 0, 'red') as DayNote).color).toBeNull(); + expect((await svc.create(day.id, trip.id, 'c', null, null, 0, 'javascript:x') as DayNote).color).toBeNull(); }); - it('DAYNOTE-SVC-020: the note survives a bad colour instead of being rejected', () => { + it('DAYNOTE-SVC-020: the note survives a bad colour instead of being rejected', async () => { const { trip, day } = seedTripAndDay(); - const note = svc.create(day.id, trip.id, 'Ferry at six', '18:00', null, 0, 'nonsense') as DayNote; + const note = await svc.create(day.id, trip.id, 'Ferry at six', '18:00', null, 0, 'nonsense') as DayNote; expect(note.text).toBe('Ferry at six'); expect(note.time).toBe('18:00'); }); - it('DAYNOTE-SVC-021: update changes the colour and can clear it again', () => { + it('DAYNOTE-SVC-021: update changes the colour and can clear it again', async () => { const { trip, day } = seedTripAndDay(); - const note = svc.create(day.id, trip.id, 'Museum', null, null, 0, '#2563eb') as DayNote; + const note = await svc.create(day.id, trip.id, 'Museum', null, null, 0, '#2563eb') as DayNote; - expect((svc.update(note.id, note, { color: '#16a34a' }) as DayNote).color).toBe('#16a34a'); - expect((svc.update(note.id, svc.getNote(note.id, day.id, trip.id)!, { color: null }) as DayNote).color).toBeNull(); + expect((await svc.update(note.id, note, { color: '#16a34a' }) as DayNote).color).toBe('#16a34a'); + expect((await svc.update(note.id, (await svc.getNote(note.id, day.id, trip.id))!, { color: null }) as DayNote).color).toBeNull(); }); - it('DAYNOTE-SVC-022: an update that says nothing about the colour keeps it', () => { + it('DAYNOTE-SVC-022: an update that says nothing about the colour keeps it', async () => { const { trip, day } = seedTripAndDay(); - const note = svc.create(day.id, trip.id, 'Museum', null, null, 0, '#9333ea') as DayNote; + const note = await svc.create(day.id, trip.id, 'Museum', null, null, 0, '#9333ea') as DayNote; - const updated = svc.update(note.id, note, { text: 'Museum, book ahead' }) as DayNote; + const updated = await svc.update(note.id, note, { text: 'Museum, book ahead' }) as DayNote; expect(updated.text).toBe('Museum, book ahead'); expect(updated.color).toBe('#9333ea'); @@ -211,18 +211,18 @@ describe('note colours', () => { // ── list ────────────────────────────────────────────────────────────────────── describe('list', () => { - it('DAYNOTE-SVC-010: returns [] for a day without notes', () => { + it('DAYNOTE-SVC-010: returns [] for a day without notes', async () => { const { trip, day } = seedTripAndDay(); - expect(svc.list(day.id, trip.id)).toEqual([]); + expect(await svc.list(day.id, trip.id)).toEqual([]); }); - it('DAYNOTE-SVC-011: orders by sort_order then created_at and scopes to day+trip', () => { + it('DAYNOTE-SVC-011: orders by sort_order then created_at and scopes to day+trip', async () => { const { trip, day } = seedTripAndDay(); const otherDay = createDay(testDb, trip.id); - const second = svc.create(day.id, trip.id, 'second', undefined, undefined, 5) as DayNote; - const first = svc.create(day.id, trip.id, 'first', undefined, undefined, 1) as DayNote; - svc.create(otherDay.id, trip.id, 'elsewhere'); - const notes = svc.list(day.id, trip.id) as DayNote[]; + const second = await svc.create(day.id, trip.id, 'second', undefined, undefined, 5) as DayNote; + const first = await svc.create(day.id, trip.id, 'first', undefined, undefined, 1) as DayNote; + await svc.create(otherDay.id, trip.id, 'elsewhere'); + const notes = await svc.list(day.id, trip.id) as DayNote[]; expect(notes.map((n) => n.id)).toEqual([first.id, second.id]); }); }); @@ -230,39 +230,39 @@ describe('list', () => { // ── getNote ─────────────────────────────────────────────────────────────────── describe('getNote', () => { - it('DAYNOTE-SVC-012: returns the note only under its own day and trip', () => { + it('DAYNOTE-SVC-012: returns the note only under its own day and trip', async () => { const { trip, day } = seedTripAndDay(); const otherDay = createDay(testDb, trip.id); - const note = svc.create(day.id, trip.id, 'Lunch') as DayNote; - expect(svc.getNote(note.id, day.id, trip.id)).toEqual(note); - expect(svc.getNote(note.id, otherDay.id, trip.id)).toBeUndefined(); - expect(svc.getNote(note.id, day.id, trip.id + 1)).toBeUndefined(); + const note = await svc.create(day.id, trip.id, 'Lunch') as DayNote; + expect(await svc.getNote(note.id, day.id, trip.id)).toEqual(note); + expect(await svc.getNote(note.id, otherDay.id, trip.id)).toBeUndefined(); + expect(await svc.getNote(note.id, day.id, trip.id + 1)).toBeUndefined(); }); }); // ── update ──────────────────────────────────────────────────────────────────── describe('update', () => { - it('DAYNOTE-SVC-013: merges omitted fields from the current row (JS-side, full-row UPDATE)', () => { + it('DAYNOTE-SVC-013: merges omitted fields from the current row (JS-side, full-row UPDATE)', async () => { const { trip, day } = seedTripAndDay(); - const note = svc.create(day.id, trip.id, 'Lunch', '12:00', '🍜', 2) as DayNote; - const updated = svc.update(note.id, note, { icon: '🍣' }) as DayNote; + const note = await svc.create(day.id, trip.id, 'Lunch', '12:00', '🍜', 2) as DayNote; + const updated = await svc.update(note.id, note, { icon: '🍣' }) as DayNote; expect(updated).toMatchObject({ id: note.id, text: 'Lunch', time: '12:00', icon: '🍣', sort_order: 2 }); }); - it('DAYNOTE-SVC-014: explicit null time clears it, undefined keeps it', () => { + it('DAYNOTE-SVC-014: explicit null time clears it, undefined keeps it', async () => { const { trip, day } = seedTripAndDay(); - const note = svc.create(day.id, trip.id, 'Lunch', '12:00') as DayNote; - const kept = svc.update(note.id, note, { text: 'Lunch!' }) as DayNote; + const note = await svc.create(day.id, trip.id, 'Lunch', '12:00') as DayNote; + const kept = await svc.update(note.id, note, { text: 'Lunch!' }) as DayNote; expect(kept.time).toBe('12:00'); - const cleared = svc.update(note.id, kept, { time: null }) as DayNote; + const cleared = await svc.update(note.id, kept, { time: null }) as DayNote; expect(cleared.time).toBeNull(); }); - it('DAYNOTE-SVC-015: trims only the new text and preserves sort_order 0 writes', () => { + it('DAYNOTE-SVC-015: trims only the new text and preserves sort_order 0 writes', async () => { const { trip, day } = seedTripAndDay(); - const note = svc.create(day.id, trip.id, 'Lunch', undefined, undefined, 5) as DayNote; - const updated = svc.update(note.id, note, { text: ' Dinner ', sort_order: 0 }) as DayNote; + const note = await svc.create(day.id, trip.id, 'Lunch', undefined, undefined, 5) as DayNote; + const updated = await svc.update(note.id, note, { text: ' Dinner ', sort_order: 0 }) as DayNote; expect(updated.text).toBe('Dinner'); expect(updated.sort_order).toBe(0); }); @@ -271,10 +271,10 @@ describe('update', () => { // ── remove ──────────────────────────────────────────────────────────────────── describe('remove', () => { - it('DAYNOTE-SVC-016: deletes by bare id (trip-scoping is the caller getNote)', () => { + it('DAYNOTE-SVC-016: deletes by bare id (trip-scoping is the caller getNote)', async () => { const { trip, day } = seedTripAndDay(); - const note = svc.create(day.id, trip.id, 'Lunch') as DayNote; - svc.remove(note.id); + const note = await svc.create(day.id, trip.id, 'Lunch') as DayNote; + await svc.remove(note.id); expect(testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(note.id)).toBeUndefined(); }); }); diff --git a/server/tests/unit/nest/days.controller.test.ts b/server/tests/unit/nest/days.controller.test.ts index 7c04692c2..6c4c3ce00 100644 --- a/server/tests/unit/nest/days.controller.test.ts +++ b/server/tests/unit/nest/days.controller.test.ts @@ -12,8 +12,8 @@ import type { User } from '../../../src/types'; const user = { id: 1, role: 'user', email: 'u@example.test' } as User; const trip = { user_id: 1 }; -function thrown(fn: () => unknown): { status: number; body: unknown } { - try { fn(); } catch (err) { +async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> { + try { await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; return { status: e.getStatus(), body: e.getResponse() }; @@ -42,23 +42,23 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)' // for the whole controller now, so a handler is only ever reached for a trip the // user may see. What is left here is what the handlers themselves still decide. - it('GET / returns the list service result verbatim (the { days } envelope)', () => { - const svc = daysSvc({ list: vi.fn().mockReturnValue({ days: [{ id: 1 }] }) } as Partial); - expect(new DaysController(svc).list(user, '5')).toEqual({ days: [{ id: 1 }] }); + it('GET / returns the list service result verbatim (the { days } envelope)', async () => { + const svc = daysSvc({ list: vi.fn().mockResolvedValue({ days: [{ id: 1 }] }) } as Partial); + expect(await new DaysController(svc).list(user, '5')).toEqual({ days: [{ id: 1 }] }); }); - it('POST / creates + broadcasts', () => { - const create = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn(); - expect(new DaysController(daysSvc({ create, broadcast } as Partial)).create(user, '5', { date: '2026-07-01' }, 'sock')).toEqual({ day: { id: 9 } }); + it('POST / creates + broadcasts', async () => { + const create = vi.fn().mockResolvedValue({ id: 9 }); const broadcast = vi.fn(); + expect(await new DaysController(daysSvc({ create, broadcast } as Partial)).create(user, '5', { date: '2026-07-01' }, 'sock')).toEqual({ day: { id: 9 } }); expect(create).toHaveBeenCalledWith('5', '2026-07-01', undefined); expect(broadcast).toHaveBeenCalledWith('5', 'day:created', { day: { id: 9 } }, 'sock'); }); - it('POST / with a position inserts + broadcasts day:reordered', () => { - const insert = vi.fn().mockReturnValue({ id: 12 }); const create = vi.fn(); const broadcast = vi.fn(); + it('POST / with a position inserts + broadcasts day:reordered', async () => { + const insert = vi.fn().mockResolvedValue({ id: 12 }); const create = vi.fn(); const broadcast = vi.fn(); const svc = daysSvc({ insert, create, broadcast } as Partial); - expect(new DaysController(svc).create(user, '5', { position: 0 }, 'sock')).toEqual({ day: { id: 12 } }); + expect(await new DaysController(svc).create(user, '5', { position: 0 }, 'sock')).toEqual({ day: { id: 12 } }); expect(insert).toHaveBeenCalledWith('5', 0); expect(create).not.toHaveBeenCalled(); expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { day: { id: 12 } }, 'sock'); @@ -67,18 +67,18 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)' describe('PUT /reorder', () => { - it('400 when orderedIds is missing', () => { - expect(thrown(() => new DaysController(daysSvc()).reorder(user, '5', rawReorderBody({})))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } }); + it('400 when orderedIds is missing', async () => { + expect(await thrown(() => new DaysController(daysSvc()).reorder(user, '5', rawReorderBody({})))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } }); }); - it('400 when orderedIds is not an array', () => { - expect(thrown(() => new DaysController(daysSvc()).reorder(user, '5', rawReorderBody({ orderedIds: 'nope' })))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } }); + it('400 when orderedIds is not an array', async () => { + expect(await thrown(() => new DaysController(daysSvc()).reorder(user, '5', rawReorderBody({ orderedIds: 'nope' })))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } }); }); - it('maps a DayReorderError to 400 with its message', () => { + it('maps a DayReorderError to 400 with its message', async () => { const reorder = vi.fn(() => { throw new DayReorderError('orderedIds must be a permutation of the trip day ids.'); }); const svc = daysSvc({ reorder } as Partial); - expect(thrown(() => new DaysController(svc).reorder(user, '5', { orderedIds: [9] }))).toEqual({ + expect(await thrown(() => new DaysController(svc).reorder(user, '5', { orderedIds: [9] }))).toEqual({ status: 400, body: { error: 'orderedIds must be a permutation of the trip day ids.' }, }); }); @@ -99,17 +99,17 @@ describe('DaysController (parity with the legacy /api/trips/:tripId/days route)' }); }); - it('PUT /:id 404 when the day is missing, else updates', () => { - expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial)).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Day not found' } }); - const update = vi.fn().mockReturnValue({ id: 9, title: 'T' }); - const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), update } as Partial); - expect(new DaysController(svc).update(user, '5', '9', { title: 'T' })).toEqual({ day: { id: 9, title: 'T' } }); + it('PUT /:id 404 when the day is missing, else updates', async () => { + expect(await thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockResolvedValue(undefined) } as Partial)).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Day not found' } }); + const update = vi.fn().mockResolvedValue({ id: 9, title: 'T' }); + const svc = daysSvc({ getDay: vi.fn().mockResolvedValue({ id: 9 }), update } as Partial); + expect(await new DaysController(svc).update(user, '5', '9', { title: 'T' })).toEqual({ day: { id: 9, title: 'T' } }); }); - it('DELETE /:id 404 when missing, else success', () => { - expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial)).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Day not found' } }); - const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), remove: vi.fn() } as Partial); - expect(new DaysController(svc).remove(user, '5', '9')).toEqual({ success: true }); + it('DELETE /:id 404 when missing, else success', async () => { + expect(await thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockResolvedValue(undefined) } as Partial)).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Day not found' } }); + const svc = daysSvc({ getDay: vi.fn().mockResolvedValue({ id: 9 }), remove: vi.fn() } as Partial); + expect(await new DaysController(svc).remove(user, '5', '9')).toEqual({ success: true }); }); }); @@ -131,31 +131,31 @@ describe('DayNotesController (parity with the legacy /api/.../days/:dayId/notes // Trip access and day_edit are TripAccessGuard's now (trip-access.guard.test.ts); // what stays here is what this handler decides for itself. - it('404 day, 400 empty text, then creates', () => { - expect(thrown(() => new DayNotesController(notesSvc({ dayExists: vi.fn().mockReturnValue(false) } as Partial)).create(user, '5', '3', { text: 'ok' }))).toEqual({ status: 404, body: { error: 'Day not found' } }); - expect(thrown(() => new DayNotesController(notesSvc({ dayExists: vi.fn().mockReturnValue(true) } as Partial)).create(user, '5', '3', { text: ' ' }))).toEqual({ status: 400, body: { error: 'Text required' } }); - const create = vi.fn().mockReturnValue({ id: 7 }); const broadcast = vi.fn(); - const svc = notesSvc({ dayExists: vi.fn().mockReturnValue(true), create, broadcast } as Partial); - expect(new DayNotesController(svc).create(user, '5', '3', { text: 'Lunch', time: '12:00' }, 'sock')).toEqual({ note: { id: 7 } }); + it('404 day, 400 empty text, then creates', async () => { + expect(await thrown(() => new DayNotesController(notesSvc({ dayExists: vi.fn().mockResolvedValue(false) } as Partial)).create(user, '5', '3', { text: 'ok' }))).toEqual({ status: 404, body: { error: 'Day not found' } }); + expect(await thrown(() => new DayNotesController(notesSvc({ dayExists: vi.fn().mockResolvedValue(true) } as Partial)).create(user, '5', '3', { text: ' ' }))).toEqual({ status: 400, body: { error: 'Text required' } }); + const create = vi.fn().mockResolvedValue({ id: 7 }); const broadcast = vi.fn(); + const svc = notesSvc({ dayExists: vi.fn().mockResolvedValue(true), create, broadcast } as Partial); + expect(await new DayNotesController(svc).create(user, '5', '3', { text: 'Lunch', time: '12:00' }, 'sock')).toEqual({ note: { id: 7 } }); expect(create).toHaveBeenCalledWith('3', '5', 'Lunch', '12:00', undefined, undefined, undefined); expect(broadcast).toHaveBeenCalledWith('5', 'dayNote:created', { dayId: 3, note: { id: 7 } }, 'sock'); }); - it('GET / returns notes; PUT/DELETE 404 when the note is missing', () => { - const svc = notesSvc({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); - expect(new DayNotesController(svc).list(user, '5', '3')).toEqual({ notes: [{ id: 1 }] }); - expect(thrown(() => new DayNotesController(notesSvc({ getNote: vi.fn().mockReturnValue(undefined) } as Partial)).update(user, '5', '3', '9', { text: 'x' }))).toEqual({ status: 404, body: { error: 'Note not found' } }); - expect(thrown(() => new DayNotesController(notesSvc({ getNote: vi.fn().mockReturnValue(undefined) } as Partial)).remove(user, '5', '3', '9'))).toEqual({ status: 404, body: { error: 'Note not found' } }); + it('GET / returns notes; PUT/DELETE 404 when the note is missing', async () => { + const svc = notesSvc({ list: vi.fn().mockResolvedValue([{ id: 1 }]) } as Partial); + expect(await new DayNotesController(svc).list(user, '5', '3')).toEqual({ notes: [{ id: 1 }] }); + expect(await thrown(() => new DayNotesController(notesSvc({ getNote: vi.fn().mockResolvedValue(undefined) } as Partial)).update(user, '5', '3', '9', { text: 'x' }))).toEqual({ status: 404, body: { error: 'Note not found' } }); + expect(await thrown(() => new DayNotesController(notesSvc({ getNote: vi.fn().mockResolvedValue(undefined) } as Partial)).remove(user, '5', '3', '9'))).toEqual({ status: 404, body: { error: 'Note not found' } }); }); - it('PUT/DELETE update + delete a note with broadcasts', () => { - const update = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn(); - const u = notesSvc({ getNote: vi.fn().mockReturnValue({ id: 9 }), update, broadcast } as Partial); - expect(new DayNotesController(u).update(user, '5', '3', '9', { text: 'x' }, 'sock')).toEqual({ note: { id: 9 } }); + it('PUT/DELETE update + delete a note with broadcasts', async () => { + const update = vi.fn().mockResolvedValue({ id: 9 }); const broadcast = vi.fn(); + const u = notesSvc({ getNote: vi.fn().mockResolvedValue({ id: 9 }), update, broadcast } as Partial); + expect(await new DayNotesController(u).update(user, '5', '3', '9', { text: 'x' }, 'sock')).toEqual({ note: { id: 9 } }); expect(broadcast).toHaveBeenCalledWith('5', 'dayNote:updated', { dayId: 3, note: { id: 9 } }, 'sock'); const remove = vi.fn(); const b2 = vi.fn(); - const d = notesSvc({ getNote: vi.fn().mockReturnValue({ id: 9 }), remove, broadcast: b2 } as Partial); - expect(new DayNotesController(d).remove(user, '5', '3', '9', 'sock')).toEqual({ success: true }); + const d = notesSvc({ getNote: vi.fn().mockResolvedValue({ id: 9 }), remove, broadcast: b2 } as Partial); + expect(await new DayNotesController(d).remove(user, '5', '3', '9', 'sock')).toEqual({ success: true }); expect(b2).toHaveBeenCalledWith('5', 'dayNote:deleted', { noteId: 9, dayId: 3 }, 'sock'); }); }); diff --git a/server/tests/unit/nest/days.service.test.ts b/server/tests/unit/nest/days.service.test.ts index 94a184a2f..e8921dca9 100644 --- a/server/tests/unit/nest/days.service.test.ts +++ b/server/tests/unit/nest/days.service.test.ts @@ -58,8 +58,8 @@ import { PermissionsService } from '../../../src/nest/permissions/permissions.se import { DaysService, DayReorderError, addDays } from '../../../src/nest/days/days.service'; // Was days.bridge, deleted with the other three that had no consumer outside the // container. The assertions stayed; they point at the service now. -const bridgeGetDay = (id: string | number, tripId: string | number) => svc.getDay(id, tripId); -const bridgeListDays = (tripId: string | number) => svc.list(tripId); +const bridgeGetDay = async (id: string | number, tripId: string | number) => await svc.getDay(id, tripId); +const bridgeListDays = async (tripId: string | number) => await svc.list(tripId); import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import { makeAccommodationsService } from '../../helpers/accommodations-service'; @@ -69,7 +69,7 @@ import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; 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))); + svc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), new QueryHelpersService(new DatabaseService(testDb)), await createTestUnitOfWork(testDb)); }); let accommodations: Awaited>; beforeAll(async () => { @@ -92,19 +92,19 @@ afterAll(() => { // ── verifyTripAccess ────────────────────────────────────────────────────────── describe('verifyTripAccess', () => { - it('DAY-SVC-001 — returns trip row for owner', () => { + it('DAY-SVC-001 — returns trip row for owner', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const result = svc.verifyTripAccess(trip.id, user.id) as any; + const result = await svc.verifyTripAccess(trip.id, user.id) as any; expect(result).toBeDefined(); expect(result.id).toBe(trip.id); }); - it('DAY-SVC-002 — returns falsy for non-member', () => { + it('DAY-SVC-002 — returns falsy for non-member', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); - expect(svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy(); + expect(await svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy(); }); }); @@ -199,19 +199,19 @@ describe('list', () => { // ── create ──────────────────────────────────────────────────────────────────── describe('create (service)', () => { - it('DAY-SVC-009 — creates a day with auto-incremented day_number', () => { + it('DAY-SVC-009 — creates a day with auto-incremented day_number', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const d1 = svc.create(trip.id) as any; - const d2 = svc.create(trip.id) as any; + const d1 = await svc.create(trip.id) as any; + const d2 = await svc.create(trip.id) as any; expect(d1.day_number).toBe(1); expect(d2.day_number).toBe(2); }); - it('DAY-SVC-010 — returns day with empty assignments array', () => { + it('DAY-SVC-010 — returns day with empty assignments array', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const day = svc.create(trip.id) as any; + const day = await svc.create(trip.id) as any; expect(Array.isArray(day.assignments)).toBe(true); expect(day.assignments).toHaveLength(0); }); @@ -220,19 +220,19 @@ describe('create (service)', () => { // ── getDay / update / remove ────────────────────────────────────────────────── describe('getDay', () => { - it('DAY-SVC-011 — returns day when id and tripId match', () => { + it('DAY-SVC-011 — returns day when id and tripId match', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; - const found = svc.getDay(day.id, trip.id) as any; + const found = await svc.getDay(day.id, trip.id) as any; expect(found).toBeDefined(); expect(found.id).toBe(day.id); }); - it('DAY-SVC-012 — returns undefined for non-existent day', () => { + it('DAY-SVC-012 — returns undefined for non-existent day', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - expect(svc.getDay(99999, trip.id)).toBeUndefined(); + expect(await svc.getDay(99999, trip.id)).toBeUndefined(); }); }); @@ -256,12 +256,12 @@ describe('update', () => { }); describe('remove', () => { - it('DAY-SVC-015 — deletes the day', () => { + it('DAY-SVC-015 — deletes the day', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id) as any; - svc.remove(day.id); - expect(svc.getDay(day.id, trip.id)).toBeUndefined(); + await svc.remove(day.id); + expect(await svc.getDay(day.id, trip.id)).toBeUndefined(); }); }); @@ -282,12 +282,12 @@ describe('remove', () => { // 029/031/032 pin the same behavior on the service. describe('DaysService — the surface the deleted bridge exposed', () => { - it('DAY-SVC-027 — getDay delegates to DaysService.getDay', () => { + it('DAY-SVC-027 — getDay delegates to DaysService.getDay', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); - expect(bridgeGetDay(day.id, trip.id)!.id).toBe(day.id); - expect(bridgeGetDay(99999, trip.id)).toBeUndefined(); + expect((await bridgeGetDay(day.id, trip.id))!.id).toBe(day.id); + expect(await bridgeGetDay(99999, trip.id)).toBeUndefined(); }); it('DAY-SVC-028 — listDays delegates to DaysService.list', async () => { @@ -305,7 +305,7 @@ describe('DaysService — the surface the deleted bridge exposed', () => { expect(addDays('2026-06-07', -7)).toBe('2026-05-31'); }); - it('DAY-SVC-031 — restampReservationDates re-stamps a booking onto its day\'s new date', () => { + it('DAY-SVC-031 — restampReservationDates re-stamps a booking onto its day\'s new date', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { date: '2026-01-01' }); @@ -313,7 +313,7 @@ describe('DaysService — the surface the deleted bridge exposed', () => { 'INSERT INTO reservations (trip_id, day_id, title, reservation_time) VALUES (?, ?, ?, ?)' ).run(trip.id, day.id, 'Dinner', '2026-01-01T19:00'); - svc.restampReservationDates( + await svc.restampReservationDates( trip.id, new Map([[day.id, '2026-01-01']]), new Map([[day.id, '2026-01-05']]), @@ -323,7 +323,7 @@ describe('DaysService — the surface the deleted bridge exposed', () => { expect(row.reservation_time).toBe('2026-01-05T19:00'); }); - it('DAY-SVC-032 — resyncAccommodationDays re-anchors a stay to the day holding its old date', () => { + it('DAY-SVC-032 — resyncAccommodationDays re-anchors a stay to the day holding its old date', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const d1 = createDay(testDb, trip.id, { date: '2026-01-01' }); @@ -338,7 +338,7 @@ describe('DaysService — the surface the deleted bridge exposed', () => { testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-01-01', d2.id); testDb.prepare('UPDATE days SET day_number = 0 WHERE id = ?').run(d2.id); - svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-01-01'], [d2.id, '2026-01-02']])); + await svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-01-01'], [d2.id, '2026-01-02']])); const row = testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(accom.id) as { start_day_id: number; end_day_id: number }; expect(row.start_day_id).toBe(d2.id); @@ -411,7 +411,7 @@ describe('quirk fixes', () => { // jumps to the wrong day. describe('restampReservationDates', () => { - it('DAY-SVC-037 — leaves a booking alone when it has no day, no time, or its day did not move', () => { + it('DAY-SVC-037 — leaves a booking alone when it has no day, no time, or its day did not move', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const stayer = createDay(testDb, trip.id, { date: '2026-01-01' }); @@ -425,7 +425,7 @@ describe('restampReservationDates', () => { const offMap = Number(insert.run(trip.id, unmapped.id, 'Off map', '2026-01-02T12:00').lastInsertRowid); const halfOff = Number(insert.run(trip.id, halfMapped.id, 'Half off', '2026-01-03T12:00').lastInsertRowid); - svc.restampReservationDates( + await svc.restampReservationDates( trip.id, new Map([[stayer.id, '2026-01-01'], [halfMapped.id, '2026-01-03']]), // stayer keeps its date, unmapped is in neither map, halfMapped only in the old one. @@ -445,7 +445,7 @@ describe('restampReservationDates', () => { }); }); - it('DAY-SVC-038 — shifts every transport leg by the booking\'s day delta and skips a leg with no date', () => { + it('DAY-SVC-038 — shifts every transport leg by the booking\'s day delta and skips a leg with no date', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { date: '2026-01-01' }); @@ -462,7 +462,7 @@ describe('restampReservationDates', () => { const arrival = Number(endpoint.run(flight, 'to', 1, 'NRT', 35.76, 140.38, '2026-01-02').lastInsertRowid); const undated = Number(endpoint.run(flight, 'to', 2, 'Unknown', 0, 0, null).lastInsertRowid); - svc.restampReservationDates(trip.id, new Map([[day.id, '2026-01-01']]), new Map([[day.id, '2026-01-04']])); + await svc.restampReservationDates(trip.id, new Map([[day.id, '2026-01-01']]), new Map([[day.id, '2026-01-04']])); const legs = Object.fromEntries( (testDb.prepare('SELECT id, local_date FROM reservation_endpoints WHERE reservation_id = ?').all(flight) as { id: number; local_date: string | null }[]) @@ -471,7 +471,7 @@ describe('restampReservationDates', () => { expect(legs).toEqual({ [departure]: '2026-01-04', [arrival]: '2026-01-05', [undated]: null }); }); - it('DAY-SVC-039 — follows end_day_id and keeps a date-only end time date-only', () => { + it('DAY-SVC-039 — follows end_day_id and keeps a date-only end time date-only', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const start = createDay(testDb, trip.id, { date: '2026-01-01' }); @@ -483,7 +483,7 @@ describe('restampReservationDates', () => { const spanning = Number(insert.run(trip.id, start.id, end.id, 'Sleeper train', '2026-01-01T15:00', '2026-01-03').lastInsertRowid); const openEnded = Number(insert.run(trip.id, null, end.id, 'Open ended', null, null).lastInsertRowid); - svc.restampReservationDates( + await svc.restampReservationDates( trip.id, new Map([[start.id, '2026-01-01'], [end.id, '2026-01-03']]), new Map([[start.id, '2026-01-02'], [end.id, '2026-01-05']]), @@ -506,19 +506,19 @@ describe('resyncAccommodationDays', () => { "INSERT INTO reservations (trip_id, day_id, title, type, accommodation_id, reservation_time) VALUES (?, ?, 'Hotel', 'hotel', ?, ?)" ).run(tripId, dayId, accId, time).lastInsertRowid); - it('DAY-SVC-040 — returns before touching a hotel booking when the trip has no stays', () => { + it('DAY-SVC-040 — returns before touching a hotel booking when the trip has no stays', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { date: '2026-05-01' }); const res = linkHotel(trip.id, 4242, null, null); - svc.resyncAccommodationDays(trip.id, new Map([[day.id, '2026-04-01']])); + await svc.resyncAccommodationDays(trip.id, new Map([[day.id, '2026-04-01']])); // No stay owns this booking any more, so the restamp must not adopt it. expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(res)).toMatchObject({ day_id: null }); }); - it('DAY-SVC-041 — leaves a stay glued to its rows when the snapshot has no date for them', () => { + it('DAY-SVC-041 — leaves a stay glued to its rows when the snapshot has no date for them', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const d1 = createDay(testDb, trip.id, { date: '2026-05-01' }); @@ -527,7 +527,7 @@ describe('resyncAccommodationDays', () => { const stay = createDayAccommodation(testDb, trip.id, place.id, d1.id, d2.id); const res = linkHotel(trip.id, stay.id, d2.id, '2026-04-30T14:00'); - svc.resyncAccommodationDays(trip.id, new Map()); + await svc.resyncAccommodationDays(trip.id, new Map()); expect(testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(stay.id)) .toMatchObject({ start_day_id: d1.id, end_day_id: d2.id }); @@ -537,7 +537,7 @@ describe('resyncAccommodationDays', () => { .toMatchObject({ day_id: d1.id, reservation_time: '2026-05-01T14:00' }); }); - it('DAY-SVC-042 — does not rewrite a stay whose days already hold their old dates', () => { + it('DAY-SVC-042 — does not rewrite a stay whose days already hold their old dates', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const d1 = createDay(testDb, trip.id, { date: '2026-05-01' }); @@ -546,7 +546,7 @@ describe('resyncAccommodationDays', () => { const stay = createDayAccommodation(testDb, trip.id, place.id, d1.id, d2.id); const res = linkHotel(trip.id, stay.id, d1.id, null); - svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02']])); + await svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02']])); expect(testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(stay.id)) .toMatchObject({ start_day_id: d1.id, end_day_id: d2.id }); @@ -555,7 +555,7 @@ describe('resyncAccommodationDays', () => { .toMatchObject({ reservation_time: '2026-05-01' }); }); - it('DAY-SVC-043 — refuses to re-anchor a stay onto an inverted day pair', () => { + it('DAY-SVC-043 — refuses to re-anchor a stay onto an inverted day pair', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const d1 = createDay(testDb, trip.id, { date: '2026-05-01' }); @@ -568,13 +568,13 @@ describe('resyncAccommodationDays', () => { testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-02', d1.id); testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-01', d2.id); - svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02']])); + await svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02']])); expect(testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(stay.id)) .toMatchObject({ start_day_id: d1.id, end_day_id: d2.id }); }); - it('DAY-SVC-044 — leaves a stay alone when one of its old dates is outside the new range', () => { + it('DAY-SVC-044 — leaves a stay alone when one of its old dates is outside the new range', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const d1 = createDay(testDb, trip.id, { date: '2026-05-01' }); @@ -586,7 +586,7 @@ describe('resyncAccommodationDays', () => { const endGone = createDayAccommodation(testDb, trip.id, place.id, d1.id, d2.id); const startGone = createDayAccommodation(testDb, trip.id, place.id, d2.id, d3.id); - svc.resyncAccommodationDays( + await svc.resyncAccommodationDays( trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-04-29'], [d3.id, '2026-05-03']]), ); @@ -597,7 +597,7 @@ describe('resyncAccommodationDays', () => { .toMatchObject({ start_day_id: d2.id, end_day_id: d3.id }); }); - it('DAY-SVC-045 — skips the linked-booking restamp when the start day has no date', () => { + it('DAY-SVC-045 — skips the linked-booking restamp when the start day has no date', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); @@ -605,7 +605,7 @@ describe('resyncAccommodationDays', () => { const stay = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id); const res = linkHotel(trip.id, stay.id, null, null); - svc.resyncAccommodationDays(trip.id, new Map([[day.id, null]])); + await svc.resyncAccommodationDays(trip.id, new Map([[day.id, null]])); // On a dateless trip there is no date to stamp; writing one would invent a // calendar the trip does not have. @@ -619,7 +619,7 @@ describe('reorder', () => { testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number; day_number: number; date: string | null }[]; - it('DAY-SVC-046 — rejects a same-length list that contains a foreign day id', () => { + it('DAY-SVC-046 — rejects a same-length list that contains a foreign day id', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const d1 = createDay(testDb, trip.id); @@ -628,7 +628,7 @@ describe('reorder', () => { // Same length as the trip's day list, so only the membership check can catch // it — without it the renumber would leave one day orphaned at a negative // day_number and drop another out of the itinerary. - expect(() => svc.reorder(trip.id, [d1.id, 999999])).toThrow(DayReorderError); + await expect(svc.reorder(trip.id, [d1.id, 999999])).rejects.toThrow(DayReorderError); expect(orderedDays(trip.id).map(d => d.day_number)).toEqual([1, 2]); }); @@ -682,7 +682,7 @@ describe('reorder', () => { 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); + await expect(svc.reorder(trip.id, [d2.id, d3.id, d1.id])).rejects.toThrow(DayReorderError); // The guard runs inside the transaction, so the rejected move leaves the // day numbers AND the re-pinned dates exactly as the legal move left them. expect(orderedDays(trip.id)).toEqual([ @@ -694,13 +694,13 @@ describe('reorder', () => { }); describe('insert', () => { - it('DAY-SVC-050 — appends at the end when no position is given and shifts nothing', () => { + it('DAY-SVC-050 — appends at the end when no position is given and shifts nothing', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const d1 = createDay(testDb, trip.id); const d2 = createDay(testDb, trip.id); - const created = svc.insert(trip.id); + const created = await svc.insert(trip.id); expect(created).toMatchObject({ day_number: 3, date: null, assignments: [], notes_items: [] }); const rows = testDb.prepare('SELECT id, day_number FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as @@ -779,7 +779,7 @@ describe('day shaping', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const day = svc.create(trip.id, '2026-09-14', 'Ferry to the island'); + const day = await svc.create(trip.id, '2026-09-14', 'Ferry to the island'); expect(day).toMatchObject({ date: '2026-09-14', notes: 'Ferry to the island' }); // The MCP update_day tool clears a title by sending null, which must reach @@ -799,7 +799,7 @@ describe('DaysService.canEdit', () => { 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 withStub = new DaysService(new DatabaseService(testDb), permissions, new RealtimeService(), new QueryHelpersService(new DatabaseService(testDb)), await createTestUnitOfWork(testDb)); const trip = { id: 1, user_id: 1 } as never; expect(await withStub.canEdit(trip, { id: 1, role: 'user' } as never)).toBe(true); diff --git a/server/tests/unit/nest/idempotency.interceptor.test.ts b/server/tests/unit/nest/idempotency.interceptor.test.ts index dcb5b2ed4..78ce0fa50 100644 --- a/server/tests/unit/nest/idempotency.interceptor.test.ts +++ b/server/tests/unit/nest/idempotency.interceptor.test.ts @@ -43,7 +43,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle const db = makeDb(); const h = handler('weather'); const out = await lastValueFrom( - new IdempotencyInterceptor(db).intercept(ctx({ method: 'GET', headers: {} }, makeRes()), h), + await new IdempotencyInterceptor(db).intercept(ctx({ method: 'GET', headers: {} }, makeRes()), h), ); expect(out).toBe('weather'); expect(h.handle).toHaveBeenCalled(); @@ -54,7 +54,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle const db = makeDb(); const h = handler('done'); await lastValueFrom( - new IdempotencyInterceptor(db).intercept(ctx({ method: 'POST', headers: {}, user: { id: 1 } }, makeRes()), h), + await new IdempotencyInterceptor(db).intercept(ctx({ method: 'POST', headers: {}, user: { id: 1 } }, makeRes()), h), ); expect(h.handle).toHaveBeenCalled(); expect(db.get).not.toHaveBeenCalled(); @@ -64,23 +64,26 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle const db = makeDb(); const h = handler('done'); await lastValueFrom( - new IdempotencyInterceptor(db).intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' } }, makeRes()), h), + await new IdempotencyInterceptor(db).intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' } }, makeRes()), h), ); expect(h.handle).toHaveBeenCalled(); expect(db.get).not.toHaveBeenCalled(); }); - it('rejects an over-long key with the exact legacy 400 body', () => { + it('rejects an over-long key with the exact legacy 400 body', async () => { const db = makeDb(); const h = handler('done'); + // `intercept` is async now, so the cap is reported as a rejection. Nest's + // InterceptorsConsumer already awaited the interceptor's result, so a + // synchronous throw reached it as a rejected promise too: same 400 body. const run = () => new IdempotencyInterceptor(db).intercept( ctx({ method: 'POST', headers: { 'x-idempotency-key': 'x'.repeat(129) }, user: { id: 1 } }, makeRes()), h, ); - expect(run).toThrow(HttpException); + await expect(run()).rejects.toThrow(HttpException); try { - run(); + await run(); } catch (err) { const e = err as HttpException; expect(e.getStatus()).toBe(400); @@ -94,7 +97,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle const res = makeRes(); const h = handler('should-not-run'); const out = await lastValueFrom( - new IdempotencyInterceptor(db).intercept( + await new IdempotencyInterceptor(db).intercept( ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res), h, ), @@ -114,7 +117,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle const res = makeRes(); const h = handler({ created: true }); await lastValueFrom( - new IdempotencyInterceptor(db).intercept( + await new IdempotencyInterceptor(db).intercept( ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res), h, ), @@ -135,7 +138,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle const res = makeRes(); const h = handler({ error: 'bad' }); await lastValueFrom( - new IdempotencyInterceptor(db).intercept( + await new IdempotencyInterceptor(db).intercept( ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res), h, ), @@ -152,7 +155,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle const big = { blob: 'x'.repeat(300 * 1024) }; const h = handler(big); await lastValueFrom( - new IdempotencyInterceptor(db).intercept( + await new IdempotencyInterceptor(db).intercept( ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res), h, ), @@ -170,7 +173,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle const res = makeRes(); const h = handler({ ok: true }); await lastValueFrom( - new IdempotencyInterceptor(db).intercept( + await new IdempotencyInterceptor(db).intercept( ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res), h, ), @@ -198,11 +201,11 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle let finish!: (value: unknown) => void; const slow = { handle: vi.fn(() => from(new Promise((resolve) => { finish = resolve; }))) }; const firstRes = makeRes(); - const first = lastValueFrom(interceptor.intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, firstRes), slow)); + const first = lastValueFrom(await interceptor.intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, firstRes), slow)); const secondHandler = handler({ id: 'second' }); const secondRes = makeRes(); - const second = lastValueFrom(interceptor.intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, secondRes), secondHandler)); + const second = lastValueFrom(await interceptor.intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, secondRes), secondHandler)); // The order Nest uses, and the one that makes this test worth having: the // handler's observable completes FIRST, and the response - which is what @@ -228,13 +231,13 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle let finish!: (value: unknown) => void; const slow = { handle: vi.fn(() => from(new Promise((resolve) => { finish = resolve; }))) }; - const first = lastValueFrom(interceptor.intercept( + const first = lastValueFrom(await interceptor.intercept( ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, makeRes()), slow, )); const secondHandler = handler({ id: 'second' }); - const second = lastValueFrom(interceptor.intercept( + const second = lastValueFrom(await interceptor.intercept( ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, makeRes()), secondHandler, )); @@ -252,7 +255,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle const res = makeRes(); const h = handler('done'); await lastValueFrom( - new IdempotencyInterceptor(db).intercept( + await new IdempotencyInterceptor(db).intercept( ctx({ method: 'PATCH', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories/1', user: { id: 1 } }, res), h, ), diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts index fcf93d05a..9aa04f3d8 100644 --- a/server/tests/unit/nest/oidc.service.test.ts +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -127,7 +127,7 @@ beforeAll(async () => { 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), await createTestUnitOfWork(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(), await createTestUnitOfWork(testDb)), await createTestUnitOfWork(testDb)), mailerStub, new EphemeralTokenService(), new AllowedFileTypesService(new DatabaseService(testDb)), await createTestUnitOfWork(testDb), diff --git a/server/tests/unit/nest/passkey.service.test.ts b/server/tests/unit/nest/passkey.service.test.ts index 572aae2d9..ea8fccb1f 100644 --- a/server/tests/unit/nest/passkey.service.test.ts +++ b/server/tests/unit/nest/passkey.service.test.ts @@ -120,7 +120,7 @@ beforeAll(async () => { 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), await createTestUnitOfWork(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(), await createTestUnitOfWork(testDb)), await createTestUnitOfWork(testDb)), mailerStub, new EphemeralTokenService(), new AllowedFileTypesService(new DatabaseService(testDb)), await createTestUnitOfWork(testDb), diff --git a/server/tests/unit/nest/reservations.controller.test.ts b/server/tests/unit/nest/reservations.controller.test.ts index 07156372b..a5097088e 100644 --- a/server/tests/unit/nest/reservations.controller.test.ts +++ b/server/tests/unit/nest/reservations.controller.test.ts @@ -25,8 +25,8 @@ function makeService(overrides: Partial = {}): Reservations } as unknown as ReservationsService; } -function thrown(fn: () => unknown): { status: number; body: unknown } { - try { fn(); } catch (err) { +async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> { + try { await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; return { status: e.getStatus(), body: e.getResponse() }; @@ -58,19 +58,19 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Hotel', 'lodging'); }); - it('400s on a body id belonging to another trip, without writing', () => { + it('400s on a body id belonging to another trip, without writing', async () => { const create = vi.fn(); const svc = makeService({ create, referencesOutsideTrip: vi.fn().mockReturnValue(['accommodation_id']), } as Partial); const body = { title: 'Hotel', accommodation_id: 4711 }; - expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body))) + expect(await thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body))) .toEqual({ status: 400, body: { error: 'Not part of this trip: accommodation_id' } }); expect(create).not.toHaveBeenCalled(); }); - it('400s on a body id that exists nowhere, in its own words, without writing', () => { + it('400s on a body id that exists nowhere, in its own words, without writing', async () => { const create = vi.fn(); const svc = makeService({ create, @@ -79,18 +79,18 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese const body = { title: 'Hotel', place_id: 4711 }; // Not 'Not part of this trip': an id that is part of nothing would send // the caller looking for it on another trip. - expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body))) + expect(await thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body))) .toEqual({ status: 400, body: { error: 'Unknown reference: place_id' } }); expect(create).not.toHaveBeenCalled(); }); - it('answers a foreign id with the older message when it is both', () => { + it('answers a foreign id with the older message when it is both', async () => { const svc = makeService({ create: vi.fn(), referencesOutsideTrip: vi.fn().mockReturnValue(['place_id']), unresolvedReferences: vi.fn().mockReturnValue(['place_id']), } as Partial); - expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', { title: 'Hotel', place_id: 4711 }))) + expect(await thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', { title: 'Hotel', place_id: 4711 }))) .toEqual({ status: 400, body: { error: 'Not part of this trip: place_id' } }); }); }); @@ -110,9 +110,9 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese }); describe('PUT /:id', () => { - it('404 when the reservation is missing', () => { + it('404 when the reservation is missing', async () => { const svc = makeService({ getReservation: vi.fn().mockReturnValue(undefined) } as Partial); - expect(thrown(() => new ReservationsController(svc, airtrailLink).update(user, '5', '9', { title: 'X' }))).toEqual({ status: 404, body: { error: 'Reservation not found' } }); + expect(await 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', async () => { @@ -126,19 +126,19 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Old', 'lodging'); }); - it('400s on a body id belonging to another trip, without writing', () => { + it('400s on a body id belonging to another trip, without writing', async () => { const update = vi.fn(); const svc = makeService({ getReservation: vi.fn().mockReturnValue({ title: 'Old', type: 'lodging' }), update, referencesOutsideTrip: vi.fn().mockReturnValue(['day_id', 'place_id']), } as Partial); - expect(thrown(() => new ReservationsController(svc, airtrailLink).update(user, '5', '9', { day_id: 1, place_id: 2 }))) + expect(await thrown(() => new ReservationsController(svc, airtrailLink).update(user, '5', '9', { day_id: 1, place_id: 2 }))) .toEqual({ status: 400, body: { error: 'Not part of this trip: day_id, place_id' } }); expect(update).not.toHaveBeenCalled(); }); - it('400s on a body id that exists nowhere, without writing', () => { + it('400s on a body id that exists nowhere, without writing', async () => { const update = vi.fn(); const svc = makeService({ getReservation: vi.fn().mockReturnValue({ title: 'Old', type: 'lodging' }), @@ -147,7 +147,7 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese } as Partial); // The reporter's request: a foreign-key error used to reach the caller as // a bare 500 here. - expect(thrown(() => new ReservationsController(svc, airtrailLink).update(user, '5', '9', { day_id: 1, place_id: 2 }))) + expect(await thrown(() => new ReservationsController(svc, airtrailLink).update(user, '5', '9', { day_id: 1, place_id: 2 }))) .toEqual({ status: 400, body: { error: 'Unknown reference: day_id, place_id' } }); expect(update).not.toHaveBeenCalled(); }); @@ -157,9 +157,9 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese // The 'user_ids must be an array' 400 moved to the global // ZodValidationPipe (ReservationTravelersDto). - it('404 when the reservation is off-trip / missing', () => { + it('404 when the reservation is off-trip / missing', async () => { const svc = makeService({ setTravelers: vi.fn().mockReturnValue(null) } as Partial); - expect(thrown(() => new ReservationsController(svc, airtrailLink).updateTravelers(user, '5', '9', { user_ids: [1] }))).toEqual({ status: 404, body: { error: 'Reservation not found' } }); + expect(await thrown(() => new ReservationsController(svc, airtrailLink).updateTravelers(user, '5', '9', { user_ids: [1] }))).toEqual({ status: 404, body: { error: 'Reservation not found' } }); }); it('assigns travelers, broadcasts, and returns { travelers, reservation }', () => { @@ -175,9 +175,9 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese }); describe('DELETE /:id', () => { - it('404 when nothing deleted', () => { + it('404 when nothing deleted', async () => { const svc = makeService({ remove: vi.fn().mockReturnValue({ deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null }) } as Partial); - expect(thrown(() => new ReservationsController(svc, airtrailLink).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Reservation not found' } }); + expect(await 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', async () => { diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index f5b9f7d5a..817a68027 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -520,20 +520,20 @@ describe('ReservationsService (DI-native, real SQL)', () => { }); describe('syncBudgetOnCreate', () => { - it('does nothing without a positive price', () => { - svc.syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', undefined, 'sock'); - svc.syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', { total_price: 0 }, 'sock'); + it('does nothing without a positive price', async () => { + await svc.syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', undefined, 'sock'); + await svc.syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', { total_price: 0 }, 'sock'); expect(budget.linkBudgetItemToReservation).not.toHaveBeenCalled(); }); - it('links a budget item and broadcasts budget:created', () => { + it('links a budget item and broadcasts budget:created', async () => { budget.linkBudgetItemToReservation.mockReturnValue({ id: 7 }); - svc.syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', { total_price: 200, category: 'Lodging' }, 'sock'); + await svc.syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', { total_price: 200, category: 'Lodging' }, 'sock'); expect(budget.linkBudgetItemToReservation).toHaveBeenCalledWith('5', 9, { name: 'Hotel', category: 'Lodging', total_price: 200 }); expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 7 } }, 'sock'); }); - it('falls back to type then "Other" for the category and swallows errors', () => { + it('falls back to type then "Other" for the category and swallows errors', async () => { budget.linkBudgetItemToReservation.mockImplementation(() => { throw new Error('boom'); }); expect(() => svc.syncBudgetOnCreate('5', 9, 'Hotel', undefined, { total_price: 50 }, 'sock')).not.toThrow(); }); @@ -548,43 +548,43 @@ describe('ReservationsService (DI-native, real SQL)', () => { return { trip, res, item }; } - it('deletes the linked item when the price is explicitly cleared (total_price 0)', () => { + it('deletes the linked item when the price is explicitly cleared (total_price 0)', async () => { const { trip, res, item } = linkedItem(); - svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'Hotel', 'lodging', 'Hotel', 'lodging', { total_price: 0 }, 'sock'); + await svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'Hotel', 'lodging', 'Hotel', 'lodging', { total_price: 0 }, 'sock'); expect(budget.deleteBudgetItem).toHaveBeenCalledWith(item.id, String(trip.id)); expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:deleted', { itemId: item.id }, 'sock'); }); - it('leaves the linked item alone when no budget entry is on the payload (no wipe)', () => { + it('leaves the linked item alone when no budget entry is on the payload (no wipe)', async () => { const { trip, res } = linkedItem(); - svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'Hotel', 'lodging', 'Hotel', 'lodging', undefined, 'sock'); + await svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'Hotel', 'lodging', 'Hotel', 'lodging', undefined, 'sock'); expect(budget.deleteBudgetItem).not.toHaveBeenCalled(); expect(budget.updateBudgetItem).not.toHaveBeenCalled(); expect(budget.createBudgetItem).not.toHaveBeenCalled(); }); - it('syncs the linked expense category when the booking type changes', () => { + it('syncs the linked expense category when the booking type changes', async () => { const { trip, res, item } = linkedItem({ category: 'other' }); budget.updateBudgetItem.mockReturnValue({ id: item.id, category: 'flights' }); - svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'other', undefined, 'sock'); + await svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'other', undefined, 'sock'); expect(budget.updateBudgetItem).toHaveBeenCalledWith(item.id, String(trip.id), { category: 'flights' }); expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:updated', { item: { id: item.id, category: 'flights' } }, 'sock'); }); - it('updates an existing linked item when a price is provided', () => { + it('updates an existing linked item when a price is provided', async () => { const { trip, res, item } = linkedItem(); budget.updateBudgetItem.mockReturnValue({ id: item.id }); - svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'New', 'lodging', 'Old', 'lodging', { total_price: 80 }, 'sock'); + await svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'New', 'lodging', 'Old', 'lodging', { total_price: 80 }, 'sock'); expect(budget.updateBudgetItem).toHaveBeenCalledWith(item.id, String(trip.id), { name: 'New', category: 'lodging', total_price: 80 }); expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:updated', { item: { id: item.id } }, 'sock'); }); - it('creates + links a new item when none exists, using the current title fallback', () => { + it('creates + links a new item when none exists, using the current title fallback', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id); const created = createBudgetItem(testDb, trip.id); // the row the mocked create "returns" budget.createBudgetItem.mockReturnValue({ id: created.id }); - svc.syncBudgetOnUpdate(String(trip.id), String(res.id), '', undefined, 'Old title', 'flight', { total_price: 120 }, 'sock'); + await svc.syncBudgetOnUpdate(String(trip.id), String(res.id), '', undefined, 'Old title', 'flight', { total_price: 120 }, 'sock'); expect(budget.createBudgetItem).toHaveBeenCalledWith(String(trip.id), { name: 'Old title', category: 'flight', total_price: 120 }); // The service back-links the created item to the reservation itself. expect(testDb.prepare('SELECT reservation_id FROM budget_items WHERE id = ?').get(created.id)).toEqual({ reservation_id: res.id }); @@ -926,19 +926,19 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c })); }); - it('RESV-SVC-030: syncBudgetOnUpdate type-change sync is a no-op without a linked item or when the category was hand-picked', () => { + it('RESV-SVC-030: syncBudgetOnUpdate type-change sync is a no-op without a linked item or when the category was hand-picked', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id); // no linked item at all - svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'other', undefined, undefined); + await svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'other', undefined, undefined); expect(budget.updateBudgetItem).not.toHaveBeenCalled(); // linked item whose category no longer matches the auto-derived one const item = createBudgetItem(testDb, trip.id, { category: 'Hand picked' }); testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id); - svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'other', undefined, undefined); + await svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'other', undefined, undefined); expect(budget.updateBudgetItem).not.toHaveBeenCalled(); // explicit clear with no linked item deletes nothing - svc.syncBudgetOnUpdate(String(trip.id), '999999', 'X', 'flight', 'X', 'flight', { total_price: 0 }, undefined); + await svc.syncBudgetOnUpdate(String(trip.id), '999999', 'X', 'flight', 'X', 'flight', { total_price: 0 }, undefined); expect(budget.deleteBudgetItem).not.toHaveBeenCalled(); }); diff --git a/server/tests/unit/nest/trip-members.controller.test.ts b/server/tests/unit/nest/trip-members.controller.test.ts index ff240991c..6458344b5 100644 --- a/server/tests/unit/nest/trip-members.controller.test.ts +++ b/server/tests/unit/nest/trip-members.controller.test.ts @@ -37,8 +37,8 @@ function svc(o: Partial = {}): TripMembersService { } as unknown as TripMembersService; } -function thrown(fn: () => unknown): { status: number; body: unknown } { - try { fn(); } catch (err) { +async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> { + try { await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; return { status: e.getStatus(), body: e.getResponse() }; @@ -50,14 +50,14 @@ beforeEach(() => vi.clearAllMocks()); describe('TripMembersController', () => { describe('members', () => { - it('GET 404 without access, else owner+members+current_user_id', () => { - expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).members(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + it('GET 404 without access, else owner+members+current_user_id', async () => { + expect(await thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).members(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); const s = svc({ listMembers: vi.fn().mockReturnValue({ owner: { id: 1 }, members: [] }) } as Partial); expect(tc(s).members(user, '9')).toEqual({ owner: { id: 1 }, members: [], current_user_id: 1 }); }); 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' } }); + expect(await 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); @@ -65,23 +65,23 @@ describe('members', () => { expect(notifyInvite).toHaveBeenCalledWith('9', user, 2, 'T', 'bob@x.y'); }); - it('POST 404 without trip access', () => { + it('POST 404 without trip access', async () => { const s = svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) }); - expect(thrown(() => tc(s).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(await 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', 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' } }); + expect(await 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' } }); + expect(await 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); await expect(tc(other).addMember(user, '9', { identifier: 'bob@x.y' })).rejects.toThrow('boom'); }); - it('DELETE 404 without trip access', () => { + it('DELETE 404 without trip access', async () => { const s = svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) }); - expect(thrown(() => tc(s).removeMember(user, '9', '2'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(await 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', async () => { @@ -89,7 +89,7 @@ describe('members', () => { const s = svc({ can: vi.fn().mockReturnValue(false), removeMember } as Partial); // self-removal (targetId === user.id) bypasses the permission check 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' } }); + expect(await thrown(() => tc(s).removeMember(user, '9', '2'))).toEqual({ status: 403, body: { error: 'No permission to remove members' } }); }); }); @@ -117,44 +117,44 @@ describe('POST /:id/transfer (#973)', () => { expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { trip: { id: 9, user_id: 2 } }, 'sock'); }); - it('maps NotFoundError to 404 and ValidationError to 400', () => { + it('maps NotFoundError to 404 and ValidationError to 400', async () => { const nf = svc({ transferOwnership: vi.fn().mockImplementation(() => { throw new NotFoundError('User not found'); }) } as Partial); - expect(thrown(() => tc(nf).transferOwnership(user, '9', { newOwnerId: 2 }, req))).toEqual({ status: 404, body: { error: 'User not found' } }); + expect(await thrown(() => tc(nf).transferOwnership(user, '9', { newOwnerId: 2 }, req))).toEqual({ status: 404, body: { error: 'User not found' } }); const ve = svc({ transferOwnership: vi.fn().mockImplementation(() => { throw new ValidationError('New owner must be a trip member'); }) } as Partial); - expect(thrown(() => tc(ve).transferOwnership(user, '9', { newOwnerId: 2 }, req))).toEqual({ status: 400, body: { error: 'New owner must be a trip member' } }); + expect(await thrown(() => tc(ve).transferOwnership(user, '9', { newOwnerId: 2 }, req))).toEqual({ status: 400, body: { error: 'New owner must be a trip member' } }); }); }); describe('guests (#1362)', () => { // Ownership is TripOwnerGuard's job now (OWNER-002 pins the exact message). - it('400 without a name; else creates', () => { + it('400 without a name; else creates', async () => { // A whitespace-only name still 400s with the legacy body — the service // throws after trimming (the schema only enforces 1..50 chars). const wsGuest = svc({ createGuest: vi.fn().mockImplementation(() => { throw new ValidationError('Guest name is required'); }) } as Partial); - expect(thrown(() => tc(wsGuest).createGuest(user, '9', { name: ' ' }))).toEqual({ status: 400, body: { error: 'Guest name is required' } }); + expect(await thrown(() => tc(wsGuest).createGuest(user, '9', { name: ' ' }))).toEqual({ status: 400, body: { error: 'Guest name is required' } }); const createGuest = vi.fn().mockReturnValue({ member: { id: 7, username: 'Anna', is_guest: true } }); const s = svc({ createGuest } as Partial); expect(tc(s).createGuest(user, '9', { name: 'Anna' })).toEqual({ member: { id: 7, username: 'Anna', is_guest: true } }); expect(createGuest).toHaveBeenCalledWith('9', 'Anna', user.id); }); - it('rename: 404 when the guest is missing, else success', () => { + it('rename: 404 when the guest is missing, else success', async () => { const miss = svc({ renameGuest: vi.fn().mockReturnValue(false) } as Partial); - expect(thrown(() => tc(miss).renameGuest(user, '9', '7', { name: 'Bob' }))).toEqual({ status: 404, body: { error: 'Guest not found' } }); + expect(await thrown(() => tc(miss).renameGuest(user, '9', '7', { name: 'Bob' }))).toEqual({ status: 404, body: { error: 'Guest not found' } }); const ok = svc({ renameGuest: vi.fn().mockReturnValue(true) } as Partial); expect(tc(ok).renameGuest(user, '9', '7', { name: 'Bob' })).toEqual({ success: true }); }); - it('delete: 404 when the guest is missing, else success', () => { - const miss = svc({ deleteGuest: vi.fn().mockReturnValue(false) } as Partial); - expect(thrown(() => tc(miss).deleteGuest(user, '9', '7'))).toEqual({ status: 404, body: { error: 'Guest not found' } }); - const ok = svc({ deleteGuest: vi.fn().mockReturnValue(true) } as Partial); - expect(tc(ok).deleteGuest(user, '9', '7')).toEqual({ success: true }); + it('delete: 404 when the guest is missing, else success', async () => { + const miss = svc({ deleteGuest: vi.fn().mockResolvedValue(false) } as Partial); + expect(await thrown(() => tc(miss).deleteGuest(user, '9', '7'))).toEqual({ status: 404, body: { error: 'Guest not found' } }); + const ok = svc({ deleteGuest: vi.fn().mockResolvedValue(true) } as Partial); + expect(await tc(ok).deleteGuest(user, '9', '7')).toEqual({ success: true }); }); - it('maps a ValidationError from createGuest to 400', () => { + it('maps a ValidationError from createGuest to 400', async () => { const ve = svc({ createGuest: vi.fn().mockImplementation(() => { throw new ValidationError('Guest name must be 50 characters or fewer'); }) } as Partial); - expect(thrown(() => tc(ve).createGuest(user, '9', { name: 'x'.repeat(60) }))).toEqual({ status: 400, body: { error: 'Guest name must be 50 characters or fewer' } }); + expect(await thrown(() => tc(ve).createGuest(user, '9', { name: 'x'.repeat(60) }))).toEqual({ status: 400, body: { error: 'Guest name must be 50 characters or fewer' } }); }); }); diff --git a/server/tests/unit/nest/trip-members.service.test.ts b/server/tests/unit/nest/trip-members.service.test.ts index 98246b185..487d9b0a8 100644 --- a/server/tests/unit/nest/trip-members.service.test.ts +++ b/server/tests/unit/nest/trip-members.service.test.ts @@ -73,8 +73,8 @@ const dbs = () => new DatabaseService(testDb); 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)); + budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbs().connection)); + roster = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(notifySend), await createTestUnitOfWork(dbs().connection)); }); /** @@ -105,7 +105,7 @@ async 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(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(notifySend)); + return new TripMembersService(frozen, budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(notifySend), await createTestUnitOfWork(dbs().connection)); } beforeAll(() => { @@ -314,7 +314,7 @@ describe('guest name validation', () => { expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guest.id) as { display_name: string }).display_name).toBe('Ida M.'); }); - it("MEMBERS-SVC-014: deleteGuest is trip-scoped — another trip's owner cannot erase this trip's guest", () => { + it("MEMBERS-SVC-014: deleteGuest is trip-scoped — another trip's owner cannot erase this trip's guest", async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, owner.id); @@ -324,7 +324,7 @@ describe('guest name validation', () => { // The route only proves ownership of the trip in the URL, so this check is the // only thing between it and a foreign guest's users row — and the delete // cascades every assignment that guest is on. - expect(roster.deleteGuest(otherTrip.id, guest.id)).toBe(false); + expect(await roster.deleteGuest(otherTrip.id, guest.id)).toBe(false); expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guest.id)).toBeDefined(); }); }); 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 88d83b20e..cdcebfa1a 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -100,8 +100,8 @@ 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())); + budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbs().connection)); + daysSvc = new DaysService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new QueryHelpersService(dbs()), await createTestUnitOfWork(dbs().connection)); placesSvc = new PlacesService( dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()), @@ -110,7 +110,7 @@ beforeAll(async () => { makeStorageFixture('').storage, await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection), ); - membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()); + membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)); }); const buildReadModel = async (database: DatabaseService, roster: TripMembersService = membersSvc) => diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 675cdc31f..22d9fc62f 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -123,8 +123,8 @@ 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())); + budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbs().connection)); + daysSvc = new DaysService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new QueryHelpersService(dbs()), await createTestUnitOfWork(dbs().connection)); placesSvc = new PlacesService( dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), @@ -147,8 +147,9 @@ beforeAll(async () => { new RealtimeService(), undefined as never, // unsplash — not exercised here coversFx.storage, + await createTestUnitOfWork(dbs().connection), ); - membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()); + membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)); readModelSvc = new TripReadModelService( dbs(), membersSvc, daysSvc, accommodationsSvc, budgetSvc, new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()), @@ -465,24 +466,24 @@ describe('resyncReservationDays (#1288)', () => { "INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', ?, 'restaurant', 'pending')", ).run(tripId, dayId, time).lastInsertRowid); - it('TRIP-SVC-018: changing the start date re-anchors a dated reservation to the day matching its time', () => { + it('TRIP-SVC-018: changing the start date re-anchors a dated reservation to the day matching its time', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); const resId = insertDatedReservation(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T19:00:00'); // Shift the whole range one day forward (days become 2025-06-02..06). - svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user'); + await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user'); const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; // The booking stays on its absolute date (2025-06-02) instead of shifting with its old day row. expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02')); }); - it('TRIP-SVC-019: a reservation whose date falls outside the new range keeps its day_id (not nulled)', () => { + it('TRIP-SVC-019: a reservation whose date falls outside the new range keeps its day_id (not nulled)', 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 = insertDatedReservation(trip.id, origDayId, '2025-06-02T19:00:00'); // Shift far forward so 2025-06-02 is no longer covered by any day. - svc.updateTrip(trip.id, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }, 'user'); + await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }, 'user'); const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; expect(res.day_id).toBe(origDayId); }); @@ -515,7 +516,7 @@ describe('resyncAccommodationDays (#1288)', () => { const trip = createTrip(testDb, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }); 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'); + await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-09', end_date: '2025-06-14' }, 'user'); const acc = getAcc(accId); expect(acc.start_day_id).toBe(dayFor(trip.id, '2025-06-11')); expect(acc.end_day_id).toBe(dayFor(trip.id, '2025-06-13')); @@ -534,7 +535,7 @@ describe('resyncAccommodationDays (#1288)', () => { 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')); - svc.updateTrip(trip.id, user.id, { start_date: '2025-06-09', end_date: '2025-06-14' }, 'user'); + await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-09', end_date: '2025-06-14' }, 'user'); expect(stopOf().day_id).toBe(getAcc(accId).start_day_id); expect(stopOf().day_id).toBe(dayFor(trip.id, '2025-06-11')); @@ -546,7 +547,7 @@ describe('resyncAccommodationDays (#1288)', () => { const startDayId = dayFor(trip.id, '2025-06-02'); const endDayId = dayFor(trip.id, '2025-06-03'); 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'); + await 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); expect(acc.end_day_id).toBe(endDayId); @@ -564,7 +565,7 @@ describe('resyncAccommodationDays (#1288)', () => { "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 } = 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'); + await 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 { day_id: number; reservation_time: string }; @@ -575,13 +576,13 @@ describe('resyncAccommodationDays (#1288)', () => { expect(acc.start_day_id).toBe(origDayId); }); - it('TRIP-SVC-037: a dated hotel reservation without a linked accommodation is re-anchored like other bookings', () => { + it('TRIP-SVC-037: a dated hotel reservation without a linked accommodation is re-anchored like other bookings', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); const resId = Number(testDb.prepare( "INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Imported hotel', ?, 'hotel', 'pending')", ).run(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T15:00:00').lastInsertRowid); - svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user'); + await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user'); const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02')); }); @@ -684,15 +685,15 @@ describe('guest members (#1362)', () => { expect(membersSvc.renameGuest(otherTrip.id, member.id, 'Nope')).toBe(false); }); - it('TRIP-SVC-033: deleteGuest removes the user (cascading membership), guest-only + trip-scoped', () => { + it('TRIP-SVC-033: deleteGuest removes the user (cascading membership), guest-only + trip-scoped', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const { member } = membersSvc.createGuest(trip.id, 'Carol', owner.id); // Real members are not deletable via the guest path. - expect(membersSvc.deleteGuest(trip.id, owner.id)).toBe(false); + expect(await membersSvc.deleteGuest(trip.id, owner.id)).toBe(false); - expect(membersSvc.deleteGuest(trip.id, member.id)).toBe(true); + expect(await membersSvc.deleteGuest(trip.id, member.id)).toBe(true); expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(member.id)).toBeUndefined(); expect(testDb.prepare('SELECT id FROM trip_members WHERE user_id = ?').get(member.id)).toBeUndefined(); }); @@ -1058,56 +1059,56 @@ describe('TripsService wrapper helpers', () => { // ── Branch coverage for the folded update/export/copy quirks ───────────────── describe('folded quirk branches', () => { - it('TRIP-SVC-047: updateTrip admin edit collects changes and the owner email; reminder 0 reads "none"', () => { + it('TRIP-SVC-047: updateTrip admin edit collects changes and the owner email; reminder 0 reads "none"', async () => { const { user: owner } = createUser(testDb); const { user: admin } = createUser(testDb); testDb.prepare("UPDATE users SET role = 'admin' WHERE id = ?").run(admin.id); const trip = createTrip(testDb, owner.id, { title: 'Old' }); - const result = svc.updateTrip(trip.id, admin.id, { title: 'New', is_archived: true, reminder_days: 0 }, 'admin'); + const result = await svc.updateTrip(trip.id, admin.id, { title: 'New', is_archived: true, reminder_days: 0 }, 'admin'); expect(result.isAdminEdit).toBe(true); expect(result.ownerEmail).toBe(owner.email); expect(result.changes).toMatchObject({ title: 'New', archived: true, reminder_days: 'none' }); expect(result.newTitle).toBe('New'); // || coercion: an empty-string title falls back to the stored one. - const kept = svc.updateTrip(trip.id, owner.id, { title: '' }, 'user'); + const kept = await svc.updateTrip(trip.id, owner.id, { title: '' }, 'user'); expect(kept.newTitle).toBe('New'); // Missing trips throw the byte-identical error; invalid ranges reject. - expect(() => svc.updateTrip(99999, owner.id, {}, 'user')).toThrow('Trip not found'); - expect(() => svc.updateTrip(trip.id, owner.id, { start_date: '2025-06-10', end_date: '2025-06-01' }, 'user')).toThrow('End date must be after start date'); + await expect(svc.updateTrip(99999, owner.id, {}, 'user')).rejects.toThrow('Trip not found'); + await expect(svc.updateTrip(trip.id, owner.id, { start_date: '2025-06-10', end_date: '2025-06-01' }, 'user')).rejects.toThrow('End date must be after start date'); }); - it('TRIP-SVC-065: updateTrip refuses a range past MAX_TRIP_DAYS before touching the row', () => { + it('TRIP-SVC-065: updateTrip refuses a range past MAX_TRIP_DAYS before touching the row', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Week', start_date: '2026-07-01', end_date: '2026-07-07' }); - expect(() => svc.updateTrip(trip.id, user.id, { title: 'Decade', end_date: '2036-07-01' }, 'user')) - .toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); + await expect(svc.updateTrip(trip.id, user.id, { title: 'Decade', end_date: '2036-07-01' }, 'user')) + .rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); expect(testDb.prepare('SELECT title, end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ title: 'Week', end_date: '2026-07-07' }); expect(getDays(trip.id)).toHaveLength(7); // Moving only the start keeps the stored end and is measured against it. - expect(() => svc.updateTrip(trip.id, user.id, { start_date: '2020-01-01' }, 'user')) - .toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); + await expect(svc.updateTrip(trip.id, user.id, { start_date: '2020-01-01' }, 'user')) + .rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); }); - it('TRIP-SVC-066: a trip whose stored range already exceeds the limit can still be renamed', () => { + it('TRIP-SVC-066: a trip whose stored range already exceeds the limit can still be renamed', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Legacy' }); testDb.prepare("UPDATE trips SET start_date = '2020-01-01', end_date = '2030-01-01' WHERE id = ?").run(trip.id); - const result = svc.updateTrip(trip.id, user.id, { title: 'Renamed' }, 'user'); + const result = await svc.updateTrip(trip.id, user.id, { title: 'Renamed' }, 'user'); expect(result.newTitle).toBe('Renamed'); expect(result.changes).toEqual({ title: 'Renamed' }); // A day_count would rebuild the grid over the whole stored range, so it is held to the limit too. - expect(() => svc.updateTrip(trip.id, user.id, { day_count: 5 }, 'user')) - .toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); + await expect(svc.updateTrip(trip.id, user.id, { day_count: 5 }, 'user')) + .rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); expect(getDays(trip.id)).toHaveLength(0); }); - it('TRIP-SVC-067: a start date moved past the stored end is refused instead of emptying the trip', () => { + it('TRIP-SVC-067: a start date moved past the stored end is refused instead of emptying the trip', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Week', start_date: '2026-07-01', end_date: '2026-07-07' }); - expect(() => svc.updateTrip(trip.id, user.id, { start_date: '2026-07-10' }, 'user')) - .toThrow('End date must be after start date'); + await expect(svc.updateTrip(trip.id, user.id, { start_date: '2026-07-10' }, 'user')) + .rejects.toThrow('End date must be after start date'); expect(testDb.prepare('SELECT start_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ start_date: '2026-07-01' }); expect(getDays(trip.id)).toHaveLength(7); }); @@ -1213,15 +1214,16 @@ describe('quirk fixes', () => { new RealtimeService(), undefined as never, coversFx.storage, + await createTestUnitOfWork(dbs().connection), ); } /** Same frozen connection, for the guest deletion that now lives on the roster. */ async function failingMembers(match: string) { return new TripMembersService( - failingConnection(match), budgetSvc, new UserCleanupService(dbs(), budgetSvc), + failingConnection(match), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), - notificationsStub(), + notificationsStub(), await createTestUnitOfWork(dbs().connection), ); } @@ -1247,10 +1249,10 @@ describe('quirk fixes', () => { 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 item = await budgetSvc.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, guest.id] }); const broken = await failingMembers('DELETE FROM users WHERE id = ? AND is_guest = 1'); - expect(() => broken.deleteGuest(trip.id, guest.id)).toThrow('boom'); + await expect(broken.deleteGuest(trip.id, guest.id)).rejects.toThrow('boom'); // Neither the guest nor their split membership was touched. expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guest.id)).toBeDefined(); diff --git a/server/tests/unit/nest/user-cleanup.service.test.ts b/server/tests/unit/nest/user-cleanup.service.test.ts index 4885266ab..7c656bf42 100644 --- a/server/tests/unit/nest/user-cleanup.service.test.ts +++ b/server/tests/unit/nest/user-cleanup.service.test.ts @@ -57,8 +57,8 @@ const dbs = new DatabaseService(testDb); 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); + budget = new BudgetService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbs.connection)); + svc = new UserCleanupService(dbs, budget, await createTestUnitOfWork(dbs.connection)); }); const installPlugin = (id: string, permissions: string[] | null) => { @@ -149,11 +149,11 @@ describe('erasePluginUserData', () => { } }); - it('USER-CLEANUP-005: survives a slim schema without the plugin tables', () => { + it('USER-CLEANUP-005: survives a slim schema without the plugin tables', async () => { const slim = new (require('better-sqlite3'))(':memory:'); slim.exec('CREATE TABLE users (id INTEGER PRIMARY KEY)'); slim.prepare('INSERT INTO users (id) VALUES (1)').run(); - const slimSvc = new UserCleanupService(new DatabaseService(slim), budget); + const slimSvc = new UserCleanupService(new DatabaseService(slim), budget, await createTestUnitOfWork(testDb)); expect(() => slimSvc.erasePluginUserData(1)).not.toThrow(); @@ -162,7 +162,7 @@ describe('erasePluginUserData', () => { }); describe('deleteUserCompletely', () => { - it('USER-CLEANUP-006: removes the user and nulls the references that have no cascade', () => { + it('USER-CLEANUP-006: removes the user and nulls the references that have no cascade', async () => { const { user: owner } = createUser(testDb); const { user: victim } = createUser(testDb, { username: 'victim' }); const trip = createTrip(testDb, owner.id); @@ -171,14 +171,14 @@ describe('deleteUserCompletely', () => { testDb.prepare("INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, 'tok', ?)") .run(trip.id, victim.id); - svc.deleteUserCompletely(victim.id); + await svc.deleteUserCompletely(victim.id); expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(victim.id)).toBeUndefined(); expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE user_id = ?').get(owner.id) as { invited_by: number | null }).invited_by).toBeNull(); expect(testDb.prepare('SELECT COUNT(*) AS c FROM share_tokens').get()).toEqual({ c: 0 }); }); - it('USER-CLEANUP-007: deletes their journeys and the entries they authored elsewhere', () => { + it('USER-CLEANUP-007: deletes their journeys and the entries they authored elsewhere', async () => { const { user: owner } = createUser(testDb); const { user: victim } = createUser(testDb, { username: 'victim' }); const ownJourney = createJourney(victim.id, 'Mine'); @@ -186,28 +186,28 @@ describe('deleteUserCompletely', () => { testDb.prepare("INSERT INTO journey_entries (journey_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, 'note', 'Guest post', '2026-08-08', 0, 0)") .run(foreignJourney, victim.id); - svc.deleteUserCompletely(victim.id); + await svc.deleteUserCompletely(victim.id); expect(testDb.prepare('SELECT id FROM journeys WHERE id = ?').get(ownJourney)).toBeUndefined(); expect(testDb.prepare('SELECT id FROM journeys WHERE id = ?').get(foreignJourney)).toBeDefined(); expect(testDb.prepare('SELECT COUNT(*) AS c FROM journey_entries').get()).toEqual({ c: 0 }); }); - it('USER-CLEANUP-008: re-derives the expense divisor before the member rows cascade away', () => { + it('USER-CLEANUP-008: re-derives the expense divisor before the member rows cascade away', async () => { const { user: owner } = createUser(testDb); const { user: victim } = createUser(testDb, { username: 'victim' }); const trip = createTrip(testDb, owner.id); testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, victim.id); - const item = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, victim.id] }); + const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, victim.id] }); testDb.prepare('UPDATE budget_items SET paid_by_user_id = ? WHERE id = ?').run(victim.id, item.id); - svc.deleteUserCompletely(victim.id); + await svc.deleteUserCompletely(victim.id); const row = testDb.prepare('SELECT persons, paid_by_user_id FROM budget_items WHERE id = ?').get(item.id) as { persons: number | null; paid_by_user_id: number | null }; expect(row).toEqual({ persons: 1, paid_by_user_id: null }); }); - it('USER-CLEANUP-009: is atomic — a failing users DELETE rolls the reference cleanup back', () => { + it('USER-CLEANUP-009: is atomic — a failing users DELETE rolls the reference cleanup back', async () => { const { user: owner } = createUser(testDb); const { user: victim } = createUser(testDb, { username: 'victim' }); const trip = createTrip(testDb, owner.id); @@ -222,7 +222,7 @@ describe('deleteUserCompletely', () => { return realRun(sql, ...params); }); - expect(() => new UserCleanupService(failing, budget).deleteUserCompletely(victim.id)).toThrow('boom'); + await expect(new UserCleanupService(failing, budget, await createTestUnitOfWork(testDb)).deleteUserCompletely(victim.id)).rejects.toThrow('boom'); expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(victim.id)).toBeDefined(); expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE user_id = ?').get(owner.id) as { invited_by: number | null }).invited_by).toBe(victim.id); diff --git a/server/tests/unit/services/airtrailImport.test.ts b/server/tests/unit/services/airtrailImport.test.ts index a950fd3a2..144b278de 100644 --- a/server/tests/unit/services/airtrailImport.test.ts +++ b/server/tests/unit/services/airtrailImport.test.ts @@ -42,7 +42,7 @@ async function makeImportService(): Promise { new ReservationsService( dbs(), permissions, - new BudgetService(dbs(), permissions, new ExchangeRatesService(), realtime), + new BudgetService(dbs(), permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs().connection)), realtime, notificationsStub(), new ReservationsReadRepository(dbs()),