Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across the engine, server, net, and CLI surfaces: - WAL/session-journal durability, checkpoint format, and crash-recovery hardening - Replication shipper/receiver, tenant isolation, and migration paths - Cluster scatter-gather, router, standalone server + health/offload endpoints - tidalctl refactored into command modules with JSON output and WAL-state tooling - Cohort, governance, signal-ledger, and vector-registry correctness fixes - Expanded UAT/integration/durability test coverage across all milestones
228 lines
6.9 KiB
Rust
228 lines
6.9 KiB
Rust
//! 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()
|
|
);
|
|
}
|