mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
94221a6d91
assignments, budget, collab, accommodations, reservations and booking-import drop their copied requireTrip/requireEdit helpers for the guard. Status codes and bodies are unchanged. places is deliberately NOT converted, and that is the finding of this PR rather than an omission. Its e2e suite documents that the body pipe answers 400 ahead of the trip 404 — a parity shift somebody made on purpose — and a Nest guard always runs before the pipe, so converting it silently reverses that ordering. The first attempt also dropped the permission check on DELETE, which the integration suite caught as a 200 where a 403 belonged. places keeps its in-handler check, and README now says why so the next person does not rediscover it the same way. collab applies the guard per handler for the same reason files does: its note-file upload route would otherwise be refused before multer had read the body, and a response sent mid-upload destroys the socket. Two coverage notes. The per-domain thresholds are regenerated because deleting a well-covered helper lowers a file's ratio without anything becoming less tested — the drops are 0.4 to 1.2 points across six domains and match the helpers removed. And the atlas integration suite gets the same admin1-bundle warm-up the unit suite got: ATLAS-013 was paying 3.1s for the one-off decompression inside its own 5s budget, which is why it only ever failed under load. It now runs in 116ms.
248 lines
13 KiB
TypeScript
248 lines
13 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { HttpException } from '@nestjs/common';
|
|
import { BudgetController } from '../../../src/nest/budget/budget.controller';
|
|
import type { BudgetService } from '../../../src/nest/budget/budget.service';
|
|
import type { User } from '../../../src/types';
|
|
|
|
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
|
const trip = { id: 5, user_id: 1 };
|
|
|
|
function makeService(overrides: Partial<BudgetService> = {}): BudgetService {
|
|
return {
|
|
verifyTripAccess: vi.fn().mockReturnValue(trip),
|
|
canEdit: vi.fn().mockReturnValue(true),
|
|
broadcast: vi.fn(),
|
|
syncReservationPrice: vi.fn(),
|
|
...overrides,
|
|
} as unknown as BudgetService;
|
|
}
|
|
|
|
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
|
try {
|
|
fn();
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
const e = err as HttpException;
|
|
return { status: e.getStatus(), body: e.getResponse() };
|
|
}
|
|
throw new Error('expected the handler to throw');
|
|
}
|
|
|
|
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
|
try {
|
|
await fn();
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
const e = err as HttpException;
|
|
return { status: e.getStatus(), body: e.getResponse() };
|
|
}
|
|
throw new Error('expected the handler to throw');
|
|
}
|
|
|
|
/** The trip row TripAccessGuard resolves and @Trip() hands to the settlement route. */
|
|
const tripRow = { id: 5, user_id: 42, currency: 'USD' } as never;
|
|
|
|
describe('BudgetController (parity with the legacy /api/trips/:tripId/budget route)', () => {
|
|
|
|
it('GET / returns items', () => {
|
|
const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<BudgetService>);
|
|
expect(new BudgetController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
|
|
});
|
|
|
|
it('GET /summary/per-person + /settlement delegate', () => {
|
|
const settlement = vi.fn().mockReturnValue({ transfers: [] });
|
|
const svc = makeService({
|
|
perPersonSummary: vi.fn().mockReturnValue([{ userId: 1, owes: 10 }]),
|
|
settlement,
|
|
} as Partial<BudgetService>);
|
|
expect(new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] });
|
|
// A trip with no currency set falls back to EUR rather than passing undefined on.
|
|
expect(new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5')).toEqual({ transfers: [] });
|
|
expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR');
|
|
});
|
|
|
|
it('GET /settlement forwards the base query and the trip currency', () => {
|
|
const settlement = vi.fn().mockReturnValue({ transfers: [] });
|
|
const svc = makeService({
|
|
verifyTripAccess: vi.fn().mockReturnValue({ id: 5, user_id: 1, currency: 'USD' }),
|
|
settlement,
|
|
} as Partial<BudgetService>);
|
|
new BudgetController(svc).settlement(user, tripRow, '5', 'GBP');
|
|
expect(settlement).toHaveBeenCalledWith('5', 'GBP', 'USD');
|
|
});
|
|
|
|
describe('settlements ledger', () => {
|
|
it('GET /settlements lists', () => {
|
|
const svc = makeService({ listSettlements: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<BudgetService>);
|
|
expect(new BudgetController(svc).listSettlements(user, '5')).toEqual({ settlements: [{ id: 1 }] });
|
|
});
|
|
|
|
|
|
// The legacy 'from_user_id, to_user_id and amount are required' 400s are now
|
|
// produced by the global ZodValidationPipe (budget.dto.ts) before the handler
|
|
// runs — covered by the integration suite, not constructible here.
|
|
|
|
it('POST /settlements creates and broadcasts (amount 0 is allowed), forwarding the display currency', async () => {
|
|
const createSettlement = vi.fn().mockResolvedValue({ id: 3, amount: 0 });
|
|
const broadcast = vi.fn();
|
|
const svc = makeService({ createSettlement, broadcast } as Partial<BudgetService>);
|
|
const res = await new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 0, currency: 'USD' }, 'sock');
|
|
expect(res).toEqual({ settlement: { id: 3, amount: 0 } });
|
|
expect(createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 0, currency: 'USD' }, user.id);
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-created', { settlement: { id: 3, amount: 0 } }, 'sock');
|
|
});
|
|
|
|
it('DELETE /settlements/:id 404 when missing', () => {
|
|
const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(false) } as Partial<BudgetService>);
|
|
expect(thrown(() => new BudgetController(svc).deleteSettlement(user, '5', '7'))).toEqual({
|
|
status: 404, body: { error: 'Settlement not found' },
|
|
});
|
|
});
|
|
|
|
it('DELETE /settlements/:id success broadcasts the numeric id', () => {
|
|
const broadcast = vi.fn();
|
|
const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(true), broadcast } as Partial<BudgetService>);
|
|
expect(new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true });
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-deleted', { settlementId: 7 }, 'sock');
|
|
});
|
|
|
|
|
|
it('PUT /settlements/:id 404 when missing', async () => {
|
|
const svc = makeService({ updateSettlement: vi.fn().mockResolvedValue(null) } as Partial<BudgetService>);
|
|
expect(await thrownAsync(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
|
|
status: 404, body: { error: 'Settlement not found' },
|
|
});
|
|
});
|
|
|
|
it('PUT /settlements/:id updates and broadcasts, forwarding the display currency', async () => {
|
|
const updateSettlement = vi.fn().mockResolvedValue({ id: 7, from_user_id: 2, to_user_id: 1, amount: 15 });
|
|
const broadcast = vi.fn();
|
|
const svc = makeService({ updateSettlement, broadcast } as Partial<BudgetService>);
|
|
const res = await new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 2, to_user_id: 1, amount: 15, currency: 'USD' }, 'sock');
|
|
expect(res).toEqual({ settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } });
|
|
expect(updateSettlement).toHaveBeenCalledWith('7', '5', { from_user_id: 2, to_user_id: 1, amount: 15, currency: 'USD' });
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-updated', { settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } }, 'sock');
|
|
});
|
|
});
|
|
|
|
describe('POST /', () => {
|
|
|
|
// The legacy 'Name is required' 400 is now produced by the global
|
|
// ZodValidationPipe (budgetCreateItemRequestSchema requires name) before
|
|
// the handler runs — covered by the integration suite.
|
|
|
|
it('creates and broadcasts', async () => {
|
|
const create = vi.fn().mockReturnValue({ id: 9, name: 'Hotel' });
|
|
const broadcast = vi.fn();
|
|
const svc = makeService({ create, broadcast } as Partial<BudgetService>);
|
|
expect(await new BudgetController(svc).create(user, '5', { name: 'Hotel', total_price: 200 }, 'sock')).toEqual({ item: { id: 9, name: 'Hotel' } });
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 9, name: 'Hotel' } }, 'sock');
|
|
});
|
|
});
|
|
|
|
describe('PUT /:id', () => {
|
|
it('404 when item missing', async () => {
|
|
const svc = makeService({ update: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
|
|
expect(await thrownAsync(() => new BudgetController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({
|
|
status: 404, body: { error: 'Budget item not found' },
|
|
});
|
|
});
|
|
|
|
it('syncs the reservation price when a linked item changes total_price', async () => {
|
|
const update = vi.fn().mockReturnValue({ id: 9, reservation_id: 42, total_price: 250 });
|
|
const syncReservationPrice = vi.fn();
|
|
const broadcast = vi.fn();
|
|
const svc = makeService({ update, syncReservationPrice, broadcast } as Partial<BudgetService>);
|
|
await new BudgetController(svc).update(user, '5', '9', { total_price: 250 }, 'sock');
|
|
expect(syncReservationPrice).toHaveBeenCalledWith('5', 42, 250, 'sock');
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, reservation_id: 42, total_price: 250 } }, 'sock');
|
|
});
|
|
|
|
it('does not sync when the item has no linked reservation', async () => {
|
|
const update = vi.fn().mockReturnValue({ id: 9, reservation_id: null, total_price: 250 });
|
|
const syncReservationPrice = vi.fn();
|
|
const svc = makeService({ update, syncReservationPrice } as Partial<BudgetService>);
|
|
await new BudgetController(svc).update(user, '5', '9', { total_price: 250 });
|
|
expect(syncReservationPrice).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('PUT /:id/members', () => {
|
|
// The legacy 'user_ids must be an array' 400 is now produced by the global
|
|
// ZodValidationPipe (budgetUpdateMembersRequestSchema) before the handler runs.
|
|
|
|
it('404 when the item is missing', () => {
|
|
const svc = makeService({ updateMembers: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
|
|
expect(thrown(() => new BudgetController(svc).updateMembers(user, '5', '9', { user_ids: [2, 3] }))).toEqual({
|
|
status: 404, body: { error: 'Budget item not found' },
|
|
});
|
|
});
|
|
|
|
it('updates members and broadcasts persons count', () => {
|
|
const updateMembers = vi.fn().mockReturnValue({ members: [{ user_id: 2 }], item: { persons: 1 } });
|
|
const broadcast = vi.fn();
|
|
const svc = makeService({ updateMembers, broadcast } as Partial<BudgetService>);
|
|
const res = new BudgetController(svc).updateMembers(user, '5', '9', { user_ids: [2] }, 'sock');
|
|
expect(res).toEqual({ members: [{ user_id: 2 }], item: { persons: 1 } });
|
|
expect(updateMembers).toHaveBeenCalledWith('9', '5', [2]);
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:members-updated', { itemId: 9, members: [{ user_id: 2 }], persons: 1 }, 'sock');
|
|
});
|
|
});
|
|
|
|
describe('PUT /:id/payers', () => {
|
|
// The legacy 'payers must be an array' 400 is now produced by the global
|
|
// ZodValidationPipe (budgetUpdatePayersRequestSchema) before the handler runs.
|
|
|
|
it('404 when the item is missing', () => {
|
|
const svc = makeService({ setPayers: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
|
|
expect(thrown(() => new BudgetController(svc).setPayers(user, '5', '9', { payers: [{ user_id: 2, amount: 10 }] }))).toEqual({
|
|
status: 404, body: { error: 'Budget item not found' },
|
|
});
|
|
});
|
|
|
|
it('sets payers and broadcasts budget:updated', () => {
|
|
const setPayers = vi.fn().mockReturnValue({ id: 9, payers: [{ user_id: 2, amount: 10 }] });
|
|
const broadcast = vi.fn();
|
|
const svc = makeService({ setPayers, broadcast } as Partial<BudgetService>);
|
|
const res = new BudgetController(svc).setPayers(user, '5', '9', { payers: [{ user_id: 2, amount: 10 }] }, 'sock');
|
|
expect(res).toEqual({ item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } });
|
|
expect(setPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]);
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } }, 'sock');
|
|
});
|
|
});
|
|
|
|
it('PUT /:id/members/:userId/paid toggles + broadcasts normalised paid flag', () => {
|
|
const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 1 });
|
|
const broadcast = vi.fn();
|
|
const svc = makeService({ toggleMemberPaid, broadcast } as Partial<BudgetService>);
|
|
expect(new BudgetController(svc).toggleMemberPaid(user, '5', '9', '2', { paid: true }, 'sock')).toEqual({ member: { user_id: 2, paid: 1 } });
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 1 }, 'sock');
|
|
});
|
|
|
|
it('PUT /:id/members/:userId/paid broadcasts paid: 0 when toggled off', () => {
|
|
const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 0 });
|
|
const broadcast = vi.fn();
|
|
const svc = makeService({ toggleMemberPaid, broadcast } as Partial<BudgetService>);
|
|
new BudgetController(svc).toggleMemberPaid(user, '5', '9', '2', { paid: false }, 'sock');
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 0 }, 'sock');
|
|
});
|
|
|
|
it('DELETE /:id 404 when missing, success otherwise', () => {
|
|
const missing = makeService({ remove: vi.fn().mockReturnValue(false) } as Partial<BudgetService>);
|
|
expect(thrown(() => new BudgetController(missing).remove(user, '5', '9'))).toEqual({
|
|
status: 404, body: { error: 'Budget item not found' },
|
|
});
|
|
const ok = makeService({ remove: vi.fn().mockReturnValue(true), broadcast: vi.fn() } as Partial<BudgetService>);
|
|
expect(new BudgetController(ok).remove(user, '5', '9')).toEqual({ success: true });
|
|
});
|
|
|
|
it('PUT /reorder/items + /reorder/categories broadcast budget:reordered', () => {
|
|
const reorderItems = vi.fn(); const reorderCategories = vi.fn(); const broadcast = vi.fn();
|
|
const svc = makeService({ reorderItems, reorderCategories, broadcast } as Partial<BudgetService>);
|
|
expect(new BudgetController(svc).reorderItems(user, '5', { orderedIds: [3, 1] }, 'sock')).toEqual({ success: true });
|
|
expect(reorderItems).toHaveBeenCalledWith('5', [3, 1]);
|
|
expect(new BudgetController(svc).reorderCategories(user, '5', { orderedCategories: ['food', 'fun'] }, 'sock')).toEqual({ success: true });
|
|
expect(reorderCategories).toHaveBeenCalledWith('5', ['food', 'fun']);
|
|
});
|
|
});
|