/** * Budget Planner integration tests. * Covers BUDGET-001 to BUDGET-010. */ import { buildApp } from '../../src/bootstrap'; import { db as testDb } from '../../src/db/database'; import { AppSettings } from '../../src/db/entities/AppSettings.entity'; import { BudgetItems } from '../../src/db/entities/BudgetItems.entity'; import { Reservations } from '../../src/db/entities/Reservations.entity'; import { authCookie } from '../helpers/auth'; import { createUser, createTrip, createBudgetItem, addTripMember, createReservation } from '../helpers/factories'; import { deleteRows, findRow, insertRow } from '../helpers/factories/rows'; import { setAppSetting } from '../helpers/factories/settings'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { MikroORM } from '@mikro-orm/core'; import type { INestApplication } from '@nestjs/common'; import type { Application } from 'express'; import request from 'supertest'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); let nestApp: INestApplication; let app: Application; let orm: MikroORM; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); orm = nestApp.get(MikroORM); }); /** A hotel expense linked to the booking. */ function seedBookedExpense(tripId: number, reservationId: number, totalPrice: number): Promise { return insertRow(orm, BudgetItems, { trip: tripId, name: 'Hotel Cost', category: 'Accommodation', total_price: totalPrice, reservation: reservationId, }); } beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); // GET /budget/settlement reaches ExchangeRatesService.getRates unconditionally // (budget.service.ts settlement()), and that is a real fetch to // api.frankfurter.dev with a 10 s abort. Without this stub the suite talks to // the internet: slow, offline-dependent, and it makes the run take minutes // longer on a machine that cannot reach it. Fail closed — every assertion here // is single-currency, so rates never enter the arithmetic. vi.stubGlobal( 'fetch', vi.fn(async () => { throw new Error('offline'); }), ); }); afterAll(async () => { vi.unstubAllGlobals(); await nestApp.close(); testDb.close(); }); // ───────────────────────────────────────────────────────────────────────────── // Create budget item // ───────────────────────────────────────────────────────────────────────────── describe('Create budget item', () => { it('BUDGET-001 — POST creates budget item', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app) .post(`/api/trips/${trip.id}/budget`) .set('Cookie', authCookie(user.id)) .send({ name: 'Flights', category: 'Transport', total_price: 500, currency: 'EUR' }); expect(res.status).toBe(201); expect(res.body.item.name).toBe('Flights'); expect(res.body.item.total_price).toBe(500); }); it('BUDGET-001 — POST without name returns 400', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app) .post(`/api/trips/${trip.id}/budget`) .set('Cookie', authCookie(user.id)) .send({ category: 'Transport', total_price: 200 }); expect(res.status).toBe(400); }); it('BUDGET-010 — non-member cannot create budget item', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, owner.id); const res = await request(app) .post(`/api/trips/${trip.id}/budget`) .set('Cookie', authCookie(other.id)) .send({ name: 'Hotels', total_price: 300 }); expect(res.status).toBe(404); }); }); // ───────────────────────────────────────────────────────────────────────────── // List budget items // ───────────────────────────────────────────────────────────────────────────── describe('List budget items', () => { it('BUDGET-002 — GET /api/trips/:tripId/budget returns all items', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); createBudgetItem(testDb, trip.id, { name: 'Flight', total_price: 300 }); createBudgetItem(testDb, trip.id, { name: 'Hotel', total_price: 500 }); const res = await request(app).get(`/api/trips/${trip.id}/budget`).set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.items).toHaveLength(2); }); it('BUDGET-002 — member can list budget items', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); createBudgetItem(testDb, trip.id, { name: 'Rental', total_price: 200 }); const res = await request(app).get(`/api/trips/${trip.id}/budget`).set('Cookie', authCookie(member.id)); expect(res.status).toBe(200); expect(res.body.items).toHaveLength(1); }); }); // ───────────────────────────────────────────────────────────────────────────── // Update budget item // ───────────────────────────────────────────────────────────────────────────── describe('Update budget item', () => { it('BUDGET-003 — PUT updates budget item fields', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const item = createBudgetItem(testDb, trip.id, { name: 'Old Name', total_price: 100 }); const res = await request(app) .put(`/api/trips/${trip.id}/budget/${item.id}`) .set('Cookie', authCookie(user.id)) .send({ name: 'New Name', total_price: 250 }); expect(res.status).toBe(200); expect(res.body.item.name).toBe('New Name'); expect(res.body.item.total_price).toBe(250); }); it('BUDGET-003 — PUT non-existent item returns 404', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const res = await request(app) .put(`/api/trips/${trip.id}/budget/99999`) .set('Cookie', authCookie(user.id)) .send({ name: 'Updated' }); expect(res.status).toBe(404); }); }); // ───────────────────────────────────────────────────────────────────────────── // Delete budget item // ───────────────────────────────────────────────────────────────────────────── describe('Delete budget item', () => { it('BUDGET-004 — DELETE removes item', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const item = createBudgetItem(testDb, trip.id); const del = await request(app).delete(`/api/trips/${trip.id}/budget/${item.id}`).set('Cookie', authCookie(user.id)); expect(del.status).toBe(200); expect(del.body.success).toBe(true); const list = await request(app).get(`/api/trips/${trip.id}/budget`).set('Cookie', authCookie(user.id)); expect(list.body.items).toHaveLength(0); }); it('BUDGET-004b — DELETE budget item does NOT delete its linked reservation', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const reservation = createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' }); const itemId = await seedBookedExpense(trip.id, reservation.id, 250); const del = await request(app).delete(`/api/trips/${trip.id}/budget/${itemId}`).set('Cookie', authCookie(user.id)); expect(del.status).toBe(200); const reservationAfter = await findRow(orm, Reservations, { id: reservation.id }); expect(reservationAfter).not.toBeNull(); }); }); // ───────────────────────────────────────────────────────────────────────────── // Members // ───────────────────────────────────────────────────────────────────────────── describe('Budget item members', () => { it('BUDGET-005 — PUT /members assigns members to budget item', async () => { const { user } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, user.id); addTripMember(testDb, trip.id, member.id); const item = createBudgetItem(testDb, trip.id); const res = await request(app) .put(`/api/trips/${trip.id}/budget/${item.id}/members`) .set('Cookie', authCookie(user.id)) .send({ user_ids: [user.id, member.id] }); expect(res.status).toBe(200); expect(res.body.members).toBeDefined(); // After assigning members, list items should include them (covers loadBudgetItems member loop) const listRes = await request(app).get(`/api/trips/${trip.id}/budget`).set('Cookie', authCookie(user.id)); expect(listRes.status).toBe(200); const foundItem = (listRes.body.items as any[]).find((i: any) => i.id === item.id); expect(foundItem).toBeDefined(); expect(foundItem.members).toHaveLength(2); }); it('BUDGET-005b — PUT /members with empty user_ids clears members', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const item = createBudgetItem(testDb, trip.id); // First assign a member await request(app) .put(`/api/trips/${trip.id}/budget/${item.id}/members`) .set('Cookie', authCookie(user.id)) .send({ user_ids: [user.id] }); // Then clear members with empty array const res = await request(app) .put(`/api/trips/${trip.id}/budget/${item.id}/members`) .set('Cookie', authCookie(user.id)) .send({ user_ids: [] }); expect(res.status).toBe(200); expect(res.body.members).toHaveLength(0); }); it('BUDGET-005 — PUT /members with non-array user_ids returns 400', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const item = createBudgetItem(testDb, trip.id); const res = await request(app) .put(`/api/trips/${trip.id}/budget/${item.id}/members`) .set('Cookie', authCookie(user.id)) .send({ user_ids: 'not-an-array' }); expect(res.status).toBe(400); }); it('BUDGET-006 — PUT /members/:userId/paid toggles paid status', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const item = createBudgetItem(testDb, trip.id); // Assign user as member first await request(app) .put(`/api/trips/${trip.id}/budget/${item.id}/members`) .set('Cookie', authCookie(user.id)) .send({ user_ids: [user.id] }); // Toggle to paid=true const res = await request(app) .put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`) .set('Cookie', authCookie(user.id)) .send({ paid: true }); expect(res.status).toBe(200); expect(res.body.member).toBeDefined(); expect(res.body.member.paid).toBe(1); // SQLite stores as integer // Toggle back to paid=false const res2 = await request(app) .put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`) .set('Cookie', authCookie(user.id)) .send({ paid: false }); expect(res2.status).toBe(200); expect(res2.body.member.paid).toBe(0); }); }); // ───────────────────────────────────────────────────────────────────────────── // Summary & Settlement // ───────────────────────────────────────────────────────────────────────────── describe('Budget summary and settlement', () => { it('BUDGET-007 — GET /summary/per-person returns per-person breakdown', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const item = createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 }); await request(app) .put(`/api/trips/${trip.id}/budget/${item.id}/members`) .set('Cookie', authCookie(user.id)) .send({ user_ids: [user.id] }); await request(app) .put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`) .set('Cookie', authCookie(user.id)) .send({ paid: true }); const res = await request(app) .get(`/api/trips/${trip.id}/budget/summary/per-person`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.summary).toHaveLength(1); const entry = res.body.summary[0]; expect(entry.user_id).toBe(user.id); expect(typeof entry.total_paid).toBe('number'); expect(entry.total_paid).toBeGreaterThan(0); }); it('BUDGET-008 — GET /settlement returns settlement transactions', async () => { const { user } = createUser(testDb); const { user: user2 } = createUser(testDb); const trip = createTrip(testDb, user.id); addTripMember(testDb, trip.id, user2.id); const item = createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 }); await request(app) .put(`/api/trips/${trip.id}/budget/${item.id}/members`) .set('Cookie', authCookie(user.id)) .send({ user_ids: [user.id, user2.id] }); // New model: who actually paid is recorded as an explicit payer (amount in // the expense currency), not a per-member "paid" toggle. await request(app) .put(`/api/trips/${trip.id}/budget/${item.id}/payers`) .set('Cookie', authCookie(user.id)) .send({ payers: [{ user_id: user.id, amount: 60 }] }); const res = await request(app).get(`/api/trips/${trip.id}/budget/settlement`).set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(Array.isArray(res.body.balances)).toBe(true); expect(Array.isArray(res.body.flows)).toBe(true); const payerBalance = res.body.balances.find((b: any) => b.user_id === user.id); const nonPayerBalance = res.body.balances.find((b: any) => b.user_id === user2.id); expect(payerBalance.balance).toBeCloseTo(30); expect(nonPayerBalance.balance).toBeCloseTo(-30); expect(res.body.flows).toHaveLength(1); expect(res.body.flows[0].from.user_id).toBe(user2.id); expect(res.body.flows[0].to.user_id).toBe(user.id); expect(res.body.flows[0].amount).toBeCloseTo(30); }); it('BUDGET-009 — settlement with no payers returns empty transactions', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); createBudgetItem(testDb, trip.id, { name: 'Train', total_price: 40 }); const res = await request(app).get(`/api/trips/${trip.id}/budget/settlement`).set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.balances).toEqual([]); expect(res.body.flows).toEqual([]); // Additive since the VND/AUD report: what the amounts are in, and what was left out. expect(res.body.currency).toBe('EUR'); expect(res.body.unconverted).toEqual({ item_ids: [], settlement_ids: [], currencies: [] }); }); }); // ───────────────────────────────────────────────────────────────────────────── // Reorder items // ───────────────────────────────────────────────────────────────────────────── describe('Reorder budget items', () => { it('BUDGET-011 — non-member gets 404 on PUT /reorder/items', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, owner.id); const item = createBudgetItem(testDb, trip.id); const res = await request(app) .put(`/api/trips/${trip.id}/budget/reorder/items`) .set('Cookie', authCookie(other.id)) .send({ orderedIds: [item.id] }); expect(res.status).toBe(404); }); it('BUDGET-012 — member without permission gets 403 on PUT /reorder/items', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); const item = createBudgetItem(testDb, trip.id); // Restrict budget_edit to trip_owner only await setAppSetting(orm, 'perm_budget_edit', 'trip_owner'); const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache'); await invalidatePermissionsCache(); const res = await request(app) .put(`/api/trips/${trip.id}/budget/reorder/items`) .set('Cookie', authCookie(member.id)) .send({ orderedIds: [item.id] }); expect(res.status).toBe(403); // Restore default await deleteRows(orm, AppSettings, { key: 'perm_budget_edit' }); await invalidatePermissionsCache(); }); it('BUDGET-013 — owner can reorder budget items — returns 200', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const item1 = createBudgetItem(testDb, trip.id, { name: 'First' }); const item2 = createBudgetItem(testDb, trip.id, { name: 'Second' }); const res = await request(app) .put(`/api/trips/${trip.id}/budget/reorder/items`) .set('Cookie', authCookie(user.id)) .send({ orderedIds: [item2.id, item1.id] }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); }); // ───────────────────────────────────────────────────────────────────────────── // Reorder categories // ───────────────────────────────────────────────────────────────────────────── describe('Reorder budget categories', () => { it('BUDGET-014 — non-member gets 404 on PUT /reorder/categories', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, owner.id); const res = await request(app) .put(`/api/trips/${trip.id}/budget/reorder/categories`) .set('Cookie', authCookie(other.id)) .send({ orderedCategories: ['Transport'] }); expect(res.status).toBe(404); }); it('BUDGET-015 — owner can reorder categories — returns 200', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); createBudgetItem(testDb, trip.id, { name: 'Flight', category: 'Transport' }); createBudgetItem(testDb, trip.id, { name: 'Hotel', category: 'Accommodation' }); const res = await request(app) .put(`/api/trips/${trip.id}/budget/reorder/categories`) .set('Cookie', authCookie(user.id)) .send({ orderedCategories: ['Accommodation', 'Transport'] }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); }); // ───────────────────────────────────────────────────────────────────────────── // Reservation price sync // ───────────────────────────────────────────────────────────────────────────── describe('Reservation price sync on budget item update', () => { it('BUDGET-016 — updating total_price syncs to linked reservation metadata', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const reservation = createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' }); // Create a budget item linked to the reservation const itemId = await seedBookedExpense(trip.id, reservation.id, 200); const res = await request(app) .put(`/api/trips/${trip.id}/budget/${itemId}`) .set('Cookie', authCookie(user.id)) .send({ total_price: 350 }); expect(res.status).toBe(200); expect(res.body.item.total_price).toBe(350); // Verify reservation metadata was synced const updatedReservation = await findRow(orm, Reservations, { id: reservation.id }); expect(updatedReservation).not.toBeNull(); const meta = JSON.parse(updatedReservation!.metadata || '{}'); expect(meta.price).toBe('350'); }); }); // ───────────────────────────────────────────────────────────────────────────── // Permission check — non-owner member trying to edit (when locked to trip_owner) // ───────────────────────────────────────────────────────────────────────────── describe('Budget edit permission enforcement', () => { it('BUDGET-017 — member cannot create item when budget_edit is restricted to trip_owner', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache'); await setAppSetting(orm, 'perm_budget_edit', 'trip_owner'); await invalidatePermissionsCache(); const res = await request(app) .post(`/api/trips/${trip.id}/budget`) .set('Cookie', authCookie(member.id)) .send({ name: 'Sneaky Expense', total_price: 100 }); expect(res.status).toBe(403); await deleteRows(orm, AppSettings, { key: 'perm_budget_edit' }); await invalidatePermissionsCache(); }); it('BUDGET-018 — member cannot reorder categories when budget_edit is restricted to trip_owner', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); createBudgetItem(testDb, trip.id, { name: 'Item', category: 'Transport' }); const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache'); await setAppSetting(orm, 'perm_budget_edit', 'trip_owner'); await invalidatePermissionsCache(); const res = await request(app) .put(`/api/trips/${trip.id}/budget/reorder/categories`) .set('Cookie', authCookie(member.id)) .send({ orderedCategories: ['Transport'] }); expect(res.status).toBe(403); await deleteRows(orm, AppSettings, { key: 'perm_budget_edit' }); await invalidatePermissionsCache(); }); });