//! M9p2 integration test — Membership Lifecycle & Stop-Forward. //! //! Validates the join / leave(stop-forward) / rejoin lifecycle end-to-end: //! community contributions flow while joined, are gated the instant a member //! leaves (without touching the local profile), and resume under a fresh epoch //! after rejoin. Membership survives close/reopen. //! //! ROADMAP §Milestone 9 Phase 2. #![allow(clippy::too_many_lines, clippy::unwrap_used)] use std::time::Duration; use tidaldb::{ CommunityId, LeaveMode, MembershipState, ShareIntent, SharePolicy, TidalDb, UserId, schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window}, }; fn schema() -> tidaldb::schema::Schema { let mut b = SchemaBuilder::new(); for name in ["view", "like", "low_quality"] { let _ = b .signal( name, EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); } b.build().unwrap() } fn share_policy() -> SharePolicy { SharePolicy::community_share(1, [ShareIntent::Like, ShareIntent::LowQuality]) } #[test] fn join_enables_eligible_contributions() { let db = TidalDb::builder() .ephemeral() .with_schema(schema()) .open() .unwrap(); let (user, community) = (UserId(1), CommunityId(10)); let epoch = db.join_community(user, community, share_policy()).unwrap(); assert_eq!(epoch, 0); assert_eq!(db.membership_epoch(user, community), Some(0)); assert_eq!( db.membership_state(user, community), Some(MembershipState::Joined) ); let now = Timestamp::now(); // Allowed intent flows. assert!( db.signal_for_community("like", EntityId::new(5), 1.0, now, user, community) .unwrap() ); // Disallowed intent (view) is gated by the share policy. assert!( !db.signal_for_community("view", EntityId::new(5), 1.0, now, user, community) .unwrap() ); // Non-member is gated. assert!( !db.signal_for_community("like", EntityId::new(5), 1.0, now, UserId(99), community) .unwrap() ); } #[test] fn leave_stop_forward_gates_contributions_but_keeps_local_profile() { let db = TidalDb::builder() .ephemeral() .with_schema(schema()) .open() .unwrap(); let (user, community) = (UserId(1), CommunityId(10)); db.join_community(user, community, share_policy()).unwrap(); let now = Timestamp::now(); let item = EntityId::new(7); // Before leave: community contribution flows. assert!( db.signal_for_community("like", item, 1.0, now, user, community) .unwrap() ); // Establish a local-profile baseline on the same item. db.signal("like", item, 1.0, now).unwrap(); let local_before = db .read_windowed_count(item, "like", Window::AllTime) .unwrap(); assert_eq!(local_before, 1, "one local like recorded"); // Leave with stop-forward. db.leave_community(user, community, LeaveMode::StopForward) .unwrap(); assert_eq!( db.membership_state(user, community), Some(MembershipState::Left) ); // After leave: community contribution is GATED (records zero). assert!( !db.signal_for_community("like", item, 1.0, now, user, community) .unwrap(), "post-leave community contribution must be gated" ); // Local profile keeps updating — local-profile-intact invariant. db.signal("like", item, 1.0, now).unwrap(); let local_after = db .read_windowed_count(item, "like", Window::AllTime) .unwrap(); assert_eq!( local_after, 2, "local profile must keep updating after leave" ); } #[test] fn rejoin_opens_new_epoch_and_reenables() { let db = TidalDb::builder() .ephemeral() .with_schema(schema()) .open() .unwrap(); let (user, community) = (UserId(2), CommunityId(20)); db.join_community(user, community, share_policy()).unwrap(); db.leave_community(user, community, LeaveMode::StopForward) .unwrap(); // Gated while left. let now = Timestamp::now(); assert!( !db.signal_for_community("like", EntityId::new(1), 1.0, now, user, community) .unwrap() ); // Rejoin -> epoch advances by exactly 1, gate clears, state Rejoined. let new_epoch = db .rejoin_community(user, community, share_policy()) .unwrap(); assert_eq!(new_epoch, 1); assert_eq!(db.membership_epoch(user, community), Some(1)); assert_eq!( db.membership_state(user, community), Some(MembershipState::Rejoined) ); // Contributions flow again under the new epoch. assert!( db.signal_for_community("like", EntityId::new(1), 1.0, now, user, community) .unwrap() ); } #[test] fn purge_leave_mode_is_rejected_until_m9p3() { let db = TidalDb::builder() .ephemeral() .with_schema(schema()) .open() .unwrap(); let (user, community) = (UserId(3), CommunityId(30)); db.join_community(user, community, share_policy()).unwrap(); let err = db.leave_community(user, community, LeaveMode::Purge); assert!(err.is_err(), "Purge leave mode is deferred to M9p3"); } #[test] fn leave_without_membership_errors() { let db = TidalDb::builder() .ephemeral() .with_schema(schema()) .open() .unwrap(); let err = db.leave_community(UserId(404), CommunityId(1), LeaveMode::StopForward); assert!(err.is_err()); } #[test] fn membership_survives_reopen_with_epoch_and_gate() { let home = tidaldb::TempTidalHome::new().unwrap(); let (user, community) = (UserId(1), CommunityId(10)); { let db = TidalDb::builder() .with_data_dir(home.path()) .with_schema(schema()) .open() .unwrap(); db.join_community(user, community, share_policy()).unwrap(); db.leave_community(user, community, LeaveMode::StopForward) .unwrap(); db.rejoin_community(user, community, share_policy()) .unwrap(); // epoch -> 1 db.shutdown().unwrap(); } // Reopen: registry rebuilt from Tag::Membership. let db = TidalDb::builder() .with_data_dir(home.path()) .with_schema(schema()) .open() .unwrap(); assert_eq!( db.membership_epoch(user, community), Some(1), "epoch must survive reopen" ); assert_eq!( db.membership_state(user, community), Some(MembershipState::Rejoined) ); // Gate was cleared on rejoin, so contributions flow after reopen. let now = Timestamp::now(); assert!( db.signal_for_community("like", EntityId::new(1), 1.0, now, user, community) .unwrap() ); }