mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
865 lines
40 KiB
TypeScript
865 lines
40 KiB
TypeScript
/**
|
|
* Budget module e2e — exercises the migrated /api/trips/:tripId/budget endpoints
|
|
* through the real JwtAuthGuard against a temp SQLite db carrying the full real,
|
|
* migrated schema (createSnapshotTestDb), so the folded BudgetService runs its
|
|
* real SQL. Only the db singleton (trip access) is mocked, and broadcasts reach
|
|
* the real transport, which has no sockets here; the permission check is a spy on the container's PermissionsService.
|
|
* ReservationsModule is mounted beside it because an expense can be linked to a
|
|
* booking (#2084), and deleting that booking has to take the expense with it.
|
|
*/
|
|
import { db } from '../../src/db/database';
|
|
import { BudgetItems } from '../../src/db/entities/BudgetItems.entity';
|
|
import { BudgetSettlements } from '../../src/db/entities/BudgetSettlements.entity';
|
|
import { Reservations } from '../../src/db/entities/Reservations.entity';
|
|
import { TripMembers } from '../../src/db/entities/TripMembers.entity';
|
|
import { Trips } from '../../src/db/entities/Trips.entity';
|
|
import { BudgetModule } from '../../src/nest/budget/budget.module';
|
|
import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
|
|
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
|
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
|
import { ReservationsModule } from '../../src/nest/reservations/reservations.module';
|
|
import { makePlace } from '../helpers/factories/places';
|
|
import { makeReservation } from '../helpers/factories/reservations';
|
|
import { countRows, deleteRows, findRow, insertRow } from '../helpers/factories/rows';
|
|
import { addTripMember, makeTrip } from '../helpers/factories/trips';
|
|
import { makeUser } from '../helpers/factories/users';
|
|
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { sessionCookie } from './harness';
|
|
import { Test } from '@nestjs/testing';
|
|
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import request from 'supertest';
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
|
|
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb } = await import('../helpers/db-mock');
|
|
const db = createSnapshotTestDb();
|
|
// Trip access reads through TripsRepository now; the module only hands out the handle.
|
|
return { db, closeDb: () => {}, reinitialize: () => {} };
|
|
});
|
|
|
|
// Since the permissions DI migration, the check is a spy on the container's
|
|
// PermissionsService singleton (created in beforeAll, after build()).
|
|
let checkPermission: MockInstance;
|
|
|
|
let orm: TestOrm;
|
|
|
|
/** The stored expense; fails the case when it is gone. */
|
|
async function budgetRow(id: number) {
|
|
const row = await findRow(orm, BudgetItems, { id });
|
|
if (!row) throw new Error(`no budget item ${id}`);
|
|
return row;
|
|
}
|
|
|
|
/** The stored settlement; fails the case when it is gone. */
|
|
async function settlementRow(id: number) {
|
|
const row = await findRow(orm, BudgetSettlements, { id });
|
|
if (!row) throw new Error(`no settlement ${id}`);
|
|
return row;
|
|
}
|
|
|
|
describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
let tripId: number;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({
|
|
imports: [
|
|
await TestUnitOfWorkModule.forRoot(db),
|
|
await createTestMikroOrmModule(db),
|
|
RealtimeModule,
|
|
BudgetModule,
|
|
ReservationsModule,
|
|
],
|
|
})
|
|
// The settlement read awaits live FX rates; the trip here is all-EUR, so a
|
|
// null result is the identity — and the test never touches the network.
|
|
.overrideProvider(ExchangeRatesService)
|
|
.useValue({ getRates: async () => null })
|
|
// A booking delete notifies the trip; nothing here is listening.
|
|
.overrideProvider(NotificationsService)
|
|
.useValue({ send: vi.fn().mockResolvedValue(undefined) })
|
|
.compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
// The temp db carries the real schema, so the auth users go in through the
|
|
// factories, pinned to the ids the session cookies are signed for.
|
|
orm = await createTestOrm(db);
|
|
await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test', role: 'user', password_version: 0 });
|
|
await makeUser(orm, { id: 2, username: 'e2e-peer', email: 'peer@example.test', role: 'user', password_version: 0 });
|
|
tripId = (await makeTrip(orm, 1, { title: 'E2E Trip', currency: 'EUR' })).id;
|
|
// The peer settles up with the owner below, so they have to be on the trip:
|
|
// a settlement between people who do not share one is refused.
|
|
await addTripMember(orm, tripId, 2);
|
|
app = await build();
|
|
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// 0b review L2 / security review F-B7: `canAccessTrip` is dead mock
|
|
// scaffolding — `TripAccessGuard` reads `TripsRepository.findAccessible`
|
|
// directly now (Plan 3c Task 0b), so `db/database`'s `canAccessTrip`
|
|
// property is never imported by production code; this line used to be a
|
|
// no-op that read as if it still controlled access.
|
|
checkPermission.mockReturnValue(true);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
await orm.close();
|
|
});
|
|
|
|
it('401 without a session cookie', async () => {
|
|
const res = await request(server).get(`/api/trips/${tripId}/budget`);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('404 when the trip is not accessible', async () => {
|
|
// Plan 3c Task 0b: TripAccessGuard reads TripsRepository.findAccessible
|
|
// directly now, a real query — `canAccessTrip.mockReturnValue(...)` no
|
|
// longer intercepts it. The trip row is seeded once in `beforeAll` (not
|
|
// re-seeded per test), so it is removed and restored around this one
|
|
// assertion instead.
|
|
await deleteRows(orm, Trips, { id: tripId });
|
|
try {
|
|
const res = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Trip not found' });
|
|
} finally {
|
|
await insertRow(orm, Trips, { id: tripId, user: 1, title: 'E2E Trip', currency: 'EUR' });
|
|
await addTripMember(orm, tripId, 2);
|
|
}
|
|
});
|
|
|
|
it('201 on create with permission, then 200 list returns the stored row', async () => {
|
|
const created = await request(server)
|
|
.post(`/api/trips/${tripId}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ name: 'Hotel', total_price: 200 });
|
|
expect(created.status).toBe(201);
|
|
expect(created.body.item).toMatchObject({
|
|
name: 'Hotel',
|
|
total_price: 200,
|
|
category: 'other',
|
|
members: [],
|
|
payers: [],
|
|
});
|
|
|
|
const row = await budgetRow(created.body.item.id);
|
|
expect({ name: row.name, total_price: row.total_price }).toEqual({ name: 'Hotel', total_price: 200 });
|
|
|
|
const list = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1));
|
|
expect(list.status).toBe(200);
|
|
expect(list.body.items.map((i: { id: number }) => i.id)).toContain(created.body.item.id);
|
|
});
|
|
|
|
it('403 on create without permission', async () => {
|
|
checkPermission.mockReturnValue(false);
|
|
const res = await request(server)
|
|
.post(`/api/trips/${tripId}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ name: 'Hotel' });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toEqual({ error: 'No permission' });
|
|
});
|
|
|
|
it('400 on member update with a non-array user_ids (Zod pipe envelope)', async () => {
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/budget/9/members`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ user_ids: 'no' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain('user_ids');
|
|
});
|
|
|
|
it('400 on create without a name (Zod pipe envelope)', async () => {
|
|
const res = await request(server)
|
|
.post(`/api/trips/${tripId}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ total_price: 10 });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error.toLowerCase()).toContain('name');
|
|
});
|
|
|
|
it('201 on a negative expense (partial reimbursement) and the settlement nets it out (#2176)', async () => {
|
|
// The dinner: user 1 fronts 90, split with user 2 → user 2 owes 45.
|
|
const dinner = await request(server)
|
|
.post(`/api/trips/${tripId}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ name: 'Dinner', payers: [{ user_id: 1, amount: 90 }], member_ids: [1, 2] });
|
|
expect(dinner.status).toBe(201);
|
|
|
|
// The refund: -30 lands with user 1, shared with user 2 → user 2's debt
|
|
// drops by 15. The write path used to null this to a payer-less 0 € row.
|
|
const refund = await request(server)
|
|
.post(`/api/trips/${tripId}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({
|
|
name: 'Hotel partial refund',
|
|
total_price: -30,
|
|
payers: [{ user_id: 1, amount: -30 }],
|
|
member_ids: [1, 2],
|
|
});
|
|
expect(refund.status).toBe(201);
|
|
expect(refund.body.item).toMatchObject({ total_price: -30 });
|
|
expect(refund.body.item.payers).toEqual([expect.objectContaining({ user_id: 1, amount: -30 })]);
|
|
expect((await budgetRow(refund.body.item.id)).total_price).toBe(-30);
|
|
|
|
const settlement = await request(server)
|
|
.get(`/api/trips/${tripId}/budget/settlement`)
|
|
.set('Cookie', sessionCookie(1));
|
|
expect(settlement.status).toBe(200);
|
|
const balances = settlement.body.balances as { user_id: number; balance: number }[];
|
|
const balance = (uid: number) => balances.find((b) => b.user_id === uid)!.balance;
|
|
// 45 owed from the dinner minus the 15 refund share = 30, and Σ balances = 0.
|
|
expect(balance(2)).toBe(-30);
|
|
expect(balance(1)).toBe(30);
|
|
expect(balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0);
|
|
|
|
// Clean up so the ledger tests below start from an empty trip.
|
|
for (const id of [dinner.body.item.id, refund.body.item.id]) {
|
|
const del = await request(server).delete(`/api/trips/${tripId}/budget/${id}`).set('Cookie', sessionCookie(1));
|
|
expect(del.status).toBe(200);
|
|
}
|
|
});
|
|
|
|
it('an expense with no payer creates no debt and no flow (#2225)', async () => {
|
|
// The shape from the report: one bill user 1 fronted for both, plus a row
|
|
// saved with "No one paid yet" that only user 2 is on. The taxi used to be
|
|
// debited with no credit behind it and its whole 60 offered to user 1.
|
|
const dinner = await request(server)
|
|
.post(`/api/trips/${tripId}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ name: 'Dinner', payers: [{ user_id: 1, amount: 100 }], member_ids: [1, 2] });
|
|
expect(dinner.status).toBe(201);
|
|
|
|
const unpaid = await request(server)
|
|
.post(`/api/trips/${tripId}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ name: 'Taxi', total_price: 60, payers: [], member_ids: [2] });
|
|
expect(unpaid.status).toBe(201);
|
|
expect(unpaid.body.item).toMatchObject({ total_price: 60, payers: [] });
|
|
|
|
const settlement = await request(server)
|
|
.get(`/api/trips/${tripId}/budget/settlement`)
|
|
.set('Cookie', sessionCookie(1));
|
|
expect(settlement.status).toBe(200);
|
|
const balances = settlement.body.balances as { user_id: number; balance: number }[];
|
|
const balance = (uid: number) => balances.find((b) => b.user_id === uid)!.balance;
|
|
// Only the dinner settles: user 2 owes half of it and not a cent of the taxi.
|
|
expect(balance(1)).toBe(50);
|
|
expect(balance(2)).toBe(-50);
|
|
expect(balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0);
|
|
expect(settlement.body.flows).toEqual([
|
|
expect.objectContaining({
|
|
amount: 50,
|
|
from: expect.objectContaining({ user_id: 2 }),
|
|
to: expect.objectContaining({ user_id: 1 }),
|
|
}),
|
|
]);
|
|
|
|
// Clean up so the ledger tests below start from an empty trip.
|
|
for (const id of [dinner.body.item.id, unpaid.body.item.id]) {
|
|
const del = await request(server).delete(`/api/trips/${tripId}/budget/${id}`).set('Cookie', sessionCookie(1));
|
|
expect(del.status).toBe(200);
|
|
}
|
|
});
|
|
|
|
it('a won trip offers whole won, and settling what it offers squares everyone (#2604)', async () => {
|
|
// ₩10,000 three ways nets to 6,666.67 / -3,333.33 / -3,333.34 in the ledger. The
|
|
// settlement answered those hundredths, the panel rounded each to 6,667 / -3,333 /
|
|
// -3,333, and Settle booked 3,333.34 won.
|
|
await makeUser(orm, {
|
|
id: 5,
|
|
username: 'e2e-third',
|
|
email: 'third@example.test',
|
|
role: 'user',
|
|
password_version: 0,
|
|
});
|
|
const krwTrip = (await makeTrip(orm, 1, { title: 'FIX KRW', currency: 'KRW' })).id;
|
|
await addTripMember(orm, krwTrip, 2);
|
|
await addTripMember(orm, krwTrip, 5);
|
|
|
|
const dinner = await request(server)
|
|
.post(`/api/trips/${krwTrip}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ name: 'Dinner', currency: 'KRW', payers: [{ user_id: 1, amount: 10000 }], member_ids: [1, 2, 5] });
|
|
expect(dinner.status).toBe(201);
|
|
|
|
const read = () =>
|
|
request(server).get(`/api/trips/${krwTrip}/budget/settlement?base=KRW`).set('Cookie', sessionCookie(1));
|
|
const before = await read();
|
|
expect(before.status).toBe(200);
|
|
const balances = before.body.balances as { user_id: number; balance: number }[];
|
|
const flows = before.body.flows as { from: { user_id: number }; to: { user_id: number }; amount: number }[];
|
|
// Which of the three carries the odd hundredth depends on the item id; what is
|
|
// shown is whole won either way, adds up to zero, and the flows move exactly it.
|
|
for (const b of balances) expect(Number.isInteger(b.balance)).toBe(true);
|
|
expect(balances.reduce((a, b) => a + b.balance, 0)).toBe(0);
|
|
expect(flows.every((f) => Number.isInteger(f.amount))).toBe(true);
|
|
expect(flows.reduce((a, f) => a + f.amount, 0)).toBe(balances.find((b) => b.user_id === 1)?.balance);
|
|
|
|
const paid = await Promise.all(
|
|
flows.map((f) =>
|
|
request(server)
|
|
.post(`/api/trips/${krwTrip}/budget/settlements`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: 'KRW' }),
|
|
),
|
|
);
|
|
expect(paid.map((r) => r.status)).toEqual(flows.map(() => 201));
|
|
const after = await read();
|
|
expect((after.body.balances as { balance: number }[]).map((b) => b.balance)).toEqual([0, 0, 0]);
|
|
expect(after.body.flows).toEqual([]);
|
|
});
|
|
|
|
it('GET /summary/per-person reads a dollar bill on a euro trip at its booked rate (#2525)', async () => {
|
|
const hotel = await request(server)
|
|
.post(`/api/trips/${tripId}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({
|
|
name: 'Aparthotel Silver',
|
|
currency: 'USD',
|
|
exchange_rate: 1.17,
|
|
payers: [{ user_id: 1, amount: 801.76 }],
|
|
member_ids: [1, 2],
|
|
});
|
|
expect(hotel.status).toBe(201);
|
|
|
|
const res = await request(server)
|
|
.get(`/api/trips/${tripId}/budget/summary/per-person`)
|
|
.set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
// 801.76 USD at 1.17 is 685.26 EUR, half of it each. The summary used to put
|
|
// 400.88 on both and leave the reader to guess the currency.
|
|
const row = (uid: number) => res.body.summary.find((r: { user_id: number }) => r.user_id === uid);
|
|
expect(row(1)).toMatchObject({ total_assigned: 342.63, total_paid: 0, items_count: 1, currency: 'EUR' });
|
|
expect(row(2)).toMatchObject({ total_assigned: 342.63, total_paid: 0, items_count: 1, currency: 'EUR' });
|
|
|
|
const del = await request(server)
|
|
.delete(`/api/trips/${tripId}/budget/${hotel.body.item.id}`)
|
|
.set('Cookie', sessionCookie(1));
|
|
expect(del.status).toBe(200);
|
|
});
|
|
|
|
it('200 on settlement update with permission, persisting the new amount and day', async () => {
|
|
const created = await request(server)
|
|
.post(`/api/trips/${tripId}/budget/settlements`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ from_user_id: 2, to_user_id: 1, amount: 10, settled_at: '2026-01-05' });
|
|
expect(created.status).toBe(201);
|
|
expect(created.body.settlement.settled_at).toBe('2026-01-05');
|
|
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/budget/settlements/${created.body.settlement.id}`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ from_user_id: 2, to_user_id: 1, amount: 15, settled_at: '2026-01-09' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.settlement).toMatchObject({
|
|
id: created.body.settlement.id,
|
|
from_user_id: 2,
|
|
to_user_id: 1,
|
|
amount: 15,
|
|
settled_at: '2026-01-09',
|
|
});
|
|
|
|
const row = await settlementRow(created.body.settlement.id);
|
|
expect({ amount: row.amount, settled_at: row.settled_at }).toEqual({ amount: 15, settled_at: '2026-01-09' });
|
|
});
|
|
|
|
it('200 on settlement update that clears the day, leaving NULL in the column', async () => {
|
|
const created = await request(server)
|
|
.post(`/api/trips/${tripId}/budget/settlements`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ from_user_id: 2, to_user_id: 1, amount: 10, settled_at: '2026-01-05' });
|
|
expect(created.status).toBe(201);
|
|
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/budget/settlements/${created.body.settlement.id}`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ from_user_id: 2, to_user_id: 1, amount: 10, settled_at: null });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.settlement.settled_at).toBeNull();
|
|
|
|
expect((await settlementRow(created.body.settlement.id)).settled_at).toBeNull();
|
|
});
|
|
|
|
it('keeps a note on a settlement, leaves it alone when omitted and clears it on blank (#2340)', async () => {
|
|
const created = await request(server)
|
|
.post(`/api/trips/${tripId}/budget/settlements`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ from_user_id: 2, to_user_id: 1, amount: 10, note: ' Cash at the airport ' });
|
|
expect(created.status).toBe(201);
|
|
expect(created.body.settlement.note).toBe('Cash at the airport');
|
|
const url = `/api/trips/${tripId}/budget/settlements/${created.body.settlement.id}`;
|
|
|
|
const kept = await request(server)
|
|
.put(url)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ from_user_id: 2, to_user_id: 1, amount: 12 });
|
|
expect(kept.body.settlement.note).toBe('Cash at the airport');
|
|
const cleared = await request(server)
|
|
.put(url)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ from_user_id: 2, to_user_id: 1, amount: 12, note: ' ' });
|
|
expect(cleared.body.settlement.note).toBeNull();
|
|
const tooLong = await request(server)
|
|
.put(url)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ from_user_id: 2, to_user_id: 1, amount: 12, note: 'x'.repeat(501) });
|
|
expect(tooLong.status).toBe(400);
|
|
});
|
|
|
|
it('404 on settlement update when it does not exist', async () => {
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/budget/settlements/424242`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ from_user_id: 2, to_user_id: 1, amount: 15 });
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Settlement not found' });
|
|
});
|
|
|
|
// Plan 4 Task 8b (U6) — :id is now parsed ONCE at the controller gate
|
|
// (toRowId), so a non-numeric id 404s cleanly through that guard instead
|
|
// of falling through to the repository and depending on SQLite's
|
|
// column-affinity CAST to simply not match (the legacy outcome was also a
|
|
// 404, same status — this pins the gate itself, not just the status).
|
|
it('404 (not 500) on a budget item update with a non-numeric :id', async () => {
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/budget/abc`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ name: 'X' });
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Budget item not found' });
|
|
});
|
|
|
|
it('404 (not 500) on a budget item delete with a non-numeric :id', async () => {
|
|
const res = await request(server).delete(`/api/trips/${tripId}/budget/abc`).set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Budget item not found' });
|
|
});
|
|
|
|
// #2084: an expense that already exists can be linked to a booking of its trip
|
|
// (several per booking), let go of again, and goes with the booking it is on.
|
|
describe('linking expenses to bookings (#2084)', () => {
|
|
let bookingId: number;
|
|
let foreignBookingId: number;
|
|
let foreignPlaceId: number;
|
|
let fareId: number;
|
|
let luggageId: number;
|
|
let seatId: number;
|
|
|
|
const metadataOf = async (id: number) => {
|
|
const row = await findRow(orm, Reservations, { id });
|
|
if (!row) throw new Error(`no reservation ${id}`);
|
|
return row.metadata ? JSON.parse(row.metadata) : null;
|
|
};
|
|
/** The expense's booking link, or undefined once the expense is gone. */
|
|
const linkOf = async (id: number) => {
|
|
const row = await findRow(orm, BudgetItems, { id });
|
|
return row ? { reservation_id: row.reservation_id ?? null } : undefined;
|
|
};
|
|
const put = (id: number, body: Record<string, unknown>) =>
|
|
request(server).put(`/api/trips/${tripId}/budget/${id}`).set('Cookie', sessionCookie(1)).send(body);
|
|
const create = (body: Record<string, unknown>) =>
|
|
request(server).post(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1)).send(body);
|
|
|
|
beforeAll(async () => {
|
|
bookingId = (
|
|
await makeReservation(orm, tripId, {
|
|
title: 'Flight',
|
|
type: 'flight',
|
|
metadata: JSON.stringify({ seat: '12A' }),
|
|
})
|
|
).id;
|
|
const otherTripId = (await makeTrip(orm, 1, { title: 'Other Trip', currency: 'EUR' })).id;
|
|
foreignBookingId = (await makeReservation(orm, otherTripId, { title: 'Elsewhere', type: 'flight' })).id;
|
|
foreignPlaceId = (await makePlace(orm, otherTripId, { name: 'Elsewhere', lat: null, lng: null, category: null }))
|
|
.id;
|
|
});
|
|
|
|
it('200 on linking two expenses to one booking, which then mirrors their sum', async () => {
|
|
const fare = await create({ name: 'Fare', total_price: 120 });
|
|
const luggage = await create({ name: 'Luggage', total_price: 30.5 });
|
|
expect(fare.status).toBe(201);
|
|
expect(luggage.status).toBe(201);
|
|
fareId = fare.body.item.id;
|
|
luggageId = luggage.body.item.id;
|
|
|
|
const first = await put(fareId, { reservation_id: bookingId });
|
|
expect(first.status).toBe(200);
|
|
expect(first.body.item).toMatchObject({ id: fareId, reservation_id: bookingId, total_price: 120 });
|
|
expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '120' });
|
|
|
|
const second = await put(luggageId, { reservation_id: bookingId });
|
|
expect(second.status).toBe(200);
|
|
expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '150.5' });
|
|
|
|
const list = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1));
|
|
const linked = (list.body.items as { id: number; reservation_id: number | null }[])
|
|
.filter((i) => i.reservation_id === bookingId)
|
|
.map((i) => i.id);
|
|
expect(linked).toEqual([fareId, luggageId]);
|
|
});
|
|
|
|
it('400 on a booking or a place from another trip, on update and on create, writing nothing', async () => {
|
|
const relink = await put(fareId, { reservation_id: foreignBookingId, name: 'Hijacked' });
|
|
expect(relink.status).toBe(400);
|
|
expect(relink.body).toEqual({ error: 'reservation_id does not belong to this trip.' });
|
|
expect(await linkOf(fareId)).toEqual({ reservation_id: bookingId });
|
|
expect((await budgetRow(fareId)).name).toBe('Fare');
|
|
expect(await metadataOf(foreignBookingId)).toBeNull();
|
|
|
|
const place = await put(fareId, { place_id: foreignPlaceId });
|
|
expect(place.status).toBe(400);
|
|
expect(place.body).toEqual({ error: 'place_id does not belong to this trip.' });
|
|
|
|
const before = await countRows(orm, BudgetItems);
|
|
const created = await create({ name: 'Smuggled', total_price: 5, reservation_id: foreignBookingId });
|
|
expect(created.status).toBe(400);
|
|
expect(created.body).toEqual({ error: 'reservation_id does not belong to this trip.' });
|
|
expect(await countRows(orm, BudgetItems)).toBe(before);
|
|
});
|
|
|
|
it('400 on a reservation_id that is not a positive integer (Zod pipe envelope)', async () => {
|
|
const res = await put(fareId, { reservation_id: 0 });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain('reservation_id');
|
|
expect(await linkOf(fareId)).toEqual({ reservation_id: bookingId });
|
|
});
|
|
|
|
it('200 on unlinking: the expense stays and the booking price is worked out again', async () => {
|
|
const res = await put(luggageId, { reservation_id: null });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.item).toMatchObject({ id: luggageId, reservation_id: null, total_price: 30.5 });
|
|
expect(await linkOf(luggageId)).toEqual({ reservation_id: null });
|
|
expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '120' });
|
|
});
|
|
|
|
it('201 on a second expense created straight onto the booking, which raises its price to the sum', async () => {
|
|
const seat = await create({ name: 'Seat', total_price: 15, reservation_id: bookingId });
|
|
expect(seat.status).toBe(201);
|
|
expect(seat.body.item.reservation_id).toBe(bookingId);
|
|
seatId = seat.body.item.id;
|
|
expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '135' });
|
|
});
|
|
|
|
it('200 on new payers for a linked expense, whose derived total the booking price follows', async () => {
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/budget/${seatId}/payers`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({
|
|
payers: [
|
|
{ user_id: 1, amount: 12 },
|
|
{ user_id: 2, amount: 6 },
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.item).toMatchObject({ id: seatId, total_price: 18 });
|
|
expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '138' });
|
|
});
|
|
|
|
it('200 on a currency-only change of a linked expense, which re-names the booking price', async () => {
|
|
const trainId = (await makeReservation(orm, tripId, { title: 'Train', type: 'train' })).id;
|
|
const ticket = await create({ name: 'Ticket', total_price: 60, reservation_id: trainId });
|
|
expect(ticket.status).toBe(201);
|
|
// In the trip currency, so the price carries no currency of its own.
|
|
expect(await metadataOf(trainId)).toEqual({ price: '60' });
|
|
|
|
const chf = await put(ticket.body.item.id, { currency: 'CHF' });
|
|
expect(chf.status).toBe(200);
|
|
expect(chf.body.item).toMatchObject({ currency: 'CHF', total_price: 60 });
|
|
expect(await metadataOf(trainId)).toEqual({ price: '60', priceCurrency: 'CHF' });
|
|
|
|
const back = await put(ticket.body.item.id, { currency: null });
|
|
expect(back.status).toBe(200);
|
|
expect(await metadataOf(trainId)).toEqual({ price: '60' });
|
|
|
|
// An expense with no currency and one naming the trip's own (EUR, in any
|
|
// case) are in the same money, so the booking shows their sum.
|
|
const bike = await create({ name: 'Bike ticket', total_price: 9.5, currency: 'eur', reservation_id: trainId });
|
|
expect(bike.status).toBe(201);
|
|
expect(await metadataOf(trainId)).toEqual({ price: '69.5', priceCurrency: 'EUR' });
|
|
});
|
|
|
|
it('deleting the booking removes every expense linked to it, and only those', async () => {
|
|
const unlinkedId = luggageId;
|
|
|
|
const res = await request(server)
|
|
.delete(`/api/trips/${tripId}/reservations/${bookingId}`)
|
|
.set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true });
|
|
|
|
expect(await findRow(orm, Reservations, { id: bookingId })).toBeNull();
|
|
expect(await linkOf(fareId)).toBeUndefined();
|
|
expect(await linkOf(seatId)).toBeUndefined();
|
|
expect(await linkOf(unlinkedId)).toEqual({ reservation_id: null });
|
|
|
|
const list = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1));
|
|
const ids = (list.body.items as { id: number }[]).map((i) => i.id);
|
|
expect(ids).not.toContain(fareId);
|
|
expect(ids).not.toContain(seatId);
|
|
expect(ids).toContain(unlinkedId);
|
|
});
|
|
});
|
|
|
|
// The VND/AUD report. The rates override above answers null for every base, which is
|
|
// exactly that server: it never reached the rates provider, so the only rates there
|
|
// are the ones the browser lends.
|
|
describe('rows no rate can convert', () => {
|
|
/** A fresh AUD trip the owner shares with user 2, so these cases never meet the ledger above. */
|
|
async function audTrip(): Promise<number> {
|
|
const id = (await makeTrip(orm, 1, { title: 'AUD Trip', currency: 'AUD' })).id;
|
|
await addTripMember(orm, id, 2);
|
|
return id;
|
|
}
|
|
const balanceOf = (body: { balances: { user_id: number; balance: number }[] }, uid: number) =>
|
|
body.balances.find((b) => b.user_id === uid)!.balance;
|
|
|
|
it('VND/AUD regression: unfrozen VND bill is listed as unconverted, freeze-rates heals it from the browser quote, settlement nets 489.00, second freeze heals nothing', async () => {
|
|
const trip = await audTrip();
|
|
const bill = await request(server)
|
|
.post(`/api/trips/${trip}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ name: 'Pho', currency: 'VND', payers: [{ user_id: 1, amount: 8920000 }], member_ids: [1, 2] });
|
|
expect(bill.status).toBe(201);
|
|
expect(bill.body.item.exchange_rate).toBe(1);
|
|
|
|
// Left out whole before anything is written: no balance in VND's order of magnitude.
|
|
const before = await request(server).get(`/api/trips/${trip}/budget/settlement`).set('Cookie', sessionCookie(1));
|
|
expect(before.status).toBe(200);
|
|
expect(before.body.currency).toBe('AUD');
|
|
expect(before.body.unconverted).toEqual({
|
|
item_ids: [bill.body.item.id],
|
|
settlement_ids: [],
|
|
currencies: ['VND'],
|
|
});
|
|
expect(before.body.balances).toEqual([]);
|
|
|
|
const fallback_fx = { base: 'AUD', rates: { VND: 18241.3, EUR: 0.61 } };
|
|
const healed = await request(server)
|
|
.post(`/api/trips/${trip}/budget/freeze-rates`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ fallback_fx });
|
|
expect(healed.status).toBe(200);
|
|
expect(healed.body.items.map((i: { id: number; exchange_rate: number }) => [i.id, i.exchange_rate])).toEqual([
|
|
[bill.body.item.id, 18241.3],
|
|
]);
|
|
expect(healed.body).toMatchObject({ settlements: [], unresolved: [] });
|
|
|
|
const after = await request(server).get(`/api/trips/${trip}/budget/settlement`).set('Cookie', sessionCookie(1));
|
|
expect(after.body.unconverted).toEqual({ item_ids: [], settlement_ids: [], currencies: [] });
|
|
// 8,920,000 VND at 18,241.3 per dollar is 489.00 AUD, half of it user 2's.
|
|
expect(after.body.finalBudgets.find((f: { user_id: number }) => f.user_id === 1).expenses).toBe(489);
|
|
expect(balanceOf(after.body, 1)).toBe(244.5);
|
|
expect(balanceOf(after.body, 2)).toBe(-244.5);
|
|
|
|
const again = await request(server)
|
|
.post(`/api/trips/${trip}/budget/freeze-rates`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ fallback_fx: { base: 'AUD', rates: { VND: 25000 } } });
|
|
expect(again.status).toBe(200);
|
|
expect(again.body).toEqual({ items: [], settlements: [], unresolved: [] });
|
|
expect((await budgetRow(bill.body.item.id)).exchange_rate).toBe(18241.3);
|
|
});
|
|
|
|
it('POST with fallback_fx freezes at entry', async () => {
|
|
const trip = await audTrip();
|
|
const res = await request(server)
|
|
.post(`/api/trips/${trip}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({
|
|
name: 'Pho',
|
|
currency: 'VND',
|
|
total_price: 100000,
|
|
fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } },
|
|
});
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.item.exchange_rate).toBe(18241.3);
|
|
expect(res.body.item).not.toHaveProperty('fallback_fx');
|
|
});
|
|
|
|
it('POST /settlements in EUR with fallback_fx freezes the transfer', async () => {
|
|
const trip = await audTrip();
|
|
const res = await request(server)
|
|
.post(`/api/trips/${trip}/budget/settlements`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({
|
|
from_user_id: 2,
|
|
to_user_id: 1,
|
|
amount: 30.5,
|
|
currency: 'EUR',
|
|
fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } },
|
|
});
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.settlement).toMatchObject({ currency: 'EUR', exchange_rate: 0.61 });
|
|
|
|
// Read back in euros at the same browser quote, the transfer is what was typed.
|
|
const s = await request(server)
|
|
.get(`/api/trips/${trip}/budget/settlement?base=EUR&base_rate=0.61`)
|
|
.set('Cookie', sessionCookie(1));
|
|
expect(s.body.currency).toBe('EUR');
|
|
expect(s.body.unconverted.settlement_ids).toEqual([]);
|
|
expect(balanceOf(s.body, 2)).toBe(30.5);
|
|
expect(balanceOf(s.body, 1)).toBe(-30.5);
|
|
});
|
|
|
|
it('GET /settlement?base=EUR answers in AUD without base_rate and in EUR with it', async () => {
|
|
const trip = await audTrip();
|
|
const dinner = await request(server)
|
|
.post(`/api/trips/${trip}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ name: 'Dinner', payers: [{ user_id: 1, amount: 100 }], member_ids: [1, 2] });
|
|
expect(dinner.status).toBe(201);
|
|
|
|
const plain = await request(server)
|
|
.get(`/api/trips/${trip}/budget/settlement?base=EUR`)
|
|
.set('Cookie', sessionCookie(1));
|
|
expect(plain.status).toBe(200);
|
|
// Trip dollars, labelled as such rather than printed as euros.
|
|
expect(plain.body.currency).toBe('AUD');
|
|
expect(balanceOf(plain.body, 2)).toBe(-50);
|
|
|
|
const quoted = await request(server)
|
|
.get(`/api/trips/${trip}/budget/settlement?base=EUR&base_rate=0.61`)
|
|
.set('Cookie', sessionCookie(1));
|
|
expect(quoted.status).toBe(200);
|
|
expect(quoted.body.currency).toBe('EUR');
|
|
expect(balanceOf(quoted.body, 2)).toBe(-30.5);
|
|
});
|
|
|
|
it('PUT currency change to VND without a rate stores 1 instead of the USD rate', async () => {
|
|
const trip = await audTrip();
|
|
const created = await request(server)
|
|
.post(`/api/trips/${trip}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ name: 'Taxi', currency: 'USD', exchange_rate: 0.65, total_price: 20 });
|
|
expect(created.body.item.exchange_rate).toBe(0.65);
|
|
|
|
const res = await request(server)
|
|
.put(`/api/trips/${trip}/budget/${created.body.item.id}`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ currency: 'VND' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.item).toMatchObject({ currency: 'VND', exchange_rate: 1 });
|
|
const stored = await budgetRow(created.body.item.id);
|
|
expect({ currency: stored.currency, exchange_rate: stored.exchange_rate }).toEqual({
|
|
currency: 'VND',
|
|
exchange_rate: 1,
|
|
});
|
|
});
|
|
|
|
it('freeze-rates 403 without budget_edit, 400 on malformed fallback_fx, other base heals nothing (unresolved VND)', async () => {
|
|
const trip = await audTrip();
|
|
const bill = await request(server)
|
|
.post(`/api/trips/${trip}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ name: 'Pho', currency: 'VND', total_price: 100000 });
|
|
expect(bill.status).toBe(201);
|
|
const freeze = (body: object) =>
|
|
request(server).post(`/api/trips/${trip}/budget/freeze-rates`).set('Cookie', sessionCookie(1)).send(body);
|
|
|
|
checkPermission.mockReturnValue(false);
|
|
const denied = await freeze({ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } });
|
|
expect(denied.status).toBe(403);
|
|
expect(denied.body).toEqual({ error: 'No permission' });
|
|
checkPermission.mockReturnValue(true);
|
|
|
|
for (const fallback_fx of [
|
|
{ base: 'aud', rates: { VND: 18241.3 } },
|
|
{ base: 'AUD', rates: { VND: 0 } },
|
|
{ base: 'AUD', rates: {} },
|
|
]) {
|
|
const malformed = await freeze({ fallback_fx });
|
|
expect(malformed.status).toBe(400);
|
|
expect(malformed.body.error).toContain('fallback_fx');
|
|
}
|
|
|
|
// A table against euros would freeze the bill against the wrong currency.
|
|
const otherBase = await freeze({ fallback_fx: { base: 'EUR', rates: { VND: 27000 } } });
|
|
expect(otherBase.status).toBe(200);
|
|
expect(otherBase.body).toEqual({ items: [], settlements: [], unresolved: ['VND'] });
|
|
expect((await budgetRow(bill.body.item.id)).exchange_rate).toBe(1);
|
|
});
|
|
|
|
it('400 on base_rate=abc', async () => {
|
|
const res = await request(server)
|
|
.get(`/api/trips/${tripId}/budget/settlement?base=EUR&base_rate=abc`)
|
|
.set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain('base_rate');
|
|
});
|
|
});
|
|
|
|
// #2604: removing someone from the trip leaves their rows on the expenses they
|
|
// were part of. Saving such an expense with the payload the form sends back used
|
|
// to drop them and shrink the total to what the rest had paid.
|
|
describe('someone who left the trip', () => {
|
|
it('PUT with the unchanged payers and split keeps them, the total and a balanced ledger; a stranger is still dropped', async () => {
|
|
await makeUser(orm, {
|
|
id: 3,
|
|
username: 'e2e-leaver',
|
|
email: 'leaver@example.test',
|
|
role: 'user',
|
|
password_version: 0,
|
|
});
|
|
await makeUser(orm, {
|
|
id: 4,
|
|
username: 'e2e-stranger',
|
|
email: 'stranger@example.test',
|
|
role: 'user',
|
|
password_version: 0,
|
|
});
|
|
const leftTripId = (await makeTrip(orm, 1, { title: 'E2E Left Trip', currency: 'EUR' })).id;
|
|
await addTripMember(orm, leftTripId, 2);
|
|
await addTripMember(orm, leftTripId, 3);
|
|
const payload = {
|
|
name: 'Rental car',
|
|
payers: [
|
|
{ user_id: 1, amount: 120 },
|
|
{ user_id: 3, amount: 60 },
|
|
],
|
|
member_ids: [1, 2, 3],
|
|
};
|
|
const created = await request(server)
|
|
.post(`/api/trips/${leftTripId}/budget`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send(payload);
|
|
expect(created.status).toBe(201);
|
|
await deleteRows(orm, TripMembers, { trip: leftTripId, user: 3 });
|
|
|
|
const saved = await request(server)
|
|
.put(`/api/trips/${leftTripId}/budget/${created.body.item.id}`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ ...payload, note: 'keys at the desk', member_ids: [1, 2, 3, 4] });
|
|
expect(saved.status).toBe(200);
|
|
expect(saved.body.item.total_price).toBe(180);
|
|
expect(saved.body.item.payers.map((p: { user_id: number }) => p.user_id).sort()).toEqual([1, 3]);
|
|
expect(saved.body.item.members.map((m: { user_id: number }) => m.user_id).sort()).toEqual([1, 2, 3]);
|
|
|
|
const settlement = await request(server)
|
|
.get(`/api/trips/${leftTripId}/budget/settlement`)
|
|
.set('Cookie', sessionCookie(1));
|
|
expect(settlement.status).toBe(200);
|
|
const balances = settlement.body.balances as { user_id: number; balance: number }[];
|
|
expect(Math.round(balances.reduce((a, b) => a + b.balance, 0) * 100)).toBe(0);
|
|
expect(balances.find((b) => b.user_id === 3)?.balance).toBe(0);
|
|
});
|
|
});
|
|
});
|