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 & Pick): 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); }); });