tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 17Markdown 144606 Sutichaykuna

⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc crates/modules/nakui/core/tests/event_log.rs Rust · 620 siq'ikuna · 18.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: absorbe nakui (ERP matemático) en modules/nakui 4d50bfc58 · Sergio ·

1//! Integration tests for the event log: round-trip persistence,
2//! replay-equivalence with the live store, and determinism verification.
3
4use std::path::{Path, PathBuf};
5
6use nakui_core::delta::FieldOp;
7use nakui_core::event_log::{
8    EventLog, ExecuteError, LogEntry, RecoverableExecuteError, Snapshot, execute_and_log,
9    execute_and_log_with_recovery, reconcile, replay, replay_with_snapshot_into, seed_and_log,
10    verify_log,
11};
12use nakui_core::executor::Executor;
13use nakui_core::store::{MemoryStore, Store, StoreError};
14use serde_json::{Value, json};
15use uuid::Uuid;
16
17fn workspace_root() -> PathBuf {
18    Path::new(env!("CARGO_MANIFEST_DIR"))
19        .parent()
20        .expect("workspace root above core/")
21        .to_path_buf()
22}
23
24fn treasury_module() -> PathBuf {
25    workspace_root().join("modules/treasury")
26}
27
28fn fresh_log_path() -> PathBuf {
29    std::env::temp_dir().join(format!("nakui_test_{}.jsonl", Uuid::new_v4()))
30}
31
32#[test]
33fn replay_reconstructs_live_store() {
34    let exec = Executor::load_module(treasury_module()).expect("load module");
35    let log_path = fresh_log_path();
36    let mut log = EventLog::open(&log_path).expect("open log");
37    let mut live = MemoryStore::new();
38
39    let a = Uuid::new_v4();
40    let b = Uuid::new_v4();
41    seed_and_log(
42        &exec,
43        &mut live,
44        &mut log,
45        "Caja",
46        a,
47        json!({
48            "id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD",
49        }),
50    )
51    .unwrap();
52    seed_and_log(
53        &exec,
54        &mut live,
55        &mut log,
56        "Caja",
57        b,
58        json!({
59            "id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD",
60        }),
61    )
62    .unwrap();
63
64    let mov_id = Uuid::new_v4();
65    execute_and_log(
66        &exec,
67        &mut live,
68        &mut log,
69        "register_cash_move",
70        &[("caja", a)],
71        json!({
72            "monto": 25_000_i64, "tipo": "in",
73            "timestamp": "2026-05-04T10:00:00Z", "memo": "x",
74            "movimiento_id": mov_id.to_string(),
75        }),
76    )
77    .unwrap();
78
79    let xfer_id = Uuid::new_v4();
80    execute_and_log(
81        &exec,
82        &mut live,
83        &mut log,
84        "transfer_between_cajas",
85        &[("source", a), ("dest", b)],
86        json!({
87            "monto": 75_000_i64,
88            "timestamp": "2026-05-04T10:30:00Z", "memo": "xf",
89            "transfer_id": xfer_id.to_string(),
90        }),
91    )
92    .unwrap();
93
94    // Failed morphism — should NOT be logged.
95    let attempt = execute_and_log(
96        &exec,
97        &mut live,
98        &mut log,
99        "transfer_between_cajas",
100        &[("source", a), ("dest", b)],
101        json!({
102            "monto": 999_999_999_i64,
103            "timestamp": "2026-05-04T10:45:00Z", "memo": "overdraw",
104            "transfer_id": Uuid::new_v4().to_string(),
105        }),
106    );
107    assert!(matches!(attempt, Err(ExecuteError::PreLog(_))));
108
109    let replayed = replay(&log).expect("replay");
110    assert_eq!(live, replayed, "replayed store must equal live store");
111
112    // Failed attempt left no trace in the log.
113    let entries = log.entries().unwrap();
114    assert_eq!(
115        entries.len(),
116        4,
117        "2 seeds + 2 successful morphisms = 4 entries; got {}",
118        entries.len()
119    );
120
121    let _ = std::fs::remove_file(&log_path);
122}
123
124#[test]
125fn verify_log_passes_for_deterministic_morphisms() {
126    let exec = Executor::load_module(treasury_module()).expect("load module");
127    let log_path = fresh_log_path();
128    let mut log = EventLog::open(&log_path).expect("open log");
129    let mut live = MemoryStore::new();
130
131    let a = Uuid::new_v4();
132    let b = Uuid::new_v4();
133    seed_and_log(
134        &exec,
135        &mut live,
136        &mut log,
137        "Caja",
138        a,
139        json!({"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD"}),
140    )
141    .unwrap();
142    seed_and_log(
143        &exec,
144        &mut live,
145        &mut log,
146        "Caja",
147        b,
148        json!({"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}),
149    )
150    .unwrap();
151    execute_and_log(
152        &exec,
153        &mut live,
154        &mut log,
155        "transfer_between_cajas",
156        &[("source", a), ("dest", b)],
157        json!({
158            "monto": 25_000_i64,
159            "timestamp": "2026-05-04T11:00:00Z", "memo": "v",
160            "transfer_id": Uuid::new_v4().to_string(),
161        }),
162    )
163    .unwrap();
164
165    verify_log(&log, &exec).expect("re-execution must produce identical ops");
166
167    let _ = std::fs::remove_file(&log_path);
168}
169
170/// Store wrapper that passes dry_run through to MemoryStore but always
171/// fails on apply. Used to simulate a transient backend failure landing
172/// AFTER the kernel has validated and the log has been written.
173struct FailOnApplyStore {
174    inner: MemoryStore,
175}
176
177impl Store for FailOnApplyStore {
178    fn load(&self, entity: &str, id: Uuid) -> Option<Value> {
179        self.inner.load(entity, id)
180    }
181    fn seed(&mut self, entity: &str, id: Uuid, data: Value) {
182        self.inner.seed(entity, id, data);
183    }
184    fn apply_dry_run(&self, ops: &[FieldOp]) -> Result<(), StoreError> {
185        self.inner.apply_dry_run(ops)
186    }
187    fn apply(&mut self, _ops: &[FieldOp]) -> Result<(), StoreError> {
188        Err(StoreError::NotFound(
189            "synthetic_apply_failure".into(),
190            Uuid::nil(),
191        ))
192    }
193    fn clear(&mut self) -> Result<(), StoreError> {
194        self.inner.clear()
195    }
196    fn iter(&self) -> Result<Box<dyn Iterator<Item = (String, Uuid, Value)> + '_>, StoreError> {
197        self.inner.iter()
198    }
199}
200
201#[test]
202fn post_log_store_failure_leaves_log_canonical() {
203    let exec = Executor::load_module(treasury_module()).expect("load module");
204    let log_path = fresh_log_path();
205    let mut log = EventLog::open(&log_path).expect("open log");
206
207    // Seed the inner store directly (no logging — we're simulating the
208    // backend independently of the log).
209    let mut inner = MemoryStore::new();
210    let a = Uuid::new_v4();
211    let b = Uuid::new_v4();
212    inner.seed(
213        "Caja",
214        a,
215        json!({"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD"}),
216    );
217    inner.seed(
218        "Caja",
219        b,
220        json!({"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}),
221    );
222    let mut store = FailOnApplyStore { inner };
223
224    let result = execute_and_log(
225        &exec,
226        &mut store,
227        &mut log,
228        "transfer_between_cajas",
229        &[("source", a), ("dest", b)],
230        json!({
231            "monto": 25_000_i64,
232            "timestamp": "2026-05-04T11:00:00Z",
233            "memo": "wal-test",
234            "transfer_id": Uuid::new_v4().to_string(),
235        }),
236    );
237
238    match result {
239        Err(ExecuteError::PostLogStore(_)) => {}
240        other => panic!("expected PostLogStore, got {:?}", other),
241    }
242
243    // Log is canonical: the morphism event is durable.
244    let entries = log.entries().expect("read");
245    assert_eq!(entries.len(), 1, "log must contain the morphism event");
246    assert!(matches!(&entries[0], LogEntry::Morphism { .. }));
247
248    // Live store is stale: apply was rejected, so saldos are unchanged.
249    assert_eq!(
250        store.load("Caja", a).unwrap().get("saldo").unwrap().as_i64(),
251        Some(200_000)
252    );
253    assert_eq!(
254        store.load("Caja", b).unwrap().get("saldo").unwrap().as_i64(),
255        Some(50_000)
256    );
257
258    let _ = std::fs::remove_file(&log_path);
259}
260
261#[test]
262fn reopen_log_resumes_from_correct_seq() {
263    let exec = Executor::load_module(treasury_module()).expect("load module");
264    let log_path = fresh_log_path();
265    let a = Uuid::new_v4();
266
267    {
268        let mut log = EventLog::open(&log_path).unwrap();
269        let mut store = MemoryStore::new();
270        seed_and_log(
271            &exec,
272            &mut store,
273            &mut log,
274            "Caja",
275            a,
276            json!({"id": a.to_string(), "name": "A", "saldo": 100_i64, "currency": "USD"}),
277        )
278        .unwrap();
279        assert_eq!(log.next_seq(), 1);
280    }
281
282    {
283        let log = EventLog::open(&log_path).unwrap();
284        assert_eq!(log.next_seq(), 1, "next_seq must persist across reopens");
285        let entries = log.entries().unwrap();
286        assert_eq!(entries.len(), 1);
287        assert!(matches!(&entries[0], LogEntry::Seed { seq: 0, .. }));
288    }
289
290    let _ = std::fs::remove_file(&log_path);
291}
292
293#[test]
294fn snapshot_plus_log_tail_replays_to_same_state() {
295    let exec = Executor::load_module(treasury_module()).expect("load");
296    let log_path = fresh_log_path();
297    let snap_path = log_path.with_extension("snap");
298    let mut log = EventLog::open(&log_path).expect("open");
299    let mut live = MemoryStore::new();
300
301    let a = Uuid::new_v4();
302    let b = Uuid::new_v4();
303    seed_and_log(
304        &exec,
305        &mut live,
306        &mut log,
307        "Caja",
308        a,
309        json!({"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD"}),
310    )
311    .unwrap();
312    seed_and_log(
313        &exec,
314        &mut live,
315        &mut log,
316        "Caja",
317        b,
318        json!({"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}),
319    )
320    .unwrap();
321    execute_and_log(
322        &exec,
323        &mut live,
324        &mut log,
325        "register_cash_move",
326        &[("caja", a)],
327        json!({
328            "monto": 25_000_i64, "tipo": "in",
329            "timestamp": "2026-05-04T10:00:00Z", "memo": "before-snap",
330            "movimiento_id": Uuid::new_v4().to_string(),
331        }),
332    )
333    .unwrap();
334
335    // Take snapshot at this point: seq 0 (seed A), 1 (seed B), 2 (deposit)
336    // are reflected in `live`. Next event will be seq 3.
337    let snap = Snapshot::from_memory_store(&live, log.next_seq() - 1);
338    snap.write(&snap_path).expect("write snapshot");
339    assert_eq!(snap.seq, 2);
340
341    // More events after the snapshot.
342    execute_and_log(
343        &exec,
344        &mut live,
345        &mut log,
346        "transfer_between_cajas",
347        &[("source", a), ("dest", b)],
348        json!({
349            "monto": 75_000_i64,
350            "timestamp": "2026-05-04T10:30:00Z", "memo": "after-snap",
351            "transfer_id": Uuid::new_v4().to_string(),
352        }),
353    )
354    .unwrap();
355
356    // Replay from snapshot + log tail; must equal live store.
357    let loaded_snap = Snapshot::load(&snap_path).expect("load").expect("present");
358    let mut replayed = MemoryStore::new();
359    replay_with_snapshot_into(&log, Some(&loaded_snap), &mut replayed).expect("replay");
360
361    assert_eq!(live, replayed, "snapshot + tail must equal full replay");
362
363    let _ = std::fs::remove_file(&log_path);
364    let _ = std::fs::remove_file(&snap_path);
365}
366
367#[test]
368fn compact_through_drops_old_entries_keeps_seq() {
369    let exec = Executor::load_module(treasury_module()).expect("load module");
370    let log_path = fresh_log_path();
371    let mut log = EventLog::open(&log_path).expect("open");
372
373    let mut live = MemoryStore::new();
374    for i in 0..5 {
375        let id = Uuid::new_v4();
376        seed_and_log(
377            &exec,
378            &mut live,
379            &mut log,
380            "Caja",
381            id,
382            json!({"id": id.to_string(), "name": format!("c{}", i), "saldo": 100_i64, "currency": "USD"}),
383        )
384        .unwrap();
385    }
386
387    assert_eq!(log.next_seq(), 5);
388    assert_eq!(log.entries().unwrap().len(), 5);
389
390    // Compact through seq 2: entries 0,1,2 are dropped; 3,4 remain.
391    log.compact_through(2).expect("compact");
392
393    let surviving = log.entries().unwrap();
394    assert_eq!(surviving.len(), 2);
395    assert_eq!(surviving[0].seq(), 3);
396    assert_eq!(surviving[1].seq(), 4);
397
398    // next_seq stays at 5 — we kept the surviving entries' counter intact.
399    // (Reopen to confirm the persisted log roundtrips this.)
400    drop(log);
401    let reopened = EventLog::open(&log_path).expect("reopen after compact");
402    assert_eq!(reopened.next_seq(), 5);
403
404    let _ = std::fs::remove_file(&log_path);
405}
406
407#[test]
408fn snapshot_then_compact_then_replay_equals_pre_compaction() {
409    let exec = Executor::load_module(treasury_module()).expect("load");
410    let log_path = fresh_log_path();
411    let snap_path = log_path.with_extension("snap");
412    let mut log = EventLog::open(&log_path).expect("open");
413    let mut live = MemoryStore::new();
414
415    let a = Uuid::new_v4();
416    seed_and_log(
417        &exec,
418        &mut live,
419        &mut log,
420        "Caja",
421        a,
422        json!({"id": a.to_string(), "name": "A", "saldo": 1_000_i64, "currency": "USD"}),
423    )
424    .unwrap();
425    for i in 0..3 {
426        execute_and_log(
427            &exec,
428            &mut live,
429            &mut log,
430            "register_cash_move",
431            &[("caja", a)],
432            json!({
433                "monto": 100_i64, "tipo": "in",
434                "timestamp": format!("2026-05-04T10:0{}:00Z", i), "memo": "x",
435                "movimiento_id": Uuid::new_v4().to_string(),
436            }),
437        )
438        .unwrap();
439    }
440    // Snapshot at seq 3 (1 seed + 3 morphisms = seqs 0..=3).
441    let snap = Snapshot::from_memory_store(&live, log.next_seq() - 1);
442    snap.write(&snap_path).expect("write snap");
443    log.compact_through(snap.seq).expect("compact");
444
445    // After compaction: log has 0 entries (all subsumed). next_seq = 4.
446    assert_eq!(log.entries().unwrap().len(), 0);
447
448    // More events after compaction.
449    execute_and_log(
450        &exec,
451        &mut live,
452        &mut log,
453        "register_cash_move",
454        &[("caja", a)],
455        json!({
456            "monto": 500_i64, "tipo": "in",
457            "timestamp": "2026-05-04T11:00:00Z", "memo": "post-compact",
458            "movimiento_id": Uuid::new_v4().to_string(),
459        }),
460    )
461    .unwrap();
462
463    // Reconstruct from snapshot + remaining log.
464    let loaded_snap = Snapshot::load(&snap_path).unwrap().unwrap();
465    let mut replayed = MemoryStore::new();
466    replay_with_snapshot_into(&log, Some(&loaded_snap), &mut replayed).expect("replay");
467    assert_eq!(live, replayed, "snapshot + post-compact log must equal live");
468
469    let _ = std::fs::remove_file(&log_path);
470    let _ = std::fs::remove_file(&snap_path);
471}
472
473#[test]
474fn reconcile_rebuilds_drifted_store_from_log() {
475    let exec = Executor::load_module(treasury_module()).expect("load module");
476    let log_path = fresh_log_path();
477    let mut log = EventLog::open(&log_path).expect("open log");
478    let mut live = MemoryStore::new();
479
480    let a = Uuid::new_v4();
481    seed_and_log(
482        &exec,
483        &mut live,
484        &mut log,
485        "Caja",
486        a,
487        json!({"id": a.to_string(), "name": "A", "saldo": 100_000_i64, "currency": "USD"}),
488    )
489    .unwrap();
490    execute_and_log(
491        &exec,
492        &mut live,
493        &mut log,
494        "register_cash_move",
495        &[("caja", a)],
496        json!({
497            "monto": 5_000_i64, "tipo": "in",
498            "timestamp": "2026-05-04T10:00:00Z", "memo": "x",
499            "movimiento_id": Uuid::new_v4().to_string(),
500        }),
501    )
502    .unwrap();
503
504    // Drift the store out-of-band: a poison record nobody logged, plus a
505    // tampered saldo on the legitimate one.
506    let ghost = Uuid::new_v4();
507    live.seed(
508        "Caja",
509        ghost,
510        json!({"id": ghost.to_string(), "name": "GHOST", "saldo": 0, "currency": "USD"}),
511    );
512    live.seed(
513        "Caja",
514        a,
515        json!({"id": a.to_string(), "name": "A", "saldo": 999_999_i64, "currency": "USD"}),
516    );
517
518    // Canonical state: replay from log into a clean store.
519    let canonical = replay(&log).expect("replay");
520    assert_ne!(live, canonical, "drift was set up to differ from log");
521
522    reconcile(&mut live, &log).expect("reconcile");
523    assert_eq!(live, canonical, "reconcile must restore log-canonical state");
524    assert!(live.load("Caja", ghost).is_none(), "poison record must be wiped");
525
526    let _ = std::fs::remove_file(&log_path);
527}
528
529#[test]
530fn execute_and_log_with_recovery_succeeds_on_clean_path() {
531    // The clean path: no apply failure means the wrapper returns the same
532    // ops as `execute_and_log` and leaves the store consistent.
533    let exec = Executor::load_module(treasury_module()).expect("load module");
534    let log_path = fresh_log_path();
535    let mut log = EventLog::open(&log_path).expect("open log");
536    let mut store = MemoryStore::new();
537
538    let a = Uuid::new_v4();
539    seed_and_log(
540        &exec,
541        &mut store,
542        &mut log,
543        "Caja",
544        a,
545        json!({"id": a.to_string(), "name": "A", "saldo": 10_000_i64, "currency": "USD"}),
546    )
547    .unwrap();
548
549    let ops = execute_and_log_with_recovery(
550        &exec,
551        &mut store,
552        &mut log,
553        "register_cash_move",
554        &[("caja", a)],
555        json!({
556            "monto": 1_000_i64, "tipo": "in",
557            "timestamp": "2026-05-04T10:00:00Z", "memo": "x",
558            "movimiento_id": Uuid::new_v4().to_string(),
559        }),
560    )
561    .expect("recovery wrapper");
562    assert!(!ops.is_empty(), "morphism produced ops");
563
564    let replayed = replay(&log).expect("replay");
565    assert_eq!(store, replayed, "store and log agree on clean path");
566
567    let _ = std::fs::remove_file(&log_path);
568}
569
570#[test]
571fn execute_and_log_with_recovery_reports_unrecoverable_when_replay_also_fails() {
572    // Apply is permanently broken — reconcile (which replays through apply)
573    // will also fail. The wrapper must surface `Unrecoverable` so the
574    // caller knows the store is no longer in sync with the log.
575    let exec = Executor::load_module(treasury_module()).expect("load module");
576    let log_path = fresh_log_path();
577    let mut log = EventLog::open(&log_path).expect("open log");
578
579    let mut inner = MemoryStore::new();
580    let a = Uuid::new_v4();
581    let b = Uuid::new_v4();
582    inner.seed(
583        "Caja",
584        a,
585        json!({"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD"}),
586    );
587    inner.seed(
588        "Caja",
589        b,
590        json!({"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}),
591    );
592    let mut store = FailOnApplyStore { inner };
593
594    let result = execute_and_log_with_recovery(
595        &exec,
596        &mut store,
597        &mut log,
598        "transfer_between_cajas",
599        &[("source", a), ("dest", b)],
600        json!({
601            "monto": 25_000_i64,
602            "timestamp": "2026-05-04T11:00:00Z",
603            "memo": "recover-fail",
604            "transfer_id": Uuid::new_v4().to_string(),
605        }),
606    );
607    assert!(
608        matches!(result, Err(RecoverableExecuteError::Unrecoverable { .. })),
609        "expected Unrecoverable, got {:?}",
610        result
611    );
612
613    // The log entry is still canonical: an operator who fixes the backend
614    // can recover via `nakui replay` later.
615    let entries = log.entries().expect("read log");
616    assert_eq!(entries.len(), 1);
617    assert!(matches!(&entries[0], LogEntry::Morphism { .. }));
618
619    let _ = std::fs::remove_file(&log_path);
620}