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.
470 lines
15 KiB
Rust
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()
|
|
}
|