//! Milestone 6 Phase 6 Integration Tests: Creator Profile Modes + Notification Caps. //! //! Tests: //! 1. `for_creator` + `for_you` ranks creator's items by user preferences. //! 2. `for_creator` + `hot` ranks hot-sorted items within creator's catalog. //! 3. Notification caps limit per-creator results. //! 4. Notification caps enforce total daily limit. //! 5. Cold-start user sees creator top content. //! 6. Regression: `for_creator` with high-ID items uses `CreatorItemsBitmap`. #![allow( clippy::unwrap_used, clippy::cast_precision_loss, clippy::similar_names )] use std::collections::HashMap; use std::time::Duration; use tidaldb::TidalDb; use tidaldb::query::retrieve::{NotificationCaps, Retrieve}; use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window}; // ── Schema ────────────────────────────────────────────────────────────────── fn test_schema() -> tidaldb::schema::Schema { let mut builder = SchemaBuilder::new(); for &(name, half_life_days) in &[ ("view", 7), ("like", 14), ("share", 7), ("skip", 1), ("completion", 30), ] { let _ = builder .signal( name, EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(half_life_days * 24 * 3600), }, ) .windows(&[ Window::OneHour, Window::TwentyFourHours, Window::SevenDays, Window::AllTime, ]) .velocity(true) .add(); } builder.build().expect("schema must be valid") } fn open_ephemeral() -> TidalDb { TidalDb::builder() .ephemeral() .with_schema(test_schema()) .open() .expect("ephemeral open") } fn write_items_for_creator(db: &TidalDb, creator_id: u64, item_ids: &[u64]) { for &id in item_ids { let mut meta = HashMap::new(); meta.insert("title".to_string(), format!("Item {id}")); meta.insert("category".to_string(), "jazz".to_string()); meta.insert("format".to_string(), "audio".to_string()); meta.insert("creator_id".to_string(), creator_id.to_string()); db.write_item_with_metadata(EntityId::new(id), &meta) .unwrap(); } } // ── Test 1: for_creator + for_you ─────────────────────────────────────────── #[test] fn for_creator_for_you_ranks_within_creator_catalog() { let db = open_ephemeral(); // Creator A has items 1-5. write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]); // Creator B has items 6-10. write_items_for_creator(&db, 200, &[6, 7, 8, 9, 10]); // Signal items 1 and 2 heavily for view (creator A). let now = Timestamp::now(); for _ in 0..20 { db.signal("view", EntityId::new(1), 1.0, now).unwrap(); db.signal("view", EntityId::new(2), 1.0, now).unwrap(); } // Query for_creator(100) + for_you profile + for_user(42). let query = Retrieve::builder() .profile("for_you") .for_user(42) .for_creator(EntityId::new(100)) .limit(10) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); // Only creator A's items should appear. for item in &results.items { let id = item.entity_id.as_u64(); assert!( (1..=5).contains(&id), "expected item from creator A, got id={id}" ); } assert!(!results.items.is_empty(), "should return some items"); } // ── Test 2: for_creator + hot ─────────────────────────────────────────────── #[test] fn for_creator_hot_ranks_by_hotness_within_catalog() { let db = open_ephemeral(); // Creator A: items 1-5. write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]); // Creator B: items 6-10. write_items_for_creator(&db, 200, &[6, 7, 8, 9, 10]); // Item 1 gets many recent views, item 2 gets few. let now = Timestamp::now(); for _ in 0..50 { db.signal("view", EntityId::new(1), 1.0, now).unwrap(); } db.signal("view", EntityId::new(2), 1.0, now).unwrap(); // Query for_creator(100) + hot profile. let query = Retrieve::builder() .profile("hot") .for_creator(EntityId::new(100)) .limit(10) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); // Only creator A's items should appear. for item in &results.items { let id = item.entity_id.as_u64(); assert!( (1..=5).contains(&id), "expected item from creator A, got id={id}" ); } assert!(!results.items.is_empty(), "should return some items"); } // ── Test 3: notification caps per-creator ─────────────────────────────────── #[test] fn notification_caps_enforce_per_creator_limit() { let db = open_ephemeral(); // Write 5 items from creator A and 5 from creator B. write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]); write_items_for_creator(&db, 200, &[6, 7, 8, 9, 10]); // User 1 follows both creators (required for notification profile). db.write_relationship( EntityId::new(1), tidaldb::entities::RelationshipType::Follows, EntityId::new(100), 1.0, Timestamp::now(), ) .unwrap(); db.write_relationship( EntityId::new(1), tidaldb::entities::RelationshipType::Follows, EntityId::new(200), 1.0, Timestamp::now(), ) .unwrap(); // Signal all items with views so they rank. let now = Timestamp::now(); for id in 1..=10u64 { for _ in 0..5 { db.signal("view", EntityId::new(id), 1.0, now).unwrap(); } } // Retrieve with notification caps: max 1 per creator, max 3 total. let query = Retrieve::builder() .profile("notification") .for_user(1) .notification_caps(NotificationCaps { max_per_creator_per_day: 1, max_total_per_day: 3, }) .limit(20) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); // Verify total <= 3. assert!( results.items.len() <= 3, "expected at most 3 results, got {}", results.items.len() ); // Verify no creator appears more than once (need to check creator_id // from item metadata -- but we can verify by item ID ranges). let creator_a_count = results .items .iter() .filter(|r| r.entity_id.as_u64() <= 5) .count(); let creator_b_count = results .items .iter() .filter(|r| r.entity_id.as_u64() > 5) .count(); assert!( creator_a_count <= 1, "expected at most 1 item from creator A, got {creator_a_count}" ); assert!( creator_b_count <= 1, "expected at most 1 item from creator B, got {creator_b_count}" ); } // ── Test 4 (UC-08): cold-start user sees top content on creator page ───────── /// A user with no preference history (cold-start) queries a creator's catalog /// via `for_creator`. The system should still return results -- falling back to /// engagement signals (e.g., view counts) rather than returning empty. /// /// This validates the UC-08 cold-start behavior: new visitors to a creator's /// profile page see the creator's most engaging content even without /// personalization context. #[test] fn cold_start_user_sees_creator_top_content() { let db = open_ephemeral(); // Creator has 5 items. write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]); // Signal items 1 and 2 heavily so they rank at the top. let now = Timestamp::now(); for _ in 0..30 { db.signal("view", EntityId::new(1), 1.0, now).unwrap(); db.signal("view", EntityId::new(2), 1.0, now).unwrap(); } // User 999 has never interacted with the system -- pure cold-start. // Deliberately no db.write_item(EntityId::new(999)) or any signals. let query = Retrieve::builder() .profile("for_you") .for_user(999) .for_creator(EntityId::new(100)) .limit(10) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); // Cold-start must not return empty -- the creator has items and signals. assert!( !results.items.is_empty(), "cold-start user should still see creator content" ); // All results must belong to creator 100 (items 1-5). for item in &results.items { let id = item.entity_id.as_u64(); assert!( (1..=5).contains(&id), "expected item from creator 100, got id={id}" ); } } // ── Test 5: notification caps total daily limit ───────────────────────────── #[test] fn notification_caps_enforce_total_limit() { let db = open_ephemeral(); // 10 items from 10 different creators. for id in 1..=10u64 { write_items_for_creator(&db, id * 100, &[id]); // User follows each creator. db.write_relationship( EntityId::new(1), tidaldb::entities::RelationshipType::Follows, EntityId::new(id * 100), 1.0, Timestamp::now(), ) .unwrap(); } // Signal all items. let now = Timestamp::now(); for id in 1..=10u64 { for _ in 0..5 { db.signal("view", EntityId::new(id), 1.0, now).unwrap(); } } let query = Retrieve::builder() .profile("notification") .for_user(1) .notification_caps(NotificationCaps { max_per_creator_per_day: 2, max_total_per_day: 3, }) .limit(20) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); assert!( results.items.len() <= 3, "expected at most 3 total results, got {}", results.items.len() ); } // ── Test 6: Regression -- for_creator uses CreatorItemsBitmap for high IDs ── /// Regression test for the bug where `for_creator` was set by the builder /// but never read by the executor. Stage 1 always called `scan_candidates` /// which caps at `max(limit * multiplier, 200)` items from the universe /// bitmap by ascending ID. For a large catalog where a creator's items have /// IDs above that cap, those items were never generated as candidates. /// /// This test writes 300 items from creator 999 at IDs 201-500 (above the /// scan cap of 200), plus 200 filler items from other creators at IDs 1-200. /// Before the fix, `for_creator(999)` would return empty because the scan /// cap never reached IDs > 200. After the fix, the `CreatorItemsBitmap` /// is used for O(k) candidate generation, returning all of creator 999's items. #[test] fn for_creator_high_id_items_uses_creator_bitmap() { let db = open_ephemeral(); // Write 200 filler items from other creators (IDs 1-200). for id in 1..=200u64 { let mut meta = HashMap::new(); meta.insert("title".to_string(), format!("Filler {id}")); meta.insert("category".to_string(), "pop".to_string()); meta.insert("format".to_string(), "audio".to_string()); meta.insert("creator_id".to_string(), "1".to_string()); db.write_item_with_metadata(EntityId::new(id), &meta) .unwrap(); } // Write 300 items from creator 999 at IDs 201-500. let creator_item_ids: Vec = (201..=500).collect(); write_items_for_creator(&db, 999, &creator_item_ids); // Query for_creator(999) with limit=50. let query = Retrieve::builder() .profile("new") .for_creator(EntityId::new(999)) .limit(50) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); // Must return results -- the bitmap path finds all 300 items regardless // of scan cap. assert!( !results.items.is_empty(), "for_creator must return results even when all items have IDs above the scan cap" ); // All returned items must belong to creator 999 (IDs 201-500). for item in &results.items { let id = item.entity_id.as_u64(); assert!( (201..=500).contains(&id), "expected item from creator 999 (IDs 201-500), got id={id}" ); } // Verify we get up to limit items (creator has 300 items, limit=50). assert_eq!( results.items.len(), 50, "should return exactly limit=50 items from the 300 available" ); // Also verify unknown creator returns empty, not an error. let query_unknown = Retrieve::builder() .profile("new") .for_creator(EntityId::new(99999)) .limit(10) .build() .unwrap(); let results_unknown = db.retrieve(&query_unknown).unwrap(); assert!( results_unknown.items.is_empty(), "unknown creator should return empty results" ); }