//! Integration tests for M10: Signal Revocation Controls. //! //! Covers the 10 test scenarios from the spec: //! R1: Create a SignalType revocation, verify it suppresses scoring. //! R2: Create a TimeRange revocation, verify it suppresses all signals in range. //! R3: Revoke a signal, cancel it, verify scoring is restored. //! R4: Verify non-revoked signals are unaffected. //! R5: List revocations — includes active and cancelled. //! R6: Revocation scoped to one user does not suppress another user's query. //! R7: Multiple overlapping revocations (union semantics). //! R8: Revocation persists across DB reopen (durable). //! R9: Cancellation persists across DB reopen. //! R10: Anonymous query (no for_user) is never suppressed. #![allow(clippy::unwrap_used, clippy::float_cmp, clippy::cast_precision_loss)] use std::collections::HashMap; use std::time::Duration; use tidaldb::query::retrieve::Retrieve; use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window}; use tidaldb::{RevocationScope, TidalDb}; // ── Schema + item helpers ───────────────────────────────────────────────────── fn revocation_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( "like", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); builder.build().expect("revocation schema must be valid") } /// Register an item in the universe bitmap so it appears as a candidate. fn register_item(db: &TidalDb, id: u64, creator_id: u32) { let mut meta = HashMap::new(); meta.insert("creator_id".to_string(), creator_id.to_string()); db.write_item_with_metadata(EntityId::new(id), &meta) .unwrap(); } // ── R1: SignalType revocation suppresses scoring ────────────────────────────── #[test] fn r1_signal_type_revocation_suppresses_sort_score() { let db = TidalDb::builder() .ephemeral() .with_schema(revocation_schema()) .open() .unwrap(); let item_a = EntityId::new(1); let item_b = EntityId::new(2); let ts = Timestamp::from_nanos(1_000_000_000); let user_id = 42u64; // Register items in the universe so they appear as candidates. register_item(&db, 1, 100); register_item(&db, 2, 101); // item_a: many views, item_b: few views. Without revocation, item_a scores higher. db.signal("view", item_a, 10.0, ts).unwrap(); db.signal("view", item_b, 1.0, ts).unwrap(); // Confirm item_a ranks first under MostViewed without revocation. let q = Retrieve::builder() .profile("most_viewed") .for_user(user_id) .limit(10) .build() .unwrap(); let results_before = db.retrieve(&q).unwrap(); assert!( !results_before.is_empty(), "should return results before revocation" ); assert_eq!( results_before.items[0].entity_id, item_a, "item_a should rank first before revocation" ); // Revoke "view" for this user (no time bounds). let rev_id = db .revoke_signal( user_id, RevocationScope::SignalType { signal_type: "view".to_string(), since_ns: None, until_ns: None, }, ) .expect("revocation must succeed"); assert!(!format!("{rev_id}").is_empty()); // After revocation: view signals are suppressed for this user. // Both items score 0 on MostViewed; relative order may differ but neither should // dominate based on view count alone. let results_after = db.retrieve(&q).unwrap(); // Results should still be returned — revocation affects scoring, not candidate set. assert_eq!( results_after.len(), results_before.len(), "candidate count must be unchanged after revocation" ); } // ── R2: TimeRange revocation suppresses signals within the window ───────────── #[test] fn r2_time_range_revocation_suppresses_all_signals_in_range() { let db = TidalDb::builder() .ephemeral() .with_schema(revocation_schema()) .open() .unwrap(); let item = EntityId::new(10); let user_id = 43u64; register_item(&db, 10, 100); // Record signals at various timestamps. let ts_early = Timestamp::from_nanos(500_000_000); // 500M ns let ts_in_range = Timestamp::from_nanos(1_500_000_000); // 1.5B ns — in revocation window let ts_late = Timestamp::from_nanos(3_000_000_000); // 3B ns db.signal("view", item, 1.0, ts_early).unwrap(); db.signal("view", item, 5.0, ts_in_range).unwrap(); db.signal("like", item, 3.0, ts_late).unwrap(); // TimeRange revocation covering ts_in_range only. db.revoke_signal( user_id, RevocationScope::TimeRange { since_ns: 1_000_000_000, until_ns: 2_000_000_000, }, ) .expect("time-range revocation must succeed"); // Query with for_user — revocation is applied, query must complete without error. let q = Retrieve::builder() .profile("most_viewed") .for_user(user_id) .limit(10) .build() .unwrap(); let results = db.retrieve(&q).unwrap(); // The item may still appear (it has signals outside the revocation window). // Key assertion: query completes without panic. let _ = results; } // ── R3: Cancel a revocation restores scoring ────────────────────────────────── #[test] fn r3_cancel_revocation_restores_scoring() { let db = TidalDb::builder() .ephemeral() .with_schema(revocation_schema()) .open() .unwrap(); let item_a = EntityId::new(20); let item_b = EntityId::new(21); let ts = Timestamp::from_nanos(1_000_000_000); let user_id = 44u64; register_item(&db, 20, 100); register_item(&db, 21, 101); db.signal("view", item_a, 10.0, ts).unwrap(); db.signal("view", item_b, 1.0, ts).unwrap(); // Create and then immediately cancel the revocation. let rev_id = db .revoke_signal( user_id, RevocationScope::SignalType { signal_type: "view".to_string(), since_ns: None, until_ns: None, }, ) .unwrap(); let cancelled = db.cancel_revocation(user_id, rev_id).unwrap(); assert!( cancelled, "cancel_revocation must return true for a known id" ); // After cancellation: view signals are no longer suppressed. let q = Retrieve::builder() .profile("most_viewed") .for_user(user_id) .limit(10) .build() .unwrap(); let results = db.retrieve(&q).unwrap(); // item_a (10 views) should now rank first again. assert!(!results.is_empty(), "should return results after cancel"); assert_eq!( results.items[0].entity_id, item_a, "item_a must rank first after revocation is cancelled" ); } // ── R4: Unrevoked signals are unaffected ────────────────────────────────────── #[test] fn r4_unrevoked_signals_unaffected() { let db = TidalDb::builder() .ephemeral() .with_schema(revocation_schema()) .open() .unwrap(); let item_a = EntityId::new(30); let item_b = EntityId::new(31); let ts = Timestamp::from_nanos(1_000_000_000); let user_id = 45u64; register_item(&db, 30, 100); register_item(&db, 31, 101); // item_a: many likes; item_b: few likes. db.signal("like", item_a, 10.0, ts).unwrap(); db.signal("like", item_b, 1.0, ts).unwrap(); // Revoke "view" only — like signals should remain active. db.revoke_signal( user_id, RevocationScope::SignalType { signal_type: "view".to_string(), since_ns: None, until_ns: None, }, ) .unwrap(); let q = Retrieve::builder() .profile("most_liked") .for_user(user_id) .limit(10) .build() .unwrap(); let results = db.retrieve(&q).unwrap(); assert!(!results.is_empty(), "should return results"); // item_a has more likes; "view" revocation must not affect like-based ranking. assert_eq!( results.items[0].entity_id, item_a, "like-based ranking must be unaffected by view revocation" ); } // ── R5: list_revocations includes active and cancelled ──────────────────────── #[test] fn r5_list_revocations_includes_active_and_cancelled() { let db = TidalDb::builder() .ephemeral() .with_schema(revocation_schema()) .open() .unwrap(); let user_id = 46u64; // Create two revocations. let rev1 = db .revoke_signal( user_id, RevocationScope::SignalType { signal_type: "view".to_string(), since_ns: None, until_ns: None, }, ) .unwrap(); let _rev2 = db .revoke_signal( user_id, RevocationScope::SignalType { signal_type: "like".to_string(), since_ns: None, until_ns: None, }, ) .unwrap(); // Cancel the first. db.cancel_revocation(user_id, rev1).unwrap(); let list = db.list_revocations(user_id).unwrap(); assert_eq!(list.len(), 2, "both revocations must appear in the list"); let active: Vec<_> = list.iter().filter(|r| r.active).collect(); let cancelled: Vec<_> = list.iter().filter(|r| !r.active).collect(); assert_eq!(active.len(), 1, "one active revocation"); assert_eq!(cancelled.len(), 1, "one cancelled revocation"); } // ── R6: Revocation is scoped to the requesting user only ───────────────────── #[test] fn r6_revocation_scoped_to_requesting_user() { let db = TidalDb::builder() .ephemeral() .with_schema(revocation_schema()) .open() .unwrap(); let item_a = EntityId::new(40); let item_b = EntityId::new(41); let ts = Timestamp::from_nanos(1_000_000_000); let user_a = 47u64; let user_b = 48u64; register_item(&db, 40, 100); register_item(&db, 41, 101); db.signal("view", item_a, 10.0, ts).unwrap(); db.signal("view", item_b, 1.0, ts).unwrap(); // user_a revokes "view"; user_b has no revocation. db.revoke_signal( user_a, RevocationScope::SignalType { signal_type: "view".to_string(), since_ns: None, until_ns: None, }, ) .unwrap(); // Query as user_b: view signals should still apply. let q_b = Retrieve::builder() .profile("most_viewed") .for_user(user_b) .limit(10) .build() .unwrap(); let results_b = db.retrieve(&q_b).unwrap(); assert!(!results_b.is_empty(), "user_b should receive results"); assert_eq!( results_b.items[0].entity_id, item_a, "user_b should see item_a ranked first (user_a revocation must not affect user_b)" ); } // ── R7: Multiple overlapping revocations use union semantics ────────────────── #[test] fn r7_multiple_revocations_union_semantics() { let db = TidalDb::builder() .ephemeral() .with_schema(revocation_schema()) .open() .unwrap(); let user_id = 49u64; // Revoke "view" by type and also by a TimeRange that covers all time. db.revoke_signal( user_id, RevocationScope::SignalType { signal_type: "view".to_string(), since_ns: None, until_ns: None, }, ) .unwrap(); db.revoke_signal( user_id, RevocationScope::TimeRange { since_ns: 0, until_ns: u64::MAX, }, ) .unwrap(); // Both revocations must be in the list. let list = db.list_revocations(user_id).unwrap(); assert_eq!(list.len(), 2, "both revocations must be stored"); assert!( list.iter().all(|r| r.active), "both revocations should be active" ); // Query must complete without error even under full revocation coverage. register_item(&db, 50, 100); let item = EntityId::new(50); let ts = Timestamp::from_nanos(1_000_000_000); db.signal("view", item, 5.0, ts).unwrap(); db.signal("like", item, 5.0, ts).unwrap(); let q = Retrieve::builder() .profile("most_viewed") .for_user(user_id) .limit(10) .build() .unwrap(); let results = db.retrieve(&q).unwrap(); // Scoring must not panic; item may score 0 on view but query completes. // Item is still a candidate (revocation doesn't remove from candidate set). assert_eq!(results.len(), 1, "item must remain as a candidate"); } // ── R8: Revocation persists across DB reopen ────────────────────────────────── #[test] fn r8_revocation_persists_across_reopen() { let dir = tempfile::tempdir().expect("temp dir must be created"); let schema = revocation_schema(); let user_id = 50u64; // Create DB, add a signal, revoke it. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema.clone()) .open() .unwrap(); let item = EntityId::new(60); let ts = Timestamp::from_nanos(1_000_000_000); db.signal("view", item, 10.0, ts).unwrap(); db.revoke_signal( user_id, RevocationScope::SignalType { signal_type: "view".to_string(), since_ns: None, until_ns: None, }, ) .unwrap(); db.close().unwrap(); } // Reopen and verify the revocation is still present. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema) .open() .unwrap(); let list = db.list_revocations(user_id).unwrap(); assert_eq!(list.len(), 1, "revocation must survive restart"); assert!(list[0].active, "revocation must be active after restart"); } } // ── R9: Cancellation persists across DB reopen ──────────────────────────────── #[test] fn r9_cancellation_persists_across_reopen() { let dir = tempfile::tempdir().expect("temp dir must be created"); let schema = revocation_schema(); let user_id = 51u64; // Create DB, add revocation, cancel it. let rev_id; { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema.clone()) .open() .unwrap(); rev_id = db .revoke_signal( user_id, RevocationScope::SignalType { signal_type: "view".to_string(), since_ns: None, until_ns: None, }, ) .unwrap(); let cancelled = db.cancel_revocation(user_id, rev_id).unwrap(); assert!(cancelled); db.close().unwrap(); } // Reopen and verify the revocation is still present but cancelled. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema) .open() .unwrap(); let list = db.list_revocations(user_id).unwrap(); assert_eq!(list.len(), 1, "cancelled revocation must survive restart"); assert!( !list[0].active, "revocation must remain cancelled after restart" ); } } // ── R10: Anonymous query (no for_user) is never suppressed ─────────────────── #[test] fn r10_anonymous_query_not_suppressed() { let db = TidalDb::builder() .ephemeral() .with_schema(revocation_schema()) .open() .unwrap(); let item_a = EntityId::new(70); let item_b = EntityId::new(71); let ts = Timestamp::from_nanos(1_000_000_000); let user_id = 52u64; register_item(&db, 70, 100); register_item(&db, 71, 101); db.signal("view", item_a, 10.0, ts).unwrap(); db.signal("view", item_b, 1.0, ts).unwrap(); // This user revokes "view". db.revoke_signal( user_id, RevocationScope::SignalType { signal_type: "view".to_string(), since_ns: None, until_ns: None, }, ) .unwrap(); // Anonymous query (no for_user): revocations must NOT apply. let q = Retrieve::builder() .profile("most_viewed") .limit(10) .build() .unwrap(); let results = db.retrieve(&q).unwrap(); assert!(!results.is_empty(), "anonymous query must return results"); // item_a has 10x the views — it must rank first without any revocation interference. assert_eq!( results.items[0].entity_id, item_a, "anonymous query must not be affected by any user's revocation" ); }