Files
tavolo/server/crates/tavolo-platform/tests/postgres_stats.rs
T
woggioni 7b3b38ccbc
CI / Build and push docker image (push) Successful in 4m20s
Rewrite the backend in Rust with Axum
Replace the Python/kaya server in server/ with a Rust Cargo workspace of
three crates mirroring the old layout: tavolo-platform (lobby API, live-game
store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions),
tavolo-scopone (pure scopone scientifico rules plus the platform adapter)
and tavolo-app (composition root and the `tavolo` binary with
serve/migrate/backfill-elo).

The HTTP and WebSocket contracts, error messages, Redis key layout and
Postgres schema are unchanged, so the existing web/ frontend and in-flight
games keep working. Notable differences: sessions are JSON under
tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is
ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations
replace aerich, and OpenTelemetry exports Rust process metrics instead of
CPython gc metrics (no Postgres/Redis client instrumentation).

Docker Compose and the Kubernetes manifest now run the single Rust image
(embedded migrations, /static and /assets served by the app); the release CI
keeps building server/Dockerfile. The full test suite (scopone rules,
platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is
ported and runs with cargo test --workspace; Redis/Postgres integration tests
are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
2026-10-02 13:54:55 +00:00

161 lines
5.7 KiB
Rust

//! 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<PgPool> {
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::<helpers::DummyState>())
.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<String, serde_json::Value> {
tavolo_platform::pagination::decode_cursor(Some(cursor))
.expect("cursor")
.expect("some")
}