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

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
}