File size: 4,722 Bytes
a896df9
 
664d562
a896df9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664d562
 
a896df9
 
 
 
 
664d562
a896df9
 
 
 
 
 
 
 
 
 
664d562
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a896df9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
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);
	});
});