mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
7266ad99ae
* fix(server): set oxc:false in vitest so the SWC transform survives the Vite 8 bump * fix(server): switch coverage to the istanbul provider (v8 under-reports branches on Vite 8 + Vitest 4) * test(nest): cover controller/service branches to clear the 80% coverage gate
166 lines
7.4 KiB
TypeScript
166 lines
7.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock the data + side-effect dependencies the wrapper reaches into directly.
|
|
const { dbMock } = vi.hoisted(() => {
|
|
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
|
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
|
});
|
|
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
|
|
|
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
|
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
|
|
|
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn(() => true) }));
|
|
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
|
|
|
|
const { getRates } = vi.hoisted(() => ({ getRates: vi.fn() }));
|
|
vi.mock('../../../src/services/exchangeRateService', () => ({ getRates }));
|
|
|
|
const { budget } = vi.hoisted(() => ({
|
|
budget: {
|
|
verifyTripAccess: vi.fn(),
|
|
listBudgetItems: vi.fn(),
|
|
getPerPersonSummary: vi.fn(),
|
|
calculateSettlement: vi.fn(),
|
|
createBudgetItem: vi.fn(),
|
|
updateBudgetItem: vi.fn(),
|
|
deleteBudgetItem: vi.fn(),
|
|
updateMembers: vi.fn(),
|
|
toggleMemberPaid: vi.fn(),
|
|
setItemPayers: vi.fn(),
|
|
listSettlements: vi.fn(),
|
|
createSettlement: vi.fn(),
|
|
deleteSettlement: vi.fn(),
|
|
reorderBudgetItems: vi.fn(),
|
|
reorderBudgetCategories: vi.fn(),
|
|
},
|
|
}));
|
|
vi.mock('../../../src/services/budgetService', () => budget);
|
|
|
|
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
|
|
|
function svc() {
|
|
return new BudgetService();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
describe('BudgetService', () => {
|
|
it('verifyTripAccess delegates to the legacy service', () => {
|
|
budget.verifyTripAccess.mockReturnValue({ id: 5, user_id: 2 });
|
|
expect(svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 });
|
|
expect(budget.verifyTripAccess).toHaveBeenCalledWith('5', 2);
|
|
});
|
|
|
|
it('canEdit forwards the ownership flag when the user owns the trip', () => {
|
|
checkPermission.mockReturnValue(true);
|
|
expect(svc().canEdit({ user_id: 1 } as never, { id: 1, role: 'user' } as never)).toBe(true);
|
|
expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 1, 1, false);
|
|
});
|
|
|
|
it('canEdit marks the user as a guest when they do not own the trip', () => {
|
|
checkPermission.mockReturnValue(false);
|
|
expect(svc().canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never)).toBe(false);
|
|
expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 2, 1, true);
|
|
});
|
|
|
|
it('broadcast forwards to the websocket helper', () => {
|
|
svc().broadcast('5', 'budget:created', { item: { id: 1 } }, 'sock');
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 1 } }, 'sock');
|
|
});
|
|
|
|
it('list / perPersonSummary delegate', () => {
|
|
budget.listBudgetItems.mockReturnValue([{ id: 1 }]);
|
|
expect(svc().list('5')).toEqual([{ id: 1 }]);
|
|
budget.getPerPersonSummary.mockReturnValue([{ userId: 1 }]);
|
|
expect(svc().perPersonSummary('5')).toEqual([{ userId: 1 }]);
|
|
});
|
|
|
|
describe('settlement', () => {
|
|
it('upper-cases the explicit base and forwards the rates', async () => {
|
|
getRates.mockResolvedValue({ USD: 1.1 });
|
|
budget.calculateSettlement.mockReturnValue({ transfers: [] });
|
|
await svc().settlement('5', 'usd', 'EUR');
|
|
expect(getRates).toHaveBeenCalledWith('USD');
|
|
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' });
|
|
});
|
|
|
|
it('falls back to the trip currency when no base is given', async () => {
|
|
getRates.mockResolvedValue(null);
|
|
await svc().settlement('5', undefined, 'gbp');
|
|
expect(getRates).toHaveBeenCalledWith('GBP');
|
|
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' });
|
|
});
|
|
|
|
it('falls back to EUR when neither base nor trip currency is present', async () => {
|
|
getRates.mockResolvedValue(null);
|
|
await svc().settlement('5', undefined, '');
|
|
expect(getRates).toHaveBeenCalledWith('EUR');
|
|
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' });
|
|
});
|
|
});
|
|
|
|
it('create / update / remove / members / paid / payers delegate', () => {
|
|
svc().create('5', { name: 'Hotel' } as never);
|
|
expect(budget.createBudgetItem).toHaveBeenCalledWith('5', { name: 'Hotel' });
|
|
svc().update('9', '5', { name: 'X' });
|
|
expect(budget.updateBudgetItem).toHaveBeenCalledWith('9', '5', { name: 'X' });
|
|
svc().remove('9', '5');
|
|
expect(budget.deleteBudgetItem).toHaveBeenCalledWith('9', '5');
|
|
svc().updateMembers('9', '5', [2, 3]);
|
|
expect(budget.updateMembers).toHaveBeenCalledWith('9', '5', [2, 3]);
|
|
svc().toggleMemberPaid('9', '5', '2', true);
|
|
expect(budget.toggleMemberPaid).toHaveBeenCalledWith('9', '5', '2', true);
|
|
svc().setPayers('9', '5', [{ user_id: 2, amount: 10 }]);
|
|
expect(budget.setItemPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]);
|
|
});
|
|
|
|
it('settlement ledger + reorder delegate', () => {
|
|
svc().listSettlements('5');
|
|
expect(budget.listSettlements).toHaveBeenCalledWith('5');
|
|
svc().createSettlement('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
|
expect(budget.createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
|
svc().deleteSettlement('7', '5');
|
|
expect(budget.deleteSettlement).toHaveBeenCalledWith('7', '5');
|
|
svc().reorderItems('5', [3, 1]);
|
|
expect(budget.reorderBudgetItems).toHaveBeenCalledWith('5', [3, 1]);
|
|
svc().reorderCategories('5', ['food', 'fun']);
|
|
expect(budget.reorderBudgetCategories).toHaveBeenCalledWith('5', ['food', 'fun']);
|
|
});
|
|
|
|
describe('syncReservationPrice', () => {
|
|
it('returns early when the reservation is not found', () => {
|
|
dbMock._stmt.get.mockReturnValueOnce(undefined);
|
|
svc().syncReservationPrice('5', 42, 250, 'sock');
|
|
expect(dbMock._stmt.run).not.toHaveBeenCalled();
|
|
expect(broadcast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('merges into existing metadata and broadcasts reservation:updated', () => {
|
|
dbMock._stmt.get
|
|
.mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME"}' }) // lookup
|
|
.mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME","price":"250"}' }); // reload
|
|
svc().syncReservationPrice('5', 42, 250, 'sock');
|
|
const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string);
|
|
expect(writtenMeta).toEqual({ vendor: 'ACME', price: '250' });
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:updated', { reservation: { id: 42, metadata: '{"vendor":"ACME","price":"250"}' } }, 'sock');
|
|
});
|
|
|
|
it('starts from an empty object when the reservation has no metadata', () => {
|
|
dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: null }).mockReturnValueOnce({ id: 42 });
|
|
svc().syncReservationPrice('5', 42, 99, undefined);
|
|
const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string);
|
|
expect(writtenMeta).toEqual({ price: '99' });
|
|
});
|
|
|
|
it('swallows errors so a sync failure never breaks the budget update', () => {
|
|
dbMock.prepare.mockImplementationOnce(() => { throw new Error('db gone'); });
|
|
expect(() => svc().syncReservationPrice('5', 42, 250, 'sock')).not.toThrow();
|
|
expect(broadcast).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|