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.
321 lines
11 KiB
Rust
321 lines
11 KiB
Rust
//! End-to-end scopone flow: lobby, four players, live play over the
|
|
//! WebSocket and the turn-timeout auto-play, plus SPA/static hosting.
|
|
|
|
use std::net::SocketAddr;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use serde_json::{json, Map, Value};
|
|
use tower::ServiceExt;
|
|
|
|
use tavolo_app::app;
|
|
use tavolo_app::config::Settings;
|
|
use tavolo_platform::oidc::OidcProviderConfig;
|
|
use tavolo_platform::session::SharedSessionStore;
|
|
|
|
const PLAYERS: [&str; 4] = ["alice", "bob", "carol", "dave"];
|
|
|
|
fn settings(static_dir: &str, turn_timeout: i64) -> Settings {
|
|
Settings {
|
|
database_url: "postgres://unused".into(),
|
|
oidc_providers: vec![OidcProviderConfig {
|
|
provider: "default".into(),
|
|
issuer: "http://localhost:8180/tavolo".into(),
|
|
client_id: "tavolo".into(),
|
|
client_secret: None,
|
|
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
|
|
post_login_redirect: "/".into(),
|
|
post_logout_redirect: "/".into(),
|
|
}],
|
|
app_host: "127.0.0.1".into(),
|
|
app_port: 0,
|
|
redis_url: None,
|
|
game_ttl_seconds: 86400,
|
|
static_dir: static_dir.into(),
|
|
hand_ack_timeout_seconds: 30,
|
|
turn_timeout_seconds: turn_timeout,
|
|
deadline_heartbeat_ms: 50,
|
|
logging_config: None,
|
|
cors_allow_origins: None,
|
|
cors_allow_origin_regex: None,
|
|
cors_allow_methods: None,
|
|
cors_allow_headers: None,
|
|
cors_allow_credentials: false,
|
|
cors_expose_headers: None,
|
|
cors_max_age: 600,
|
|
otel_enabled: false,
|
|
otel_service_name: "tavolo".into(),
|
|
otel_exporter_endpoint: None,
|
|
otel_exporter_headers: None,
|
|
otel_excluded_paths: vec!["/api/health".into()],
|
|
otel_metric_export_interval_millis: 60000,
|
|
otel_metrics_poll_interval_seconds: 15,
|
|
}
|
|
}
|
|
|
|
async fn login(store: &SharedSessionStore, sub: &str, name: &str) -> String {
|
|
let id = format!("session-{sub}");
|
|
let mut data = Map::new();
|
|
data.insert(
|
|
"oidc:default:user".into(),
|
|
json!({"sub": sub, "preferred_username": name}),
|
|
);
|
|
store.save(&id, &data, Some(3600)).await.expect("session");
|
|
format!("session_id={id}")
|
|
}
|
|
|
|
async fn body_json(response: axum::response::Response) -> Value {
|
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
serde_json::from_slice(&bytes).expect("json")
|
|
}
|
|
|
|
fn post(uri: &str, cookie: &str, body: &Value) -> Request<Body> {
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(uri)
|
|
.header("cookie", cookie)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(serde_json::to_vec(body).unwrap()))
|
|
.unwrap()
|
|
}
|
|
|
|
fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
|
|
let mut builder = Request::builder().uri(uri);
|
|
if let Some(cookie) = cookie {
|
|
builder = builder.header("cookie", cookie);
|
|
}
|
|
builder.body(Body::empty()).unwrap()
|
|
}
|
|
|
|
async fn start_server(router: axum::Router) -> 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
|
|
}
|
|
|
|
fn ws_request(addr: SocketAddr, game_id: &str, cookie: &str) -> Request<()> {
|
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
|
let mut request = format!("ws://{addr}/ws/games/{game_id}")
|
|
.into_client_request()
|
|
.expect("request");
|
|
request.headers_mut().insert(
|
|
"cookie",
|
|
axum::http::HeaderValue::from_str(cookie).expect("cookie"),
|
|
);
|
|
request
|
|
}
|
|
|
|
async fn read_json(
|
|
socket: &mut tokio_tungstenite::WebSocketStream<
|
|
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
|
>,
|
|
) -> Value {
|
|
let message = socket.next().await.expect("message").expect("ok");
|
|
match message {
|
|
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
|
serde_json::from_str(&text).expect("json")
|
|
}
|
|
other => panic!("expected text, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn scopone_lobby_play_and_turn_timeout() {
|
|
let temp = std::env::temp_dir().join(format!("tavolo-test-{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir_all(temp.join("assets")).expect("temp dir");
|
|
let test_settings = settings(temp.to_str().unwrap(), 1);
|
|
let (platform, sessions) = app::in_memory_platform(&test_settings).expect("platform");
|
|
let router = app::build_router(platform, sessions.clone(), &test_settings);
|
|
|
|
// Create the game as alice and join with the other three players.
|
|
let alice = login(&sessions, "alice", "Alice").await;
|
|
let created = router
|
|
.clone()
|
|
.oneshot(post(
|
|
"/api/games",
|
|
&alice,
|
|
&json!({"options": {"target_score": 11}}),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(created.status(), StatusCode::CREATED);
|
|
let lobby = body_json(created).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"], 3);
|
|
assert_eq!(lobby["players"][0]["team"], "A");
|
|
|
|
for (index, player) in PLAYERS.iter().enumerate().skip(1) {
|
|
let cookie = login(&sessions, player, &player.to_uppercase()).await;
|
|
let response = router
|
|
.clone()
|
|
.oneshot(post("/api/games/join", &cookie, &json!({"code": code})))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK, "player {player}");
|
|
let body = body_json(response).await;
|
|
assert_eq!(body["players"].as_array().unwrap().len(), index + 1);
|
|
if index == 3 {
|
|
assert_eq!(body["phase"], "playing");
|
|
assert_eq!(body["hand_number"], 1);
|
|
assert_eq!(body["dealer"], 0);
|
|
assert_eq!(body["turn"], 1);
|
|
assert_eq!(body["players"][index]["hand"].as_array().unwrap().len(), 10);
|
|
}
|
|
}
|
|
|
|
// Bob is on turn (seat 1): his hand is visible to him and hidden from
|
|
// alice's personalized view.
|
|
let bob = login(&sessions, "bob", "Bob").await;
|
|
let bob_view = body_json(
|
|
router
|
|
.clone()
|
|
.oneshot(get(&format!("/api/games/{game_id}"), Some(&bob)))
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(bob_view["turn"], 1);
|
|
assert_eq!(bob_view["your_turn"], true);
|
|
assert_eq!(bob_view["players"][1]["hand"].as_array().unwrap().len(), 10);
|
|
assert!(bob_view["players"][0].get("hand").is_none());
|
|
assert!(bob_view["legal_moves"].is_object());
|
|
|
|
let alice_view = body_json(
|
|
router
|
|
.clone()
|
|
.oneshot(get(&format!("/api/games/{game_id}"), Some(&alice)))
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
alice_view["players"][0]["hand"].as_array().unwrap().len(),
|
|
10
|
|
);
|
|
assert!(alice_view["players"][1].get("hand").is_none());
|
|
|
|
let addr = start_server(router.clone()).await;
|
|
let (mut ws, _) = tokio_tungstenite::connect_async(ws_request(addr, &game_id, &bob))
|
|
.await
|
|
.expect("bob connects");
|
|
let state = read_json(&mut ws).await;
|
|
assert_eq!(state["type"], "state");
|
|
// The table starts empty and the turn belongs to bob (seat 1).
|
|
assert_eq!(state["game"]["turn"], 1);
|
|
assert_eq!(state["game"]["table"].as_array().unwrap().len(), 0);
|
|
// Bob is on turn on the websocket view.
|
|
assert_eq!(state["game"]["your_turn"], true);
|
|
let hand = state["game"]["players"][1]["hand"]
|
|
.as_array()
|
|
.unwrap()
|
|
.clone();
|
|
let card = hand[0].as_str().unwrap().to_string();
|
|
let legal = state["game"]["legal_moves"]
|
|
.as_object()
|
|
.and_then(|moves| moves.get(&card))
|
|
.cloned();
|
|
let mut message = json!({"action": "play", "card": card});
|
|
if let Some(legal) = legal {
|
|
if let Some(option) = legal.as_array().and_then(|options| options.first()) {
|
|
message["capture"] = option.clone();
|
|
}
|
|
}
|
|
ws.send(tokio_tungstenite::tungstenite::Message::Text(
|
|
message.to_string(),
|
|
))
|
|
.await
|
|
.expect("send play");
|
|
let update = read_json(&mut ws).await;
|
|
assert_eq!(update["type"], "state");
|
|
assert_eq!(update["game"]["turn"], 2);
|
|
assert_eq!(update["game"]["last_move"]["seat"], 1);
|
|
assert_eq!(update["game"]["last_move"]["card"], card);
|
|
|
|
// Carol (seat 2) is on turn now with a 1s timeout: the server auto-plays.
|
|
let before = update["game"]["players"][2]["cards_left"].as_u64().unwrap();
|
|
tokio::time::sleep(std::time::Duration::from_millis(1800)).await;
|
|
let after = body_json(
|
|
router
|
|
.clone()
|
|
.oneshot(get(&format!("/api/games/{game_id}"), Some(&alice)))
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(after["turn"], 3);
|
|
assert_eq!(
|
|
after["players"][2]["cards_left"].as_u64().unwrap(),
|
|
before - 1
|
|
);
|
|
assert_eq!(after["last_move"]["seat"], 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn spa_shell_and_static_mounts() {
|
|
let temp = std::env::temp_dir().join(format!("tavolo-static-{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir_all(temp.join("assets")).expect("temp dir");
|
|
std::fs::write(temp.join("index.html"), "<html>app shell</html>").expect("index");
|
|
std::fs::write(temp.join("assets/card.png"), b"png-bytes").expect("asset");
|
|
let test_settings = settings(temp.to_str().unwrap(), 30);
|
|
let (platform, sessions) = app::in_memory_platform(&test_settings).expect("platform");
|
|
let router = app::build_router(platform, sessions, &test_settings);
|
|
|
|
let response = router.clone().oneshot(get("/", None)).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(
|
|
response.headers()["content-type"],
|
|
"text/html; charset=utf-8"
|
|
);
|
|
|
|
let response = router
|
|
.clone()
|
|
.oneshot(get("/game/abc-123", None))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK, "SPA fallback");
|
|
|
|
let response = router
|
|
.clone()
|
|
.oneshot(get("/static/index.html", None))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let response = router
|
|
.clone()
|
|
.oneshot(get("/assets/card.png", None))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(&bytes[..], b"png-bytes");
|
|
|
|
// Unknown API paths also fall back to the SPA shell, like kaya's glob;
|
|
// an unknown OIDC provider path (no route registered) does too.
|
|
let response = router
|
|
.clone()
|
|
.oneshot(get("/api/does-not-exist", None))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let response = router
|
|
.clone()
|
|
.oneshot(get("/auth/nope/login", None))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
std::fs::remove_dir_all(temp).ok();
|
|
}
|