feat(nakui-ui): validación cross-field del EntityRef (existence en store)

parse_field_value(EntityRef) sólo validaba forma (UUID parseable).
Un UUID válido pero inexistente pasaba al log/store, dejando
dangling references. Ahora validamos también existencia contra la
entity declarada en FieldSpec.ref_entity.

- Nuevo helper validate_entity_refs<S: Store>(store, refs):
  fail-fast loop sobre (label, target_entity, uuid) tuples; primer
  record ausente → error con label legible + UUID corto.
- commit_seed: durante el parse loop encolamos cada EntityRef +
  ref_entity + UUID parseado. Después del loop, una sola toma del
  store lock valida todos. Falla early: ningún log entry.
- Cobertura: SEED + EDIT. Morphism inputs ya cubierto por
  Executor::compute (load + EntityMissing) — documentado en el doc
  del helper.

5 tests nuevos del helper: happy path, fail-fast con detalles,
label en msg, lista vacía, distingue target entity.

45 tests verdes en nakui-ui.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-09 22:23:20 +00:00
parent f0c0a71860
commit ffdfa6f8d7
2 changed files with 181 additions and 0 deletions
+116
View File
@@ -705,6 +705,10 @@ impl MetaUi {
// current store value tiene algo en ese key). En el SEED path
// simplemente no se incluyen, igual que antes.
let mut to_clear: Vec<String> = Vec::new();
// EntityRef references a chequear existencia DESPUÉS del parse
// loop (necesitan lock del store; lo tomamos una sola vez).
// Tuple: (label legible, target entity, parsed UUID).
let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
for f in &spec_fields {
let raw = self
.form_inputs
@@ -720,8 +724,28 @@ impl MetaUi {
}
let value = parse_field_value(f.kind, &raw)
.map_err(|e| format!("campo '{}': {e}", f.label))?;
// Si el field es EntityRef y declara ref_entity, encolamos
// el (label, target, uuid) para validar existence en lote.
// El UUID ya está bien-formado (parse_field_value lo
// validó); ahora chequeamos que el record exista.
if f.kind == FieldKind::EntityRef {
if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
let id = Uuid::parse_str(uuid_str)
.expect("parse_field_value validated UUID");
entity_refs.push((f.label.clone(), target.clone(), id));
}
}
obj.insert(f.name.clone(), value);
}
// Validar EntityRefs contra el store actual. Una sola toma del
// lock para todas las refs.
if !entity_refs.is_empty() {
let store = self
.store
.lock()
.map_err(|_| "store mutex envenenado".to_string())?;
validate_entity_refs(&*store, &entity_refs)?;
}
// Ramificación: si `editing` está set para esta entity, es un
// edit de un record existente — emitimos Morphism con un
// FieldOp::Set por cada campo del form (sobreescribe). Si no,
@@ -941,6 +965,33 @@ fn maybe_compact_log(
)))
}
/// Valida que cada UUID en `refs` apunte a un record que realmente
/// existe en el store bajo la entity esperada. Devuelve el primer
/// error encontrado (fail-fast).
///
/// `refs` es una lista de `(label, target_entity, uuid)`. El label
/// va al error message, así que conviene que sea legible (ej:
/// `FieldSpec.label` en lugar de `FieldSpec.name`).
///
/// Sólo se llama desde el SEED path de la UI. Los inputs de morphism
/// no necesitan este check porque `Executor::compute` ya valida cada
/// input via `store.load(...).ok_or(EntityMissing)` antes de correr
/// el script Rhai.
fn validate_entity_refs<S: Store>(
store: &S,
refs: &[(String, String, Uuid)],
) -> Result<(), String> {
for (label, target, id) in refs {
if store.load(target, *id).is_none() {
return Err(format!(
"campo '{label}': record {} de '{target}' no existe en el store",
short_uuid(id)
));
}
}
Ok(())
}
/// Decide cuáles fields del `to_clear` candidate list ameritan
/// realmente un `FieldOp::Clear`: sólo los que existen en el current
/// con un valor non-null. Para fields ausentes o ya null, Clear es
@@ -2059,6 +2110,71 @@ mod tests {
let _ = std::fs::remove_file(&snap_path);
}
#[test]
fn validate_entity_refs_passes_when_all_records_exist() {
let mut store = MemoryStore::new();
let stock_id = Uuid::new_v4();
let caja_id = Uuid::new_v4();
store.seed("Stock", stock_id, json!({"sku_id": "abc"}));
store.seed("Caja", caja_id, json!({"name": "Principal"}));
let refs = vec![
("Stock".into(), "Stock".into(), stock_id),
("Caja".into(), "Caja".into(), caja_id),
];
assert!(validate_entity_refs(&store, &refs).is_ok());
}
#[test]
fn validate_entity_refs_fails_on_first_missing() {
let mut store = MemoryStore::new();
let stock_id = Uuid::new_v4();
store.seed("Stock", stock_id, json!({"sku_id": "abc"}));
let missing_caja = Uuid::new_v4();
let refs = vec![
("Stock".into(), "Stock".into(), stock_id),
("Caja".into(), "Caja".into(), missing_caja),
];
let err = validate_entity_refs(&store, &refs).unwrap_err();
assert!(err.contains("Caja"), "msg debe nombrar la entity: {err}");
assert!(
err.contains(&short_uuid(&missing_caja)),
"msg debe incluir el UUID corto: {err}"
);
}
#[test]
fn validate_entity_refs_uses_label_not_entity_in_msg() {
// Si el FieldSpec.label es distinto de la entity (ej:
// "Stock origen" en lugar de "Stock"), el error debería usar
// el label legible.
let store = MemoryStore::new();
let id = Uuid::new_v4();
let refs = vec![("Stock origen".into(), "Stock".into(), id)];
let err = validate_entity_refs(&store, &refs).unwrap_err();
assert!(
err.contains("Stock origen"),
"msg debe incluir el label: {err}"
);
}
#[test]
fn validate_entity_refs_empty_list_is_ok() {
let store = MemoryStore::new();
assert!(validate_entity_refs(&store, &[]).is_ok());
}
#[test]
fn validate_entity_refs_distinguishes_target_from_other_entities() {
// Sanity: un UUID que existe bajo entity X pero NO bajo Y
// debería fallar la validación contra Y.
let mut store = MemoryStore::new();
let id = Uuid::new_v4();
store.seed("Customer", id, json!({"name": "Acme"}));
// Mismo UUID, target distinto.
let refs = vec![("Stock".into(), "Stock".into(), id)];
assert!(validate_entity_refs(&store, &refs).is_err());
}
#[test]
fn clear_fields_skips_absent_and_null() {
let current = json!({