feat(nakui): módulo crm — clientes, pipeline de ventas, interacciones
Módulo CRM declarativo (schema.ncl + nsmc.json + morfismos Rhai) con tres entities (Cliente, Oportunidad, Interaccion) y tres morfismos: abrir_oportunidad, mover_oportunidad (pipeline con validación de transiciones) y registrar_interaccion. crm_demo: demo realista de 18 eventos que —a diferencia de los otros demos— conserva el event log e imprime el comando de nakui-explorer, así el explorador muestra un CRM con cuerpo. tests/crm.rs: 8 tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
//! Tests de integración del módulo `crm`. Mismo kernel que
|
||||
//! inventory/sales/treasury, apuntado a `modules/crm`: clientes,
|
||||
//! oportunidades que recorren un pipeline de ventas, e interacciones.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nakui_core::executor::{ExecError, Executor};
|
||||
use nakui_core::store::{MemoryStore, Store};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn crm_module() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.expect("dir del módulo nakui sobre core/")
|
||||
.join("modules/crm")
|
||||
}
|
||||
|
||||
fn seed_cliente(store: &mut MemoryStore, id: Uuid, nombre: &str) {
|
||||
store.seed(
|
||||
"Cliente",
|
||||
id,
|
||||
json!({
|
||||
"id": id.to_string(),
|
||||
"nombre": nombre,
|
||||
"email": "contacto@example.com",
|
||||
"empresa": nombre,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Abre una oportunidad y devuelve su id. Camino feliz (panica si falla).
|
||||
fn abrir_opp(exec: &Executor, store: &mut MemoryStore, cliente: Uuid) -> Uuid {
|
||||
let opp = Uuid::new_v4();
|
||||
exec.run(
|
||||
store,
|
||||
"abrir_oportunidad",
|
||||
&[("cliente", cliente)],
|
||||
json!({
|
||||
"oportunidad_id": opp.to_string(),
|
||||
"titulo": "Licencia anual",
|
||||
"monto": 12_000_i64,
|
||||
"currency": "USD",
|
||||
"timestamp": "2026-05-21T10:00:00Z",
|
||||
}),
|
||||
)
|
||||
.expect("abrir_oportunidad debe pasar");
|
||||
opp
|
||||
}
|
||||
|
||||
fn etapa(store: &MemoryStore, opp: Uuid) -> String {
|
||||
store
|
||||
.load("Oportunidad", opp)
|
||||
.and_then(|v| v.get("etapa").and_then(Value::as_str).map(String::from))
|
||||
.expect("oportunidad con etapa")
|
||||
}
|
||||
|
||||
/// Corre `mover_oportunidad`; devuelve el conteo de ops en éxito.
|
||||
// `ExecError` es un enum grande — el resto del crate convive con este
|
||||
// lint; lo suprimimos local en vez de boxear sólo este helper.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn mover(
|
||||
exec: &Executor,
|
||||
store: &mut MemoryStore,
|
||||
opp: Uuid,
|
||||
destino: &str,
|
||||
) -> Result<usize, ExecError> {
|
||||
exec.run(
|
||||
store,
|
||||
"mover_oportunidad",
|
||||
&[("oportunidad", opp)],
|
||||
json!({ "etapa": destino, "timestamp": "2026-05-21T11:00:00Z" }),
|
||||
)
|
||||
.map(|ops| ops.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abrir_crea_oportunidad_en_prospecto() {
|
||||
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||
let mut store = MemoryStore::new();
|
||||
let cliente = Uuid::new_v4();
|
||||
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||
|
||||
let opp = abrir_opp(&exec, &mut store, cliente);
|
||||
|
||||
assert_eq!(etapa(&store, opp), "prospecto", "nace en prospecto");
|
||||
let o = store.load("Oportunidad", opp).expect("oportunidad existe");
|
||||
let cid = cliente.to_string();
|
||||
assert_eq!(
|
||||
o.get("cliente_id").and_then(Value::as_str),
|
||||
Some(cid.as_str())
|
||||
);
|
||||
assert_eq!(o.get("monto").and_then(Value::as_i64), Some(12_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_avanza_hasta_ganada() {
|
||||
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||
let mut store = MemoryStore::new();
|
||||
let cliente = Uuid::new_v4();
|
||||
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||
let opp = abrir_opp(&exec, &mut store, cliente);
|
||||
|
||||
for destino in ["calificado", "propuesta", "negociacion", "ganada"] {
|
||||
mover(&exec, &mut store, opp, destino)
|
||||
.unwrap_or_else(|e| panic!("mover a {destino} debe pasar: {e:?}"));
|
||||
assert_eq!(etapa(&store, opp), destino);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_se_retrocede_en_el_pipeline() {
|
||||
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||
let mut store = MemoryStore::new();
|
||||
let cliente = Uuid::new_v4();
|
||||
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||
let opp = abrir_opp(&exec, &mut store, cliente);
|
||||
|
||||
mover(&exec, &mut store, opp, "propuesta").expect("avanzar debe pasar");
|
||||
|
||||
// prospecto está antes de propuesta → retroceso, rechazado por el script.
|
||||
let result = mover(&exec, &mut store, opp, "prospecto");
|
||||
match result {
|
||||
Err(ExecError::Rhai(_)) => {}
|
||||
other => panic!("esperaba Rhai (throw por retroceso), obtuve {other:?}"),
|
||||
}
|
||||
assert_eq!(etapa(&store, opp), "propuesta", "la etapa no cambió");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oportunidad_cerrada_no_se_mueve() {
|
||||
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||
let mut store = MemoryStore::new();
|
||||
let cliente = Uuid::new_v4();
|
||||
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||
let opp = abrir_opp(&exec, &mut store, cliente);
|
||||
|
||||
// Cerrar es legal desde cualquier etapa abierta.
|
||||
mover(&exec, &mut store, opp, "ganada").expect("cerrar debe pasar");
|
||||
|
||||
// Una oportunidad ganada ya no se mueve.
|
||||
let result = mover(&exec, &mut store, opp, "negociacion");
|
||||
match result {
|
||||
Err(ExecError::Rhai(_)) => {}
|
||||
other => panic!("esperaba Rhai (throw por cerrada), obtuve {other:?}"),
|
||||
}
|
||||
assert_eq!(etapa(&store, opp), "ganada");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn etapa_destino_desconocida_es_rechazada() {
|
||||
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||
let mut store = MemoryStore::new();
|
||||
let cliente = Uuid::new_v4();
|
||||
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||
let opp = abrir_opp(&exec, &mut store, cliente);
|
||||
|
||||
let result = mover(&exec, &mut store, opp, "facturada");
|
||||
assert!(matches!(result, Err(ExecError::Rhai(_))));
|
||||
assert_eq!(etapa(&store, opp), "prospecto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monto_negativo_es_rechazado() {
|
||||
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||
let mut store = MemoryStore::new();
|
||||
let cliente = Uuid::new_v4();
|
||||
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||
|
||||
let opp = Uuid::new_v4();
|
||||
let result = exec.run(
|
||||
&mut store,
|
||||
"abrir_oportunidad",
|
||||
&[("cliente", cliente)],
|
||||
json!({
|
||||
"oportunidad_id": opp.to_string(),
|
||||
"titulo": "Trato inválido",
|
||||
"monto": -500_i64,
|
||||
"currency": "USD",
|
||||
"timestamp": "2026-05-21T10:00:00Z",
|
||||
}),
|
||||
);
|
||||
assert!(matches!(result, Err(ExecError::Rhai(_))));
|
||||
assert!(store.load("Oportunidad", opp).is_none(), "no se creó nada");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registrar_interaccion_crea_registro() {
|
||||
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||
let mut store = MemoryStore::new();
|
||||
let cliente = Uuid::new_v4();
|
||||
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||
|
||||
let int_id = Uuid::new_v4();
|
||||
exec.run(
|
||||
&mut store,
|
||||
"registrar_interaccion",
|
||||
&[("cliente", cliente)],
|
||||
json!({
|
||||
"interaccion_id": int_id.to_string(),
|
||||
"canal": "llamada",
|
||||
"nota": "Primer contacto, interés alto",
|
||||
"timestamp": "2026-05-21T09:00:00Z",
|
||||
}),
|
||||
)
|
||||
.expect("registrar_interaccion debe pasar");
|
||||
|
||||
let i = store
|
||||
.load("Interaccion", int_id)
|
||||
.expect("interacción existe");
|
||||
assert_eq!(i.get("canal").and_then(Value::as_str), Some("llamada"));
|
||||
let cid = cliente.to_string();
|
||||
assert_eq!(
|
||||
i.get("cliente_id").and_then(Value::as_str),
|
||||
Some(cid.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canal_invalido_es_rechazado() {
|
||||
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||
let mut store = MemoryStore::new();
|
||||
let cliente = Uuid::new_v4();
|
||||
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||
|
||||
let int_id = Uuid::new_v4();
|
||||
let result = exec.run(
|
||||
&mut store,
|
||||
"registrar_interaccion",
|
||||
&[("cliente", cliente)],
|
||||
json!({
|
||||
"interaccion_id": int_id.to_string(),
|
||||
"canal": "paloma-mensajera",
|
||||
"nota": "canal inexistente",
|
||||
"timestamp": "2026-05-21T09:00:00Z",
|
||||
}),
|
||||
);
|
||||
assert!(matches!(result, Err(ExecError::Rhai(_))));
|
||||
assert!(store.load("Interaccion", int_id).is_none());
|
||||
}
|
||||
@@ -11,8 +11,8 @@ use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
|
||||
use nakui_core::drift::{DriftDiff, check_against_socket};
|
||||
use nakui_core::event_log::{EventLog, execute_and_log, seed_and_log};
|
||||
use nakui_core::drift::{check_against_socket, DriftDiff};
|
||||
use nakui_core::event_log::{execute_and_log, seed_and_log, EventLog};
|
||||
use nakui_core::executor::Executor;
|
||||
use nakui_core::run::run_server;
|
||||
use nakui_core::store::MemoryStore;
|
||||
|
||||
@@ -5,13 +5,12 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use nakui_core::delta::FieldOp;
|
||||
use nakui_core::event_log::{
|
||||
EventLog, ExecuteError, LogEntry, RecoverableExecuteError, Snapshot, execute_and_log,
|
||||
execute_and_log_with_recovery, reconcile, replay, replay_with_snapshot_into, seed_and_log,
|
||||
verify_log,
|
||||
execute_and_log, execute_and_log_with_recovery, reconcile, replay, replay_with_snapshot_into,
|
||||
seed_and_log, verify_log, EventLog, ExecuteError, LogEntry, RecoverableExecuteError, Snapshot,
|
||||
};
|
||||
use nakui_core::executor::Executor;
|
||||
use nakui_core::store::{MemoryStore, Store, StoreError};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
@@ -247,11 +246,21 @@ fn post_log_store_failure_leaves_log_canonical() {
|
||||
|
||||
// Live store is stale: apply was rejected, so saldos are unchanged.
|
||||
assert_eq!(
|
||||
store.load("Caja", a).unwrap().get("saldo").unwrap().as_i64(),
|
||||
store
|
||||
.load("Caja", a)
|
||||
.unwrap()
|
||||
.get("saldo")
|
||||
.unwrap()
|
||||
.as_i64(),
|
||||
Some(200_000)
|
||||
);
|
||||
assert_eq!(
|
||||
store.load("Caja", b).unwrap().get("saldo").unwrap().as_i64(),
|
||||
store
|
||||
.load("Caja", b)
|
||||
.unwrap()
|
||||
.get("saldo")
|
||||
.unwrap()
|
||||
.as_i64(),
|
||||
Some(50_000)
|
||||
);
|
||||
|
||||
@@ -464,7 +473,10 @@ fn snapshot_then_compact_then_replay_equals_pre_compaction() {
|
||||
let loaded_snap = Snapshot::load(&snap_path).unwrap().unwrap();
|
||||
let mut replayed = MemoryStore::new();
|
||||
replay_with_snapshot_into(&log, Some(&loaded_snap), &mut replayed).expect("replay");
|
||||
assert_eq!(live, replayed, "snapshot + post-compact log must equal live");
|
||||
assert_eq!(
|
||||
live, replayed,
|
||||
"snapshot + post-compact log must equal live"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&log_path);
|
||||
let _ = std::fs::remove_file(&snap_path);
|
||||
@@ -520,8 +532,14 @@ fn reconcile_rebuilds_drifted_store_from_log() {
|
||||
assert_ne!(live, canonical, "drift was set up to differ from log");
|
||||
|
||||
reconcile(&mut live, &log).expect("reconcile");
|
||||
assert_eq!(live, canonical, "reconcile must restore log-canonical state");
|
||||
assert!(live.load("Caja", ghost).is_none(), "poison record must be wiped");
|
||||
assert_eq!(
|
||||
live, canonical,
|
||||
"reconcile must restore log-canonical state"
|
||||
);
|
||||
assert!(
|
||||
live.load("Caja", ghost).is_none(),
|
||||
"poison record must be wiped"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&log_path);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use nakui_core::executor::Executor;
|
||||
use nakui_core::graph::{DirtyTracker, GraphError, ManifestGraph};
|
||||
use nakui_core::manifest::{
|
||||
ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec,
|
||||
};
|
||||
use nakui_core::manifest::{ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec};
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -114,14 +112,28 @@ fn treasury_data_flow_indexes_match_manifest() {
|
||||
let g = &exec.graph;
|
||||
|
||||
// Both register_cash_move and transfer_between_cajas write Caja.saldo.
|
||||
let mut writers: Vec<&str> = g.writers_of("Caja.saldo").iter().map(|s| s.as_str()).collect();
|
||||
let mut writers: Vec<&str> = g
|
||||
.writers_of("Caja.saldo")
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
writers.sort();
|
||||
assert_eq!(writers, vec!["register_cash_move", "transfer_between_cajas"]);
|
||||
assert_eq!(
|
||||
writers,
|
||||
vec!["register_cash_move", "transfer_between_cajas"]
|
||||
);
|
||||
|
||||
// Both read Caja.saldo too.
|
||||
let mut readers: Vec<&str> = g.readers_of("Caja.saldo").iter().map(|s| s.as_str()).collect();
|
||||
let mut readers: Vec<&str> = g
|
||||
.readers_of("Caja.saldo")
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
readers.sort();
|
||||
assert_eq!(readers, vec!["register_cash_move", "transfer_between_cajas"]);
|
||||
assert_eq!(
|
||||
readers,
|
||||
vec!["register_cash_move", "transfer_between_cajas"]
|
||||
);
|
||||
|
||||
// Movimiento is written only by register_cash_move.
|
||||
assert_eq!(
|
||||
@@ -246,8 +258,11 @@ fn executor_load_module_rejects_cyclic_manifest() {
|
||||
Err(e) => e,
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("graph") || msg.contains("cycle"),
|
||||
"expected graph diagnostic, got `{}`", msg);
|
||||
assert!(
|
||||
msg.contains("graph") || msg.contains("cycle"),
|
||||
"expected graph diagnostic, got `{}`",
|
||||
msg
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use nakui_core::executor::{ExecError, Executor};
|
||||
use nakui_core::store::{MemoryStore, Store};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
@@ -104,7 +104,10 @@ fn transfer_across_different_skus_is_rejected_by_conservation() {
|
||||
|
||||
match result {
|
||||
Err(ExecError::Rhai(_)) => {}
|
||||
other => panic!("expected Rhai (script throw on sku mismatch), got {:?}", other),
|
||||
other => panic!(
|
||||
"expected Rhai (script throw on sku mismatch), got {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
assert_eq!(cantidad(&store, a), 500);
|
||||
assert_eq!(cantidad(&store, c), 200);
|
||||
|
||||
@@ -13,12 +13,10 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use nakui_core::executor::{ExecError, Executor};
|
||||
use nakui_core::graph::ManifestGraph;
|
||||
use nakui_core::manifest::{
|
||||
ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec,
|
||||
};
|
||||
use nakui_core::manifest::{ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec};
|
||||
use nakui_core::rhai_executor::RhaiExecutor;
|
||||
use nakui_core::store::{MemoryStore, Store};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
@@ -207,7 +205,12 @@ fn capability_rejects_entity_mismatch_on_tracked_id() {
|
||||
let caja_id = Uuid::new_v4();
|
||||
seed_caja(&mut store, caja_id, "tracked", 100_000, "USD");
|
||||
|
||||
let result = exec.run(&mut store, "evil_entity_mismatch", &[("caja", caja_id)], json!({}));
|
||||
let result = exec.run(
|
||||
&mut store,
|
||||
"evil_entity_mismatch",
|
||||
&[("caja", caja_id)],
|
||||
json!({}),
|
||||
);
|
||||
|
||||
match result {
|
||||
Err(ExecError::CapabilityViolation { token, .. }) => {
|
||||
|
||||
@@ -173,7 +173,9 @@ fn rejects_missing_script() {
|
||||
let mut m = baseline_manifest();
|
||||
m.morphisms[0].script = "morphisms/ghost.rhai".into();
|
||||
match m.validate(&treasury_dir()) {
|
||||
Err(ValidationError::ScriptMissing { morphism, script, .. }) => {
|
||||
Err(ValidationError::ScriptMissing {
|
||||
morphism, script, ..
|
||||
}) => {
|
||||
assert_eq!(morphism, "test_op");
|
||||
assert_eq!(script, "morphisms/ghost.rhai");
|
||||
}
|
||||
@@ -200,16 +202,8 @@ fn rejects_duplicate_schema_across_files() {
|
||||
let tmp = std::env::temp_dir().join(format!("nakui_dup_{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&tmp).unwrap();
|
||||
fs::create_dir_all(tmp.join("morphisms")).unwrap();
|
||||
fs::write(
|
||||
tmp.join("a.ncl"),
|
||||
"{\n Caja = { saldo | Number },\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
tmp.join("b.ncl"),
|
||||
"{\n Caja = { monto | Number },\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(tmp.join("a.ncl"), "{\n Caja = { saldo | Number },\n}\n").unwrap();
|
||||
fs::write(tmp.join("b.ncl"), "{\n Caja = { monto | Number },\n}\n").unwrap();
|
||||
fs::write(tmp.join("morphisms/op.rhai"), "[]").unwrap();
|
||||
|
||||
let m = Manifest {
|
||||
|
||||
@@ -14,11 +14,11 @@ use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use nakui_core::event_log::{EventLog, execute_and_log, seed_and_log};
|
||||
use nakui_core::event_log::{execute_and_log, seed_and_log, EventLog};
|
||||
use nakui_core::executor::Executor;
|
||||
use nakui_core::run::run_server;
|
||||
use nakui_core::store::MemoryStore;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
@@ -118,9 +118,7 @@ fn run_server_full_protocol_round_trip() {
|
||||
if resp["protocol"] != json!(1) {
|
||||
return Err(format!("protocol mismatch: {}", resp));
|
||||
}
|
||||
let morphisms = resp["morphisms"]
|
||||
.as_array()
|
||||
.ok_or("morphisms not array")?;
|
||||
let morphisms = resp["morphisms"].as_array().ok_or("morphisms not array")?;
|
||||
if !morphisms.iter().any(|m| m["name"] == "register_cash_move") {
|
||||
return Err("register_cash_move missing from describe".into());
|
||||
}
|
||||
@@ -183,9 +181,13 @@ fn run_server_full_protocol_round_trip() {
|
||||
}
|
||||
|
||||
// Bad JSON — connection survives, server keeps serving.
|
||||
conn.writer.write_all(b"not json\n").map_err(|e| e.to_string())?;
|
||||
conn.writer
|
||||
.write_all(b"not json\n")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut line = String::new();
|
||||
conn.reader.read_line(&mut line).map_err(|e| e.to_string())?;
|
||||
conn.reader
|
||||
.read_line(&mut line)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let parsed: Value = serde_json::from_str(line.trim()).map_err(|e| e.to_string())?;
|
||||
if parsed["ok"] != json!(false) {
|
||||
return Err(format!("bad request didn't get error: {}", parsed));
|
||||
@@ -207,7 +209,10 @@ fn run_server_full_protocol_round_trip() {
|
||||
});
|
||||
|
||||
run_server(executor, log, store, None, &socket_path).expect("server clean exit");
|
||||
client.join().expect("client thread joined").expect("client assertions");
|
||||
client
|
||||
.join()
|
||||
.expect("client thread joined")
|
||||
.expect("client assertions");
|
||||
|
||||
assert!(
|
||||
!socket_path.exists(),
|
||||
|
||||
@@ -29,12 +29,12 @@ use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use nakui_core::event_log::{EventLog, seed_and_log};
|
||||
use nakui_core::event_log::{seed_and_log, EventLog};
|
||||
use nakui_core::executor::Executor;
|
||||
use nakui_core::run::run_server;
|
||||
use nakui_core::store::Store;
|
||||
use nakui_core::surreal_store::SurrealStore;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
@@ -232,8 +232,7 @@ fn run_server_skips_replay_when_persistent_store_is_in_sync() {
|
||||
// Out-of-band mutation: open the persistent store directly and
|
||||
// change the saldo. Marker stays at the same seq.
|
||||
{
|
||||
let mut store =
|
||||
SurrealStore::new_persistent(&store_path).expect("reopen for poison");
|
||||
let mut store = SurrealStore::new_persistent(&store_path).expect("reopen for poison");
|
||||
store.seed(
|
||||
"Caja",
|
||||
caja,
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use nakui_core::executor::{ExecError, Executor};
|
||||
use nakui_core::store::{MemoryStore, Store};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
@@ -95,10 +95,7 @@ fn sale_decreases_stock_and_increases_caja() {
|
||||
.expect("venta must be persisted");
|
||||
assert_eq!(venta.get("total").and_then(Value::as_i64), Some(500_000));
|
||||
assert_eq!(venta.get("cantidad").and_then(Value::as_i64), Some(100));
|
||||
assert_eq!(
|
||||
venta.get("currency").and_then(Value::as_str),
|
||||
Some("USD")
|
||||
);
|
||||
assert_eq!(venta.get("currency").and_then(Value::as_str), Some("USD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nakui_core::event_log::{
|
||||
EventLog, LogEntry, VerifyError, execute_and_log, replay, seed_and_log, verify_log,
|
||||
execute_and_log, replay, seed_and_log, verify_log, EventLog, LogEntry, VerifyError,
|
||||
};
|
||||
use nakui_core::executor::Executor;
|
||||
use nakui_core::store::MemoryStore;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
@@ -66,12 +66,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn deposit_5k(
|
||||
exec: &Executor,
|
||||
store: &mut MemoryStore,
|
||||
log: &mut EventLog,
|
||||
caja: Uuid,
|
||||
) {
|
||||
fn deposit_5k(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, caja: Uuid) {
|
||||
execute_and_log(
|
||||
exec,
|
||||
store,
|
||||
@@ -122,7 +117,10 @@ fn executor_exposes_per_morphism_schema_hash() {
|
||||
// Re-loading the same module yields the same hashes — the contract
|
||||
// depends only on the bytes on disk, not load-time state.
|
||||
let exec2 = Executor::load_module(treasury_module()).expect("reload");
|
||||
assert_eq!(exec.schema_hash("register_cash_move"), exec2.schema_hash("register_cash_move"));
|
||||
assert_eq!(
|
||||
exec.schema_hash("register_cash_move"),
|
||||
exec2.schema_hash("register_cash_move")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -202,7 +200,10 @@ fn verify_log_rejects_log_after_morphism_script_changes() {
|
||||
// Reload — the hash for register_cash_move must change.
|
||||
let exec2 = Executor::load_module(&temp.path).expect("reload v2");
|
||||
let new_hash = exec2.schema_hash("register_cash_move").unwrap();
|
||||
assert_ne!(original_hash, new_hash, "real source edit must move the hash");
|
||||
assert_ne!(
|
||||
original_hash, new_hash,
|
||||
"real source edit must move the hash"
|
||||
);
|
||||
|
||||
// verify_log must surface SchemaMismatch, not OpsMismatch — the
|
||||
// schema check runs first because "rules changed" is more
|
||||
@@ -361,7 +362,10 @@ fn verify_log_rejects_seed_after_schema_changes() {
|
||||
|
||||
let exec2 = Executor::load_module(&temp.path).expect("reload v2");
|
||||
let new_hash = exec2.schema_bundle_hash;
|
||||
assert_ne!(original_hash, new_hash, "schema.ncl byte change must move the bundle hash");
|
||||
assert_ne!(
|
||||
original_hash, new_hash,
|
||||
"schema.ncl byte change must move the bundle hash"
|
||||
);
|
||||
|
||||
let log = EventLog::open(&log_path).unwrap();
|
||||
match verify_log(&log, &exec2) {
|
||||
@@ -433,7 +437,11 @@ fn morphism_script_change_does_not_flag_unrelated_seeds() {
|
||||
// Modify a Rhai script. Bundle stays the same.
|
||||
let script_path = temp.path.join("morphisms/register_cash_move.rhai");
|
||||
let original = std::fs::read_to_string(&script_path).expect("read");
|
||||
std::fs::write(&script_path, format!("{}\n// rhai-only mutation\n", original)).unwrap();
|
||||
std::fs::write(
|
||||
&script_path,
|
||||
format!("{}\n// rhai-only mutation\n", original),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let exec2 = Executor::load_module(&temp.path).expect("reload");
|
||||
let log = EventLog::open(&log_path).unwrap();
|
||||
|
||||
@@ -9,12 +9,12 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use nakui_core::event_log::{
|
||||
EventLog, Snapshot, SnapshotMismatchError, execute_and_log, replay, seed_and_log,
|
||||
execute_and_log, replay, seed_and_log, EventLog, Snapshot, SnapshotMismatchError,
|
||||
};
|
||||
use nakui_core::executor::Executor;
|
||||
use nakui_core::run::run_server;
|
||||
use nakui_core::store::{MemoryStore, Store};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
@@ -166,7 +166,10 @@ fn snapshot_then_compact_then_run_server_resumes_correctly() {
|
||||
let socket_for_client = socket_path.clone();
|
||||
let client = thread::spawn(move || -> Result<(), String> {
|
||||
let mut conn = connect_with_retry(&socket_for_client);
|
||||
let resp = exchange(&mut conn, json!({"op": "load", "entity": "Caja", "id": caja.to_string()}));
|
||||
let resp = exchange(
|
||||
&mut conn,
|
||||
json!({"op": "load", "entity": "Caja", "id": caja.to_string()}),
|
||||
);
|
||||
if resp["value"]["saldo"].as_i64() != Some(112_500) {
|
||||
return Err(format!(
|
||||
"expected saldo 112_500 (100k seed + 5k + 7.5k from snapshot), got {}",
|
||||
@@ -191,9 +194,15 @@ fn snapshot_then_compact_then_run_server_resumes_correctly() {
|
||||
}),
|
||||
);
|
||||
if resp["ok"] != json!(true) {
|
||||
return Err(format!("execute on snapshot-booted server failed: {}", resp));
|
||||
return Err(format!(
|
||||
"execute on snapshot-booted server failed: {}",
|
||||
resp
|
||||
));
|
||||
}
|
||||
let resp = exchange(&mut conn, json!({"op": "load", "entity": "Caja", "id": caja.to_string()}));
|
||||
let resp = exchange(
|
||||
&mut conn,
|
||||
json!({"op": "load", "entity": "Caja", "id": caja.to_string()}),
|
||||
);
|
||||
if resp["value"]["saldo"].as_i64() != Some(113_500) {
|
||||
return Err(format!("post-execute saldo wrong: {}", resp));
|
||||
}
|
||||
@@ -234,10 +243,7 @@ fn run_server_refuses_snapshot_with_wrong_schema_hash() {
|
||||
let store = MemoryStore::new();
|
||||
let result = run_server(exec, log, store, Some(bad_snap), &socket_path);
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(nakui_core::run::RunError::SnapshotMismatch(_))
|
||||
),
|
||||
matches!(result, Err(nakui_core::run::RunError::SnapshotMismatch(_))),
|
||||
"expected SnapshotMismatch, got {:?}",
|
||||
result
|
||||
);
|
||||
@@ -352,7 +358,8 @@ fn snapshot_write_recovers_from_stale_tempfile() {
|
||||
|
||||
let exec = Executor::load_module(treasury_module()).expect("load");
|
||||
let snap = Snapshot::capture(&MemoryStore::new(), 0, &exec);
|
||||
snap.write(&snap_path).expect("write despite stale tempfile");
|
||||
snap.write(&snap_path)
|
||||
.expect("write despite stale tempfile");
|
||||
|
||||
// Tempfile should be renamed (not orphaned), so it's gone.
|
||||
assert!(
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nakui_core::event_log::{EventLog, execute_and_log, replay, seed_and_log};
|
||||
use nakui_core::event_log::{execute_and_log, replay, seed_and_log, EventLog};
|
||||
use nakui_core::executor::Executor;
|
||||
use nakui_core::store::{MemoryStore, Store};
|
||||
use serde_json::json;
|
||||
@@ -27,13 +27,7 @@ fn fresh_log_path() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("nakui_hash_{}.jsonl", Uuid::new_v4()))
|
||||
}
|
||||
|
||||
fn seed_two_cajas(
|
||||
exec: &Executor,
|
||||
store: &mut MemoryStore,
|
||||
log: &mut EventLog,
|
||||
a: Uuid,
|
||||
b: Uuid,
|
||||
) {
|
||||
fn seed_two_cajas(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, a: Uuid, b: Uuid) {
|
||||
seed_and_log(
|
||||
exec,
|
||||
store,
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::path::PathBuf;
|
||||
|
||||
use nakui_core::store::Store;
|
||||
use nakui_core::surreal_store::SurrealStore;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn fresh_db_path() -> PathBuf {
|
||||
@@ -39,9 +39,7 @@ fn data_survives_close_and_reopen() {
|
||||
|
||||
{
|
||||
let store = SurrealStore::new_persistent(&path).expect("reopen persistent");
|
||||
let loaded = store
|
||||
.load("Caja", id)
|
||||
.expect("record must survive reopen");
|
||||
let loaded = store.load("Caja", id).expect("record must survive reopen");
|
||||
assert_eq!(
|
||||
loaded.get("saldo").and_then(Value::as_i64),
|
||||
Some(12_345),
|
||||
|
||||
@@ -8,12 +8,12 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use nakui_core::delta::{FieldOp, FieldPath};
|
||||
use nakui_core::event_log::{
|
||||
EventLog, execute_and_log, reconcile, replay_into, seed_and_log, verify_log,
|
||||
execute_and_log, reconcile, replay_into, seed_and_log, verify_log, EventLog,
|
||||
};
|
||||
use nakui_core::executor::Executor;
|
||||
use nakui_core::store::{MemoryStore, Store, StoreError};
|
||||
use nakui_core::surreal_store::SurrealStore;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
@@ -240,8 +240,24 @@ fn verify_log_against_surreal_passes() {
|
||||
|
||||
let a = Uuid::new_v4();
|
||||
let b = Uuid::new_v4();
|
||||
seed_and_log(&exec, &mut live, &mut log, "Caja", a, caja_data(a, 200_000, "USD")).unwrap();
|
||||
seed_and_log(&exec, &mut live, &mut log, "Caja", b, caja_data(b, 50_000, "USD")).unwrap();
|
||||
seed_and_log(
|
||||
&exec,
|
||||
&mut live,
|
||||
&mut log,
|
||||
"Caja",
|
||||
a,
|
||||
caja_data(a, 200_000, "USD"),
|
||||
)
|
||||
.unwrap();
|
||||
seed_and_log(
|
||||
&exec,
|
||||
&mut live,
|
||||
&mut log,
|
||||
"Caja",
|
||||
b,
|
||||
caja_data(b, 50_000, "USD"),
|
||||
)
|
||||
.unwrap();
|
||||
execute_and_log(
|
||||
&exec,
|
||||
&mut live,
|
||||
@@ -432,8 +448,24 @@ fn iter_and_hash_state_round_trip_against_surreal() {
|
||||
let mut live = SurrealStore::new_in_memory().expect("live");
|
||||
let a = Uuid::new_v4();
|
||||
let b = Uuid::new_v4();
|
||||
seed_and_log(&exec, &mut live, &mut log, "Caja", a, caja_data(a, 200_000, "USD")).unwrap();
|
||||
seed_and_log(&exec, &mut live, &mut log, "Caja", b, caja_data(b, 50_000, "USD")).unwrap();
|
||||
seed_and_log(
|
||||
&exec,
|
||||
&mut live,
|
||||
&mut log,
|
||||
"Caja",
|
||||
a,
|
||||
caja_data(a, 200_000, "USD"),
|
||||
)
|
||||
.unwrap();
|
||||
seed_and_log(
|
||||
&exec,
|
||||
&mut live,
|
||||
&mut log,
|
||||
"Caja",
|
||||
b,
|
||||
caja_data(b, 50_000, "USD"),
|
||||
)
|
||||
.unwrap();
|
||||
execute_and_log(
|
||||
&exec,
|
||||
&mut live,
|
||||
@@ -453,12 +485,17 @@ fn iter_and_hash_state_round_trip_against_surreal() {
|
||||
// iter must enumerate every record.
|
||||
let recs: Vec<_> = live.iter().expect("iter").collect();
|
||||
let by_entity: std::collections::HashMap<&str, usize> =
|
||||
recs.iter().fold(std::collections::HashMap::new(), |mut m, (e, _, _)| {
|
||||
*m.entry(e.as_str()).or_insert(0) += 1;
|
||||
m
|
||||
});
|
||||
recs.iter()
|
||||
.fold(std::collections::HashMap::new(), |mut m, (e, _, _)| {
|
||||
*m.entry(e.as_str()).or_insert(0) += 1;
|
||||
m
|
||||
});
|
||||
assert_eq!(by_entity.get("Caja").copied(), Some(2), "two Cajas");
|
||||
assert_eq!(by_entity.get("Movimiento").copied(), Some(1), "one Movimiento");
|
||||
assert_eq!(
|
||||
by_entity.get("Movimiento").copied(),
|
||||
Some(1),
|
||||
"one Movimiento"
|
||||
);
|
||||
|
||||
// canonical order: entities sorted, ids byte-sorted within entity.
|
||||
let entities: Vec<&str> = recs.iter().map(|(e, _, _)| e.as_str()).collect();
|
||||
@@ -496,7 +533,15 @@ fn reconcile_rebuilds_drifted_surreal_store_from_log() {
|
||||
let mut store = SurrealStore::new_in_memory().expect("surreal");
|
||||
|
||||
let a = Uuid::new_v4();
|
||||
seed_and_log(&exec, &mut store, &mut log, "Caja", a, caja_data(a, 100_000, "USD")).unwrap();
|
||||
seed_and_log(
|
||||
&exec,
|
||||
&mut store,
|
||||
&mut log,
|
||||
"Caja",
|
||||
a,
|
||||
caja_data(a, 100_000, "USD"),
|
||||
)
|
||||
.unwrap();
|
||||
execute_and_log(
|
||||
&exec,
|
||||
&mut store,
|
||||
@@ -518,7 +563,9 @@ fn reconcile_rebuilds_drifted_surreal_store_from_log() {
|
||||
store.seed("Caja", ghost, caja_data(ghost, 0, "USD"));
|
||||
store.seed("Caja", a, caja_data(a, 999_999, "USD"));
|
||||
assert_eq!(
|
||||
store.load("Caja", a).and_then(|v| v.get("saldo").and_then(Value::as_i64)),
|
||||
store
|
||||
.load("Caja", a)
|
||||
.and_then(|v| v.get("saldo").and_then(Value::as_i64)),
|
||||
Some(999_999),
|
||||
"drift was applied"
|
||||
);
|
||||
@@ -528,7 +575,9 @@ fn reconcile_rebuilds_drifted_surreal_store_from_log() {
|
||||
// After reconcile: ghost gone, saldo = 100_000 (seed) + 5_000 (deposit).
|
||||
assert!(store.load("Caja", ghost).is_none(), "poison record wiped");
|
||||
assert_eq!(
|
||||
store.load("Caja", a).and_then(|v| v.get("saldo").and_then(Value::as_i64)),
|
||||
store
|
||||
.load("Caja", a)
|
||||
.and_then(|v| v.get("saldo").and_then(Value::as_i64)),
|
||||
Some(105_000),
|
||||
"reconcile must restore log-canonical saldo"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user