Spaces:
Sleeping
Sleeping
assafvayner HF Staff
fix(ledger): heal truncated ledger, validate events on write, reconcile pending
664d562 Download tests/unit/ledger/fold.test.ts from assafvayner/splitwise: direct link, hf CLI and curl.
- Browser
- Download file 4.72 kB
-
https://huggingface.co/spaces/assafvayner/splitwise/resolve/main/tests/unit/ledger/fold.test.ts
- Command line
-
hf download hf://spaces/assafvayner/splitwise/tests/unit/ledger/fold.test.ts
-
curl -L -o fold.test.ts https://huggingface.co/spaces/assafvayner/splitwise/resolve/main/tests/unit/ledger/fold.test.ts
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); | |
| }); | |
| }); | |