//! Store, deadline scheduler and stats behavior of the platform. mod helpers; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use futures_util::StreamExt; use helpers::*; use serde_json::{json, Map}; use tokio::sync::Notify; #[tokio::test] async fn store_round_trips_a_session_and_indexes_join_codes() { let test = make_platform(50); let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await; let router = test.router(); let response = call(&router, post_json("/api/games", Some(&cookie), &json!({}))).await; assert_eq!(response.status(), 201); let lobby = body_json(response).await; let game_id = lobby["id"].as_str().unwrap().to_string(); let code = lobby["join_code"].as_str().unwrap().to_string(); assert_eq!(lobby["seats_open"], 1); let platform = &test.platform; let loaded = platform.game_store.load(&game_id).await.unwrap().unwrap(); assert_eq!(loaded.join_code, code); assert!(loaded.seated("alice")); assert_eq!(platform.game_store.count_ongoing().await.unwrap(), 1); // Codes are case-insensitive. let by_code = platform .game_store .find_by_code(&code.to_lowercase()) .await .unwrap() .unwrap(); assert_eq!(by_code.id, game_id); } #[tokio::test] async fn store_lock_serializes_mutations() { let test = make_platform(50); let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await; let router = test.router(); let lobby = body_json(call(&router, post_json("/api/games", Some(&cookie), &json!({}))).await).await; let game_id = lobby["id"].as_str().unwrap().to_string(); let store = test.platform.game_store.clone(); let first = store.acquire_lock(&game_id).await.unwrap(); let acquired = Arc::new(AtomicBool::new(false)); let acquired_clone = acquired.clone(); let store_clone = store.clone(); let game_id_clone = game_id.clone(); let waiter = tokio::spawn(async move { let _lock = store_clone.acquire_lock(&game_id_clone).await.unwrap(); acquired_clone.store(true, Ordering::SeqCst); }); tokio::time::sleep(std::time::Duration::from_millis(150)).await; assert!(!acquired.load(Ordering::SeqCst), "lock was not exclusive"); first.release().await.unwrap(); tokio::time::timeout(std::time::Duration::from_secs(2), waiter) .await .expect("waiter acquired the lock") .expect("waiter task"); assert!(acquired.load(Ordering::SeqCst)); } #[tokio::test] async fn store_publish_reaches_subscribers() { let test = make_platform(50); let store = test.platform.game_store.clone(); let mut events = store.subscribe("some-game").await.unwrap(); store.publish("some-game").await.unwrap(); let notified = tokio::time::timeout(std::time::Duration::from_secs(2), events.next()).await; assert!(notified.is_ok(), "subscriber was not notified"); } #[tokio::test] async fn deadline_fires_an_auto_play() { let test = make_platform(20); let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await; let router = test.router(); let lobby = body_json( call( &router, post_json( "/api/games", Some(&cookie), &json!({"options": {"target": 100, "deadline_in_seconds": 0.05}}), ), ) .await, ) .await; let game_id = lobby["id"].as_str().unwrap().to_string(); let code = lobby["join_code"].as_str().unwrap().to_string(); let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await; // Joining the last player arms the tick deadline. let response = call( &router, post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})), ) .await; assert_eq!(response.status(), 200); tokio::time::sleep(std::time::Duration::from_millis(400)).await; let game = test .platform .game_store .load(&game_id) .await .unwrap() .unwrap(); let view = test .platform .registry .require("dummy") .unwrap() .view_for(&game, "alice"); assert!( view["plays"].as_u64().unwrap() >= 1, "deadline never fired: {view}" ); } #[tokio::test] async fn deadline_finishes_a_match_and_persists_it() { let test = make_platform(20); let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await; let router = test.router(); let lobby = body_json( call( &router, post_json( "/api/games", Some(&cookie), &json!({"options": {"target": 1, "deadline_in_seconds": 0.05}}), ), ) .await, ) .await; let game_id = lobby["id"].as_str().unwrap().to_string(); let code = lobby["join_code"].as_str().unwrap().to_string(); let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await; call( &router, post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})), ) .await; tokio::time::sleep(std::time::Duration::from_millis(400)).await; let game = test .platform .game_store .load(&game_id) .await .unwrap() .unwrap(); let engine = test.platform.registry.require("dummy").unwrap().clone(); assert!(engine.is_finished(&game)); assert!(game.stats_saved, "the deadline finish was not persisted"); assert_eq!(test.platform.game_store.count_ongoing().await.unwrap(), 0); let matches = test .platform .stats .my_matches("alice", None, 20, None) .await .unwrap() .0; assert_eq!(matches.len(), 1); assert_eq!(matches[0].result["target"], 1); } #[tokio::test] async fn finalized_match_updates_history_ratings_and_leaderboard() { let test = make_platform(50); let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await; let router = test.router(); let lobby = body_json( call( &router, post_json( "/api/games", Some(&cookie), &json!({"options": {"target": 1}}), ), ) .await, ) .await; let game_id = lobby["id"].as_str().unwrap().to_string(); let code = lobby["join_code"].as_str().unwrap().to_string(); let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await; call( &router, post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})), ) .await; // Bob plays the winning move directly through the platform. let mut game = test .platform .game_store .load(&game_id) .await .unwrap() .unwrap(); let engine = test.platform.registry.require("dummy").unwrap().clone(); engine .handle_action(&mut game, "bob", "play", &Map::new()) .unwrap(); test.platform .scheduler .finalize_mutation(&mut game) .await .unwrap(); let response = call(&router, get("/api/me/matches", Some(&bob_cookie))).await; assert_eq!(response.status(), 200); let history = body_json(response).await; assert_eq!(history["next_cursor"], json!(null)); let entry = &history["results"][0]; assert_eq!(entry["you_won"], true); assert_eq!(entry["your_elo_delta"], 16); assert_eq!(entry["players"].as_array().unwrap().len(), 2); let ratings = body_json(call(&router, get("/api/me/ratings", Some(&bob_cookie))).await).await; assert_eq!(ratings["results"][0]["rating"], 1516); assert_eq!(ratings["results"][0]["matches_played"], 1); let leaderboard = body_json(call(&router, get("/api/leaderboard", None)).await).await; let results = leaderboard["results"].as_array().unwrap(); assert_eq!(results[0]["user_sub"], "bob"); assert_eq!(results[0]["wins"], 1); assert_eq!(results[1]["user_sub"], "alice"); assert_eq!(results[1]["elo"], 1484); } #[tokio::test] async fn stale_deadline_is_discarded_without_effect() { let test = make_platform(50); let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await; let router = test.router(); let lobby = body_json( call( &router, post_json( "/api/games", Some(&cookie), &json!({"options": {"target": 100, "deadline_in_seconds": 0.05}}), ), ) .await, ) .await; let game_id = lobby["id"].as_str().unwrap().to_string(); let code = lobby["join_code"].as_str().unwrap().to_string(); let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await; call( &router, post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})), ) .await; // Wait for one auto-play, then deliver the same (now stale) entry again. tokio::time::sleep(std::time::Duration::from_millis(400)).await; let game = test .platform .game_store .load(&game_id) .await .unwrap() .unwrap(); let engine = test.platform.registry.require("dummy").unwrap().clone(); let plays = engine.view_for(&game, "alice")["plays"].as_u64().unwrap(); let stale = json!({ "game_id": game_id, "kind": "tick", "token": format!("tick:{}", plays.saturating_sub(1)), }) .to_string(); test.platform.scheduler.process_due(&stale).await.unwrap(); let after = test .platform .game_store .load(&game_id) .await .unwrap() .unwrap(); assert_eq!( engine.view_for(&after, "alice")["plays"].as_u64().unwrap(), plays ); } #[tokio::test] async fn in_memory_notify_wakes_without_deadlines() { let notify = Arc::new(Notify::new()); let wait = notify.clone(); let task = tokio::spawn(async move { wait.notified().await; true }); tokio::time::sleep(std::time::Duration::from_millis(20)).await; notify.notify_waiters(); assert!( tokio::time::timeout(std::time::Duration::from_secs(1), task) .await .unwrap() .unwrap() ); }