use std::path::PathBuf; use rusqlite::Connection; use zagan_bet::blackjack::BlackjackState; use zagan_bet::server::core::{ConnectionState, ServerCore}; use zagan_bet::server::database; use zagan_bet::server::games::registry::GameRegistry; use zagan_bet::server::games::roulette::RouletteGame; use zagan_bet::server::games::slots::SlotsGame; use zagan_bet::roulette::Bets; fn setup(name: &str, grid: &str) -> (ServerCore, String) { let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) .join(format!("server-core-{name}.db")) .to_string_lossy() .into_owned(); let _ = std::fs::remove_file(&path); let conn = Connection::open(&path).unwrap(); database::init_db(&conn); let registry = GameRegistry::new(); registry.register_game(SlotsGame::with_grid(grid)); // Low bcrypt cost keeps the tests fast. (ServerCore::with_registry(conn, registry, 4), path) } /// Column-major 5x3 grid with no winning line. const LOSS: &str = "CLOOGDSLCDCOGLS"; /// Top line is C C C D D; it pays 2x total bet. const WIN3: &str = "CCCCLLCLODGSDGS"; fn setup_roulette(name: &str, number: u8) -> (ServerCore, String) { let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) .join(format!("server-core-{name}.db")) .to_string_lossy() .into_owned(); let _ = std::fs::remove_file(&path); let conn = Connection::open(&path).unwrap(); database::init_db(&conn); let registry = GameRegistry::new(); registry.register_game(RouletteGame::with_result(number)); (ServerCore::with_registry(conn, registry, 4), path) } fn setup_blackjack(name: &str, deck: &[&str]) -> (ServerCore, String) { let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) .join(format!("server-core-{name}.db")) .to_string_lossy() .into_owned(); let _ = std::fs::remove_file(&path); let conn = Connection::open(&path).unwrap(); database::init_db(&conn); let deck = deck.iter().map(|card| card.to_string()).collect(); ( ServerCore::with_registry(conn, GameRegistry::with_defaults(), 4) .with_blackjack_deck(deck), path, ) } fn blackjack_state(response: &str) -> BlackjackState { let raw = response .strip_prefix("BLACKJACK_STATE ") .expect("blackjack state response"); serde_json::from_str(raw).expect("valid blackjack state") } fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { assert_eq!( core.handle_message(state, &format!("REGISTER {user} credential")), format!("REGISTERED {user}") ); } fn login(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { assert_eq!( core.handle_message(state, &format!("AUTH {user} credential")), format!("OK {user}") ); } #[test] fn register_stores_a_bcrypt_hash_and_auth_accepts_it() { let (mut core, path) = setup("register", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); let conn = Connection::open(&path).unwrap(); let stored = database::get_hashed_password(&conn, "alice").unwrap(); assert!(stored.starts_with("$2"), "not a bcrypt hash: {stored}"); assert_ne!(stored, "credential"); assert!(bcrypt::verify("credential", &stored).unwrap()); assert_eq!( core.handle_message(&mut state, "AUTH alice credential"), "OK alice" ); assert_eq!(state.user(), Some("alice")); } #[test] fn the_same_credential_gets_a_different_salt() { let (mut core, path) = setup("salt", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); register(&mut core, &mut state, "bob"); let conn = Connection::open(&path).unwrap(); let alice = database::get_hashed_password(&conn, "alice").unwrap(); let bob = database::get_hashed_password(&conn, "bob").unwrap(); assert_ne!(alice, bob); } #[test] fn legacy_sha256_rows_are_upgraded_on_login() { let (mut core, path) = setup("legacy", LOSS); let conn = Connection::open(&path).unwrap(); database::add_user(&conn, "legacy", "deadbeef").unwrap(); let mut state = ConnectionState::default(); assert_eq!( core.handle_message(&mut state, "AUTH legacy deadbeef"), "OK legacy" ); let stored = database::get_hashed_password(&conn, "legacy").unwrap(); assert!(stored.starts_with("$2"), "legacy hash was not upgraded"); assert!(bcrypt::verify("deadbeef", &stored).unwrap()); let mut other = ConnectionState::default(); assert_eq!( core.handle_message(&mut other, "AUTH legacy wrong"), "ERROR Invalid credentials" ); } #[test] fn duplicate_registration_returns_an_error() { let (mut core, _path) = setup("duplicate", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); assert_eq!( core.handle_message(&mut state, "REGISTER alice credential"), "ERROR User already exists" ); } #[test] fn a_malicious_username_cannot_change_another_balance() { let (mut core, _path) = setup("injection", LOSS); let mut admin = ConnectionState::default(); register(&mut core, &mut admin, "victim"); // No spaces, so it survives the three-part REGISTER/AUTH parser. let evil = "x';UPDATE/**/users/**/SET/**/balance=999999;--"; register(&mut core, &mut admin, evil); let mut evil_state = ConnectionState::default(); login(&mut core, &mut evil_state, evil); let response = core.handle_message(&mut evil_state, "PLAY_START slots 10"); assert!( response.starts_with("GAME_RESULT"), "spin failed: {response}" ); assert_eq!(core.balance("victim"), Some(1000)); assert_eq!(core.balance(evil), Some(990)); assert_eq!(core.play_count(evil), 1); } #[test] fn plays_record_the_game_type_and_game_specific_details() { let (mut core, path) = setup("details", WIN3); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let response = core.handle_message(&mut state, "PLAY_START slots 10"); assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}"); let conn = Connection::open(&path).unwrap(); let (game_type, details): (String, String) = conn .query_row( "SELECT game_type, details FROM plays WHERE username = 'alice'", [], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap(); assert_eq!(game_type, "slots"); let details: serde_json::Value = serde_json::from_str(&details).unwrap(); assert_eq!(details["reels"], "CCCCLLCLODGSDGS"); assert_eq!(details["win_type"], "win"); assert_eq!(details["win_lines"], "1"); assert_eq!(details["win_cells"], "0,3,6"); } #[test] fn roulette_settles_split_bets_and_records_details() { let (mut core, path) = setup_roulette("roulette", 17); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let bets = r#"{"red":10,"black":10,"even":10,"odd":10,"numbers":[{"number":17,"amount":5}]}"#; let response = core.handle_message(&mut state, &format!("PLAY_START roulette 45 {bets}")); assert!( response.starts_with("ROULETTE_RESULT"), "response: {response}" ); assert!( response.contains(" 220 45 1175 17 black "), "response: {response}" ); assert!(response.ends_with(&format!(" {bets}")), "response: {response}"); assert_eq!(core.balance("alice"), Some(1175)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); let conn = Connection::open(&path).unwrap(); let (game_type, details): (String, String) = conn .query_row( "SELECT game_type, details FROM plays WHERE username = 'alice'", [], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap(); assert_eq!(game_type, "roulette"); let details: serde_json::Value = serde_json::from_str(&details).unwrap(); assert_eq!(details["number"], "17"); assert_eq!(details["color"], "black"); let stored: Bets = serde_json::from_str(details["bets"].as_str().unwrap()).unwrap(); assert_eq!(stored.total(), 45); } #[test] fn a_failed_audit_insert_rolls_back_the_balance_change() { let (mut core, path) = setup("rollback", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let conn = Connection::open(&path).unwrap(); conn.execute_batch( "CREATE TRIGGER fail_plays BEFORE INSERT ON plays BEGIN SELECT RAISE(ABORT, 'boom'); END;", ) .unwrap(); assert_eq!( core.handle_message(&mut state, "PLAY_START slots 10"), "ERROR Failed to update balance" ); assert_eq!(core.balance("alice"), Some(1000)); assert_eq!(core.play_count("alice"), 0); assert_eq!(core.session_count(), 0); } #[test] fn spins_write_audit_rows_and_leave_no_sessions() { let (mut core, _path) = setup("audit", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); for _ in 0..3 { let response = core.handle_message(&mut state, "PLAY_START slots 10"); assert!( response.starts_with("GAME_RESULT"), "spin failed: {response}" ); assert_eq!(core.session_count(), 0); } assert_eq!(core.balance("alice"), Some(970)); assert_eq!(core.play_count("alice"), 3); } #[test] fn a_winning_spin_updates_the_balance_and_the_audit_row() { let (mut core, _path) = setup("win", WIN3); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let response = core.handle_message(&mut state, "PLAY_START slots 10"); assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}"); assert!(response.contains(" 10 10 1000 "), "response: {response}"); assert!(response.ends_with(" win 1 0,3,6"), "response: {response}"); assert_eq!(core.balance("alice"), Some(1000)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } #[test] fn insufficient_balance_does_not_write_an_audit_row() { let (mut core, path) = setup("insufficient", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let conn = Connection::open(&path).unwrap(); conn.execute("UPDATE users SET balance = 5 WHERE username = 'alice'", []) .unwrap(); assert_eq!( core.handle_message(&mut state, "PLAY_START slots 10"), "ERROR Insufficient balance" ); assert_eq!(core.balance("alice"), Some(5)); assert_eq!(core.play_count("alice"), 0); assert_eq!(core.session_count(), 0); } #[test] fn a_negative_balance_is_rejected_without_wrapping() { let (mut core, path) = setup("negative", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let conn = Connection::open(&path).unwrap(); conn.execute( "UPDATE users SET balance = -5 WHERE username = 'alice'", [], ) .unwrap(); assert_eq!( core.handle_message(&mut state, "PLAY_START slots 10"), "ERROR Invalid balance" ); assert_eq!(core.balance("alice"), Some(-5)); assert_eq!(core.play_count("alice"), 0); } #[test] fn unknown_games_are_rejected_before_a_session_is_created() { let (mut core, _path) = setup("unknown", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); assert_eq!( core.handle_message(&mut state, "PLAY_START nope 10"), "ERROR Unknown game type: nope" ); assert_eq!(core.session_count(), 0); } #[test] fn blackjack_reserves_the_bet_and_settles_a_hit_hand() { let (mut core, path) = setup_blackjack( "blackjack-hit", &["10S", "9D", "6H", "8C", "5D"], ); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let response = core.handle_message(&mut state, "PLAY_START blackjack 10"); let current = blackjack_state(&response); assert_eq!(current.phase, "player"); assert_eq!(current.player_total, 16); assert_eq!(current.dealer, vec!["9D".to_string(), "?".to_string()]); assert_eq!(current.balance, 990); assert_eq!(current.actions, vec!["hit", "stand", "double"]); assert_eq!(core.session_count(), 1); let response = core.handle_message( &mut state, &format!("BLACKJACK_ACTION {} hit", current.session_id), ); let done = blackjack_state(&response); assert_eq!(done.phase, "done"); assert_eq!(done.result, "win"); assert_eq!(done.winnings, 20); assert_eq!(done.balance, 1010); assert_eq!(core.balance("alice"), Some(1010)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); let conn = Connection::open(&path).unwrap(); let (game_type, details): (String, String) = conn .query_row( "SELECT game_type, details FROM plays WHERE username = 'alice'", [], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap(); assert_eq!(game_type, "blackjack"); let details: BlackjackState = serde_json::from_str(&details).unwrap(); assert_eq!(details.result, "win"); assert_eq!(details.winnings, 20); } #[test] fn blackjack_double_reserves_an_extra_bet() { let (mut core, _path) = setup_blackjack( "blackjack-double", &["5S", "9D", "5H", "8C", "10D"], ); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); assert_eq!(current.balance, 990); let done = blackjack_state(&core.handle_message( &mut state, &format!("BLACKJACK_ACTION {} double", current.session_id), )); assert_eq!(done.phase, "done"); assert_eq!(done.bet, 20); assert_eq!(done.winnings, 40); assert_eq!(done.balance, 1020); assert_eq!(core.balance("alice"), Some(1020)); assert_eq!(core.play_count("alice"), 1); } #[test] fn blackjack_natural_settles_immediately() { let (mut core, _path) = setup_blackjack("blackjack-natural", &["AS", "9D", "KH", "8C"]); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let done = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); assert_eq!(done.phase, "done"); assert_eq!(done.result, "blackjack"); assert_eq!(done.winnings, 25); assert_eq!(done.balance, 1015); assert_eq!(core.session_count(), 0); assert_eq!(core.play_count("alice"), 1); } #[test] fn blackjack_forfeit_keeps_the_reserved_bet() { let (mut core, _path) = setup_blackjack("blackjack-cancel", &["10S", "9D", "6H", "8C"]); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); assert_eq!(current.balance, 990); let forfeited = blackjack_state(&core.handle_message( &mut state, &format!("BLACKJACK_ACTION {} forfeit", current.session_id), )); assert_eq!(forfeited.result, "forfeit"); assert_eq!(forfeited.balance, 990); assert_eq!(core.balance("alice"), Some(990)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } #[test] fn blackjack_disconnect_forfeits_the_reserved_bet() { let (mut core, _path) = setup_blackjack("blackjack-disconnect", &["10S", "9D", "6H", "8C"]); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); assert_eq!(current.balance, 990); core.disconnect(&state); assert_eq!(core.balance("alice"), Some(990)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); }