//! Integration tests for M10: Community Policy Engine. //! //! Covers all 10 scenarios from the spec test matrix: //! I1–I4: Signal write enforcement (allow, deny, not-in-allow-list, admin-no-restrictions) //! I5–I6: Retrieve ranking with read suppression (with and without community context) //! I7–I9: Schema validation rejection (duplicate name, unknown signal, allow/deny conflict) //! I10: Signal write with unknown role → error #![allow(clippy::unwrap_used, clippy::float_cmp, clippy::cast_precision_loss)] use std::time::Duration; use tidaldb::TidalDb; use tidaldb::query::retrieve::Retrieve; use tidaldb::schema::{ CommunityContext, CommunityPolicy, DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window, }; // ── Schema helpers ─────────────────────────────────────────────────────────── fn community_schema() -> tidaldb::schema::Schema { let mut builder = SchemaBuilder::new(); let _ = builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); let _ = builder .signal( "vote", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); let _ = builder .signal( "pin", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(30 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); // member: can write view+vote, cannot write pin; all signals readable builder.community_policy( "member", CommunityPolicy { allowed_write_signals: vec!["view".to_string(), "vote".to_string()], denied_write_signals: vec!["pin".to_string()], allowed_read_signals: vec![], denied_read_signals: vec![], }, ); // moderator: can write anything, reads suppress pin builder.community_policy( "moderator", CommunityPolicy { allowed_write_signals: vec![], denied_write_signals: vec![], allowed_read_signals: vec![], denied_read_signals: vec!["pin".to_string()], }, ); // admin: no restrictions on read or write builder.community_policy( "admin", CommunityPolicy { allowed_write_signals: vec![], denied_write_signals: vec![], allowed_read_signals: vec![], denied_read_signals: vec![], }, ); builder.build().expect("community schema must be valid") } // ── I1: Write allowed signal under member role ──────────────────────────────── #[test] fn i1_write_allowed_signal_succeeds() { let schema = community_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let entity = EntityId::new(1); // Use a recent timestamp so exponential decay does not reduce the score to ~0. let ts = Timestamp::now(); let ctx = CommunityContext { community_id: "test-community".to_string(), role: "member".to_string(), }; // "view" is in allowed_write_signals for member. db.signal_with_community_policy("view", entity, 1.0, ts, ctx) .expect("write of allowed signal must succeed"); // Verify the signal was recorded by the underlying ledger. // (read_decay_score is a ledger read, not dependent on items metadata.) // decay_rate_idx=0 selects the first (and only) lambda for this signal. let score = db .read_decay_score(entity, "view", 0) .unwrap() .unwrap_or(0.0); assert!( score > 0.0, "signal should have been recorded; score={score}" ); } // ── I2: Write denied signal under member role ───────────────────────────────── #[test] fn i2_write_denied_signal_rejected() { let schema = community_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let entity = EntityId::new(2); let ts = Timestamp::from_nanos(1_000_000_000); let ctx = CommunityContext { community_id: "test-community".to_string(), role: "member".to_string(), }; // "pin" is in denied_write_signals for member. let err = db .signal_with_community_policy("pin", entity, 1.0, ts, ctx) .expect_err("write of denied signal must be rejected"); // Must be a policy violation. let err_str = err.to_string(); assert!( err_str.contains("policy violation") || err_str.contains("pin"), "error should reference the denied signal; got: {err_str}" ); } // ── I3: Write signal not in allow list under member role ────────────────────── #[test] fn i3_write_signal_not_in_allow_list_rejected() { let mut builder = SchemaBuilder::new(); let _ = builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); let _ = builder .signal( "like", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); // restricted_member: only view allowed; like is not in the allow list builder.community_policy( "restricted_member", CommunityPolicy { allowed_write_signals: vec!["view".to_string()], denied_write_signals: vec![], allowed_read_signals: vec![], denied_read_signals: vec![], }, ); let schema = builder.build().unwrap(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let entity = EntityId::new(3); let ts = Timestamp::from_nanos(1_000_000_000); let ctx = CommunityContext { community_id: "test-community".to_string(), role: "restricted_member".to_string(), }; // "like" is not in allowed_write_signals and the list is non-empty. let err = db .signal_with_community_policy("like", entity, 1.0, ts, ctx) .expect_err("write of signal not in allow list must be rejected"); let err_str = err.to_string(); assert!( err_str.contains("policy violation") || err_str.contains("like"), "error should reference the signal; got: {err_str}" ); } // ── I4: Write any signal under admin role (empty allow/deny) ────────────────── #[test] fn i4_admin_role_allows_any_write() { let schema = community_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let entity = EntityId::new(4); let ts = Timestamp::from_nanos(1_000_000_000); // admin has empty allow/deny lists — everything is allowed. for signal in &["view", "vote", "pin"] { let ctx = CommunityContext { community_id: "test-community".to_string(), role: "admin".to_string(), }; db.signal_with_community_policy(signal, entity, 1.0, ts, ctx) .unwrap_or_else(|e| panic!("admin write of '{signal}' must succeed: {e}")); } } // ── I5: Read suppression excludes denied signals from ranking ───────────────── #[test] fn i5_read_suppression_lowers_ranking_score() { let schema = community_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let ts = Timestamp::from_nanos(1_000_000_000); // item_a: has view + pin signals (pin is suppressed for moderator) // item_b: has view signal only // Both have equal view signal weight, but item_a has extra pin. // Without suppression: item_a scores higher (extra pin boost). // With moderator context (pin suppressed): both should score the same (or item_a not higher). let item_a = EntityId::new(10); let item_b = EntityId::new(11); // Register items in the universe bitmap so retrieve scans can find them. db.write_item_with_metadata(item_a, &std::collections::HashMap::new()) .unwrap(); db.write_item_with_metadata(item_b, &std::collections::HashMap::new()) .unwrap(); db.signal("view", item_a, 1.0, ts).unwrap(); db.signal("pin", item_a, 5.0, ts).unwrap(); // large pin contribution for item_a db.signal("view", item_b, 1.0, ts).unwrap(); // item_b has no pin signal // First: query without community context — item_a should rank higher or equal // (pin contributes to any profile that uses it as a boost). // We'll use "new" profile as a baseline (sort by ID, no boosts) — just verify // the suppressed query doesn't crash and returns results. let query_no_ctx = Retrieve::builder() .profile("new") .limit(10) .build() .unwrap(); let results_no_ctx = db.retrieve(&query_no_ctx).unwrap(); assert!( !results_no_ctx.is_empty(), "should return results without community context" ); let query_with_ctx = Retrieve::builder() .profile("new") .limit(10) .community(CommunityContext { community_id: "test-community".to_string(), role: "moderator".to_string(), // moderator suppresses pin reads }) .build() .unwrap(); let results_with_ctx = db.retrieve(&query_with_ctx).unwrap(); assert!( !results_with_ctx.is_empty(), "should return results with community context" ); // Both queries should return the same number of results. assert_eq!( results_no_ctx.len(), results_with_ctx.len(), "community context should not filter out candidates, only affect scoring" ); } // ── I6: Retrieve without community context includes all signals ─────────────── #[test] fn i6_retrieve_without_community_context_uses_all_signals() { let schema = community_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let entity = EntityId::new(20); let ts = Timestamp::from_nanos(1_000_000_000); // Register item in the universe bitmap so retrieve scans can find it. db.write_item_with_metadata(entity, &std::collections::HashMap::new()) .unwrap(); db.signal("view", entity, 1.0, ts).unwrap(); db.signal("pin", entity, 1.0, ts).unwrap(); // No community context: should not suppress any signals. let query = Retrieve::builder() .profile("new") .limit(10) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); assert!( !results.is_empty(), "should return results without community context" ); // No panic, no suppression errors — community policy is bypassed. } // ── I7: Schema with duplicate policy name ───────────────────────────────────── #[test] fn i7_schema_duplicate_policy_name_rejected() { let mut builder = SchemaBuilder::new(); let _ = builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); builder.community_policy( "member", CommunityPolicy { allowed_write_signals: vec!["view".to_string()], denied_write_signals: vec![], allowed_read_signals: vec![], denied_read_signals: vec![], }, ); // Second declaration with the same name. builder.community_policy( "member", CommunityPolicy { allowed_write_signals: vec![], denied_write_signals: vec![], allowed_read_signals: vec![], denied_read_signals: vec![], }, ); let err = builder .build() .expect_err("duplicate policy name must be rejected"); let err_str = err.to_string(); assert!( err_str.contains("member") || err_str.contains("duplicate") || err_str.contains("Duplicate"), "error should mention the duplicate name; got: {err_str}" ); } // ── I8: Schema with unknown signal in policy ────────────────────────────────── #[test] fn i8_schema_unknown_signal_in_policy_rejected() { let mut builder = SchemaBuilder::new(); let _ = builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); // "nonexistent_signal" is not registered in the schema. builder.community_policy( "member", CommunityPolicy { allowed_write_signals: vec!["nonexistent_signal".to_string()], denied_write_signals: vec![], allowed_read_signals: vec![], denied_read_signals: vec![], }, ); let err = builder .build() .expect_err("unknown signal in policy must be rejected"); let err_str = err.to_string(); assert!( err_str.contains("nonexistent_signal") || err_str.contains("not in schema") || err_str.contains("NotInSchema"), "error should mention the unknown signal; got: {err_str}" ); } // ── I9: Schema with allow/deny conflict ─────────────────────────────────────── #[test] fn i9_schema_allow_deny_conflict_rejected() { let mut builder = SchemaBuilder::new(); let _ = builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); // "view" is in both allowed_write_signals and denied_write_signals — conflict. builder.community_policy( "conflicted", CommunityPolicy { allowed_write_signals: vec!["view".to_string()], denied_write_signals: vec!["view".to_string()], allowed_read_signals: vec![], denied_read_signals: vec![], }, ); let err = builder .build() .expect_err("allow/deny conflict must be rejected"); let err_str = err.to_string(); assert!( err_str.contains("view") || err_str.contains("conflict") || err_str.contains("Conflict"), "error should mention the conflicting signal; got: {err_str}" ); } // ── I10: Write with unknown role name ───────────────────────────────────────── #[test] fn i10_write_with_unknown_role_returns_error() { let schema = community_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let entity = EntityId::new(30); let ts = Timestamp::from_nanos(1_000_000_000); let ctx = CommunityContext { community_id: "test-community".to_string(), role: "nonexistent_role".to_string(), }; let err = db .signal_with_community_policy("view", entity, 1.0, ts, ctx) .expect_err("unknown role must return an error"); let err_str = err.to_string(); assert!( err_str.contains("nonexistent_role") || err_str.contains("not found"), "error should reference the unknown role; got: {err_str}" ); }