mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
chore: move budget backend to proper DI
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Unit tests for MCP resources (resources.ts).
|
||||
* Tests all 7 legacy-registrar resources via InMemoryTransport + Client
|
||||
* (trek://categories moved to the DI-discovered CategoriesMcp — see
|
||||
* Tests all 6 legacy-registrar resources via InMemoryTransport + Client
|
||||
* (trek://trips/{tripId}/budget, .../budget/per-person and
|
||||
* .../budget/settlement moved to the DI-discovered BudgetMcp — see
|
||||
* tools-budget.test.ts and tools-budget-advanced.test.ts;
|
||||
* trek://categories moved to the DI-discovered CategoriesMcp — see
|
||||
* tools-categories.test.ts; trek://trips/{tripId}/todos moved to TodoMcp —
|
||||
* see tools-todos.test.ts; trek://trips/{tripId}/packing and .../packing/bags
|
||||
* moved to PackingMcp — see tools-packing.test.ts;
|
||||
@@ -48,7 +51,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createPlace, addTripMember, createBudgetItem, createReservation, createCollabNote, createBucketListItem, createVisitedCountry } from '../../helpers/factories';
|
||||
import { createUser, createTrip, createPlace, addTripMember, createReservation, createCollabNote, createBucketListItem, createVisitedCountry } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -186,32 +189,9 @@ describe('Resource: trek://trips/{tripId}/places', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/budget', () => {
|
||||
it('returns budget items for a trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createBudgetItem(testDb, trip.id, { name: 'Hotel', category: 'Accommodation', total_price: 200 });
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/budget` });
|
||||
const items = parseResourceResult(result) as any[];
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].name).toBe('Hotel');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for unauthorized trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/budget` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
// The trek://trips/{tripId}/budget resource moved to the DI-discovered
|
||||
// src/nest/budget/budget.mcp.ts — its cases live in tools-budget.test.ts (the
|
||||
// registry attaches via registerTools, which this withTools:false harness skips).
|
||||
|
||||
// The trek://trips/{tripId}/packing and .../packing/bags resources moved to the
|
||||
// DI-discovered PackingMcp — see tools-packing.test.ts.
|
||||
|
||||
@@ -36,7 +36,7 @@ import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
@@ -304,3 +304,35 @@ describe('Tool: delete_budget_item', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource: trek://trips/{tripId}/budget (moved from the legacy resources.ts
|
||||
// registrar to the DI-discovered BudgetMcp — attached via the nest-mcp registry)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/budget', () => {
|
||||
it('returns budget items for a trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createBudgetItem(testDb, trip.id, { name: 'Hotel', category: 'Accommodation', total_price: 200 });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget` });
|
||||
const items = parseResourceResult(result) as { name: string }[];
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].name).toBe('Hotel');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for unauthorized trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget` });
|
||||
const data = parseResourceResult(result) as { error?: string };
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+57
-32
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* Settlement-math and FX-freeze unit tests for BudgetService over a prepare-stub
|
||||
* DB mock. Moved 1:1 from the legacy tests/unit/services/budgetService.test.ts
|
||||
* (same cases, incl. the #1335/#1445/#1426 pins); the SUT is now the folded
|
||||
* BudgetService with a constructor-injected ExchangeRatesService stub (was a
|
||||
* path mock of the deleted exchange-rates.bridge), and the raw settlement
|
||||
* update is exercised as applySettlementUpdate (the no-freeze write the REST
|
||||
* updateSettlement wraps).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── DB mock setup ────────────────────────────────────────────────────────────
|
||||
@@ -11,17 +20,33 @@ const mockDb = vi.hoisted(() => {
|
||||
run: vi.fn(),
|
||||
})),
|
||||
},
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: vi.fn(() => true),
|
||||
isOwner: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => mockDb);
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
const mockRates = vi.hoisted(() => ({ getRates: vi.fn() }));
|
||||
vi.mock('../../../src/nest/budget/exchange-rates.bridge', () => mockRates);
|
||||
const mockRates = { getRates: vi.fn() };
|
||||
|
||||
import { calculateSettlement, updateSettlement, freezeForeignRate } from '../../../src/services/budgetService';
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import type { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src/types';
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
const permissionsStub = { checkPermission: vi.fn(() => true) } as unknown as PermissionsService;
|
||||
|
||||
const budget = new BudgetService(
|
||||
new DatabaseService(mockDb.db as unknown as Database.Database),
|
||||
permissionsStub,
|
||||
mockRates as unknown as ExchangeRatesService,
|
||||
);
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
// Who actually paid is recorded as explicit payers (budget_item_payers); members
|
||||
@@ -85,14 +110,14 @@ beforeEach(() => {
|
||||
describe('calculateSettlement', () => {
|
||||
it('returns empty balances and flows when trip has no items', () => {
|
||||
setupDb([], [], []);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
expect(result.balances).toEqual([]);
|
||||
expect(result.flows).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no flows when there are items but no members', () => {
|
||||
setupDb([makeItem(1, 100)], [], [makePayer(1, 1, 100, 'alice')]);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
expect(result.flows).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -102,7 +127,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
expect(result.flows).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -113,7 +138,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
expect(alice.balance).toBe(50);
|
||||
@@ -131,7 +156,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 90, 'alice')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
const carol = result.balances.find(b => b.user_id === 3)!;
|
||||
@@ -148,7 +173,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 20, 'alice'), makePayer(1, 2, 20, 'bob'), makePayer(1, 3, 20, 'carol')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
for (const b of result.balances) {
|
||||
expect(Math.abs(b.balance)).toBeLessThanOrEqual(0.01);
|
||||
}
|
||||
@@ -162,7 +187,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const flow = result.flows[0];
|
||||
expect(flow.from.username).toBe('bob'); // debtor
|
||||
expect(flow.to.username).toBe('alice'); // creditor
|
||||
@@ -175,7 +200,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 10, 'alice')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
for (const b of result.balances) {
|
||||
const str = b.balance.toString();
|
||||
const decimals = str.includes('.') ? str.split('.')[1].length : 0;
|
||||
@@ -200,7 +225,7 @@ describe('calculateSettlement', () => {
|
||||
],
|
||||
[makePayer(1, 1, 100, 'alice'), makePayer(2, 2, 60, 'bob')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
expect(alice.balance).toBe(20);
|
||||
@@ -219,7 +244,7 @@ describe('calculateSettlement', () => {
|
||||
}
|
||||
return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() };
|
||||
});
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
expect(bob.balance).toBe(30);
|
||||
@@ -238,7 +263,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 110, 'alice')],
|
||||
);
|
||||
const result = calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } });
|
||||
const result = budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } });
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
// 110 / 1.1 = 100 EUR; Bob owes half = 50 (frozen). With the live 1.2 it would be ~45.83.
|
||||
expect(bob.balance).toBeCloseTo(-50, 2);
|
||||
@@ -250,7 +275,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 120, 'alice')],
|
||||
);
|
||||
const result = calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } });
|
||||
const result = budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } });
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
// 120 / 1.2 (live) = 100 EUR; Bob owes 50 — unchanged behaviour for pre-#1335 rows.
|
||||
expect(bob.balance).toBeCloseTo(-50, 2);
|
||||
@@ -267,7 +292,7 @@ describe('calculateSettlement', () => {
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
[makeSettlementRow(1, 2, 1, 62.5, 'USD', 1.25)],
|
||||
);
|
||||
const result = calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: 0.5 } });
|
||||
const result = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: 0.5 } });
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
expect(bob.balance).toBeCloseTo(0, 2); // settled — no residual re-opens
|
||||
});
|
||||
@@ -282,7 +307,7 @@ describe('calculateSettlement', () => {
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
[makeSettlementRow(1, 2, 1, 62.5, null, 1)],
|
||||
);
|
||||
const result = calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: 0.5 } });
|
||||
const result = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: 0.5 } });
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
// settleToTrip(62.5) = 62.5 * 0.5 = 31.25 EUR; balance -50 + 31.25 = -18.75 EUR → reopens.
|
||||
expect(Math.abs(bob.balance)).toBeGreaterThan(1);
|
||||
@@ -300,7 +325,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 45, 'alice'), makePayer(1, 2, 45, 'bob')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance;
|
||||
|
||||
expect(balance(1)).toBeCloseTo(15, 2);
|
||||
@@ -316,7 +341,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 70, 'alice'), makePayer(1, 2, 30, 'bob')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance;
|
||||
|
||||
expect(balance(1)).toBeCloseTo(20, 2);
|
||||
@@ -340,7 +365,7 @@ describe('calculateSettlement', () => {
|
||||
makePayer(2, 2, 25, 'bob'), makePayer(2, 3, 30, 'carol'),
|
||||
],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const sum = result.balances.reduce((a, b) => a + b.balance, 0);
|
||||
|
||||
expect(sum).toBeCloseTo(0, 2);
|
||||
@@ -354,7 +379,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 33.34, 'alice'), makePayer(1, 2, 33.34, 'bob'), makePayer(1, 3, 33.33, 'carol')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const sum = result.balances.reduce((a, b) => a + b.balance, 0);
|
||||
|
||||
expect(sum).toBeCloseTo(0, 2);
|
||||
@@ -375,7 +400,7 @@ describe('freezeForeignRate', () => {
|
||||
});
|
||||
mockRates.getRates.mockResolvedValue({ EUR: 1, USD: 1.25 });
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'usd' };
|
||||
await freezeForeignRate(1, data);
|
||||
await budget.freezeForeignRate(1, data);
|
||||
expect(mockRates.getRates).toHaveBeenCalledWith('EUR');
|
||||
expect(data.exchange_rate).toBe(1.25);
|
||||
});
|
||||
@@ -384,14 +409,14 @@ describe('freezeForeignRate', () => {
|
||||
mockDb.db.prepare.mockImplementation((sql: string) =>
|
||||
sql.includes('FROM trips') ? tripRow('EUR') : { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'EUR' };
|
||||
await freezeForeignRate(1, data);
|
||||
await budget.freezeForeignRate(1, data);
|
||||
expect(mockRates.getRates).not.toHaveBeenCalled();
|
||||
expect(data.exchange_rate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('respects an explicit exchange_rate from the caller', async () => {
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD', exchange_rate: 2 };
|
||||
await freezeForeignRate(1, data);
|
||||
await budget.freezeForeignRate(1, data);
|
||||
expect(mockRates.getRates).not.toHaveBeenCalled();
|
||||
expect(data.exchange_rate).toBe(2);
|
||||
});
|
||||
@@ -401,7 +426,7 @@ describe('freezeForeignRate', () => {
|
||||
sql.includes('FROM trips') ? tripRow('EUR') : { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
|
||||
mockRates.getRates.mockResolvedValue(null);
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
|
||||
await freezeForeignRate(1, data);
|
||||
await budget.freezeForeignRate(1, data);
|
||||
expect(data.exchange_rate).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -412,7 +437,7 @@ describe('freezeForeignRate', () => {
|
||||
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
});
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
|
||||
await freezeForeignRate(1, data, 9);
|
||||
await budget.freezeForeignRate(1, data, 9);
|
||||
expect(mockRates.getRates).not.toHaveBeenCalled();
|
||||
expect(data.exchange_rate).toBeUndefined();
|
||||
});
|
||||
@@ -422,7 +447,7 @@ describe('freezeForeignRate', () => {
|
||||
sql.includes('FROM trips') ? tripRow('EUR') : { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
|
||||
// the settlement already holds USD — pass it as existingCurrency → keep the frozen rate
|
||||
await freezeForeignRate(1, data, undefined, 'USD');
|
||||
await budget.freezeForeignRate(1, data, undefined, 'USD');
|
||||
expect(mockRates.getRates).not.toHaveBeenCalled();
|
||||
expect(data.exchange_rate).toBeUndefined();
|
||||
});
|
||||
@@ -432,14 +457,14 @@ describe('freezeForeignRate', () => {
|
||||
sql.includes('FROM trips') ? tripRow('EUR') : { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
|
||||
mockRates.getRates.mockResolvedValue({ EUR: 1, USD: 1.25 });
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
|
||||
await freezeForeignRate(1, data, undefined, 'GBP'); // was GBP → now USD → re-freeze
|
||||
await budget.freezeForeignRate(1, data, undefined, 'GBP'); // was GBP → now USD → re-freeze
|
||||
expect(data.exchange_rate).toBe(1.25);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateSettlement ──────────────────────────────────────────────────────────
|
||||
// ── applySettlementUpdate (the raw no-freeze write behind updateSettlement) ──
|
||||
|
||||
describe('updateSettlement', () => {
|
||||
describe('applySettlementUpdate', () => {
|
||||
it('returns null when the settlement is not in the trip', () => {
|
||||
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||
if (sql.includes('SELECT id FROM budget_settlements')) {
|
||||
@@ -447,7 +472,7 @@ describe('updateSettlement', () => {
|
||||
}
|
||||
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
});
|
||||
expect(updateSettlement(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10 })).toBeNull();
|
||||
expect(budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10 })).toBeNull();
|
||||
});
|
||||
|
||||
it('updates the row (rounded to cents) and returns the refreshed settlement', () => {
|
||||
@@ -467,7 +492,7 @@ describe('updateSettlement', () => {
|
||||
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
});
|
||||
|
||||
const res = updateSettlement(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 });
|
||||
const res = budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 });
|
||||
// from, to, rounded amount, currency-flag(0)/value(null), rate-flag(null)/value(1), id.
|
||||
// No currency/exchange_rate passed → both CASE guards keep the existing columns.
|
||||
expect(run).toHaveBeenCalledWith(2, 1, 10.13, 0, null, null, 1, 7);
|
||||
+114
-40
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* DB-backed unit tests for budgetService trip-scoping (BUDGET-SVC-DB-001+).
|
||||
* DB-backed unit tests for BudgetService trip-scoping (BUDGET-SVC-DB-001+).
|
||||
* Uses a real in-memory SQLite DB so the SQL WHERE clauses are exercised.
|
||||
* BUDGET-SVC-DB-001 through 014 moved 1:1 from the legacy
|
||||
* tests/unit/services/budgetServiceDb.test.ts; 015–018 pin the budget.bridge
|
||||
* delegation (the bridge instance runs over the same mocked db Proxy).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
@@ -27,23 +30,43 @@ vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
// Frozen snapshot of the rates in play in #1543 (rates[X] = units of X per 1 base).
|
||||
const RATES: Record<string, Record<string, number>> = {
|
||||
RUB: { RUB: 1, USD: 0.013042, EUR: 0.011412 },
|
||||
EUR: { EUR: 1, USD: 1.1429, RUB: 87.63 },
|
||||
};
|
||||
vi.mock('../../../src/nest/budget/exchange-rates.bridge', () => ({
|
||||
getRates: vi.fn(async (base: string) => RATES[base.toUpperCase()] ?? null),
|
||||
const { RATES } = vi.hoisted(() => ({
|
||||
RATES: {
|
||||
RUB: { RUB: 1, USD: 0.013042, EUR: 0.011412 },
|
||||
EUR: { EUR: 1, USD: 1.1429, RUB: 87.63 },
|
||||
} as Record<string, Record<string, number>>,
|
||||
}));
|
||||
// Constructor-injected since the fold; the class is mocked at the module path so
|
||||
// the budget.bridge instance (which news up its own ExchangeRatesService) sees
|
||||
// the same deterministic rates as the SUT.
|
||||
vi.mock('../../../src/nest/budget/exchange-rates.service', () => ({
|
||||
ExchangeRatesService: class {
|
||||
async getRates(base: string) {
|
||||
return RATES[base.toUpperCase()] ?? null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import { createBudgetItem, updateBudgetItem, updateMembers, toggleMemberPaid, calculateSettlement, rebaseTripCurrency } from '../../../src/services/budgetService';
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import * as bridge from '../../../src/nest/budget/budget.bridge';
|
||||
import { createGuest, deleteGuest } from '../../../src/services/tripService';
|
||||
|
||||
const budget = new BudgetService(
|
||||
new DatabaseService(testDb),
|
||||
new PermissionsService(new DatabaseService(testDb)),
|
||||
new ExchangeRatesService(),
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
@@ -77,7 +100,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const guests = ['G1', 'G2', 'G3'].map(n => createGuest(trip.id, n, owner.id).member);
|
||||
const item = createBudgetItem(trip.id, {
|
||||
const item = budget.createBudgetItem(trip.id, {
|
||||
name: 'Dinner', total_price: 400,
|
||||
member_ids: [owner.id, ...guests.map(g => g.id)],
|
||||
});
|
||||
@@ -97,7 +120,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const guest = createGuest(trip.id, 'G1', owner.id).member;
|
||||
// No member rows — `persons` is just a number someone typed.
|
||||
const item = createBudgetItem(trip.id, { name: 'Rental', total_price: 300, persons: 6 });
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Rental', total_price: 300, persons: 6 });
|
||||
|
||||
deleteGuest(trip.id, guest.id);
|
||||
|
||||
@@ -108,7 +131,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const guest = createGuest(trip.id, 'G1', owner.id).member;
|
||||
const item = createBudgetItem(trip.id, { name: 'Taxi', total_price: 50, member_ids: [guest.id] });
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 50, member_ids: [guest.id] });
|
||||
|
||||
deleteGuest(trip.id, guest.id);
|
||||
|
||||
@@ -120,12 +143,12 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const guest = createGuest(trip.id, 'G1', owner.id).member;
|
||||
const item = createBudgetItem(trip.id, { name: 'Dinner', total_price: 200, member_ids: [owner.id, guest.id] });
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 200, member_ids: [owner.id, guest.id] });
|
||||
|
||||
deleteGuest(trip.id, guest.id);
|
||||
|
||||
// A client that loaded before the deletion still sends the guest back (#1553).
|
||||
const updated = updateBudgetItem(item.id, trip.id, { member_ids: [owner.id, guest.id] });
|
||||
const updated = budget.updateBudgetItem(item.id, trip.id, { member_ids: [owner.id, guest.id] });
|
||||
|
||||
expect(updated!.members.map(m => m.user_id)).toEqual([owner.id]);
|
||||
expect(personsOf(item.id)).toBe(1);
|
||||
@@ -135,10 +158,10 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const guest = createGuest(trip.id, 'G1', owner.id).member;
|
||||
const item = createBudgetItem(trip.id, { name: 'Drinks', total_price: 60, member_ids: [owner.id, guest.id] });
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Drinks', total_price: 60, member_ids: [owner.id, guest.id] });
|
||||
|
||||
deleteGuest(trip.id, guest.id);
|
||||
const result = updateMembers(item.id, trip.id, [owner.id, guest.id]);
|
||||
const result = budget.updateMembers(item.id, trip.id, [owner.id, guest.id]);
|
||||
|
||||
expect(result!.members.map(m => m.user_id)).toEqual([owner.id]);
|
||||
expect(personsOf(item.id)).toBe(1);
|
||||
@@ -149,10 +172,10 @@ describe('toggleMemberPaid trip-scoping', () => {
|
||||
it('BUDGET-SVC-DB-001: toggles paid for an item that belongs to the given trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip A' });
|
||||
const item = createBudgetItem(trip.id, { name: 'Hotel', total_price: 100 });
|
||||
updateMembers(item.id, trip.id, [user.id]);
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100 });
|
||||
budget.updateMembers(item.id, trip.id, [user.id]);
|
||||
|
||||
const member = toggleMemberPaid(item.id, trip.id, user.id, true);
|
||||
const member = budget.toggleMemberPaid(item.id, trip.id, user.id, true);
|
||||
|
||||
expect(member).not.toBeNull();
|
||||
expect(paidFlag(item.id, user.id)).toBe(1);
|
||||
@@ -162,11 +185,11 @@ describe('toggleMemberPaid trip-scoping', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tripA = createTrip(testDb, user.id, { title: 'Trip A' });
|
||||
const tripB = createTrip(testDb, user.id, { title: 'Trip B' });
|
||||
const itemB = createBudgetItem(tripB.id, { name: 'Foreign expense', total_price: 50 });
|
||||
updateMembers(itemB.id, tripB.id, [user.id]);
|
||||
const itemB = budget.createBudgetItem(tripB.id, { name: 'Foreign expense', total_price: 50 });
|
||||
budget.updateMembers(itemB.id, tripB.id, [user.id]);
|
||||
|
||||
// Caller passes a trip they can access (A) but the item lives in trip B.
|
||||
const member = toggleMemberPaid(itemB.id, tripA.id, user.id, true);
|
||||
const member = budget.toggleMemberPaid(itemB.id, tripA.id, user.id, true);
|
||||
|
||||
expect(member).toBeNull();
|
||||
expect(paidFlag(itemB.id, user.id)).toBe(0); // unchanged
|
||||
@@ -180,7 +203,7 @@ describe('calculateSettlement custom splits', () => {
|
||||
const trip = createTrip(testDb, alice.id, { title: 'Trip' });
|
||||
|
||||
// 100 total, custom split: Alice owes 90, Bob owes 10. Alice paid the whole bill.
|
||||
createBudgetItem(trip.id, {
|
||||
budget.createBudgetItem(trip.id, {
|
||||
name: 'Dinner',
|
||||
payers: [{ user_id: alice.id, amount: 100 }],
|
||||
members: [
|
||||
@@ -189,7 +212,7 @@ describe('calculateSettlement custom splits', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const result = calculateSettlement(trip.id);
|
||||
const result = budget.calculateSettlement(trip.id);
|
||||
|
||||
// Alice paid 100 but owes 90 → net +10 (creditor); Bob owes 10 → net -10 (debtor).
|
||||
// With the equal-split bug both owe 50, so the flow would be 50 instead of 10.
|
||||
@@ -214,9 +237,9 @@ function seedIssue1543Trip(tripCurrency: string) {
|
||||
|
||||
// 9 000 ₽ nobody has paid yet, 9 000 ₽ paid by me, and $100 paid by me. The USD row
|
||||
// carries the rate frozen at entry time: units of USD per 1 RUB.
|
||||
createBudgetItem(trip.id, { name: 'Проезд обратно', total_price: 9000, currency: 'RUB', members });
|
||||
createBudgetItem(trip.id, { name: 'Проезд туда', currency: 'RUB', payers: [{ user_id: me.id, amount: 9000 }], members });
|
||||
createBudgetItem(trip.id, {
|
||||
budget.createBudgetItem(trip.id, { name: 'Проезд обратно', total_price: 9000, currency: 'RUB', members });
|
||||
budget.createBudgetItem(trip.id, { name: 'Проезд туда', currency: 'RUB', payers: [{ user_id: me.id, amount: 9000 }], members });
|
||||
budget.createBudgetItem(trip.id, {
|
||||
name: 'test', currency: 'USD', exchange_rate: 0.013042,
|
||||
payers: [{ user_id: me.id, amount: 100 }], members,
|
||||
});
|
||||
@@ -227,7 +250,7 @@ describe('calculateSettlement with a foreign-currency expense (#1543)', () => {
|
||||
it('BUDGET-SVC-DB-004: nets in the trip currency instead of inflating the foreign share ~27x', () => {
|
||||
const { trip, me, danil, serega } = seedIssue1543Trip('RUB');
|
||||
|
||||
const result = calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB });
|
||||
const result = budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB });
|
||||
const balanceOf = (id: number) => result.balances.find(b => b.user_id === id)!.balance;
|
||||
|
||||
// Total spend is 9 000 + 9 000 + $100 (≈7 668 ₽), so each of the three owes a third
|
||||
@@ -248,7 +271,7 @@ describe('calculateSettlement with a foreign-currency expense (#1543)', () => {
|
||||
const { trip, danil } = seedIssue1543Trip('RUB');
|
||||
|
||||
// Same trip, viewed in EUR: every balance is the RUB one converted once, at the end.
|
||||
const inEur = calculateSettlement(trip.id, { base: 'EUR', tripCurrency: 'RUB', rates: RATES.EUR });
|
||||
const inEur = budget.calculateSettlement(trip.id, { base: 'EUR', tripCurrency: 'RUB', rates: RATES.EUR });
|
||||
const danilEur = inEur.balances.find(b => b.user_id === danil.id)!.balance;
|
||||
|
||||
const shareRub = (18000 + 100 / RATES.RUB.USD) / 3;
|
||||
@@ -269,11 +292,11 @@ describe('rebaseTripCurrency', () => {
|
||||
|
||||
// An expense that inherits the trip's base (currency NULL), one booked in USD, and
|
||||
// one already in the incoming currency.
|
||||
const implicit = createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members }) as { id: number };
|
||||
const usd = createBudgetItem(trip.id, { name: 'USD', total_price: 100, currency: 'USD', exchange_rate: 1.1429, members }) as { id: number };
|
||||
const rub = createBudgetItem(trip.id, { name: 'RUB', total_price: 9000, currency: 'RUB', exchange_rate: 87.63, members }) as { id: number };
|
||||
const implicit = budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members }) as { id: number };
|
||||
const usd = budget.createBudgetItem(trip.id, { name: 'USD', total_price: 100, currency: 'USD', exchange_rate: 1.1429, members }) as { id: number };
|
||||
const rub = budget.createBudgetItem(trip.id, { name: 'RUB', total_price: 9000, currency: 'RUB', exchange_rate: 87.63, members }) as { id: number };
|
||||
|
||||
await rebaseTripCurrency(trip.id, 'RUB');
|
||||
await budget.rebaseTripCurrency(trip.id, 'RUB');
|
||||
|
||||
// The implicit row really held euros, so it is stamped EUR rather than silently
|
||||
// becoming 100 ₽, and every rate is re-anchored to the new base.
|
||||
@@ -290,15 +313,15 @@ describe('rebaseTripCurrency', () => {
|
||||
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
|
||||
const members = [{ user_id: alice.id }, { user_id: bob.id }];
|
||||
|
||||
createBudgetItem(trip.id, { name: 'Hotel', payers: [{ user_id: alice.id, amount: 100 }], members });
|
||||
createBudgetItem(trip.id, { name: 'Dinner', currency: 'USD', exchange_rate: 1.1429, payers: [{ user_id: bob.id, amount: 60 }], members });
|
||||
budget.createBudgetItem(trip.id, { name: 'Hotel', payers: [{ user_id: alice.id, amount: 100 }], members });
|
||||
budget.createBudgetItem(trip.id, { name: 'Dinner', currency: 'USD', exchange_rate: 1.1429, payers: [{ user_id: bob.id, amount: 60 }], members });
|
||||
|
||||
const before = calculateSettlement(trip.id, { base: 'EUR', tripCurrency: 'EUR', rates: RATES.EUR });
|
||||
const before = budget.calculateSettlement(trip.id, { base: 'EUR', tripCurrency: 'EUR', rates: RATES.EUR });
|
||||
|
||||
await rebaseTripCurrency(trip.id, 'RUB');
|
||||
await budget.rebaseTripCurrency(trip.id, 'RUB');
|
||||
testDb.prepare("UPDATE trips SET currency = 'RUB' WHERE id = ?").run(trip.id);
|
||||
|
||||
const after = calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB });
|
||||
const after = budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB });
|
||||
|
||||
for (const b of before.balances) {
|
||||
const rub = after.balances.find(x => x.user_id === b.user_id)!.balance;
|
||||
@@ -322,7 +345,7 @@ describe('rebaseTripCurrency', () => {
|
||||
const jpy = priced(1500, 'JPY');
|
||||
const free = priced(null, null);
|
||||
|
||||
await rebaseTripCurrency(trip.id, 'JPY');
|
||||
await budget.rebaseTripCurrency(trip.id, 'JPY');
|
||||
|
||||
const placeRow = (id: number) =>
|
||||
testDb.prepare('SELECT price, currency FROM places WHERE id = ?')
|
||||
@@ -340,10 +363,61 @@ describe('rebaseTripCurrency', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
|
||||
const item = createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] }) as { id: number };
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] }) as { id: number };
|
||||
|
||||
await rebaseTripCurrency(trip.id, 'EUR');
|
||||
await budget.rebaseTripCurrency(trip.id, 'EUR');
|
||||
|
||||
expect(itemRow(item.id)).toEqual({ currency: null, exchange_rate: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('budget.bridge delegation', () => {
|
||||
it('BUDGET-SVC-DB-015: listBudgetItems returns the hydrated list through the bridge', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100, member_ids: [user.id] });
|
||||
|
||||
const items = bridge.listBudgetItems(trip.id);
|
||||
|
||||
expect(items.map(i => i.id)).toEqual([item.id]);
|
||||
expect(items[0].members.map(m => m.user_id)).toEqual([user.id]);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-016: removeUserFromBudgetItems re-derives persons through the bridge', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb, { username: 'other' });
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, other.id] });
|
||||
|
||||
bridge.removeUserFromBudgetItems(other.id);
|
||||
|
||||
const row = testDb.prepare('SELECT persons FROM budget_items WHERE id = ?').get(item.id) as { persons: number | null };
|
||||
expect(row.persons).toBe(1);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-017: rebaseTripCurrency pins the implicit currency through the bridge', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] });
|
||||
|
||||
await bridge.rebaseTripCurrency(trip.id, 'RUB');
|
||||
|
||||
const row = testDb.prepare('SELECT currency, exchange_rate FROM budget_items WHERE id = ?').get(item.id) as { currency: string | null; exchange_rate: number };
|
||||
expect(row).toEqual({ currency: 'EUR', exchange_rate: RATES.RUB.EUR });
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-018: linkBudgetItemToReservation stamps the reservation id through the bridge', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const reservationId = Number(testDb
|
||||
.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')")
|
||||
.run(trip.id).lastInsertRowid);
|
||||
|
||||
const item = bridge.linkBudgetItemToReservation(trip.id, reservationId, { name: 'Flight', total_price: 200 });
|
||||
|
||||
expect(item.reservation_id).toBe(reservationId);
|
||||
const row = testDb.prepare('SELECT reservation_id FROM budget_items WHERE id = ?').get(item.id) as { reservation_id: number | null };
|
||||
expect(row.reservation_id).toBe(reservationId);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,27 @@
|
||||
/**
|
||||
* Seam tests for the folded BudgetService: the permission/broadcast edges, the
|
||||
* controller-facing composite methods (create/update/settlement wrappers that
|
||||
* freeze the FX rate before the raw write — incl. the #1445 stored-currency
|
||||
* thread-through) and the wrapper-only syncReservationPrice SQL. The SQL-heavy
|
||||
* paths themselves are covered by budget.service.db.test.ts (real :memory: DB)
|
||||
* and budget.service.calc.test.ts (settlement math over a prepare-stub mock).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the data + side-effect dependencies the wrapper reaches into directly.
|
||||
// Mock the data + side-effect dependencies the service 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 { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: dbMock,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
canAccessTrip,
|
||||
getPlaceWithTags: () => null,
|
||||
isOwner: () => false,
|
||||
}));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
@@ -16,35 +32,11 @@ vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
const checkPermission = vi.fn(() => true);
|
||||
const permissionsStub = { checkPermission } as unknown as PermissionsService;
|
||||
|
||||
// Constructor-injected stub since the exchange-rates fold (was a path mock of
|
||||
// the deleted services/exchangeRateService).
|
||||
// Constructor-injected ExchangeRatesService stub (as in the pre-fold wrapper).
|
||||
import type { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
const getRates = vi.fn();
|
||||
const exchangeRatesStub = { getRates } as unknown as ExchangeRatesService;
|
||||
|
||||
const { budget } = vi.hoisted(() => ({
|
||||
budget: {
|
||||
verifyTripAccess: vi.fn(),
|
||||
listBudgetItems: vi.fn(),
|
||||
getPerPersonSummary: vi.fn(),
|
||||
calculateSettlement: vi.fn(),
|
||||
freezeForeignRate: 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(),
|
||||
updateSettlement: 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() {
|
||||
@@ -57,10 +49,10 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('BudgetService', () => {
|
||||
it('verifyTripAccess delegates to the legacy service', () => {
|
||||
budget.verifyTripAccess.mockReturnValue({ id: 5, user_id: 2 });
|
||||
it('verifyTripAccess resolves through canAccessTrip (services/tripAccess)', () => {
|
||||
canAccessTrip.mockReturnValue({ id: 5, user_id: 2 });
|
||||
expect(svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 });
|
||||
expect(budget.verifyTripAccess).toHaveBeenCalledWith('5', 2);
|
||||
expect(canAccessTrip).toHaveBeenCalledWith('5', 2);
|
||||
});
|
||||
|
||||
it('canEdit forwards the ownership flag when the user owns the trip', () => {
|
||||
@@ -80,70 +72,91 @@ describe('BudgetService', () => {
|
||||
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 }]);
|
||||
it('list / perPersonSummary resolve through the folded SQL methods', () => {
|
||||
const s = svc();
|
||||
const listSpy = vi.spyOn(s, 'listBudgetItems').mockReturnValue([{ id: 1 }] as never);
|
||||
expect(s.list('5')).toEqual([{ id: 1 }]);
|
||||
expect(listSpy).toHaveBeenCalledWith('5');
|
||||
const summarySpy = vi.spyOn(s, 'getPerPersonSummary').mockReturnValue([{ userId: 1 }] as never);
|
||||
expect(s.perPersonSummary('5')).toEqual([{ userId: 1 }]);
|
||||
expect(summarySpy).toHaveBeenCalledWith('5');
|
||||
});
|
||||
|
||||
describe('settlement', () => {
|
||||
it('upper-cases the explicit base and forwards the rates', async () => {
|
||||
const s = svc();
|
||||
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
|
||||
getRates.mockResolvedValue({ USD: 1.1 });
|
||||
budget.calculateSettlement.mockReturnValue({ transfers: [] });
|
||||
await svc().settlement('5', 'usd', 'EUR');
|
||||
await s.settlement('5', 'usd', 'EUR');
|
||||
expect(getRates).toHaveBeenCalledWith('USD');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' });
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' });
|
||||
});
|
||||
|
||||
it('falls back to the trip currency when no base is given', async () => {
|
||||
const s = svc();
|
||||
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
|
||||
getRates.mockResolvedValue(null);
|
||||
await svc().settlement('5', undefined, 'gbp');
|
||||
await s.settlement('5', undefined, 'gbp');
|
||||
expect(getRates).toHaveBeenCalledWith('GBP');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' });
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' });
|
||||
});
|
||||
|
||||
it('falls back to EUR when neither base nor trip currency is present', async () => {
|
||||
const s = svc();
|
||||
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
|
||||
getRates.mockResolvedValue(null);
|
||||
await svc().settlement('5', undefined, '');
|
||||
await s.settlement('5', undefined, '');
|
||||
expect(getRates).toHaveBeenCalledWith('EUR');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' });
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' });
|
||||
});
|
||||
});
|
||||
|
||||
it('create / update / remove / members / paid / payers delegate', async () => {
|
||||
await svc().create('5', { name: 'Hotel' } as never);
|
||||
expect(budget.createBudgetItem).toHaveBeenCalledWith('5', { name: 'Hotel' });
|
||||
await 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('create / update freeze the FX rate before the raw write', async () => {
|
||||
const s = svc();
|
||||
const freezeSpy = vi.spyOn(s, 'freezeForeignRate').mockResolvedValue();
|
||||
const createSpy = vi.spyOn(s, 'createBudgetItem').mockReturnValue({ id: 1 } as never);
|
||||
await s.create('5', { name: 'Hotel' });
|
||||
expect(freezeSpy).toHaveBeenCalledWith('5', { name: 'Hotel' });
|
||||
expect(createSpy).toHaveBeenCalledWith('5', { name: 'Hotel' });
|
||||
|
||||
const updateSpy = vi.spyOn(s, 'updateBudgetItem').mockReturnValue({ id: 9 } as never);
|
||||
await s.update('9', '5', { name: 'X' });
|
||||
// the item id is threaded through so an unchanged-currency edit keeps the frozen rate
|
||||
expect(freezeSpy).toHaveBeenCalledWith('5', { name: 'X' }, '9');
|
||||
expect(updateSpy).toHaveBeenCalledWith('9', '5', { name: 'X' });
|
||||
});
|
||||
|
||||
it('settlement ledger + reorder delegate', async () => {
|
||||
svc().listSettlements('5');
|
||||
expect(budget.listSettlements).toHaveBeenCalledWith('5');
|
||||
// createSettlement freezes the FX rate (await) before delegating.
|
||||
await svc().createSettlement('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
||||
expect(budget.freezeForeignRate).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 });
|
||||
expect(budget.createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
||||
budget.listSettlements.mockReturnValue([{ id: 7, currency: 'USD' }] as never);
|
||||
await svc().updateSettlement('7', '5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' });
|
||||
// the settlement's stored currency is threaded through so an unchanged-currency edit keeps the frozen rate (#1445)
|
||||
expect(budget.freezeForeignRate).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' }, undefined, 'USD');
|
||||
expect(budget.updateSettlement).toHaveBeenCalledWith('7', '5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' });
|
||||
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']);
|
||||
it('remove / setPayers resolve through the folded SQL methods', () => {
|
||||
const s = svc();
|
||||
const deleteSpy = vi.spyOn(s, 'deleteBudgetItem').mockReturnValue(true);
|
||||
expect(s.remove('9', '5')).toBe(true);
|
||||
expect(deleteSpy).toHaveBeenCalledWith('9', '5');
|
||||
|
||||
const payersSpy = vi.spyOn(s, 'setItemPayers').mockReturnValue({ id: 9 } as never);
|
||||
s.setPayers('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
expect(payersSpy).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
});
|
||||
|
||||
describe('settlement ledger wrappers (#1445 freeze-then-write)', () => {
|
||||
it('createSettlement freezes the FX rate (await) before the raw insert', async () => {
|
||||
const s = svc();
|
||||
const freezeSpy = vi.spyOn(s, 'freezeForeignRate').mockResolvedValue();
|
||||
const insertSpy = vi.spyOn(s, 'insertSettlement').mockReturnValue({ id: 7 } as never);
|
||||
await s.createSettlement('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
||||
expect(freezeSpy).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 });
|
||||
expect(insertSpy).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
||||
});
|
||||
|
||||
it('updateSettlement threads the stored currency through the freeze', async () => {
|
||||
const s = svc();
|
||||
const freezeSpy = vi.spyOn(s, 'freezeForeignRate').mockResolvedValue();
|
||||
vi.spyOn(s, 'listSettlements').mockReturnValue([{ id: 7, currency: 'USD' }] as never);
|
||||
const applySpy = vi.spyOn(s, 'applySettlementUpdate').mockReturnValue({ id: 7 } as never);
|
||||
await s.updateSettlement('7', '5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' });
|
||||
// the settlement's stored currency is threaded through so an unchanged-currency edit keeps the frozen rate (#1445)
|
||||
expect(freezeSpy).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' }, undefined, 'USD');
|
||||
expect(applySpy).toHaveBeenCalledWith('7', '5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncReservationPrice', () => {
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
* dedicated tests; the budget-domain fold moved it inside the src/nest/**
|
||||
* coverage gate. 001–012 pin the fetch/cache behavior (including the parity
|
||||
* quirks kept on purpose: `|| 'EUR'` falsy coercion, stale-cache fallback, the
|
||||
* `>1 keys` failure heuristic); 019 pins the exchange-rates.bridge delegation
|
||||
* and the module-scoped cache shared between the DI instance and the bridge
|
||||
* instance; 021–022 pin the post-fold quirk fixes (AbortSignal timeout,
|
||||
* `>1 keys` failure heuristic); 019 pins the module-scoped cache shared across
|
||||
* instances (the DI singleton and the budget.bridge instance wrap one feed —
|
||||
* originally pinned via the exchange-rates.bridge, deleted with the budget
|
||||
* fold); 021–022 pin the post-fold quirk fixes (AbortSignal timeout,
|
||||
* response-size cap, logged failures). 013–018 and 020 covered the dead
|
||||
* convertWithRates export and were removed with it in the quirk fixes.
|
||||
*
|
||||
@@ -15,7 +16,6 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import { getRates as bridgeGetRates } from '../../../src/nest/budget/exchange-rates.bridge';
|
||||
|
||||
const TTL_MS = 6 * 60 * 60 * 1000; // mirrors the service's 6h TTL
|
||||
|
||||
@@ -139,13 +139,14 @@ describe('ExchangeRatesService.getRates', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchange-rates.bridge', () => {
|
||||
it('FX-SVC-019: getRates delegates and shares the module-scoped cache with the DI instance', async () => {
|
||||
describe('cross-instance cache sharing', () => {
|
||||
it('FX-SVC-019: a second instance (the budget.bridge shape) serves the module-scoped cache', async () => {
|
||||
// Prime the cache through the DI-style instance…
|
||||
const primed = await svc.getRates('AUD');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
// …then the bridge (its own instance) must serve the same cached feed.
|
||||
expect(await bridgeGetRates('AUD')).toBe(primed);
|
||||
// …then a separately-constructed instance (as budget.bridge news up) must
|
||||
// serve the same cached feed instead of refetching.
|
||||
expect(await new ExchangeRatesService().getRates('AUD')).toBe(primed);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,10 +37,9 @@ vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
const checkPermission = vi.fn(() => true);
|
||||
const permissionsStub = { checkPermission } as unknown as PermissionsService;
|
||||
|
||||
const { budget } = vi.hoisted(() => ({
|
||||
budget: { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() },
|
||||
}));
|
||||
vi.mock('../../../src/services/budgetService', () => budget);
|
||||
// Constructor-injected since the budget fold (was a path mock of the deleted
|
||||
// services/budgetService).
|
||||
const budget = { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() };
|
||||
|
||||
const { notif } = vi.hoisted(() => ({ notif: { send: vi.fn().mockResolvedValue(undefined) } }));
|
||||
vi.mock('../../../src/services/notificationService', () => notif);
|
||||
@@ -52,9 +51,10 @@ import { createUser, createTrip, createReservation, createBudgetItem, createPlac
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||||
import type { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import * as bridge from '../../../src/nest/reservations/reservations.bridge';
|
||||
|
||||
const svc = new ReservationsService(new DatabaseService(testDb), permissionsStub);
|
||||
const svc = new ReservationsService(new DatabaseService(testDb), permissionsStub, budget as unknown as BudgetService);
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -27,17 +27,18 @@ const { tripSvc } = vi.hoisted(() => ({
|
||||
}));
|
||||
vi.mock('../../../src/services/tripService', () => tripSvc);
|
||||
vi.mock('../../../src/services/placeService', () => ({ listPlaces: () => [] }));
|
||||
vi.mock('../../../src/services/budgetService', () => ({
|
||||
listBudgetItems: () => [],
|
||||
rebaseTripCurrency: vi.fn(async () => {}),
|
||||
}));
|
||||
import { TripsService } from '../../../src/nest/trips/trips.service';
|
||||
import type { TodoService } from '../../../src/nest/todo/todo.service';
|
||||
import type { PackingService } from '../../../src/nest/packing/packing.service';
|
||||
import type { FilesService } from '../../../src/nest/files/files.service';
|
||||
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||||
import type { DaysService } from '../../../src/nest/days/days.service';
|
||||
import { rebaseTripCurrency } from '../../../src/services/budgetService';
|
||||
import type { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
|
||||
// Constructor-injected since the budget fold (was a path mock of the deleted
|
||||
// services/budgetService).
|
||||
const rebaseTripCurrency = vi.fn(async () => {});
|
||||
const budgetStub = { listBudgetItems: () => [], rebaseTripCurrency } as unknown as BudgetService;
|
||||
|
||||
const todoStub = { listItems: () => [] } as unknown as TodoService;
|
||||
const packingListItems = vi.fn(() => []);
|
||||
@@ -45,7 +46,7 @@ const packingStub = { listItems: packingListItems } as unknown as PackingService
|
||||
const filesStub = { listFiles: () => [] } as unknown as FilesService;
|
||||
const reservationsStub = { list: () => [] } as unknown as ReservationsService;
|
||||
const daysStub = { list: () => ({ days: [1] }), listAccommodations: () => [] } as unknown as DaysService;
|
||||
function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub, filesStub, reservationsStub, daysStub, permissionsStub); }
|
||||
function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub, filesStub, reservationsStub, daysStub, permissionsStub, budgetStub); }
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => {
|
||||
|
||||
@@ -56,6 +56,9 @@ const budgetStub = {
|
||||
return id === '404' ? null : { id: Number(id), trip_id: Number(tid), ...input };
|
||||
},
|
||||
remove(id: string, _tid: string) { return id !== '404'; },
|
||||
// The listCostsForTrip/listCostsForUser closures inject this since the budget
|
||||
// fold (was the legacy listBudgetItems path import).
|
||||
listBudgetItems: vi.fn(() => []),
|
||||
} as unknown as BudgetService;
|
||||
|
||||
// Edit permission — flip per test to exercise the gates (constructor-injected
|
||||
@@ -124,16 +127,6 @@ const assignmentsStub = {
|
||||
placeExists: vi.fn((placeId: number) => placeId === 7),
|
||||
getAssignmentForTrip: vi.fn((id: number) => (id === 99 ? undefined : { id, day_id: 3 })),
|
||||
} as unknown as AssignmentsService;
|
||||
// The extra named exports satisfy the real reservations.service.ts module (now
|
||||
// loaded un-mocked as a factory constructor dep); its instance is stubbed, so
|
||||
// these are never called.
|
||||
vi.mock('../../../src/services/budgetService', () => ({
|
||||
listBudgetItems: vi.fn(() => []),
|
||||
createBudgetItem: vi.fn(),
|
||||
updateBudgetItem: vi.fn(),
|
||||
deleteBudgetItem: vi.fn(),
|
||||
linkBudgetItemToReservation: vi.fn(),
|
||||
}));
|
||||
// Packing is a constructor-injected stub (same behaviors as the old path mock).
|
||||
const packingStub = {
|
||||
listItems: vi.fn((tid: number | string, userId?: number) => [{ id: 1, trip_id: tid, name: 'Socks', _uid: userId }]),
|
||||
|
||||
Reference in New Issue
Block a user