//! Redis-backed store tests, run only when `TEST_REDIS_URL` points at a //! throwaway Redis instance. The compose stack exposes one on //! `redis://127.0.0.1:6379/0`. mod helpers; use std::sync::Arc; use helpers::DummyEngine; use tavolo_platform::registry::GameRegistry; use tavolo_platform::store::{GameStore, RedisGameStore}; fn redis_url() -> Option { std::env::var("TEST_REDIS_URL") .ok() .filter(|url| !url.is_empty()) } async fn store(ttl: i64) -> Option { let url = redis_url()?; let mut registry = GameRegistry::new(); registry.register(Arc::new(DummyEngine)).expect("register"); Some( RedisGameStore::new(&url, Arc::new(registry), ttl) .await .expect("redis store"), ) } async fn create_game(store: &RedisGameStore) -> tavolo_platform::GameSession { use tavolo_platform::engine::{GameEngine, GameSession, Seat}; let engine = DummyEngine; let unique = uuid::Uuid::new_v4().to_string(); let mut game = GameSession::new(unique.clone(), engine.id(), unique[..6].to_uppercase()); game.creator_sub = "alice".into(); game.players = vec![Seat::new("alice", "Alice")]; game.created_at = Some(tavolo_platform::datetime::now()); engine .create(&mut game, &serde_json::Map::new()) .expect("create"); store.save(&game).await.expect("save"); game } #[tokio::test] async fn redis_round_trips_sessions_codes_and_ongoing_counts() { let Some(store) = store(60).await else { eprintln!("TEST_REDIS_URL unset: skipping"); return; }; let game = create_game(&store).await; let loaded = store.load(&game.id).await.expect("load").expect("found"); assert_eq!(loaded.join_code, game.join_code); assert!(loaded.seated("alice")); let by_code = store .find_by_code(&game.join_code.to_lowercase()) .await .expect("find") .expect("found"); assert_eq!(by_code.id, game.id); assert!(store.count_ongoing().await.expect("count") >= 1); // Save with TTL still lists the game; a second save does not duplicate. store.save(&loaded).await.expect("save again"); assert!(store.count_ongoing().await.expect("count") >= 1); store .remove_deadline(&format!("missing-{}", game.id)) .await .unwrap(); store .remove_deadline(&serde_json::json!({}).to_string()) .await .unwrap(); } #[tokio::test] async fn redis_locks_are_exclusive_and_released() { let Some(store) = store(60).await else { eprintln!("TEST_REDIS_URL unset: skipping"); return; }; let game = create_game(&store).await; let first = store.acquire_lock(&game.id).await.expect("first lock"); let second_store = store_second().await.expect("second store"); let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false)); let acquired_clone = acquired.clone(); let game_id = game.id.clone(); let waiter = tokio::spawn(async move { let _lock = second_store .acquire_lock(&game_id) .await .expect("second lock"); acquired_clone.store(true, std::sync::atomic::Ordering::SeqCst); }); tokio::time::sleep(std::time::Duration::from_millis(200)).await; assert!(!acquired.load(std::sync::atomic::Ordering::SeqCst)); first.release().await.expect("release"); tokio::time::timeout(std::time::Duration::from_secs(3), waiter) .await .expect("waiter acquired") .expect("waiter task"); assert!(acquired.load(std::sync::atomic::Ordering::SeqCst)); } #[tokio::test] async fn redis_pubsub_signals_and_deadline_queue() { let Some(store) = store(60).await else { eprintln!("TEST_REDIS_URL unset: skipping"); return; }; use futures_util::StreamExt; let game = create_game(&store).await; let mut events = store.subscribe(&game.id).await.expect("subscribe"); // The subscription registers asynchronously; a short delay avoids racing // the PUBLISH with the SUBSCRIBE. tokio::time::sleep(std::time::Duration::from_millis(150)).await; store.publish(&game.id).await.expect("publish"); let signalled = tokio::time::timeout(std::time::Duration::from_secs(3), events.next()).await; assert!(signalled.is_ok(), "no change signal received"); let member = format!("deadline-{}", uuid::Uuid::new_v4()); store.add_deadline(&member, 0.0).await.expect("add"); tokio::time::sleep(std::time::Duration::from_millis(100)).await; let due = store .due_deadlines(tavolo_platform::datetime::now().timestamp() as f64, 10) .await .expect("due"); assert!(due.contains(&member), "{due:?}"); assert!(store.next_deadline().await.expect("next").is_some()); store.remove_deadline(&member).await.expect("remove"); } async fn store_second() -> Option { store(60).await }