571 lines
18 KiB
Rust
571 lines
18 KiB
Rust
//! 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"
|
|
);
|
|
}
|