Rewrite the backend in Rust with Axum
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.
This commit is contained in:
woggioni committed 2026-10-02 13:54:55 +00:00
1 parent adc3d340b1
commit 7b3b38ccbc
136 files changed
+16821 -11777

No files matched your search

@@ -0,0 +1,469 @@
//! Shared fixtures for the tavolo-platform test suite.
//!
//! `DummyEngine` is a tiny two-player game implementing the platform's
//! `GameEngine` contract, so every platform behaviour (lobby, store,
//! websockets, deadlines, stats) is exercised without importing any real
//! game. Its rules: the first player to reach `target` plays wins.
#![allow(dead_code)]
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, Response, StatusCode};
use axum::Router;
use futures_util::StreamExt;
use serde_json::{json, Map, Value};
use tower::ServiceExt;
use tavolo_platform::deadlines::DeadlineScheduler;
use tavolo_platform::engine::{Deadline, GameEngine, GameSession, MatchResult, PlayerResult, Seat};
use tavolo_platform::errors::{Error, GameError, Result};
use tavolo_platform::oidc::{OidcProviderConfig, OidcProviders};
use tavolo_platform::platform::{Platform, SharedPlatform};
use tavolo_platform::registry::GameRegistry;
use tavolo_platform::session::{session_middleware, InMemorySessionStore, SharedSessionStore};
use tavolo_platform::stats::{InMemoryStatsRepo, SharedStatsRepo};
use tavolo_platform::store::{InMemoryGameStore, SharedGameStore};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct DummyState {
pub target: i64,
pub plays: Vec<String>,
pub started: bool,
pub finished: bool,
pub winner: Option<String>,
pub deadline_in_seconds: Option<f64>,
}
pub struct DummyEngine;
fn state_of(session: &GameSession) -> Result<&DummyState> {
session
.state
.as_ref()
.and_then(|state| state.downcast_ref::<DummyState>())
.ok_or_else(|| Error::Internal("dummy state missing".into()))
}
fn state_of_mut(session: &mut GameSession) -> Result<&mut DummyState> {
session
.state
.as_mut()
.and_then(|state| state.downcast_mut::<DummyState>())
.ok_or_else(|| Error::Internal("dummy state missing".into()))
}
impl DummyEngine {
fn play(session: &mut GameSession, user_sub: &str) -> Result<()> {
let state = state_of_mut(session)?;
state.plays.push(user_sub.to_string());
if state.plays.len() as i64 >= state.target {
state.finished = true;
state.winner = Some(user_sub.to_string());
}
Ok(())
}
}
impl GameEngine for DummyEngine {
fn id(&self) -> &'static str {
"dummy"
}
fn name(&self) -> &'static str {
"Dummy game"
}
fn description(&self) -> &'static str {
"A two-player toy game for testing the platform."
}
fn min_players(&self) -> usize {
2
}
fn max_players(&self) -> usize {
2
}
fn options_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"target": {
"type": "integer",
"minimum": 1,
"default": 3,
"description": "Plays needed to win the match.",
},
"deadline_in_seconds": {
"type": "number",
"description": "Arm a tick deadline this far in the future.",
},
},
})
}
fn create(&self, session: &mut GameSession, options: &Map<String, Value>) -> Result<()> {
let target = options.get("target").and_then(Value::as_i64).unwrap_or(3);
if target < 1 || options.get("target").is_some_and(|v| v.is_boolean()) {
return Err(GameError::illegal_move("target must be a positive integer").into());
}
let creator = session.players[0].clone();
session.players[0] = Seat {
user_sub: creator.user_sub,
display_name: creator.display_name,
team: Some("A".into()),
};
session.state = Some(Box::new(DummyState {
target,
plays: Vec::new(),
started: false,
finished: false,
winner: None,
deadline_in_seconds: options.get("deadline_in_seconds").and_then(Value::as_f64),
}));
Ok(())
}
fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()> {
if state_of(session)?.started {
return Err(GameError::game_not_started("game has already started").into());
}
if session.seated(user_sub) {
return Err(GameError::already_joined("already joined this game").into());
}
if session.players.len() >= 2 {
return Err(GameError::lobby_full("game is full").into());
}
let team = if session.players.is_empty() { "A" } else { "B" };
session.players.push(Seat {
user_sub: user_sub.into(),
display_name: display_name.into(),
team: Some(team.into()),
});
if session.players.len() == 2 {
state_of_mut(session)?.started = true;
}
Ok(())
}
fn handle_action(
&self,
session: &mut GameSession,
user_sub: &str,
action: &str,
_payload: &Map<String, Value>,
) -> Result<()> {
if !state_of(session)?.started {
return Err(GameError::game_not_started("the game has not started yet").into());
}
if state_of(session)?.finished {
return Err(GameError::game_finished("the match is over").into());
}
if !session.seated(user_sub) {
return Err(GameError::not_your_turn("you are not seated in this game").into());
}
if action != "play" {
return Err(GameError::illegal_move(format!(
"unknown action: {}",
tavolo_platform::http::py_repr(&Value::String(action.to_string()))
))
.into());
}
DummyEngine::play(session, user_sub)
}
fn view_for(&self, session: &GameSession, user_sub: &str) -> Value {
let state = state_of(session).expect("dummy state");
json!({
"started": state.started,
"finished": state.finished,
"winner": state.winner,
"plays": state.plays.len(),
"target": state.target,
"viewer_seated": session.seated(user_sub),
})
}
fn lobby_view(&self, session: &GameSession) -> Value {
let state = state_of(session).expect("dummy state");
json!({
"phase": if state.started { "playing" } else { "lobby" },
"target": state.target,
})
}
fn in_lobby(&self, session: &GameSession) -> bool {
state_of(session)
.map(|state| !state.started)
.unwrap_or(false)
}
fn is_finished(&self, session: &GameSession) -> bool {
state_of(session)
.map(|state| state.finished)
.unwrap_or(false)
}
fn result(&self, session: &GameSession) -> Result<MatchResult> {
let state = state_of(session)?;
let Some(winner) = state.winner.clone() else {
return Err(GameError::illegal_move("no result: the match is not finished").into());
};
let subs: Vec<String> = session
.players
.iter()
.map(|seat| seat.user_sub.clone())
.collect();
Ok(MatchResult {
teams: vec![vec![subs[0].clone()], vec![subs[1].clone()]],
winner_team: subs.iter().position(|sub| *sub == winner).unwrap_or(0),
players: session
.players
.iter()
.enumerate()
.map(|(index, seat)| PlayerResult {
user_sub: seat.user_sub.clone(),
seat: index as i64,
won: seat.user_sub == winner,
team: seat.team.clone(),
score: state
.plays
.iter()
.filter(|sub| **sub == seat.user_sub)
.count() as f64,
details: json!({
"plays": state.plays.iter().filter(|sub| **sub == seat.user_sub).count(),
}),
})
.collect(),
summary: json!({
"target": state.target,
"plays": state.plays.len(),
"winner": winner,
}),
})
}
fn state_to_json(&self, state: &(dyn std::any::Any + Send + Sync)) -> Result<Value> {
let state = state
.downcast_ref::<DummyState>()
.ok_or_else(|| Error::Internal("dummy state missing".into()))?;
Ok(serde_json::to_value(state)?)
}
fn state_from_json(&self, data: &Value) -> Result<Box<dyn std::any::Any + Send + Sync>> {
Ok(Box::new(serde_json::from_value::<DummyState>(
data.clone(),
)?))
}
fn next_deadline(&self, session: &GameSession) -> Result<Option<Deadline>> {
let state = state_of(session)?;
let Some(seconds) = state.deadline_in_seconds else {
return Ok(None);
};
if !state.started || state.finished {
return Ok(None);
}
let due_at = tavolo_platform::datetime::now()
+ chrono::Duration::milliseconds((seconds * 1000.0) as i64);
Ok(Some(Deadline {
kind: "tick".into(),
due_at,
token: format!("tick:{}", state.plays.len()),
}))
}
fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()> {
let current = self.next_deadline(session)?;
match current {
Some(current) if current.kind == kind && current.token == token => {}
_ => return Err(GameError::illegal_move("stale deadline").into()),
}
let user_sub = session.players[0].user_sub.clone();
DummyEngine::play(session, &user_sub)
}
}
fn test_oidc_config(provider: &str) -> OidcProviderConfig {
OidcProviderConfig {
provider: provider.into(),
issuer: format!("http://localhost:8180/{provider}"),
client_id: "tavolo".into(),
client_secret: None,
redirect_uri: format!("http://localhost:8080/auth/{provider}/callback"),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}
}
pub struct TestPlatform {
pub platform: SharedPlatform,
pub session_store: SharedSessionStore,
pub stats: Arc<InMemoryStatsRepo>,
}
impl TestPlatform {
pub fn router(&self) -> Router {
Router::new()
.merge(
tavolo_platform::routes::router(&["default".to_string()])
.with_state(self.platform.clone()),
)
.layer(axum::middleware::from_fn_with_state(
self.session_store.clone(),
session_middleware,
))
}
}
pub fn make_platform(heartbeat_ms: u64) -> TestPlatform {
let mut registry = GameRegistry::new();
registry
.register(Arc::new(DummyEngine))
.expect("register dummy");
let registry = Arc::new(registry);
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry.clone()));
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
let stats = Arc::new(InMemoryStatsRepo::new());
let stats_repo: SharedStatsRepo = stats.clone();
let oidc =
Arc::new(OidcProviders::new(vec![test_oidc_config("default")]).expect("oidc providers"));
let scheduler = DeadlineScheduler::new(
game_store.clone(),
registry.clone(),
stats_repo.clone(),
heartbeat_ms,
20,
);
scheduler.start();
let platform = Arc::new(Platform {
registry,
game_store,
scheduler,
oidc,
stats: stats_repo,
});
TestPlatform {
platform,
session_store,
stats,
}
}
/// Persist a logged-in session and return the `Cookie` header value for it.
pub async fn login_cookie(
store: &SharedSessionStore,
provider: &str,
sub: &str,
name: &str,
) -> String {
let id = format!("session-{sub}");
let mut data = Map::new();
data.insert(
format!("oidc:{provider}:user"),
json!({"sub": sub, "preferred_username": name}),
);
store
.save(&id, &data, Some(3600))
.await
.expect("save session");
format!("session_id={id}")
}
pub async fn call(router: &Router, request: Request<Body>) -> Response<axum::body::Body> {
router.clone().oneshot(request).await.expect("request")
}
pub async fn body_json(response: Response<Body>) -> Value {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
serde_json::from_slice(&bytes).expect("json body")
}
pub fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
let mut builder = Request::builder().method("GET").uri(uri);
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
builder.body(Body::empty()).expect("request")
}
pub fn post_json(uri: &str, cookie: Option<&str>, body: &Value) -> Request<Body> {
let mut builder = Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json");
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
builder
.body(Body::from(serde_json::to_vec(body).expect("json")))
.expect("request")
}
pub async fn read_text(
mut socket: tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> String {
let message = socket
.next()
.await
.expect("some message")
.expect("ok message");
match message {
tokio_tungstenite::tungstenite::Message::Text(text) => text.to_string(),
other => panic!("expected text message, got {other:?}"),
}
}
pub async fn read_json(
socket: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> Value {
let message = socket
.next()
.await
.expect("some message")
.expect("ok message");
match message {
tokio_tungstenite::tungstenite::Message::Text(text) => {
serde_json::from_str(&text).expect("json message")
}
other => panic!("expected text message, got {other:?}"),
}
}
pub fn ws_request(addr: std::net::SocketAddr, game_id: &str, cookie: Option<&str>) -> Request<()> {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let url = format!("ws://{addr}/ws/games/{game_id}");
let mut request = url.into_client_request().expect("websocket client request");
if let Some(cookie) = cookie {
request.headers_mut().insert(
"cookie",
axum::http::HeaderValue::from_str(cookie).expect("cookie header"),
);
}
request
}
pub async fn start_server(router: Router) -> std::net::SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
addr
}
pub fn json_status(response: &Response<Body>) -> StatusCode {
response.status()
}
@@ -0,0 +1,410 @@
//! HTTP routes and WebSocket protocol of the platform.
mod helpers;
use futures_util::SinkExt;
use helpers::*;
use serde_json::json;
use tokio_tungstenite::tungstenite::handshake::client::Response as WsResponse;
async fn create_and_start(test: &helpers::TestPlatform) -> (String, String, String, String) {
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&alice),
&json!({"options": {"target": 3}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 200);
(game_id, code, alice, bob)
}
#[tokio::test]
async fn game_types_lists_the_registered_engine() {
let test = make_platform(50);
let router = test.router();
let response = call(&router, get("/api/game-types", None)).await;
assert_eq!(response.status(), 200);
let body = body_json(response).await;
assert_eq!(body["results"][0]["id"], "dummy");
assert_eq!(body["results"][0]["min_players"], 2);
assert_eq!(body["results"][0]["options_schema"]["type"], "object");
}
#[tokio::test]
async fn create_requires_authentication() {
let test = make_platform(50);
let router = test.router();
let response = call(&router, post_json("/api/games", None, &json!({}))).await;
assert_eq!(response.status(), 401);
let body = body_json(response).await;
assert_eq!(body["error"], "unauthenticated");
}
#[tokio::test]
async fn create_validates_game_type_and_options() {
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!({"game_type": "nope"})),
)
.await;
assert_eq!(response.status(), 400);
let body = body_json(response).await;
assert_eq!(body["error"], "unknown game_type: 'nope'");
let response = call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 0}}),
),
)
.await;
assert_eq!(response.status(), 400);
let body = body_json(response).await;
assert_eq!(body["error"], "target must be a positive integer");
let response = call(
&router,
post_json("/api/games", Some(&cookie), &json!({"options": 3})),
)
.await;
assert_eq!(response.status(), 400);
assert_eq!(
body_json(response).await["error"],
"options must be an object"
);
}
#[tokio::test]
async fn join_flow_and_snapshot_authorization() {
let test = make_platform(50);
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby =
body_json(call(&router, post_json("/api/games", Some(&alice), &json!({}))).await).await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
// Missing / unknown code.
let response = call(
&router,
post_json("/api/games/join", Some(&alice), &json!({})),
)
.await;
assert_eq!(response.status(), 400);
assert_eq!(body_json(response).await["error"], "code is required");
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": "ZZZZZZ"})),
)
.await;
assert_eq!(response.status(), 404);
// Preview before joining.
let preview = body_json(
call(
&router,
get(&format!("/api/games/preview?code={code}"), Some(&bob)),
)
.await,
)
.await;
assert_eq!(preview["game_name"], "Dummy game");
assert_eq!(preview["seats_open"], 1);
assert_eq!(preview["players"][0]["sub"], "alice");
// The creator cannot join their own (still open) lobby twice.
let response = call(
&router,
post_json("/api/games/join", Some(&alice), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 409);
assert_eq!(
body_json(response).await["error"],
"already joined this game"
);
// Bob joins and starts the match.
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 200);
let started = body_json(response).await;
assert_eq!(started["started"], true);
assert_eq!(started["your_turn"], json!(null));
// Bob cannot rejoin a started game; a third player finds the lobby full.
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 409);
assert_eq!(
body_json(response).await["error"],
"game has already started"
);
let carol = login_cookie(&test.session_store, "default", "carol", "Carol").await;
let response = call(
&router,
post_json("/api/games/join", Some(&carol), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 409);
assert_eq!(
body_json(response).await["error"],
"game has already started"
);
// Snapshots: unknown game 404, non-seated 403, seated 200, anonymous 401.
let response = call(&router, get("/api/games/no-such-id", Some(&alice))).await;
assert_eq!(response.status(), 404);
let response = call(&router, get(&format!("/api/games/{game_id}"), Some(&carol))).await;
assert_eq!(response.status(), 403);
let response = call(&router, get(&format!("/api/games/{game_id}"), Some(&alice))).await;
assert_eq!(response.status(), 200);
let snapshot = body_json(response).await;
assert_eq!(snapshot["id"], game_id);
assert_eq!(snapshot["viewer_seated"], true);
let response = call(&router, get(&format!("/api/games/{game_id}"), None)).await;
assert_eq!(response.status(), 401);
// The whoami endpoint reflects the session identity.
let me = body_json(call(&router, get("/api/me", Some(&alice))).await).await;
assert_eq!(me["sub"], "alice");
assert_eq!(me["name"], "Alice");
let response = call(&router, get("/api/me", None)).await;
assert_eq!(response.status(), 401);
}
#[tokio::test]
async fn auth_helpers_are_provider_agnostic() {
let test = make_platform(50);
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let providers = body_json(call(&router, get("/api/auth/providers", None)).await).await;
assert_eq!(
providers["providers"],
json!([{"name": "default", "login_url": "/auth/default/login"}])
);
let response = call(&router, get("/auth/login", None)).await;
assert_eq!(response.status(), 302);
assert_eq!(
response.headers()["location"].to_str().unwrap(),
"/auth/default/login"
);
let response = call(&router, get("/auth/logout", Some(&alice))).await;
assert_eq!(response.status(), 302);
assert_eq!(
response.headers()["location"].to_str().unwrap(),
"/auth/default/logout"
);
let response = call(&router, get("/auth/logout", None)).await;
assert_eq!(response.headers()["location"].to_str().unwrap(), "/");
let response = call(&router, get("/api/openapi.json", None)).await;
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn websocket_rejects_unauthorized_unknown_and_unseated() {
let test = make_platform(50);
let (game_id, _code, alice, _bob) = create_and_start(&test).await;
let carol = login_cookie(&test.session_store, "default", "carol", "Carol").await;
let addr = start_server(test.router()).await;
let error = tokio_tungstenite::connect_async(ws_request(addr, &game_id, None))
.await
.expect_err("anonymous upgrade must be refused");
match error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
assert_eq!(response.status(), 401);
}
other => panic!("unexpected error: {other:?}"),
}
let error = tokio_tungstenite::connect_async(ws_request(addr, "unknown-game", Some(&alice)))
.await
.expect_err("unknown game upgrade must be refused");
match error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
assert_eq!(response.status(), 404);
}
other => panic!("unexpected error: {other:?}"),
}
let error = tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&carol)))
.await
.expect_err("non-seated upgrade must be refused");
match error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
assert_eq!(response.status(), 403);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn websocket_broadcasts_moves_and_game_over() {
let test = make_platform(50);
let (game_id, _code, alice, bob) = create_and_start(&test).await;
let addr = start_server(test.router()).await;
let (mut alice_ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
.await
.expect("alice connects");
let (mut bob_ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&bob)))
.await
.expect("bob connects");
let first = read_json(&mut alice_ws).await;
assert_eq!(first["type"], "state");
assert_eq!(first["game"]["viewer_seated"], true);
let first_bob = read_json(&mut bob_ws).await;
assert_eq!(first_bob["type"], "state");
// Alice plays; both connections see the update.
alice_ws
.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "play"}).to_string(),
))
.await
.expect("send play");
let update = read_json(&mut alice_ws).await;
assert_eq!(update["game"]["plays"], 1);
let update = read_json(&mut bob_ws).await;
assert_eq!(update["game"]["plays"], 1);
// Requesting a snapshot resends the state.
bob_ws
.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "state"}).to_string(),
))
.await
.expect("send state");
let snapshot = read_json(&mut bob_ws).await;
assert_eq!(snapshot["type"], "state");
assert_eq!(snapshot["game"]["plays"], 1);
}
#[tokio::test]
async fn websocket_reports_errors_and_malformed_messages() {
let test = make_platform(50);
let (game_id, _code, alice, _bob) = create_and_start(&test).await;
let addr = start_server(test.router()).await;
let (mut ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
.await
.expect("alice connects");
read_json(&mut ws).await;
ws.send(tokio_tungstenite::tungstenite::Message::Text(
"not json".into(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["type"], "error");
assert_eq!(error["message"], "invalid JSON");
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!([1, 2]).to_string(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["message"], "message must be a JSON object");
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "dance"}).to_string(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["type"], "error");
assert_eq!(error["code"], "illegal_move");
assert_eq!(error["message"], "unknown action: 'dance'");
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": 7}).to_string(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["message"], "unknown action: 7");
}
#[tokio::test]
async fn websocket_sends_game_over_once_the_match_finishes() {
let test = make_platform(50);
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&alice),
&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();
call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
let addr = start_server(test.router()).await;
let (mut ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
.await
.expect("alice connects");
read_json(&mut ws).await;
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "play"}).to_string(),
))
.await
.unwrap();
let state = read_json(&mut ws).await;
assert_eq!(state["type"], "state");
assert_eq!(state["game"]["finished"], true);
let game_over = read_json(&mut ws).await;
assert_eq!(game_over["type"], "game_over");
}
@@ -0,0 +1,160 @@
//! 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")
}
@@ -0,0 +1,138 @@
//! Redis-backed store tests, run only when `TEST_REDIS_URL` points at a
//! throwaway Redis instance. The compose stack exposes one on
//! `redis://127.0.0.1:6379/0`.
mod helpers;
use std::sync::Arc;
use helpers::DummyEngine;
use tavolo_platform::registry::GameRegistry;
use tavolo_platform::store::{GameStore, RedisGameStore};
fn redis_url() -> Option<String> {
std::env::var("TEST_REDIS_URL")
.ok()
.filter(|url| !url.is_empty())
}
async fn store(ttl: i64) -> Option<RedisGameStore> {
let url = redis_url()?;
let mut registry = GameRegistry::new();
registry.register(Arc::new(DummyEngine)).expect("register");
Some(
RedisGameStore::new(&url, Arc::new(registry), ttl)
.await
.expect("redis store"),
)
}
async fn create_game(store: &RedisGameStore) -> tavolo_platform::GameSession {
use tavolo_platform::engine::{GameEngine, GameSession, Seat};
let engine = DummyEngine;
let unique = uuid::Uuid::new_v4().to_string();
let mut game = GameSession::new(unique.clone(), engine.id(), unique[..6].to_uppercase());
game.creator_sub = "alice".into();
game.players = vec![Seat::new("alice", "Alice")];
game.created_at = Some(tavolo_platform::datetime::now());
engine
.create(&mut game, &serde_json::Map::new())
.expect("create");
store.save(&game).await.expect("save");
game
}
#[tokio::test]
async fn redis_round_trips_sessions_codes_and_ongoing_counts() {
let Some(store) = store(60).await else {
eprintln!("TEST_REDIS_URL unset: skipping");
return;
};
let game = create_game(&store).await;
let loaded = store.load(&game.id).await.expect("load").expect("found");
assert_eq!(loaded.join_code, game.join_code);
assert!(loaded.seated("alice"));
let by_code = store
.find_by_code(&game.join_code.to_lowercase())
.await
.expect("find")
.expect("found");
assert_eq!(by_code.id, game.id);
assert!(store.count_ongoing().await.expect("count") >= 1);
// Save with TTL still lists the game; a second save does not duplicate.
store.save(&loaded).await.expect("save again");
assert!(store.count_ongoing().await.expect("count") >= 1);
store
.remove_deadline(&format!("missing-{}", game.id))
.await
.unwrap();
store
.remove_deadline(&serde_json::json!({}).to_string())
.await
.unwrap();
}
#[tokio::test]
async fn redis_locks_are_exclusive_and_released() {
let Some(store) = store(60).await else {
eprintln!("TEST_REDIS_URL unset: skipping");
return;
};
let game = create_game(&store).await;
let first = store.acquire_lock(&game.id).await.expect("first lock");
let second_store = store_second().await.expect("second store");
let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false));
let acquired_clone = acquired.clone();
let game_id = game.id.clone();
let waiter = tokio::spawn(async move {
let _lock = second_store
.acquire_lock(&game_id)
.await
.expect("second lock");
acquired_clone.store(true, std::sync::atomic::Ordering::SeqCst);
});
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert!(!acquired.load(std::sync::atomic::Ordering::SeqCst));
first.release().await.expect("release");
tokio::time::timeout(std::time::Duration::from_secs(3), waiter)
.await
.expect("waiter acquired")
.expect("waiter task");
assert!(acquired.load(std::sync::atomic::Ordering::SeqCst));
}
#[tokio::test]
async fn redis_pubsub_signals_and_deadline_queue() {
let Some(store) = store(60).await else {
eprintln!("TEST_REDIS_URL unset: skipping");
return;
};
use futures_util::StreamExt;
let game = create_game(&store).await;
let mut events = store.subscribe(&game.id).await.expect("subscribe");
// The subscription registers asynchronously; a short delay avoids racing
// the PUBLISH with the SUBSCRIBE.
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
store.publish(&game.id).await.expect("publish");
let signalled = tokio::time::timeout(std::time::Duration::from_secs(3), events.next()).await;
assert!(signalled.is_ok(), "no change signal received");
let member = format!("deadline-{}", uuid::Uuid::new_v4());
store.add_deadline(&member, 0.0).await.expect("add");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let due = store
.due_deadlines(tavolo_platform::datetime::now().timestamp() as f64, 10)
.await
.expect("due");
assert!(due.contains(&member), "{due:?}");
assert!(store.next_deadline().await.expect("next").is_some());
store.remove_deadline(&member).await.expect("remove");
}
async fn store_second() -> Option<RedisGameStore> {
store(60).await
}
@@ -0,0 +1,317 @@
//! 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()
);
}