Files
tavolo/server/crates/tavolo-platform/tests/store_deadlines_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

318 lines
10 KiB
Rust

//! 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()
);
}