tidaldb/tidal/tests/m9p2_membership.rs
jx12n 3bcfb3c576 feat: Bazel build, crate docs/ai-lookup, docker images, and engine hardening
- Add BUILD.bazel across tidal, tidal-net, tidal-server, tidalctl for bzlmod build
- Add tidal/ crate docs (README, CHANGELOG, CONTRIBUTING, AGENTS, CLAUDE, API, ARCHITECTURE) and ai-lookup reference
- Add docker standalone/cluster/deploy images, compose, and prometheus config
- Harden WAL (batch format, writer, dedup, diagnostics), text syncer/collectors, and vector registry
- Expand tidalctl CLI and tests; restructure WAL/visibility integration test suites
- Refine tidal-net transport/client/server and tidal-server cluster/scatter-gather
2026-06-07 18:29:38 -06:00

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::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()
);
}