# Task 05: Cross-Region Session Integration Tests

## Delivers

Integration test suite in `tidal/tests/m8p4_session.rs` verifying: agent roaming between regions, session visibility within 2 seconds, idempotent writes, and hard-negative monotonicity across regions.

## Complexity: M

## Dependencies

- Tasks 01–04 complete

## Technical Design

```rust
// tidal/tests/m8p4_session.rs

use tidaldb::replication::{
    InProcessTransportFactory, SessionReplicationBridge, IdempotencyStore,
};
use tidaldb::session::{SessionId, SessionManager, SessionSeqNoTracker};
use tidaldb::entities::HardNegAction;
use tidaldb::replication::crdt::HlcTimestamp;

/// Helper: create a pair of TidalDb instances linked by InProcessTransport.
async fn setup_two_region_cluster() -> (TidalDb, TidalDb, Arc<InProcessTransportFactory>) {
    let factory = Arc::new(InProcessTransportFactory::new());
    let transport_a = factory.connect(RegionId(0));
    let transport_b = factory.connect(RegionId(1));

    let db_a = TidalDb::builder()
        .ephemeral()
        .with_schema(schema())
        .with_cluster(NodeConfig {
            region_id: RegionId(0),
            shard_id: ShardId(0),
            role: NodeRole::Leader,
        })
        .with_transport(transport_a)
        .open()
        .unwrap();

    let db_b = TidalDb::builder()
        .ephemeral()
        .with_schema(schema())
        .with_cluster(NodeConfig {
            region_id: RegionId(1),
            shard_id: ShardId(0),
            role: NodeRole::Follower,
        })
        .with_transport(transport_b)
        .open()
        .unwrap();

    (db_a, db_b, factory)
}

/// Agent roaming: session started in us-east, readable in eu-west.
#[tokio::test]
async fn test_session_cross_region_visibility() {
    let (db_a, db_b, _factory) = setup_two_region_cluster().await;

    let user = EntityId::new(1);
    let session_id = db_a.start_session(user, Default::default()).unwrap();

    // Write 5 preference signals in region A.
    for i in 0..5u64 {
        let item = EntityId::new(100 + i);
        db_a.signal_in_session(session_id, "view", item, 1.0, Timestamp::now()).unwrap();
    }

    // Allow replication to propagate (< 2 seconds using InProcessTransport).
    tokio::time::sleep(Duration::from_millis(200)).await;

    // Read session signals from region B.
    let session_b = db_b.get_session(session_id).unwrap();
    assert!(session_b.is_some(), "session should be visible in region B");

    let signals = db_b.session_signals(session_id).unwrap();
    assert_eq!(signals.len(), 5, "all 5 preference signals should be visible in region B");
}

/// Idempotent replication: duplicate session events produce no double-counting.
#[tokio::test]
async fn test_session_replication_idempotent() {
    let (db_a, db_b, factory) = setup_two_region_cluster().await;

    let user = EntityId::new(2);
    let session_id = db_a.start_session(user, Default::default()).unwrap();
    let item = EntityId::new(200);

    db_a.signal_in_session(session_id, "like", item, 1.0, Timestamp::now()).unwrap();

    // Let it replicate once.
    tokio::time::sleep(Duration::from_millis(100)).await;

    // Force re-send (simulated duplicate).
    factory.replay_last_session_batch(RegionId(1)).await;

    tokio::time::sleep(Duration::from_millis(50)).await;

    // Signal count on B should still be 1, not 2.
    let count = db_b.read_windowed_count(item, "like", Window::OneHour).unwrap();
    // (Session signals are user-scoped; verify via session data, not global ledger)
    let session_data = db_b.session_signals(session_id).unwrap();
    assert_eq!(session_data.len(), 1, "no double-counting from duplicate replication");
}

/// Hard negative monotonicity: hide in region A, unhide (lower HLC) in region B.
/// After replication: item is suppressed in BOTH regions.
#[tokio::test]
async fn test_hardneg_monotonicity_hide_wins() {
    let (db_a, db_b, _factory) = setup_two_region_cluster().await;

    let user = EntityId::new(3);
    let item = EntityId::new(300);

    // Region A hides item at t=100 (higher HLC).
    let ts_hide = HlcTimestamp { wall_ns: 100, logical: 0, node_id: 0 };
    db_a.hide_item_with_ts(user, item, ts_hide).unwrap();

    // Region B has an earlier unhide at t=50 (already in state before partition).
    let ts_unhide = HlcTimestamp { wall_ns: 50, logical: 0, node_id: 1 };
    db_b.unhide_item_with_ts(user, item, ts_unhide).unwrap();

    // Allow replication to propagate.
    tokio::time::sleep(Duration::from_millis(200)).await;

    // After replication: both regions should suppress the item.
    let results_b = db_b.retrieve(&Retrieve::builder()
        .for_user(user)
        .candidates(vec![item])
        .build()
        .unwrap()
    ).unwrap();
    assert!(
        results_b.items.is_empty(),
        "hidden item must not appear in region B results after replication"
    );
}

/// Hard negative: explicit unhide with HIGHER HLC does clear the hide.
#[tokio::test]
async fn test_hardneg_explicit_unhide_with_higher_hlc() {
    let (db_a, db_b, _factory) = setup_two_region_cluster().await;

    let user = EntityId::new(4);
    let item = EntityId::new(400);

    // Both regions: hide at t=50.
    let ts_hide = HlcTimestamp { wall_ns: 50, logical: 0, node_id: 0 };
    db_a.hide_item_with_ts(user, item, ts_hide).unwrap();

    tokio::time::sleep(Duration::from_millis(100)).await;

    // User explicitly un-hides at t=200 (higher than hide).
    let ts_unhide = HlcTimestamp { wall_ns: 200, logical: 0, node_id: 1 };
    db_b.unhide_item_with_ts(user, item, ts_unhide).unwrap();

    tokio::time::sleep(Duration::from_millis(200)).await;

    // After full replication + LWW resolution: item should appear (unhide wins).
    let results_a = db_a.retrieve(&Retrieve::builder()
        .for_user(user)
        .candidates(vec![item])
        .build()
        .unwrap()
    ).unwrap();
    assert_eq!(results_a.items.len(), 1, "unhide with higher HLC should make item visible again");
}

/// Seqno HWM: writes with seqno <= HWM are idempotent no-ops on receiver.
#[tokio::test]
async fn test_session_seqno_hwm_rejects_duplicates() {
    let tracker = SessionSeqNoTracker::new();
    let session = SessionId::new();

    // Sequence 1..5 -- all accepted.
    for i in 1..=5u64 {
        assert!(tracker.should_apply(session, SessionSeqNo(i)));
    }

    // Re-send seqno 3 -- rejected.
    assert!(!tracker.should_apply(session, SessionSeqNo(3)));

    // Seqno 6 -- accepted.
    assert!(tracker.should_apply(session, SessionSeqNo(6)));

    // HWM should be 6 now.
    assert_eq!(tracker.hwm(session), SessionSeqNo(6));
}
```

## Acceptance Criteria

- [ ] `test_session_cross_region_visibility`: 5 session signals written in region A visible in region B within 200ms (in-process transport)
- [ ] `test_session_replication_idempotent`: duplicate session batch replay produces no double-counting
- [ ] `test_hardneg_monotonicity_hide_wins`: hide at higher HLC suppresses item in both regions after cross-region replication
- [ ] `test_hardneg_explicit_unhide_with_higher_hlc`: unhide at strictly higher HLC restores visibility after replication
- [ ] `test_session_seqno_hwm_rejects_duplicates`: HWM tracker unit test with 5 monotonic accepts + 1 duplicate reject + 1 resume accept
- [ ] All 5 tests pass in `cargo test --test m8p4_session`
- [ ] `cargo clippy -D warnings` and `cargo fmt` pass
