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

411 lines
14 KiB
Rust

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