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.
139 lines
4.8 KiB
Rust
139 lines
4.8 KiB
Rust
//! 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
|
|
}
|