//! M9p3 integration test — Retroactive Purge & Deterministic Rematerialization. //! //! Validates the full purge flow: multiple users contribute to a community, //! one user purges their prior contributions, and the community aggregate //! rematerializes to exactly the remaining contributors — deterministically, //! idempotently, with the local profile untouched and the purge surviving a //! close/reopen. //! //! ROADMAP §Milestone 9 Phase 3. #![allow(clippy::too_many_lines, clippy::unwrap_used)] use std::time::Duration; use tidaldb::{ CommunityId, ShareIntent, SharePolicy, TidalDb, UserId, schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window}, }; fn schema() -> tidaldb::schema::Schema { let mut b = SchemaBuilder::new(); let _ = b .signal( "like", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); b.build().unwrap() } fn policy() -> SharePolicy { SharePolicy::community_share(1, [ShareIntent::Like]) } /// Join `users` and have each contribute one `like` on `item` to `community`. fn seed(db: &TidalDb, community: CommunityId, item: EntityId, users: &[u64]) { let now = Timestamp::now(); for &u in users { db.join_community(UserId(u), community, policy()).unwrap(); assert!( db.signal_for_community("like", item, 1.0, now, UserId(u), community) .unwrap() ); } } #[test] fn purge_removes_only_target_user_and_rematerializes() { let db = TidalDb::builder() .ephemeral() .with_schema(schema()) .open() .unwrap(); let (community, item) = (CommunityId(1), EntityId::new(100)); seed(&db, community, item, &[1, 2, 3]); // Aggregate reflects all 3 contributors. assert_eq!( db.read_community_windowed_count(community, item, "like", Window::AllTime) .unwrap(), 3 ); // User 2 purges their prior contributions (all epochs). let receipt = db .purge_prior_contributions(UserId(2), community, 0..=u32::MAX) .unwrap(); assert_eq!(receipt.contributions_removed, 1); assert!(receipt.watermark_ns > 0); // Rematerialized aggregate = remaining 2 contributors. assert_eq!( db.read_community_windowed_count(community, item, "like", Window::AllTime) .unwrap(), 2 ); // Purge watermark is exposed. assert_eq!( db.community_purge_watermark(community), Some(receipt.watermark_ns) ); } #[test] fn purge_is_idempotent() { let db = TidalDb::builder() .ephemeral() .with_schema(schema()) .open() .unwrap(); let (community, item) = (CommunityId(1), EntityId::new(1)); seed(&db, community, item, &[1, 2, 3]); db.purge_prior_contributions(UserId(2), community, 0..=u32::MAX) .unwrap(); let after_first = db .read_community_windowed_count(community, item, "like", Window::AllTime) .unwrap(); // Re-purge the same range: removes nothing further, aggregate unchanged. let receipt2 = db .purge_prior_contributions(UserId(2), community, 0..=u32::MAX) .unwrap(); assert_eq!(receipt2.contributions_removed, 0); assert_eq!( db.read_community_windowed_count(community, item, "like", Window::AllTime) .unwrap(), after_first, "repeated purge must yield an identical aggregate" ); } #[test] fn purge_does_not_touch_local_profile() { let db = TidalDb::builder() .ephemeral() .with_schema(schema()) .open() .unwrap(); let (community, item) = (CommunityId(1), EntityId::new(7)); db.join_community(UserId(1), community, policy()).unwrap(); let now = Timestamp::now(); // User 1 contributes to the community AND has local likes on the same item. db.signal_for_community("like", item, 1.0, now, UserId(1), community) .unwrap(); db.signal("like", item, 1.0, now).unwrap(); db.signal("like", item, 1.0, now).unwrap(); let local_before = db .read_windowed_count(item, "like", Window::AllTime) .unwrap(); assert_eq!(local_before, 2); // Purge the user's community contributions. db.purge_prior_contributions(UserId(1), community, 0..=u32::MAX) .unwrap(); // Community aggregate emptied, but the LOCAL profile is untouched. assert_eq!( db.read_community_windowed_count(community, item, "like", Window::AllTime) .unwrap(), 0 ); assert_eq!( db.read_windowed_count(item, "like", Window::AllTime) .unwrap(), 2, "purge must never touch the local profile" ); } #[test] fn purge_respects_epoch_range_via_rejoin() { let db = TidalDb::builder() .ephemeral() .with_schema(schema()) .open() .unwrap(); let (community, item) = (CommunityId(2), EntityId::new(3)); let user = UserId(5); let now = Timestamp::now(); // Epoch 0 contribution. db.join_community(user, community, policy()).unwrap(); db.signal_for_community("like", item, 1.0, now, user, community) .unwrap(); // Leave + rejoin -> epoch 1 contribution. db.leave_community(user, community, tidaldb::LeaveMode::StopForward) .unwrap(); db.rejoin_community(user, community, policy()).unwrap(); db.signal_for_community("like", item, 1.0, now, user, community) .unwrap(); assert_eq!( db.read_community_windowed_count(community, item, "like", Window::AllTime) .unwrap(), 2 ); // Purge only epoch 0; the epoch-1 (post-rejoin) contribution survives. let receipt = db .purge_prior_contributions(user, community, 0..=0) .unwrap(); assert_eq!(receipt.contributions_removed, 1); assert_eq!( db.read_community_windowed_count(community, item, "like", Window::AllTime) .unwrap(), 1 ); } #[test] fn purge_survives_reopen() { let home = tidaldb::TempTidalHome::new().unwrap(); let (community, item) = (CommunityId(1), EntityId::new(100)); { let db = TidalDb::builder() .with_data_dir(home.path()) .with_schema(schema()) .open() .unwrap(); seed(&db, community, item, &[1, 2, 3]); db.purge_prior_contributions(UserId(2), community, 0..=u32::MAX) .unwrap(); assert_eq!( db.read_community_windowed_count(community, item, "like", Window::AllTime) .unwrap(), 2 ); db.shutdown().unwrap(); } // Reopen: the community aggregate is restored from the Tag::Community // checkpoint with the purge already applied. let db = TidalDb::builder() .with_data_dir(home.path()) .with_schema(schema()) .open() .unwrap(); assert_eq!( db.read_community_windowed_count(community, item, "like", Window::AllTime) .unwrap(), 2, "purged community aggregate must survive reopen" ); }