fix(server): fix the quirks preserved by the budget DI migration

This commit is contained in:
jubnl
2026-07-28 00:04:50 +02:00
parent 40f3103c04
commit 351b5fb4ec
6 changed files with 278 additions and 179 deletions
@@ -83,7 +83,13 @@ describe('Tool: set_budget_item_members', () => {
// Regression: returns a hydrated item, not the raw row from updateMembers.
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
expect(Array.isArray(data.item.payers)).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:members-updated', expect.any(Object));
// Quirk fix: the broadcast carries the REST payload shape (the legacy
// MCP-specific { item } shape was a silent no-op in the client handler).
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:members-updated', expect.objectContaining({
itemId: item.id,
members: expect.arrayContaining([expect.objectContaining({ user_id: user.id })]),
persons: 1,
}));
});
});
@@ -215,7 +221,9 @@ describe('Tool: toggle_budget_member_paid', () => {
});
const data = parseToolResult(result) as any;
expect(data.member).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:member-paid-updated', expect.any(Object));
// Quirk fix: the broadcast carries the REST payload shape (the legacy
// MCP-specific { itemId, member } shape was a silent no-op in the client).
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:member-paid-updated', expect.objectContaining({ itemId: item.id, userId: user.id, paid: 1 }));
});
});
@@ -485,9 +485,11 @@ describe('applySettlementUpdate', () => {
return { get: vi.fn(), all: vi.fn(), run };
}
if (sql.includes('FROM budget_settlements')) {
return { get: vi.fn(), all: vi.fn(() => [
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 10.13, from_username: 'bob', to_username: 'alice', from_avatar: null, to_avatar: null },
]), run: vi.fn() };
// Quirk fix: the re-select is a targeted single-row get, not a full
// listSettlements scan.
return { get: vi.fn(() => (
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 10.13, from_username: 'bob', to_username: 'alice', from_avatar: null, to_avatar: null }
)), all: vi.fn(() => []), run: vi.fn() };
}
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
});
@@ -3,7 +3,9 @@
* 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).
* delegation (the bridge instance runs over the same mocked db Proxy);
* 019–020 pin the post-fold quirk fixes (COALESCE(display_name) on
* settlements, transactional multi-statement writes).
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -421,3 +423,30 @@ describe('budget.bridge delegation', () => {
expect(row.reservation_id).toBe(reservationId);
});
});
describe('post-fold quirk fixes', () => {
it('BUDGET-SVC-DB-019: settlements prefer display_name over username (quirk fix)', () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Alice Displayed', alice.id);
const trip = createTrip(testDb, alice.id);
const created = budget.insertSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 10 }, alice.id);
expect(created!.from_username).toBe('Alice Displayed');
expect(created!.to_username).toBe('bob');
expect(budget.listSettlements(trip.id)[0].from_username).toBe('Alice Displayed');
});
it('BUDGET-SVC-DB-020: a failing item insert rolls back the category-order side write (quirk fix)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// NOT NULL violation on name fires after the category-order upsert — the
// legacy non-transactional create leaked the budget_category_order row.
expect(() => budget.createBudgetItem(trip.id, { name: null as unknown as string, category: 'atomic-test' })).toThrow();
const cat = testDb.prepare("SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = 'atomic-test'").get(trip.id);
expect(cat).toBeUndefined();
});
});
@@ -150,10 +150,13 @@ describe('BudgetService', () => {
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);
// Quirk fix: the stored-currency lookup is a targeted getSettlement, not
// a full listSettlements scan.
const getSpy = vi.spyOn(s, 'getSettlement').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(getSpy).toHaveBeenCalledWith('7', '5');
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' });
});