chore: move budget backend to proper DI

This commit is contained in:
jubnl
2026-07-27 23:48:05 +02:00
parent b0041f34ba
commit 7f34dadf70
39 changed files with 1780 additions and 1490 deletions
+9 -29
View File
@@ -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.
+33 -1
View File
@@ -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();
});
});
});
@@ -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);
@@ -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);
});
});
+86 -73
View File
@@ -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(() => {
+7 -6
View File
@@ -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 }]),