//! M9 Community Profile Sync Integration Tests. //! //! Tests the opt-in community personalization layer: //! - join_community / is_community_member / get_community_memberships //! - Signal forwarding to community aggregates via signal_with_context //! - Membership persistence across restart //! - Validation of community name constraints #![allow(clippy::unwrap_used)] use std::time::Duration; use tidaldb::TidalDb; use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window}; // ── Schema ────────────────────────────────────────────────────────────────── fn community_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::OneHour, Window::TwentyFourHours, Window::AllTime]) .velocity(true) .add(); let _ = builder .signal( "like", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(14 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); builder.build().expect("community schema must be valid") } fn open_ephemeral() -> TidalDb { TidalDb::builder() .ephemeral() .with_schema(community_schema()) .open() .expect("ephemeral open") } // ── Test 1: join_and_query_membership ──────────────────────────────────────── #[test] fn join_and_query_membership() { let db = open_ephemeral(); assert!(!db.is_community_member(1, "jazz").unwrap()); assert_eq!( db.get_community_memberships(1).unwrap(), Vec::::new() ); db.join_community(1, "jazz").unwrap(); assert!(db.is_community_member(1, "jazz").unwrap()); assert!(!db.is_community_member(1, "blues").unwrap()); // different community assert!(!db.is_community_member(2, "jazz").unwrap()); // different user let memberships = db.get_community_memberships(1).unwrap(); assert_eq!(memberships, vec!["jazz"]); } // ── Test 2: signal_routes_to_community_aggregate ───────────────────────────── #[test] fn signal_routes_to_community_aggregate() { let db = open_ephemeral(); let user_id = 10u64; let item_id = EntityId::new(100); db.join_community(user_id, "jazz").unwrap(); let ts = Timestamp::now(); db.signal_with_context("view", item_id, 1.0, ts, Some(user_id), None) .unwrap(); // Global ledger should have the signal. let global_score = db.read_decay_score(item_id, "view", 0).unwrap(); assert!(global_score.is_some(), "global ledger must have the signal"); assert!(global_score.unwrap() > 0.0); // Community aggregate should also have the signal. let community_count = db .cohort_ledger() .read_windowed_count("community::jazz", item_id, "view", Window::AllTime) .unwrap(); assert_eq!( community_count, 1, "community aggregate must reflect the forwarded signal" ); } // ── Test 3: nonmember_signal_not_forwarded ─────────────────────────────────── #[test] fn nonmember_signal_not_forwarded() { let db = open_ephemeral(); let nonmember_id = 20u64; let item_id = EntityId::new(200); // User 20 never joins any community. let ts = Timestamp::now(); db.signal_with_context("view", item_id, 1.0, ts, Some(nonmember_id), None) .unwrap(); // Global ledger should have the signal. let global_score = db.read_decay_score(item_id, "view", 0).unwrap(); assert!(global_score.is_some()); // Community aggregate must NOT have any signal (user was not a member). let community_count = db .cohort_ledger() .read_windowed_count("community::jazz", item_id, "view", Window::AllTime) .unwrap(); assert_eq!( community_count, 0, "non-member signal must not appear in community aggregate" ); } // ── Test 4: multiple_communities_independent ───────────────────────────────── #[test] fn multiple_communities_independent() { let db = open_ephemeral(); let user_id = 30u64; let item_id = EntityId::new(300); db.join_community(user_id, "jazz").unwrap(); db.join_community(user_id, "blues").unwrap(); let memberships = db.get_community_memberships(user_id).unwrap(); assert_eq!(memberships, vec!["blues", "jazz"]); // sorted let ts = Timestamp::now(); db.signal_with_context("view", item_id, 1.0, ts, Some(user_id), None) .unwrap(); // Both community aggregates should have the signal. let jazz_count = db .cohort_ledger() .read_windowed_count("community::jazz", item_id, "view", Window::AllTime) .unwrap(); let blues_count = db .cohort_ledger() .read_windowed_count("community::blues", item_id, "view", Window::AllTime) .unwrap(); assert_eq!(jazz_count, 1, "jazz aggregate must have the signal"); assert_eq!(blues_count, 1, "blues aggregate must have the signal"); } // ── Test 5: membership_persists_across_restart ─────────────────────────────── #[test] fn membership_persists_across_restart() { let dir = tempfile::tempdir().unwrap(); let schema = community_schema(); // First open: join community. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema.clone()) .open() .unwrap(); db.join_community(1, "jazz").unwrap(); db.join_community(1, "blues").unwrap(); assert!(db.is_community_member(1, "jazz").unwrap()); db.close().unwrap(); } // Second open: memberships should be loaded from storage. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema) .open() .unwrap(); let memberships = db.get_community_memberships(1).unwrap(); assert_eq!( memberships, vec!["blues", "jazz"], "memberships must survive restart" ); assert!(db.is_community_member(1, "jazz").unwrap()); assert!(db.is_community_member(1, "blues").unwrap()); assert!(!db.is_community_member(1, "rock").unwrap()); db.close().unwrap(); } } // ── Test 6: join_idempotent ─────────────────────────────────────────────────── #[test] fn join_idempotent() { let db = open_ephemeral(); db.join_community(1, "jazz").unwrap(); db.join_community(1, "jazz").unwrap(); // second call — idempotent let memberships = db.get_community_memberships(1).unwrap(); assert_eq!(memberships.len(), 1, "idempotent join must not duplicate"); assert_eq!(memberships[0], "jazz"); } // ── Test 7: invalid_community_name_empty ───────────────────────────────────── #[test] fn invalid_community_name_empty() { let db = open_ephemeral(); let result = db.join_community(1, ""); assert!(result.is_err(), "empty community name must return an error"); let err = result.unwrap_err(); assert!( matches!(err, tidaldb::schema::TidalError::InvalidInput { .. }), "expected InvalidInput, got {err:?}" ); } // ── Test 8: invalid_community_name_too_long ────────────────────────────────── #[test] fn invalid_community_name_too_long() { let db = open_ephemeral(); let long_name = "a".repeat(65); // 65 bytes, exceeds 64-byte limit let result = db.join_community(1, &long_name); assert!( result.is_err(), "65-byte community name must return an error" ); let err = result.unwrap_err(); assert!( matches!(err, tidaldb::schema::TidalError::InvalidInput { .. }), "expected InvalidInput, got {err:?}" ); } // ── Test 9: forwarding_does_not_block_primary_write ────────────────────────── #[test] fn forwarding_does_not_block_primary_write() { // This test verifies that the primary signal write path is unaffected by // community state. The community_membership index is always populated, but // even if forwarding encountered any issue, the primary write must succeed. let db = open_ephemeral(); let user_id = 99u64; let item_id = EntityId::new(999); db.join_community(user_id, "jazz").unwrap(); let ts = Timestamp::now(); let result = db.signal_with_context("view", item_id, 1.0, ts, Some(user_id), None); assert!( result.is_ok(), "signal_with_context must succeed for community member" ); // Primary ledger is updated. let score = db.read_decay_score(item_id, "view", 0).unwrap(); assert!(score.is_some(), "primary ledger must have the signal"); assert!(score.unwrap() > 0.0); } // ── Test 10: community_aggregate_windowed_count ─────────────────────────────── #[test] fn community_aggregate_windowed_count() { let db = open_ephemeral(); let user_id = 50u64; let item_id = EntityId::new(500); db.join_community(user_id, "tech").unwrap(); let ts = Timestamp::now(); // Write 3 signals. for _ in 0..3 { db.signal_with_context("view", item_id, 1.0, ts, Some(user_id), None) .unwrap(); } let count = db .cohort_ledger() .read_windowed_count("community::tech", item_id, "view", Window::AllTime) .unwrap(); assert_eq!( count, 3, "community aggregate must count all 3 forwarded signals" ); // A second item from the same user also gets forwarded. let item2_id = EntityId::new(501); db.signal_with_context("like", item2_id, 1.0, ts, Some(user_id), None) .unwrap(); let like_count = db .cohort_ledger() .read_windowed_count("community::tech", item2_id, "like", Window::AllTime) .unwrap(); assert_eq!(like_count, 1, "like signal must be forwarded to community"); }