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:
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()
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user