use loro::LoroValue;
use nodedb_crdt::constraint::ConstraintSet;
use nodedb_crdt::policy::CollectionPolicy;
use nodedb_crdt::pre_validate::PreValidationResult;
use nodedb_crdt::validator::ProposedChange;
use crate::types::TenantId;
use super::core::TenantCrdtEngine;
fn test_constraints() -> ConstraintSet {
let mut cs = ConstraintSet::new();
cs.add_unique("users_email_unique", "users", "email");
cs.add_not_null("users_name_nn", "users", "name");
cs
}
#[test]
fn valid_write_applies() {
let mut engine = TenantCrdtEngine::new(TenantId::new(1), 0, test_constraints()).unwrap();
let change = ProposedChange {
collection: "users".into(),
row_id: "u1".into(),
surrogate: nodedb_types::Surrogate::ZERO,
fields: vec![
("name".into(), LoroValue::String("Alice".into())),
(
"email".into(),
LoroValue::String("alice@example.com".into()),
),
],
};
engine
.validate_and_apply(
1,
nodedb_crdt::CrdtAuthContext::default(),
&change,
b"delta".to_vec(),
)
.unwrap();
assert!(engine.row_exists("users", "u1"));
assert_eq!(engine.dlq_len(), 0);
}
#[test]
fn constraint_violation_routes_to_dlq() {
let mut engine = TenantCrdtEngine::new(TenantId::new(1), 0, test_constraints()).unwrap();
engine
.validator
.policies_mut()
.set("users", CollectionPolicy::strict());
let change = ProposedChange {
collection: "users".into(),
row_id: "u1".into(),
surrogate: nodedb_types::Surrogate::ZERO,
fields: vec![("email".into(), LoroValue::String("a@b.com".into()))],
};
let err = engine
.validate_and_apply(
42,
nodedb_crdt::CrdtAuthContext::default(),
&change,
b"delta".to_vec(),
)
.unwrap_err();
assert!(matches!(err, crate::Error::Crdt(_)));
assert_eq!(engine.dlq_len(), 1);
}
#[test]
fn pre_validate_fast_rejects() {
let engine = TenantCrdtEngine::new(TenantId::new(1), 0, test_constraints()).unwrap();
let change = ProposedChange {
collection: "users".into(),
row_id: "u1".into(),
surrogate: nodedb_types::Surrogate::ZERO,
fields: vec![("email".into(), LoroValue::String("a@b.com".into()))],
};
match engine.pre_validate(&change) {
PreValidationResult::FastReject { constraint, .. } => {
assert_eq!(constraint, "users_name_nn");
}
_ => panic!("expected fast reject"),
}
}
#[test]
fn unique_violation_after_first_write() {
let mut engine = TenantCrdtEngine::new(TenantId::new(1), 0, test_constraints()).unwrap();
engine
.validator
.policies_mut()
.set("users", CollectionPolicy::strict());
let first = ProposedChange {
collection: "users".into(),
row_id: "u1".into(),
surrogate: nodedb_types::Surrogate::ZERO,
fields: vec![
("name".into(), LoroValue::String("Alice".into())),
(
"email".into(),
LoroValue::String("alice@example.com".into()),
),
],
};
engine
.validate_and_apply(
1,
nodedb_crdt::CrdtAuthContext::default(),
&first,
b"d1".to_vec(),
)
.unwrap();
let second = ProposedChange {
collection: "users".into(),
row_id: "u2".into(),
surrogate: nodedb_types::Surrogate::ZERO,
fields: vec![
("name".into(), LoroValue::String("Bob".into())),
(
"email".into(),
LoroValue::String("alice@example.com".into()),
),
],
};
assert!(
engine
.validate_and_apply(
2,
nodedb_crdt::CrdtAuthContext::default(),
&second,
b"d2".to_vec()
)
.is_err()
);
assert_eq!(engine.dlq_len(), 1);
}
#[test]
fn separate_collections_have_isolated_docs() {
let mut engine = TenantCrdtEngine::new(TenantId::new(1), 0, ConstraintSet::new()).unwrap();
let change = ProposedChange {
collection: "users".into(),
row_id: "u1".into(),
surrogate: nodedb_types::Surrogate::ZERO,
fields: vec![("name".into(), LoroValue::String("Alice".into()))],
};
engine
.validate_and_apply(
1,
nodedb_crdt::CrdtAuthContext::default(),
&change,
b"d".to_vec(),
)
.unwrap();
assert!(engine.row_exists("users", "u1"));
assert!(!engine.row_exists("orders", "u1"));
assert!(engine.read_row("users", "u1").is_some());
assert!(engine.read_row("orders", "u1").is_none());
}
fn fk_constraints() -> ConstraintSet {
let mut cs = ConstraintSet::new();
cs.add_foreign_key("posts_author_fk", "posts", "author_id", "users", "id");
cs
}
fn apply_change(
engine: &mut TenantCrdtEngine,
collection: &str,
row_id: &str,
fields: Vec<(String, LoroValue)>,
) -> crate::Result<()> {
let change = ProposedChange {
collection: collection.into(),
row_id: row_id.into(),
surrogate: nodedb_types::Surrogate::ZERO,
fields,
};
engine.validate_and_apply(
1,
nodedb_crdt::CrdtAuthContext::default(),
&change,
b"d".to_vec(),
)
}
#[test]
fn cross_collection_fk_rejects_missing_referent() {
let mut engine = TenantCrdtEngine::new(TenantId::new(2), 0, fk_constraints()).unwrap();
engine.set_collection_policy_typed("posts", CollectionPolicy::strict());
let result = apply_change(
&mut engine,
"posts",
"p1",
vec![
("title".into(), LoroValue::String("Hello".into())),
("author_id".into(), LoroValue::String("u1".into())),
],
);
assert!(result.is_err());
assert_eq!(engine.dlq_len(), 1);
assert!(!engine.row_exists("posts", "p1"));
}
#[test]
fn cross_collection_fk_accepts_after_referent_inserted() {
let mut engine = TenantCrdtEngine::new(TenantId::new(3), 0, fk_constraints()).unwrap();
engine.set_collection_policy_typed("posts", CollectionPolicy::strict());
apply_change(
&mut engine,
"users",
"u1",
vec![("name".into(), LoroValue::String("Alice".into()))],
)
.unwrap();
apply_change(
&mut engine,
"posts",
"p1",
vec![
("title".into(), LoroValue::String("Hello".into())),
("author_id".into(), LoroValue::String("u1".into())),
],
)
.unwrap();
assert!(engine.row_exists("users", "u1"));
assert!(engine.row_exists("posts", "p1"));
assert_eq!(engine.dlq_len(), 0);
}
#[test]
fn array_surrogate_satisfies_cross_engine_fk() {
let mut cs = ConstraintSet::new();
cs.add_foreign_key("posts_author_fk", "posts", "author_id", "users", "id");
let mut engine = TenantCrdtEngine::new(TenantId::new(4), 0, cs).unwrap();
engine.set_collection_policy_typed("posts", CollectionPolicy::strict());
engine.register_array_surrogate("arr_42");
apply_change(
&mut engine,
"posts",
"p1",
vec![
("title".into(), LoroValue::String("Hello".into())),
("author_id".into(), LoroValue::String("arr_42".into())),
],
)
.unwrap();
assert!(engine.row_exists("posts", "p1"));
assert_eq!(engine.dlq_len(), 0);
}
#[test]
fn installed_constraints_are_enforced_and_droppable() {
let mut engine = TenantCrdtEngine::new(TenantId::new(1), 0, ConstraintSet::new()).unwrap();
engine
.validator
.policies_mut()
.set("users", CollectionPolicy::strict());
let unique_email = nodedb_crdt::Constraint {
name: "users_email_unique".into(),
collection: "users".into(),
field: "email".into(),
kind: nodedb_crdt::ConstraintKind::Unique,
};
assert!(engine.set_collection_constraints("users", 1, vec![unique_email.clone()]));
let mk = |row: &str, coll: &str, email: &str| ProposedChange {
collection: coll.into(),
row_id: row.into(),
surrogate: nodedb_types::Surrogate::ZERO,
fields: vec![("email".into(), LoroValue::String(email.into()))],
};
engine
.validate_and_apply(
1,
nodedb_crdt::CrdtAuthContext::default(),
&mk("u1", "users", "a@b.com"),
b"d".to_vec(),
)
.unwrap();
assert!(
engine
.validate_and_apply(
1,
nodedb_crdt::CrdtAuthContext::default(),
&mk("u2", "users", "a@b.com"),
b"d".to_vec()
)
.is_err(),
"duplicate email must violate the installed UNIQUE constraint"
);
engine
.validate_and_apply(
1,
nodedb_crdt::CrdtAuthContext::default(),
&mk("p1", "posts", "a@b.com"),
b"d".to_vec(),
)
.unwrap();
assert!(engine.drop_collection_constraints("users", 2));
engine
.validate_and_apply(
1,
nodedb_crdt::CrdtAuthContext::default(),
&mk("u3", "users", "a@b.com"),
b"d".to_vec(),
)
.unwrap();
assert!(engine.row_exists("users", "u3"));
}
#[test]
fn set_collection_constraints_replaces_rather_than_accumulates() {
let mut engine = TenantCrdtEngine::new(TenantId::new(1), 0, ConstraintSet::new()).unwrap();
let c = nodedb_crdt::Constraint {
name: "users_email_unique".into(),
collection: "users".into(),
field: "email".into(),
kind: nodedb_crdt::ConstraintKind::Unique,
};
engine.set_collection_constraints("users", 1, vec![c.clone()]);
engine.set_collection_constraints("users", 1, vec![c.clone()]);
assert_eq!(engine.validator.constraints_for("users").len(), 1);
engine.set_collection_constraints("users", 2, Vec::<nodedb_crdt::Constraint>::new());
assert_eq!(engine.validator.constraints_for("users").len(), 0);
}
fn unique_named(name: &str) -> nodedb_crdt::Constraint {
nodedb_crdt::Constraint {
name: name.into(),
collection: "users".into(),
field: "email".into(),
kind: nodedb_crdt::ConstraintKind::Unique,
}
}
#[test]
fn set_constraint_version_fence_rejects_stale_and_accepts_newer() {
let mut engine = TenantCrdtEngine::new(TenantId::new(1), 0, ConstraintSet::new()).unwrap();
let v5 = unique_named("rule_v5");
assert!(engine.set_collection_constraints("users", 5, vec![v5.clone()]));
let installed = engine.constraints_for_collection("users");
assert_eq!(installed.len(), 1);
assert_eq!(installed[0].name, "rule_v5");
let v3 = unique_named("rule_v3");
assert!(!engine.set_collection_constraints("users", 3, vec![v3.clone()]));
let unchanged = engine.constraints_for_collection("users");
assert_eq!(unchanged.len(), 1);
assert_eq!(unchanged[0].name, "rule_v5");
let v7 = unique_named("rule_v7");
assert!(engine.set_collection_constraints("users", 7, vec![v7.clone()]));
let replaced = engine.constraints_for_collection("users");
assert_eq!(replaced.len(), 1);
assert_eq!(replaced[0].name, "rule_v7");
}
#[test]
fn drop_constraint_version_fence_rejects_stale_and_accepts_newer() {
let mut engine = TenantCrdtEngine::new(TenantId::new(1), 0, ConstraintSet::new()).unwrap();
assert!(engine.set_collection_constraints("users", 5, vec![unique_named("rule_v5")]));
assert!(!engine.drop_collection_constraints("users", 4));
assert_eq!(engine.constraints_for_collection("users").len(), 1);
assert!(engine.drop_collection_constraints("users", 6));
assert_eq!(
engine.constraints_for_collection("users"),
Vec::<nodedb_crdt::Constraint>::new()
);
}
#[test]
fn set_constraint_same_version_is_idempotent() {
let mut engine = TenantCrdtEngine::new(TenantId::new(1), 0, ConstraintSet::new()).unwrap();
let rule = unique_named("rule_v5");
assert!(engine.set_collection_constraints("users", 5, vec![rule.clone()]));
assert!(engine.set_collection_constraints("users", 5, vec![rule.clone()]));
let installed = engine.constraints_for_collection("users");
assert_eq!(installed.len(), 1);
assert_eq!(installed[0].name, "rule_v5");
}
#[test]
fn purge_clears_constraints_and_resets_version_fence() {
let mut engine = TenantCrdtEngine::new(TenantId::new(1), 0, ConstraintSet::new()).unwrap();
assert!(engine.set_collection_constraints("users", 5, vec![unique_named("old_rule")]));
engine.purge_collection("users").unwrap();
assert_eq!(
engine.constraints_for_collection("users"),
Vec::<nodedb_crdt::Constraint>::new()
);
assert!(engine.set_collection_constraints("users", 1, vec![unique_named("new_rule")]));
let installed = engine.constraints_for_collection("users");
assert_eq!(installed.len(), 1);
assert_eq!(installed[0].name, "new_rule");
}