From 37b3f0f3da59360f0de5114352b3ab0209890058 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 21:13:57 +0000 Subject: [PATCH] test(trips): the copy case for shared expenses gets a suite of its own --- .../unit/nest/trips.copy-expenses.test.ts | 164 ++++++++++++++++++ server/tests/unit/nest/trips.service.test.ts | 74 +------- 2 files changed, 165 insertions(+), 73 deletions(-) create mode 100644 server/tests/unit/nest/trips.copy-expenses.test.ts diff --git a/server/tests/unit/nest/trips.copy-expenses.test.ts b/server/tests/unit/nest/trips.copy-expenses.test.ts new file mode 100644 index 000000000..31df405ba --- /dev/null +++ b/server/tests/unit/nest/trips.copy-expenses.test.ts @@ -0,0 +1,164 @@ +/** + * TripsService.copy and the expenses of people the copy does not have (#2604). + * TRIP-SVC-094, moved out of trips.service.test.ts. + * + * Real in-memory SQLite over the container: the service comes out of + * TripsModule, and the rows are seeded and read through the ORM factories. + */ +import { AssignmentParticipants } from '../../../src/db/entities/AssignmentParticipants.entity'; +import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.entity'; +import { BudgetItemPayers } from '../../../src/db/entities/BudgetItemPayers.entity'; +import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; +import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; +import { BudgetService } from '../../../src/nest/budget/budget.service'; +import { TripsModule } from '../../../src/nest/trips/trips.module'; +import { TripsService } from '../../../src/nest/trips/trips.service'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { makeDayAssignment } from '../../helpers/factories/itinerary'; +import { makePlace } from '../../helpers/factories/places'; +import { countRows, findRows, insertRow } from '../../helpers/factories/rows'; +import { addTripMember, makeTrip, readTripDays } from '../../helpers/factories/trips'; +import { makeUser } from '../../helpers/factories/users'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { MikroORM, type EntityClass, type FilterQuery } from '@mikro-orm/core'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const db = createSnapshotTestDb(); +let t: TestModule; +let svc: TripsService; +let budget: BudgetService; +const orm = () => t.get(MikroORM); + +beforeAll(async () => { + t = await createTestModule({ db, imports: [TripsModule] }); + svc = t.get(TripsService); + budget = t.get(BudgetService); +}); + +afterAll(async () => { + await t.close(); + db.close(); +}); + +/** Only `cols` of the stored rows matching `where`, the way a SELECT of those columns returns them. */ +async function storedColumns( + entity: EntityClass, + where: FilterQuery, + cols: string[], +): Promise>> { + const rows = (await findRows(orm(), entity, where)) as Array>; + return rows.map((row) => Object.fromEntries(cols.map((col) => [col, row[col]]))); +} + +describe('copy: expenses shared with people who are not on the copy (#2604)', () => { + /** + * #2604: the copy has nobody on it but the copier, yet it used to take every + * payer and split row along. Balances in the copy then named people who could + * not see it, and the first save of such an expense dropped them and shrank + * its total. An expense anyone else pays for or shares now comes across as a + * planning entry with its total; one that is the copier's alone keeps it all. + */ + it('TRIP-SVC-094: copy keeps nobody outside the copy on its expenses and keeps every total (#2604)', async () => { + const { user: owner } = await makeUser(orm()); + const { user: friend } = await makeUser(orm()); + const trip = await makeTrip(orm(), owner.id, { + title: 'Shared costs', + start_date: '2025-07-01', + end_date: '2025-07-01', + }); + await addTripMember(orm(), trip.id, friend.id); + const days = await readTripDays(orm(), trip.id); + const place = await makePlace(orm(), trip.id, { name: 'Harbour' }); + const assignment = await makeDayAssignment(orm(), days[0].id, place.id); + await insertRow(orm(), AssignmentParticipants, { assignment: assignment.id, user: owner.id }); + await insertRow(orm(), AssignmentParticipants, { assignment: assignment.id, user: friend.id }); + + const solo = Number( + await insertRow(orm(), BudgetItems, { + trip: trip.id, + category: 'Food', + name: 'Solo lunch', + total_price: 50, + persons: 1, + paidByUser: owner.id, + }), + ); + await insertRow(orm(), BudgetItemMembers, { budgetItem: solo, user: owner.id, paid: 1, amount: null }); + await insertRow(orm(), BudgetItemPayers, { budgetItem: solo, user: owner.id, amount: 50 }); + + const shared = Number( + await insertRow(orm(), BudgetItems, { + trip: trip.id, + category: 'Transport', + name: 'Rental car', + total_price: 180, + persons: 2, + paidByUser: friend.id, + ticket_json: JSON.stringify({ items: [{ name: 'Car', price: '180', parts: [owner.id, friend.id] }] }), + }), + ); + await insertRow(orm(), BudgetItemMembers, { budgetItem: shared, user: owner.id, paid: 0, amount: null }); + await insertRow(orm(), BudgetItemMembers, { budgetItem: shared, user: friend.id, paid: 1, amount: null }); + await insertRow(orm(), BudgetItemPayers, { budgetItem: shared, user: owner.id, amount: 120 }); + await insertRow(orm(), BudgetItemPayers, { budgetItem: shared, user: friend.id, amount: 60 }); + + // Paid by the friend alone, with a typed head count and no split rows. + const theirs = Number( + await insertRow(orm(), BudgetItems, { + trip: trip.id, + category: 'Food', + name: 'Seafood dinner', + total_price: 90, + persons: 4, + }), + ); + await insertRow(orm(), BudgetItemPayers, { budgetItem: theirs, user: friend.id, amount: 90 }); + + const newTripId = await svc.copy(trip.id, owner.id, 'Shared costs copy'); + + const copied = await findRows(orm(), BudgetItems, { trip: newTripId }, { name: 'asc' }); + const byName = (name: string) => { + const row = copied.find((c) => c.name === name); + if (!row) throw new Error(`no copied expense named ${name}`); + return row; + }; + expect(copied.map((c) => [c.name, c.total_price])).toEqual([ + ['Rental car', 180], + ['Seafood dinner', 90], + ['Solo lunch', 50], + ]); + + // The copier's own expense is copied whole. + const soloCopy = byName('Solo lunch').id; + expect(byName('Solo lunch')).toMatchObject({ persons: 1, paid_by_user_id: owner.id }); + expect(await storedColumns(BudgetItemMembers, { budgetItem: soloCopy }, ['user_id', 'paid'])).toEqual([ + { user_id: owner.id, paid: 1 }, + ]); + expect(await storedColumns(BudgetItemPayers, { budgetItem: soloCopy }, ['user_id', 'amount'])).toEqual([ + { user_id: owner.id, amount: 50 }, + ]); + + // A shared one keeps its total as a planning entry: nobody pays, nobody owes. + expect(byName('Rental car')).toMatchObject({ persons: null, paid_by_user_id: null, ticket_json: null }); + expect(await countRows(orm(), BudgetItemMembers, { budgetItem: byName('Rental car').id })).toBe(0); + expect(await countRows(orm(), BudgetItemPayers, { budgetItem: byName('Rental car').id })).toBe(0); + expect(byName('Seafood dinner')).toMatchObject({ persons: 4, paid_by_user_id: null }); + expect(await countRows(orm(), BudgetItemPayers, { budgetItem: byName('Seafood dinner').id })).toBe(0); + + // Nothing in the copy points at the friend, and its ledger squares. + const [newDay] = await readTripDays(orm(), newTripId); + const [newAssignment] = await findRows(orm(), DayAssignments, { day: newDay.id }); + expect(await storedColumns(AssignmentParticipants, { assignment: newAssignment.id }, ['user_id'])).toEqual([ + { user_id: owner.id }, + ]); + const settlement = await budget.calculateSettlement(newTripId); + expect(settlement.balances.map((b) => b.user_id)).not.toContain(friend.id); + expect(Math.round(settlement.balances.reduce((a, b) => a + b.balance, 0) * 100)).toBe(0); + + // The source keeps every row. + expect(await countRows(orm(), BudgetItemPayers, { budgetItem: shared })).toBe(2); + expect(await countRows(orm(), BudgetItemMembers, { budgetItem: shared })).toBe(2); + expect(await countRows(orm(), AssignmentParticipants, { assignment: assignment.id })).toBe(2); + }); +}); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index d011e054d..bf0677b82 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -1424,7 +1424,7 @@ describe('folded trip CRUD', () => { expect(newRes.url).toBe('https://example.test/booking'); // The friend is not on the copy, so the shared expense comes across as a - // planning entry with its total and no split (#2604, see TRIP-SVC-094). + // planning entry with its total and no split (#2604, see TRIP-SVC-094 in trips.copy-expenses.test.ts). expect(newItem).toMatchObject({ total_price: 240, persons: null }); expect(await storedRows(BudgetItemMembers, { budgetItem: newItem.id }, undefined, ['user_id'])).toEqual([]); expect(await storedRows(BudgetItemPayers, { budgetItem: newItem.id }, undefined, ['user_id'])).toEqual([]); @@ -1438,78 +1438,6 @@ describe('folded trip CRUD', () => { const todos = await storedRows(TodoItems, { trip: newTripId }, undefined, ['name', 'checked']) as any[]; expect(todos).toEqual([{ name: 'Book transfer', checked: 0 }]); }); - - /** - * #2604: the copy has nobody on it but the copier, yet it used to take every - * payer and split row along. Balances in the copy then named people who could - * not see it, and the first save of such an expense dropped them and shrank - * its total. An expense anyone else pays for or shares now comes across as a - * planning entry with its total; one that is the copier's alone keeps it all. - */ - it('TRIP-SVC-094: copy keeps nobody outside the copy on its expenses and keeps every total (#2604)', async () => { - const { user: owner } = createUser(testDb); - const { user: friend } = createUser(testDb); - const trip = createTrip(testDb, owner.id, { title: 'Shared costs', start_date: '2025-07-01', end_date: '2025-07-01' }); - addTripMember(testDb, trip.id, friend.id); - const days = await getDays(trip.id); - const place = createPlace(testDb, trip.id, { name: 'Harbour' }); - const assignment = createDayAssignment(testDb, days[0].id, place.id); - await insertRow(await orm(), AssignmentParticipants, { assignment: assignment.id, user: owner.id }); - await insertRow(await orm(), AssignmentParticipants, { assignment: assignment.id, user: friend.id }); - - const solo = Number(await insertRow(await orm(), BudgetItems, { trip: trip.id, category: 'Food', name: 'Solo lunch', total_price: 50, persons: 1, paidByUser: owner.id })); - await insertRow(await orm(), BudgetItemMembers, { budgetItem: solo, user: owner.id, paid: 1, amount: null }); - await insertRow(await orm(), BudgetItemPayers, { budgetItem: solo, user: owner.id, amount: 50 }); - - const shared = Number(await insertRow(await orm(), BudgetItems, { - trip: trip.id, category: 'Transport', name: 'Rental car', total_price: 180, persons: 2, paidByUser: friend.id, - ticket_json: JSON.stringify({ items: [{ name: 'Car', price: '180', parts: [owner.id, friend.id] }] }), - })); - await insertRow(await orm(), BudgetItemMembers, { budgetItem: shared, user: owner.id, paid: 0, amount: null }); - await insertRow(await orm(), BudgetItemMembers, { budgetItem: shared, user: friend.id, paid: 1, amount: null }); - await insertRow(await orm(), BudgetItemPayers, { budgetItem: shared, user: owner.id, amount: 120 }); - await insertRow(await orm(), BudgetItemPayers, { budgetItem: shared, user: friend.id, amount: 60 }); - - // Paid by the friend alone, with a typed head count and no split rows. - const theirs = Number(await insertRow(await orm(), BudgetItems, { trip: trip.id, category: 'Food', name: 'Seafood dinner', total_price: 90, persons: 4 })); - await insertRow(await orm(), BudgetItemPayers, { budgetItem: theirs, user: friend.id, amount: 90 }); - - const newTripId = await svc.copy(trip.id, owner.id, 'Shared costs copy'); - - const copied = await storedRows(BudgetItems, { trip: newTripId }, { name: 'asc' }, ['id', 'name', 'total_price', 'persons', 'paid_by_user_id', 'ticket_json']) as { - id: number; name: string; total_price: number; persons: number | null; paid_by_user_id: number | null; ticket_json: string | null; - }[]; - const byName = (name: string) => { - const row = copied.find(c => c.name === name); - if (!row) throw new Error(`no copied expense named ${name}`); - return row; - }; - expect(copied.map(c => [c.name, c.total_price])).toEqual([['Rental car', 180], ['Seafood dinner', 90], ['Solo lunch', 50]]); - - // The copier's own expense is copied whole. - expect(byName('Solo lunch')).toMatchObject({ persons: 1, paid_by_user_id: owner.id }); - expect(await storedRows(BudgetItemMembers, { budgetItem: byName('Solo lunch').id }, undefined, ['user_id', 'paid'])).toEqual([{ user_id: owner.id, paid: 1 }]); - expect(await storedRows(BudgetItemPayers, { budgetItem: byName('Solo lunch').id }, undefined, ['user_id', 'amount'])).toEqual([{ user_id: owner.id, amount: 50 }]); - - // A shared one keeps its total as a planning entry: nobody pays, nobody owes. - expect(byName('Rental car')).toMatchObject({ persons: null, paid_by_user_id: null, ticket_json: null }); - expect(await countRows(await orm(), BudgetItemMembers, { budgetItem: byName('Rental car').id })).toBe(0); - expect(await countRows(await orm(), BudgetItemPayers, { budgetItem: byName('Rental car').id })).toBe(0); - expect(byName('Seafood dinner')).toMatchObject({ persons: 4, paid_by_user_id: null }); - expect(await countRows(await orm(), BudgetItemPayers, { budgetItem: byName('Seafood dinner').id })).toBe(0); - - // Nothing in the copy points at the friend, and its ledger squares. - const newAssignment = (await getAssignments((await getDays(newTripId))[0].id))[0]; - expect(await storedRows(AssignmentParticipants, { assignment: newAssignment.id }, undefined, ['user_id'])).toEqual([{ user_id: owner.id }]); - const settlement = await budgetSvc.calculateSettlement(newTripId); - expect(settlement.balances.map(b => b.user_id)).not.toContain(friend.id); - expect(Math.round(settlement.balances.reduce((a, b) => a + b.balance, 0) * 100)).toBe(0); - - // The source keeps every row. - expect(await countRows(await orm(), BudgetItemPayers, { budgetItem: shared })).toBe(2); - expect(await countRows(await orm(), BudgetItemMembers, { budgetItem: shared })).toBe(2); - expect(await countRows(await orm(), AssignmentParticipants, { assignment: assignment.id })).toBe(2); - }); }); // ── Wrapper helpers (delegating members of the aggregate root) ────────────────