//! Postgres-backed stats tests, run only when `TEST_DATABASE_URL` points at a //! throwaway Postgres database. The compose stack exposes one on //! `postgres://tavolo:password@localhost:5432/tavolo`. mod helpers; use helpers::DummyEngine; use serde_json::Map; use sqlx::PgPool; use tavolo_platform::engine::{GameEngine, GameSession, Seat}; use tavolo_platform::stats::{PostgresStatsRepo, StatsRepo}; async fn pool() -> Option { let url = std::env::var("TEST_DATABASE_URL") .ok() .filter(|url| !url.is_empty())?; let pool = sqlx::postgres::PgPoolOptions::new() .max_connections(4) .connect(&url) .await .expect("connect"); sqlx::raw_sql(include_str!("../../tavolo-app/migrations/0001_init.sql")) .execute(&pool) .await .expect("schema"); // Each test starts from a clean slate; other sessions may hold rows, so // only this test's users are removed. sqlx::query("DELETE FROM match_player WHERE user_sub IN ('alice', 'bob', 'carol', 'dave')") .execute(&pool) .await .expect("clean players"); sqlx::query("DELETE FROM player_rating WHERE user_sub IN ('alice', 'bob', 'carol', 'dave')") .execute(&pool) .await .expect("clean ratings"); sqlx::query( "DELETE FROM \"match\" WHERE id NOT IN (SELECT DISTINCT match_id FROM match_player)", ) .execute(&pool) .await .expect("clean matches"); Some(pool) } fn finished_game(id: &str, winner_plays: &str) -> GameSession { let engine = DummyEngine; let mut game = GameSession::new(id.to_string(), engine.id(), "ABCDEF"); game.creator_sub = "alice".into(); game.players = vec![Seat::new("alice", "Alice"), Seat::new("bob", "Bob")]; game.players[0].team = Some("A".into()); game.players[1].team = Some("B".into()); game.created_at = Some(tavolo_platform::datetime::now() - chrono::Duration::minutes(5)); let mut options = Map::new(); options.insert("target".into(), serde_json::json!(1)); engine .create(&mut game, &options) .expect("create dummy state"); { let state = game .state .as_mut() .and_then(|state| state.downcast_mut::()) .expect("dummy state"); state.started = true; } engine .handle_action(&mut game, winner_plays, "play", &Map::new()) .expect("winning play"); game.finished_at = Some(tavolo_platform::datetime::now()); game } #[tokio::test] async fn postgres_persists_matches_history_ratings_and_leaderboard() { let Some(pool) = pool().await else { eprintln!("TEST_DATABASE_URL unset: skipping"); return; }; let repo = PostgresStatsRepo::new(pool.clone()); let engine = DummyEngine; let game = finished_game("00000000-0000-0000-0000-000000000001", "bob"); assert!(engine.is_finished(&game)); repo.save_match(&game, &engine).await.expect("save match"); // History for the winner includes the result and the Elo delta. let (matches, cursor) = repo .my_matches("bob", None, 20, None) .await .expect("history"); assert_eq!(matches.len(), 1); assert!(cursor.is_none()); assert_eq!(matches[0].result["winner"], "bob"); assert!(matches[0].you_won); assert_eq!(matches[0].your_elo_delta, Some(16)); assert_eq!(matches[0].players.len(), 2); // Ratings: 1500 + 16 for bob, 1500 - 16 for alice. let ratings = repo.my_ratings("bob").await.expect("ratings"); assert_eq!(ratings.len(), 1); assert_eq!(ratings[0].rating, 1516); assert_eq!(ratings[0].matches_played, 1); let alice_ratings = repo.my_ratings("alice").await.expect("ratings"); assert_eq!(alice_ratings[0].rating, 1484); // Leaderboard: winner first. let leaderboard = repo.leaderboard(None, "dummy").await.expect("leaderboard"); let bob = leaderboard .iter() .find(|entry| entry.user_sub == "bob") .expect("bob ranked"); let alice = leaderboard .iter() .find(|entry| entry.user_sub == "alice") .expect("alice ranked"); assert_eq!(bob.elo, 1516); assert_eq!(bob.wins, 1); assert_eq!(alice.elo, 1484); assert_eq!(leaderboard[0].user_sub, "bob"); // Backfill replays the same match and reproduces the deltas. let replayed = repo.backfill_elo().await.expect("backfill"); assert!(replayed >= 1); let ratings = repo.my_ratings("bob").await.expect("ratings"); assert_eq!(ratings[0].rating, 1516); // A second match loses to a fresh page cursor: page size 1 has more. let game2 = finished_game("00000000-0000-0000-0000-000000000002", "alice"); repo.save_match(&game2, &engine) .await .expect("save match 2"); let (page, cursor) = repo.my_matches("alice", None, 1, None).await.expect("page"); assert_eq!(page.len(), 1); assert!(cursor.is_some(), "expected a next cursor"); let cursor_map = cursor.as_deref().map(parse_cursor); let (next, _) = repo .my_matches("alice", None, 1, cursor_map.as_ref()) .await .expect("next page"); assert_eq!(next.len(), 1); assert_ne!(page[0].id, next[0].id); // Game-type filters are applied. let (filtered, _) = repo .my_matches("alice", Some("dummy"), 20, None) .await .expect("filtered"); assert_eq!(filtered.len(), 2); let (none, _) = repo .my_matches("alice", Some("scopone_scientifico"), 20, None) .await .expect("filtered"); assert!(none.is_empty()); } fn parse_cursor(cursor: &str) -> serde_json::Map { tavolo_platform::pagination::decode_cursor(Some(cursor)) .expect("cursor") .expect("some") }