CI / Build and push docker image (push) Successful in 4m20s
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.
161 lines
5.7 KiB
Rust
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")
|
|
}
|