mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
refactor(budget, common, day-notes, days): async DB-touching methods; transactions through UnitOfWork
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await this.uow.transactional(async () => {
|
||||
await this.cleanupUserReferences(userId);
|
||||
this.erasePluginUserData(userId);
|
||||
this.db.run('DELETE FROM users WHERE id = ?', userId);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<number> {
|
||||
// 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<string | null> {
|
||||
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<boolean> {
|
||||
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,
|
||||
|
||||
@@ -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<BudgetItemMember>(`
|
||||
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<BudgetItemPayer>(`
|
||||
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<BudgetItemReceipt[]> {
|
||||
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<number> {
|
||||
private async rosterMemberIds(tripId: string | number, userIds: number[]): Promise<Set<number>> {
|
||||
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<number> = new Set()) {
|
||||
private async unlinkReceipts(budgetItemId: number | string, keep: ReadonlySet<number> = 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<BudgetItem>(`
|
||||
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<BudgetItem>('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<BudgetItem | null> {
|
||||
const item = this.db.get<BudgetItem>('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<BudgetItem>('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<BudgetItem>('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<boolean> {
|
||||
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<void> {
|
||||
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<BudgetItem>('SELECT * FROM budget_items WHERE id = ?', id)!;
|
||||
return { members, item: updated };
|
||||
});
|
||||
}
|
||||
|
||||
removeUserFromBudgetItems(userId: number): void {
|
||||
this.db.transaction(() => {
|
||||
async removeUserFromBudgetItems(userId: number): Promise<void> {
|
||||
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<string, number> | 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<SettlementRow>(
|
||||
`${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<SettlementRow>(
|
||||
`${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<boolean> {
|
||||
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<BudgetService['createBudgetItem']>[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<BudgetService['updateBudgetItem']>[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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
const reservation = this.db.get<{ id: number; metadata: string | null }>(
|
||||
'SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?',
|
||||
|
||||
@@ -37,10 +37,10 @@ export class CostsRpc {
|
||||
) {}
|
||||
|
||||
@PluginMethod('costs.getByTrip', { permission: 'db:read:costs' })
|
||||
getByTrip(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
return this.guards.tripRead(params, ctx, async () => {
|
||||
async getByTrip(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
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 });
|
||||
|
||||
@@ -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<boolean> {
|
||||
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);
|
||||
|
||||
@@ -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<boolean> {
|
||||
if (!env.isDemoMode()) return false;
|
||||
const user = db.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId);
|
||||
return isDemoEmail(user?.email);
|
||||
|
||||
@@ -21,9 +21,9 @@ export class IdempotencyCleanupJob implements OnApplicationBootstrap {
|
||||
this.registrar.register('idempotency-cleanup', '0 3 * * *', () => this.tick());
|
||||
}
|
||||
|
||||
tick(): void {
|
||||
async tick(): Promise<void> {
|
||||
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)`);
|
||||
}
|
||||
|
||||
@@ -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<number> {
|
||||
const cutoff = Math.floor(now / 1000) - ttlSeconds;
|
||||
const result = database.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
|
||||
return result.changes;
|
||||
|
||||
@@ -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<unknown> {
|
||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
|
||||
const req = context.switchToHttp().getRequest<Request & { user?: { id: number } }>();
|
||||
const res = context.switchToHttp().getResponse<Response>();
|
||||
|
||||
@@ -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<Observable<unknown>> {
|
||||
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<IdempotencyRow | undefined> {
|
||||
return this.database.get<IdempotencyRow>(
|
||||
'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<unknown> {
|
||||
): Promise<Observable<unknown>> {
|
||||
const originalJson = res.json.bind(res);
|
||||
const database = this.database;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,9 +28,9 @@ export class DayNotesRpc {
|
||||
) {}
|
||||
|
||||
@PluginMethod('daynotes.list', { permission: 'db:read:daynotes' })
|
||||
list(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
return this.guards.tripRead(params, ctx, () =>
|
||||
this.dayNotes.list(num(params.dayId, 'dayId'), num(params.tripId, 'tripId')),
|
||||
async list(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<TripAccess | undefined> {
|
||||
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<DayNote>('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<void> {
|
||||
this.dbs.run('DELETE FROM day_notes WHERE id = ?', id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<Day>('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<void> {
|
||||
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<number, string | null>,
|
||||
newDateById: Map<number, string | null>,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<number, string | null>,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
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<number, string | null>();
|
||||
@@ -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;
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<string, never>, 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<string, never>, 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<string, never>, 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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<LegOutcome | { error: string }> {
|
||||
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<string | null> => {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
// 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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<TripsService['updateTrip']>[2], user?.role ?? 'user');
|
||||
const result = await this.trips.updateTrip(tripId, actor, input as Parameters<TripsService['updateTrip']>[2], user?.role ?? 'user');
|
||||
this.realtime.broadcast(tripId, 'trip:updated', { trip: result.updatedTrip });
|
||||
return result.updatedTrip;
|
||||
} catch (e) {
|
||||
|
||||
@@ -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<UpdateTripResult> {
|
||||
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<string, unknown> = {};
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -136,19 +136,19 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
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<McpRegistry> {
|
||||
);
|
||||
// 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<McpRegistry> {
|
||||
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<McpRegistry> {
|
||||
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),
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
expect(new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true });
|
||||
const svc = makeService({ deleteSettlement: vi.fn().mockResolvedValue(true), broadcast } as Partial<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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<BudgetService>);
|
||||
expect(new BudgetController(ok).remove(user, '5', '9')).toEqual({ success: true });
|
||||
const ok = makeService({ remove: vi.fn().mockResolvedValue(true), broadcast: vi.fn() } as Partial<BudgetService>);
|
||||
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<BudgetService>);
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: <T>(fn: () => Promise<T>) => 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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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: <T>(fn: () => Promise<T>) => 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DaysService>);
|
||||
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<DaysService>);
|
||||
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<DaysService>)).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<DaysService>)).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<DaysService>);
|
||||
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<DaysService>);
|
||||
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<DaysService>)).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<DaysService>);
|
||||
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<DaysService>)).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<DaysService>);
|
||||
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<DaysService>)).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<DaysService>);
|
||||
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<DaysService>)).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<DaysService>);
|
||||
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<DayNotesService>)).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<DayNotesService>)).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<DayNotesService>);
|
||||
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<DayNotesService>)).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<DayNotesService>)).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<DayNotesService>);
|
||||
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<DayNotesService>);
|
||||
expect(new DayNotesController(svc).list(user, '5', '3')).toEqual({ notes: [{ id: 1 }] });
|
||||
expect(thrown(() => new DayNotesController(notesSvc({ getNote: vi.fn().mockReturnValue(undefined) } as Partial<DayNotesService>)).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<DayNotesService>)).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<DayNotesService>);
|
||||
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<DayNotesService>)).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<DayNotesService>)).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<DayNotesService>);
|
||||
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<DayNotesService>);
|
||||
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<DayNotesService>);
|
||||
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<DayNotesService>);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ReturnType<typeof makeAccommodationsService>>;
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -25,8 +25,8 @@ function makeService(overrides: Partial<ReservationsService> = {}): 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<ReservationsService>);
|
||||
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<ReservationsService>);
|
||||
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<ReservationsService>);
|
||||
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<ReservationsService>);
|
||||
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<ReservationsService>);
|
||||
// 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<ReservationsService>);
|
||||
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<ReservationsService>);
|
||||
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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ function svc(o: Partial<TripMembersService> = {}): 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<TripMembersService>);
|
||||
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<TripMembersService>);
|
||||
@@ -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<TripMembersService>);
|
||||
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<TripMembersService>);
|
||||
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<TripMembersService>);
|
||||
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<TripMembersService>);
|
||||
// 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<TripMembersService>);
|
||||
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<TripMembersService>);
|
||||
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<TripMembersService>);
|
||||
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<TripMembersService>);
|
||||
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<TripMembersService>);
|
||||
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<TripMembersService>);
|
||||
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<TripMembersService>);
|
||||
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<TripMembersService>);
|
||||
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<TripMembersService>);
|
||||
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<TripMembersService>);
|
||||
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<TripMembersService>);
|
||||
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' } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -42,7 +42,7 @@ async function makeImportService(): Promise<AirtrailImportService> {
|
||||
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()),
|
||||
|
||||
Reference in New Issue
Block a user