splitwise / tests /unit /ledger /fold.test.ts
assafvayner's picture
assafvayner HF Staff
fix(ledger): heal truncated ledger, validate events on write, reconcile pending
664d562
Raw History Blame Contribute Delete
4.72 kB
import { describe, expect, it } from 'vitest';
import { decodeEvent, encodeEvent, encodeJsonl, parseJsonl, type LedgerEvent } from '../../../src/lib/server/ledger/events';
import { applyEvent, compareEvents, foldEvents, newFoldedState } from '../../../src/lib/server/ledger/fold';
import type { Expense, Settlement } from '../../../src/lib/domain/types';
const audit = { createdBy: 'u_a', createdAt: '2026-01-01T00:00:00.000Z', updatedBy: 'u_a', updatedAt: '2026-01-01T00:00:00.000Z' };
const expense = (id: string, version: number, description: string): Expense => ({
id, version, description, amountCents: 100, currency: 'USD', date: '2026-01-01', paidBy: 'u_a',
split: { mode: 'equal', participants: ['u_a'] }, shares: { u_a: 100 }, ...audit
});
const settlement = (id: string, version: number): Settlement => ({ id, version, from: 'u_a', to: 'u_b', amountCents: 5, date: '2026-01-01', ...audit });
let n = 0;
const ev = (partial: Partial<LedgerEvent> & Pick<LedgerEvent, 'kind' | 'entityId' | 'op' | 'version'>): LedgerEvent =>
({ id: `01HZ${String(n++).padStart(22, '0')}`, ts: `2026-01-01T00:00:${String(n).padStart(2, '0')}.000Z`, actor: 'u_a', ...partial }) as LedgerEvent;
describe('events encoding', () => {
it('round-trips through JSON and JSONL', () => {
const e1 = ev({ kind: 'expense', entityId: 'e_1', op: 'upsert', version: 1, data: expense('e_1', 1, 'a') });
const e2 = ev({ kind: 'settlement', entityId: 's_1', op: 'delete', version: 2 });
expect(decodeEvent(encodeEvent(e1))).toEqual(e1);
expect(parseJsonl(new TextDecoder().decode(encodeJsonl([e1, e2])))).toEqual({ events: [e1, e2], truncated: false });
expect(parseJsonl('\n\n')).toEqual({ events: [], truncated: false });
});
it('skips only a truncated final line without a newline', () => {
const e1 = ev({ kind: 'expense', entityId: 'e_1', op: 'upsert', version: 1, data: expense('e_1', 1, 'a') });
const good = new TextDecoder().decode(encodeJsonl([e1]));
expect(parseJsonl(good + '{"id":"01HZ')).toEqual({ events: [e1], truncated: true });
expect(() => parseJsonl(good + '{"id":"01HZ\n')).toThrow();
expect(() => parseJsonl('{"id":"01HZ\n' + good)).toThrow();
});
it('rejects malformed events', () => {
expect(() => decodeEvent(new TextEncoder().encode('{"id":"x"}'))).toThrow();
expect(() => parseJsonl('not json\n')).toThrow();
});
});
describe('compareEvents', () => {
it('breaks version ties by ts, then by id', () => {
const shape = { kind: 'expense', entityId: 'e_1', op: 'delete', version: 1 } as const;
const lowId = '01HZ'.padEnd(26, '0');
const early = ev({ ...shape, id: lowId, ts: '2026-01-01T00:00:01.000Z' });
const late = ev({ ...shape, id: lowId, ts: '2026-01-01T00:00:02.000Z' });
expect(compareEvents(late, early)).toBeGreaterThan(0);
expect(compareEvents(early, late)).toBeLessThan(0);
const higher = ev({ ...shape, id: '01HZ'.padEnd(25, '0') + '1', ts: early.ts });
expect(compareEvents(higher, early)).toBeGreaterThan(0);
expect(compareEvents(early, higher)).toBeLessThan(0);
expect(compareEvents(early, early)).toBe(0);
});
});
describe('foldEvents', () => {
it('keeps the highest version per entity and honours tombstones', () => {
const events: LedgerEvent[] = [
ev({ kind: 'expense', entityId: 'e_1', op: 'upsert', version: 1, data: expense('e_1', 1, 'v1') }),
ev({ kind: 'expense', entityId: 'e_1', op: 'upsert', version: 2, data: expense('e_1', 2, 'v2') }),
ev({ kind: 'settlement', entityId: 's_1', op: 'upsert', version: 1, data: settlement('s_1', 1) }),
ev({ kind: 'settlement', entityId: 's_1', op: 'delete', version: 2 })
];
const state = foldEvents(events);
expect(state.expenses.get('e_1')?.description).toBe('v2');
expect(state.settlements.has('s_1')).toBe(false);
expect(state.history.get('e_1')).toHaveLength(2);
expect(state.eventIds.size).toBe(4);
});
it('is order-independent and deduplicates by event id', () => {
const a = ev({ kind: 'expense', entityId: 'e_1', op: 'upsert', version: 1, data: expense('e_1', 1, 'v1') });
const b = ev({ kind: 'expense', entityId: 'e_1', op: 'upsert', version: 2, data: expense('e_1', 2, 'v2') });
const s1 = foldEvents([a, b, a, b]);
const s2 = foldEvents([b, a]);
expect(s1.expenses.get('e_1')?.description).toBe('v2');
expect(s2.expenses.get('e_1')?.description).toBe('v2');
expect(s1.eventIds.size).toBe(2);
expect(s1.history.get('e_1')).toHaveLength(2);
});
it('applyEvent returns false for duplicates', () => {
const state = newFoldedState();
const a = ev({ kind: 'expense', entityId: 'e_1', op: 'upsert', version: 1, data: expense('e_1', 1, 'v1') });
expect(applyEvent(state, a)).toBe(true);
expect(applyEvent(state, a)).toBe(false);
});
});