//! 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 { 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 { 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, >, ) -> 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"), "app shell").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(); }