From 141ad0acb6add63e3eec58bf9aaaec269ed9fea0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 21:13:57 +0000 Subject: [PATCH] test(budget): the cases for people who left the trip get a suite of their own --- .../unit/nest/budget.former-members.test.ts | 246 ++++++++++++++++++ .../tests/unit/nest/budget.service.db.test.ts | 162 ------------ 2 files changed, 246 insertions(+), 162 deletions(-) create mode 100644 server/tests/unit/nest/budget.former-members.test.ts diff --git a/server/tests/unit/nest/budget.former-members.test.ts b/server/tests/unit/nest/budget.former-members.test.ts new file mode 100644 index 000000000..b59aa0b09 --- /dev/null +++ b/server/tests/unit/nest/budget.former-members.test.ts @@ -0,0 +1,246 @@ +/** + * People who left the trip keep their part of an expense (#2604). + * BUDGET-SVC-DB-087 through 094, moved out of budget.service.db.test.ts. + * + * Real in-memory SQLite over the container: BudgetService and TripMembersService + * come out of TripMembersModule, so a member is removed from the trip the way the + * app removes one, and the expense saves afterwards run the real roster filter. + */ +import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.entity'; +import { BudgetService } from '../../../src/nest/budget/budget.service'; +import { TripMembersModule } from '../../../src/nest/trip-members/trip-members.module'; +import { TripMembersService } from '../../../src/nest/trip-members/trip-members.service'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { findRow } from '../../helpers/factories/rows'; +import { addTripMember, makeTrip } from '../../helpers/factories/trips'; +import { makeUser } from '../../helpers/factories/users'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { MikroORM } from '@mikro-orm/core'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const db = createSnapshotTestDb(); +let t: TestModule; +let budget: BudgetService; +let membersSvc: TripMembersService; +const orm = () => t.get(MikroORM); + +beforeAll(async () => { + t = await createTestModule({ db, imports: [TripMembersModule] }); + budget = t.get(BudgetService); + membersSvc = t.get(TripMembersService); +}); + +afterAll(async () => { + await t.close(); + db.close(); +}); + +async function paidFlag(itemId: number, memberId: number): Promise { + const row = await findRow(orm(), BudgetItemMembers, { budgetItem: itemId, user: memberId }); + return row?.paid; +} + +// ── People who left the trip keep their part of an expense (#2604) ─────────── +// Removing someone from the trip leaves their payer and split rows in place. +// Saving the expense used to run every id back through the roster filter, so an +// unchanged save dropped them, total_price shrank to what the rest had paid and +// a custom split stopped adding up. Ids already on the expense stay allowed; a +// stranger who never was on it is still dropped (#1952). + +describe('someone who left the trip keeps their part of an expense (#2604)', () => { + async function tripWithFormerMember() { + const { user: alice } = await makeUser(orm()); + const { user: bob } = await makeUser(orm()); + const { user: carol } = await makeUser(orm()); + const trip = await makeTrip(orm(), alice.id); + await addTripMember(orm(), trip.id, bob.id); + await addTripMember(orm(), trip.id, carol.id); + return { alice, bob, carol, trip }; + } + const balanceSum = (balances: { balance: number }[]) => Math.round(balances.reduce((a, b) => a + b.balance, 0) * 100); + + it('BUDGET-SVC-DB-087: an unchanged save keeps a former member as payer and split member', async () => { + const { alice, bob, carol, trip } = await tripWithFormerMember(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Rental car', + payers: [ + { user_id: alice.id, amount: 120 }, + { user_id: bob.id, amount: 60 }, + ], + member_ids: [alice.id, bob.id, carol.id], + }); + await membersSvc.removeMember(trip.id, bob.id); + + // What the expense form sends back: the ids it was seeded with. + const saved = await budget.updateBudgetItem(item.id, trip.id, { + note: 'keys at the desk', + payers: [ + { user_id: alice.id, amount: 120 }, + { user_id: bob.id, amount: 60 }, + ], + member_ids: [alice.id, bob.id, carol.id], + }); + + expect(saved?.total_price).toBe(180); + expect(saved?.payers.map((p) => p.user_id).sort()).toEqual([alice.id, bob.id].sort()); + expect(saved?.members.map((m) => m.user_id).sort()).toEqual([alice.id, bob.id, carol.id].sort()); + expect(saved?.persons).toBe(3); + const settlement = await budget.calculateSettlement(trip.id); + expect(balanceSum(settlement.balances)).toBe(0); + }); + + it('BUDGET-SVC-DB-088: a custom split with a former member still adds up after a save', async () => { + const { alice, bob, carol, trip } = await tripWithFormerMember(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Dinner', + payers: [{ user_id: bob.id, amount: 90 }], + members: [ + { user_id: alice.id, amount: 30 }, + { user_id: bob.id, amount: 30 }, + { user_id: carol.id, amount: 30 }, + ], + }); + await membersSvc.removeMember(trip.id, bob.id); + + const saved = await budget.updateBudgetItem(item.id, trip.id, { + payers: [{ user_id: bob.id, amount: 90 }], + members: [ + { user_id: alice.id, amount: 30 }, + { user_id: bob.id, amount: 30 }, + { user_id: carol.id, amount: 30 }, + ], + }); + + expect(saved?.total_price).toBe(90); + expect(saved?.members.reduce((a, m) => a + (m.amount ?? 0), 0)).toBe(90); + const settlement = await budget.calculateSettlement(trip.id); + expect(balanceSum(settlement.balances)).toBe(0); + expect(settlement.balances.find((b) => b.user_id === bob.id)?.balance).toBe(60); + }); + + it('BUDGET-SVC-DB-089: a stranger who never was on the expense is still dropped', async () => { + const { alice, bob, trip } = await tripWithFormerMember(); + const { user: outsider } = await makeUser(orm()); + const item = await budget.createBudgetItem(trip.id, { + name: 'Taxi', + payers: [{ user_id: alice.id, amount: 40 }], + member_ids: [alice.id, bob.id], + }); + + const saved = await budget.updateBudgetItem(item.id, trip.id, { + payers: [ + { user_id: alice.id, amount: 40 }, + { user_id: outsider.id, amount: 10 }, + ], + member_ids: [alice.id, bob.id, outsider.id], + }); + + expect(saved?.payers.map((p) => p.user_id)).toEqual([alice.id]); + expect(saved?.members.map((m) => m.user_id)).not.toContain(outsider.id); + expect(saved?.total_price).toBe(40); + const viaMembers = await budget.updateMembers(item.id, trip.id, [alice.id, outsider.id]); + expect(viaMembers?.members.map((m) => m.user_id)).toEqual([alice.id]); + }); + + it('BUDGET-SVC-DB-090: leaving a former member out of the save removes them', async () => { + const { alice, bob, carol, trip } = await tripWithFormerMember(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Museum', + payers: [{ user_id: alice.id, amount: 30 }], + member_ids: [alice.id, bob.id, carol.id], + }); + await membersSvc.removeMember(trip.id, bob.id); + + const saved = await budget.updateBudgetItem(item.id, trip.id, { + payers: [{ user_id: alice.id, amount: 30 }], + member_ids: [alice.id, carol.id], + }); + + expect(saved?.members.map((m) => m.user_id).sort()).toEqual([alice.id, carol.id].sort()); + // Once gone from the expense, they cannot be put back while off the trip. + const again = await budget.updateBudgetItem(item.id, trip.id, { member_ids: [alice.id, bob.id, carol.id] }); + expect(again?.members.map((m) => m.user_id)).not.toContain(bob.id); + }); + + it('BUDGET-SVC-DB-091: a former member who was only in the split can be recorded as the payer', async () => { + const { alice, bob, trip } = await tripWithFormerMember(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Tickets', + payers: [{ user_id: alice.id, amount: 50 }], + member_ids: [alice.id, bob.id], + }); + await membersSvc.removeMember(trip.id, bob.id); + + const saved = await budget.updateBudgetItem(item.id, trip.id, { + payers: [{ user_id: bob.id, amount: 50 }], + member_ids: [alice.id, bob.id], + }); + + expect(saved?.payers.map((p) => p.user_id)).toEqual([bob.id]); + expect(saved?.total_price).toBe(50); + }); + + it('BUDGET-SVC-DB-092: the paid ticks survive a form save', async () => { + const { alice, bob, carol, trip } = await tripWithFormerMember(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Hotel', + payers: [{ user_id: alice.id, amount: 300 }], + member_ids: [alice.id, bob.id, carol.id], + }); + await budget.toggleMemberPaid(item.id, trip.id, carol.id, true); + await budget.toggleMemberPaid(item.id, trip.id, bob.id, true); + await membersSvc.removeMember(trip.id, bob.id); + + await budget.updateBudgetItem(item.id, trip.id, { + name: 'Hotel Central', + payers: [{ user_id: alice.id, amount: 300 }], + member_ids: [alice.id, bob.id, carol.id], + }); + + expect(await paidFlag(item.id, carol.id)).toBe(1); + expect(await paidFlag(item.id, bob.id)).toBe(1); + expect(await paidFlag(item.id, alice.id)).toBe(0); + }); + + it('BUDGET-SVC-DB-093: the payer and member routes keep a former member too', async () => { + const { alice, bob, trip } = await tripWithFormerMember(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Ferry', + payers: [ + { user_id: alice.id, amount: 20 }, + { user_id: bob.id, amount: 20 }, + ], + member_ids: [alice.id, bob.id], + }); + await membersSvc.removeMember(trip.id, bob.id); + + const payers = await budget.setItemPayers(item.id, trip.id, [ + { user_id: alice.id, amount: 20 }, + { user_id: bob.id, amount: 20 }, + ]); + expect(payers?.total_price).toBe(40); + expect(payers?.payers.map((p) => p.user_id).sort()).toEqual([alice.id, bob.id].sort()); + + const members = await budget.updateMembers(item.id, trip.id, [alice.id, bob.id]); + expect(members?.members.map((m) => m.user_id).sort()).toEqual([alice.id, bob.id].sort()); + }); + + it('BUDGET-SVC-DB-094: a new expense still takes only people on the trip', async () => { + const { alice, bob, trip } = await tripWithFormerMember(); + await membersSvc.removeMember(trip.id, bob.id); + + const item = (await budget.createBudgetItem(trip.id, { + name: 'Lunch', + payers: [ + { user_id: alice.id, amount: 20 }, + { user_id: bob.id, amount: 20 }, + ], + member_ids: [alice.id, bob.id], + })) as { payers: { user_id: number }[]; members: { user_id: number }[]; total_price: number }; + + expect(item.payers.map((p) => p.user_id)).toEqual([alice.id]); + expect(item.members.map((m) => m.user_id)).toEqual([alice.id]); + expect(item.total_price).toBe(20); + }); +}); diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index e2899fe04..3e00d9e5a 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -2112,165 +2112,3 @@ describe('rows no rate can convert, and the rates that heal them', () => { } }); }); - -// ── People who left the trip keep their part of an expense (#2604) ─────────── -// Removing someone from the trip leaves their payer and split rows in place. -// Saving the expense used to run every id back through the roster filter, so an -// unchanged save dropped them, total_price shrank to what the rest had paid and -// a custom split stopped adding up. Ids already on the expense stay allowed; a -// stranger who never was on it is still dropped (#1952). - -describe('someone who left the trip keeps their part of an expense (#2604)', () => { - async function tripWithFormerMember() { - const { user: alice } = createUser(testDb, { username: 'alice' }); - const { user: bob } = createUser(testDb, { username: 'bob' }); - const { user: carol } = createUser(testDb, { username: 'carol' }); - const trip = createTrip(testDb, alice.id); - addTripMember(testDb, trip.id, bob.id); - addTripMember(testDb, trip.id, carol.id); - return { alice, bob, carol, trip }; - } - const balanceSum = (balances: { balance: number }[]) => Math.round(balances.reduce((a, b) => a + b.balance, 0) * 100); - - it('BUDGET-SVC-DB-087: an unchanged save keeps a former member as payer and split member', async () => { - const { alice, bob, carol, trip } = await tripWithFormerMember(); - const item = await budget.createBudgetItem(trip.id, { - name: 'Rental car', - payers: [{ user_id: alice.id, amount: 120 }, { user_id: bob.id, amount: 60 }], - member_ids: [alice.id, bob.id, carol.id], - }); - await membersSvc.removeMember(trip.id, bob.id); - - // What the expense form sends back: the ids it was seeded with. - const saved = await budget.updateBudgetItem(item.id, trip.id, { - note: 'keys at the desk', - payers: [{ user_id: alice.id, amount: 120 }, { user_id: bob.id, amount: 60 }], - member_ids: [alice.id, bob.id, carol.id], - }); - - expect(saved?.total_price).toBe(180); - expect(saved?.payers.map(p => p.user_id).sort()).toEqual([alice.id, bob.id].sort()); - expect(saved?.members.map(m => m.user_id).sort()).toEqual([alice.id, bob.id, carol.id].sort()); - expect(saved?.persons).toBe(3); - const settlement = await budget.calculateSettlement(trip.id); - expect(balanceSum(settlement.balances)).toBe(0); - }); - - it('BUDGET-SVC-DB-088: a custom split with a former member still adds up after a save', async () => { - const { alice, bob, carol, trip } = await tripWithFormerMember(); - const item = await budget.createBudgetItem(trip.id, { - name: 'Dinner', - payers: [{ user_id: bob.id, amount: 90 }], - members: [{ user_id: alice.id, amount: 30 }, { user_id: bob.id, amount: 30 }, { user_id: carol.id, amount: 30 }], - }); - await membersSvc.removeMember(trip.id, bob.id); - - const saved = await budget.updateBudgetItem(item.id, trip.id, { - payers: [{ user_id: bob.id, amount: 90 }], - members: [{ user_id: alice.id, amount: 30 }, { user_id: bob.id, amount: 30 }, { user_id: carol.id, amount: 30 }], - }); - - expect(saved?.total_price).toBe(90); - expect(saved?.members.reduce((a, m) => a + (m.amount ?? 0), 0)).toBe(90); - const settlement = await budget.calculateSettlement(trip.id); - expect(balanceSum(settlement.balances)).toBe(0); - expect(settlement.balances.find(b => b.user_id === bob.id)?.balance).toBe(60); - }); - - it('BUDGET-SVC-DB-089: a stranger who never was on the expense is still dropped', async () => { - const { alice, bob, trip } = await tripWithFormerMember(); - const { user: outsider } = createUser(testDb, { username: 'outsider' }); - const item = await budget.createBudgetItem(trip.id, { - name: 'Taxi', payers: [{ user_id: alice.id, amount: 40 }], member_ids: [alice.id, bob.id], - }); - - const saved = await budget.updateBudgetItem(item.id, trip.id, { - payers: [{ user_id: alice.id, amount: 40 }, { user_id: outsider.id, amount: 10 }], - member_ids: [alice.id, bob.id, outsider.id], - }); - - expect(saved?.payers.map(p => p.user_id)).toEqual([alice.id]); - expect(saved?.members.map(m => m.user_id)).not.toContain(outsider.id); - expect(saved?.total_price).toBe(40); - const viaMembers = await budget.updateMembers(item.id, trip.id, [alice.id, outsider.id]); - expect(viaMembers?.members.map(m => m.user_id)).toEqual([alice.id]); - }); - - it('BUDGET-SVC-DB-090: leaving a former member out of the save removes them', async () => { - const { alice, bob, carol, trip } = await tripWithFormerMember(); - const item = await budget.createBudgetItem(trip.id, { - name: 'Museum', payers: [{ user_id: alice.id, amount: 30 }], member_ids: [alice.id, bob.id, carol.id], - }); - await membersSvc.removeMember(trip.id, bob.id); - - const saved = await budget.updateBudgetItem(item.id, trip.id, { - payers: [{ user_id: alice.id, amount: 30 }], member_ids: [alice.id, carol.id], - }); - - expect(saved?.members.map(m => m.user_id).sort()).toEqual([alice.id, carol.id].sort()); - // Once gone from the expense, they cannot be put back while off the trip. - const again = await budget.updateBudgetItem(item.id, trip.id, { member_ids: [alice.id, bob.id, carol.id] }); - expect(again?.members.map(m => m.user_id)).not.toContain(bob.id); - }); - - it('BUDGET-SVC-DB-091: a former member who was only in the split can be recorded as the payer', async () => { - const { alice, bob, trip } = await tripWithFormerMember(); - const item = await budget.createBudgetItem(trip.id, { - name: 'Tickets', payers: [{ user_id: alice.id, amount: 50 }], member_ids: [alice.id, bob.id], - }); - await membersSvc.removeMember(trip.id, bob.id); - - const saved = await budget.updateBudgetItem(item.id, trip.id, { - payers: [{ user_id: bob.id, amount: 50 }], member_ids: [alice.id, bob.id], - }); - - expect(saved?.payers.map(p => p.user_id)).toEqual([bob.id]); - expect(saved?.total_price).toBe(50); - }); - - it('BUDGET-SVC-DB-092: the paid ticks survive a form save', async () => { - const { alice, bob, carol, trip } = await tripWithFormerMember(); - const item = await budget.createBudgetItem(trip.id, { - name: 'Hotel', payers: [{ user_id: alice.id, amount: 300 }], member_ids: [alice.id, bob.id, carol.id], - }); - await budget.toggleMemberPaid(item.id, trip.id, carol.id, true); - await budget.toggleMemberPaid(item.id, trip.id, bob.id, true); - await membersSvc.removeMember(trip.id, bob.id); - - await budget.updateBudgetItem(item.id, trip.id, { - name: 'Hotel Central', payers: [{ user_id: alice.id, amount: 300 }], member_ids: [alice.id, bob.id, carol.id], - }); - - expect(await paidFlag(item.id, carol.id)).toBe(1); - expect(await paidFlag(item.id, bob.id)).toBe(1); - expect(await paidFlag(item.id, alice.id)).toBe(0); - }); - - it('BUDGET-SVC-DB-093: the payer and member routes keep a former member too', async () => { - const { alice, bob, trip } = await tripWithFormerMember(); - const item = await budget.createBudgetItem(trip.id, { - name: 'Ferry', payers: [{ user_id: alice.id, amount: 20 }, { user_id: bob.id, amount: 20 }], member_ids: [alice.id, bob.id], - }); - await membersSvc.removeMember(trip.id, bob.id); - - const payers = await budget.setItemPayers(item.id, trip.id, [{ user_id: alice.id, amount: 20 }, { user_id: bob.id, amount: 20 }]); - expect(payers?.total_price).toBe(40); - expect(payers?.payers.map(p => p.user_id).sort()).toEqual([alice.id, bob.id].sort()); - - const members = await budget.updateMembers(item.id, trip.id, [alice.id, bob.id]); - expect(members?.members.map(m => m.user_id).sort()).toEqual([alice.id, bob.id].sort()); - }); - - it('BUDGET-SVC-DB-094: a new expense still takes only people on the trip', async () => { - const { alice, bob, trip } = await tripWithFormerMember(); - await membersSvc.removeMember(trip.id, bob.id); - - const item = await budget.createBudgetItem(trip.id, { - name: 'Lunch', payers: [{ user_id: alice.id, amount: 20 }, { user_id: bob.id, amount: 20 }], member_ids: [alice.id, bob.id], - }) as { payers: { user_id: number }[]; members: { user_id: number }[]; total_price: number }; - - expect(item.payers.map(p => p.user_id)).toEqual([alice.id]); - expect(item.members.map(m => m.user_id)).toEqual([alice.id]); - expect(item.total_price).toBe(20); - }); -});