mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
test(trips): the copy case for shared expenses gets a suite of its own
This commit is contained in:
@@ -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<T extends object>(
|
||||
entity: EntityClass<T>,
|
||||
where: FilterQuery<T>,
|
||||
cols: string[],
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const rows = (await findRows(orm(), entity, where)) as Array<Record<string, unknown>>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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) ────────────────
|
||||
|
||||
Reference in New Issue
Block a user