⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc
crates/modules/nakui/core/tests/event_log.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
chore: absorbe nakui (ERP matemático) en modules/nakui
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}