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.
318 lines
10 KiB
Rust
318 lines
10 KiB
Rust
//! 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()
|
|
);
|
|
}
|