Files
tavolo/server/crates/tavolo-platform/tests/helpers/mod.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

470 lines
15 KiB
Rust

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