diff --git a/tests/match_reversal.rs b/tests/match_reversal.rs new file mode 100644 index 0000000..36fded8 --- /dev/null +++ b/tests/match_reversal.rs @@ -0,0 +1,625 @@ +//! Match reversal/deletion tests +//! +//! Ensures that deleting matches correctly reverts ratings + +use sqlx::SqlitePool; +use sqlx::sqlite::SqlitePoolOptions; + +async fn setup_test_db() -> SqlitePool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("Failed to create test database"); + + sqlx::query(r#" + CREATE TABLE players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + email TEXT DEFAULT '', + singles_rating REAL DEFAULT 1500.0, + singles_rd REAL DEFAULT 350.0, + doubles_rating REAL DEFAULT 1500.0, + doubles_rd REAL DEFAULT 350.0, + created_at TEXT DEFAULT (datetime('now')) + ) + "#).execute(&pool).await.unwrap(); + + sqlx::query(r#" + CREATE TABLE matches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + match_type TEXT NOT NULL, + team1_score INTEGER NOT NULL, + team2_score INTEGER NOT NULL, + timestamp TEXT DEFAULT (datetime('now')), + session_id INTEGER + ) + "#).execute(&pool).await.unwrap(); + + sqlx::query(r#" + CREATE TABLE match_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + match_id INTEGER NOT NULL, + player_id INTEGER NOT NULL, + team INTEGER NOT NULL, + rating_before REAL, + rating_after REAL, + rating_change REAL, + FOREIGN KEY (match_id) REFERENCES matches(id), + FOREIGN KEY (player_id) REFERENCES players(id) + ) + "#).execute(&pool).await.unwrap(); + + pool +} + +#[tokio::test] +async fn test_singles_match_reversal() { + let pool = setup_test_db().await; + + // Create two players + sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)") + .bind("Reversal A") + .bind(1500.0) + .execute(&pool) + .await + .unwrap(); + + sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)") + .bind("Reversal B") + .bind(1500.0) + .execute(&pool) + .await + .unwrap(); + + let pa_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?") + .bind("Reversal A") + .fetch_one(&pool) + .await + .unwrap(); + + let pb_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?") + .bind("Reversal B") + .fetch_one(&pool) + .await + .unwrap(); + + // Record match with rating changes + let match_result = sqlx::query( + "INSERT INTO matches (match_type, team1_score, team2_score) VALUES (?, ?, ?)" + ) + .bind("singles") + .bind(11) + .bind(5) + .execute(&pool) + .await + .unwrap(); + + let match_id = match_result.last_insert_rowid(); + + let rating_change_a = 10.0; + let rating_change_b = -10.0; + + // Record participants with rating changes + sqlx::query( + "INSERT INTO match_participants (match_id, player_id, team, rating_before, rating_after, rating_change) VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(match_id) + .bind(pa_id.0) + .bind(1) + .bind(1500.0) + .bind(1510.0) + .bind(rating_change_a) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO match_participants (match_id, player_id, team, rating_before, rating_after, rating_change) VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(match_id) + .bind(pb_id.0) + .bind(2) + .bind(1500.0) + .bind(1490.0) + .bind(rating_change_b) + .execute(&pool) + .await + .unwrap(); + + // Apply rating changes + sqlx::query("UPDATE players SET singles_rating = singles_rating + ? WHERE id = ?") + .bind(rating_change_a) + .bind(pa_id.0) + .execute(&pool) + .await + .unwrap(); + + sqlx::query("UPDATE players SET singles_rating = singles_rating + ? WHERE id = ?") + .bind(rating_change_b) + .bind(pb_id.0) + .execute(&pool) + .await + .unwrap(); + + // Verify ratings changed + let rating_a: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?") + .bind(pa_id.0) + .fetch_one(&pool) + .await + .unwrap(); + assert!((rating_a.0 - 1510.0).abs() < 0.01); + + let rating_b: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?") + .bind(pb_id.0) + .fetch_one(&pool) + .await + .unwrap(); + assert!((rating_b.0 - 1490.0).abs() < 0.01); + + // NOW REVERSE THE MATCH + // Get rating changes to revert + let participants: Vec<(i64, f64)> = sqlx::query_as( + "SELECT player_id, rating_change FROM match_participants WHERE match_id = ?" + ) + .bind(match_id) + .fetch_all(&pool) + .await + .unwrap(); + + // Revert each player's rating + for (player_id, change) in participants { + sqlx::query("UPDATE players SET singles_rating = singles_rating - ? WHERE id = ?") + .bind(change) + .bind(player_id) + .execute(&pool) + .await + .unwrap(); + } + + // Delete match participants and match + sqlx::query("DELETE FROM match_participants WHERE match_id = ?") + .bind(match_id) + .execute(&pool) + .await + .unwrap(); + + sqlx::query("DELETE FROM matches WHERE id = ?") + .bind(match_id) + .execute(&pool) + .await + .unwrap(); + + // Verify ratings are back to original + let final_rating_a: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?") + .bind(pa_id.0) + .fetch_one(&pool) + .await + .unwrap(); + assert!((final_rating_a.0 - 1500.0).abs() < 0.01, + "Player A rating not reverted: {}", final_rating_a.0); + + let final_rating_b: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?") + .bind(pb_id.0) + .fetch_one(&pool) + .await + .unwrap(); + assert!((final_rating_b.0 - 1500.0).abs() < 0.01, + "Player B rating not reverted: {}", final_rating_b.0); +} + +#[tokio::test] +async fn test_doubles_match_reversal() { + let pool = setup_test_db().await; + + // Create four players + let players = [ + ("D1", 1500.0), ("D2", 1520.0), ("D3", 1480.0), ("D4", 1500.0) + ]; + + for (name, rating) in &players { + sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)") + .bind(*name) + .bind(*rating) + .execute(&pool) + .await + .unwrap(); + } + + let d1_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = 'D1'") + .fetch_one(&pool).await.unwrap(); + let d2_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = 'D2'") + .fetch_one(&pool).await.unwrap(); + let d3_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = 'D3'") + .fetch_one(&pool).await.unwrap(); + let d4_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = 'D4'") + .fetch_one(&pool).await.unwrap(); + + let d1_id = d1_id.0; + let d2_id = d2_id.0; + let d3_id = d3_id.0; + let d4_id = d4_id.0; + + // Record doubles match + let match_result = sqlx::query( + "INSERT INTO matches (match_type, team1_score, team2_score) VALUES (?, ?, ?)" + ) + .bind("doubles") + .bind(11) + .bind(8) + .execute(&pool) + .await + .unwrap(); + + let match_id = match_result.last_insert_rowid(); + + // Team 1 (D1 + D2) wins, Team 2 (D3 + D4) loses + // Each player has different rating change due to effective opponent + let changes = [ + (d1_id, 1, 8.0), + (d2_id, 1, 5.0), // Stronger player gains less + (d3_id, 2, -7.0), + (d4_id, 2, -6.0), + ]; + + for (player_id, team, change) in &changes { + let before = match *team { + 1 => if *player_id == d1_id { 1500.0 } else { 1520.0 }, + _ => if *player_id == d3_id { 1480.0 } else { 1500.0 }, + }; + + sqlx::query( + "INSERT INTO match_participants (match_id, player_id, team, rating_before, rating_after, rating_change) VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(match_id) + .bind(player_id) + .bind(team) + .bind(before) + .bind(before + change) + .bind(change) + .execute(&pool) + .await + .unwrap(); + + // Apply rating change + sqlx::query("UPDATE players SET singles_rating = singles_rating + ? WHERE id = ?") + .bind(change) + .bind(player_id) + .execute(&pool) + .await + .unwrap(); + } + + // Store original ratings for comparison + let original_ratings: Vec<(i64, f64)> = vec![ + (d1_id, 1500.0), (d2_id, 1520.0), (d3_id, 1480.0), (d4_id, 1500.0) + ]; + + // REVERSE THE MATCH + let participants: Vec<(i64, f64)> = sqlx::query_as( + "SELECT player_id, rating_change FROM match_participants WHERE match_id = ?" + ) + .bind(match_id) + .fetch_all(&pool) + .await + .unwrap(); + + for (player_id, change) in participants { + sqlx::query("UPDATE players SET singles_rating = singles_rating - ? WHERE id = ?") + .bind(change) + .bind(player_id) + .execute(&pool) + .await + .unwrap(); + } + + sqlx::query("DELETE FROM match_participants WHERE match_id = ?") + .bind(match_id) + .execute(&pool) + .await + .unwrap(); + + sqlx::query("DELETE FROM matches WHERE id = ?") + .bind(match_id) + .execute(&pool) + .await + .unwrap(); + + // Verify all ratings reverted + for (player_id, original_rating) in original_ratings { + let final_rating: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?") + .bind(player_id) + .fetch_one(&pool) + .await + .unwrap(); + + assert!((final_rating.0 - original_rating).abs() < 0.01, + "Player {} rating not reverted: {} (expected {})", + player_id, final_rating.0, original_rating); + } +} + +#[tokio::test] +async fn test_multiple_match_reversal() { + let pool = setup_test_db().await; + + sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)") + .bind("Multi A") + .bind(1500.0) + .execute(&pool) + .await + .unwrap(); + + sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)") + .bind("Multi B") + .bind(1500.0) + .execute(&pool) + .await + .unwrap(); + + let pa_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?") + .bind("Multi A") + .fetch_one(&pool) + .await + .unwrap(); + + let pb_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?") + .bind("Multi B") + .fetch_one(&pool) + .await + .unwrap(); + + // Record 5 matches + let mut match_ids = vec![]; + let mut changes_a = vec![]; + let mut changes_b = vec![]; + + for i in 0..5 { + let score1 = 11; + let score2 = 5 + i; + let change_a = 10.0 - i as f64; + let change_b = -(10.0 - i as f64); + + let match_result = sqlx::query( + "INSERT INTO matches (match_type, team1_score, team2_score) VALUES (?, ?, ?)" + ) + .bind("singles") + .bind(score1) + .bind(score2) + .execute(&pool) + .await + .unwrap(); + + let match_id = match_result.last_insert_rowid(); + match_ids.push(match_id); + changes_a.push(change_a); + changes_b.push(change_b); + + // Record participants + sqlx::query( + "INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)" + ) + .bind(match_id) + .bind(pa_id.0) + .bind(1) + .bind(change_a) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)" + ) + .bind(match_id) + .bind(pb_id.0) + .bind(2) + .bind(change_b) + .execute(&pool) + .await + .unwrap(); + + // Apply changes + sqlx::query("UPDATE players SET singles_rating = singles_rating + ? WHERE id = ?") + .bind(change_a) + .bind(pa_id.0) + .execute(&pool) + .await + .unwrap(); + + sqlx::query("UPDATE players SET singles_rating = singles_rating + ? WHERE id = ?") + .bind(change_b) + .bind(pb_id.0) + .execute(&pool) + .await + .unwrap(); + } + + // Verify ratings after 5 matches + // A gains: 10+9+8+7+6 = 40 + // B loses: -40 + let rating_a: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?") + .bind(pa_id.0) + .fetch_one(&pool) + .await + .unwrap(); + assert!((rating_a.0 - 1540.0).abs() < 0.01); + + // Delete matches in REVERSE order (LIFO - last in, first out) + for match_id in match_ids.iter().rev() { + let participants: Vec<(i64, f64)> = sqlx::query_as( + "SELECT player_id, rating_change FROM match_participants WHERE match_id = ?" + ) + .bind(match_id) + .fetch_all(&pool) + .await + .unwrap(); + + for (player_id, change) in participants { + sqlx::query("UPDATE players SET singles_rating = singles_rating - ? WHERE id = ?") + .bind(change) + .bind(player_id) + .execute(&pool) + .await + .unwrap(); + } + + sqlx::query("DELETE FROM match_participants WHERE match_id = ?") + .bind(match_id) + .execute(&pool) + .await + .unwrap(); + + sqlx::query("DELETE FROM matches WHERE id = ?") + .bind(match_id) + .execute(&pool) + .await + .unwrap(); + } + + // Verify both players back to 1500 + let final_a: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?") + .bind(pa_id.0) + .fetch_one(&pool) + .await + .unwrap(); + assert!((final_a.0 - 1500.0).abs() < 0.01); + + let final_b: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?") + .bind(pb_id.0) + .fetch_one(&pool) + .await + .unwrap(); + assert!((final_b.0 - 1500.0).abs() < 0.01); +} + +#[tokio::test] +async fn test_reversal_preserves_other_matches() { + let pool = setup_test_db().await; + + // Create 3 players: A plays both B and C + for (name, rating) in [("Preserve A", 1500.0), ("Preserve B", 1500.0), ("Preserve C", 1500.0)] { + sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)") + .bind(name) + .bind(rating) + .execute(&pool) + .await + .unwrap(); + } + + let pa_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?") + .bind("Preserve A") + .fetch_one(&pool) + .await + .unwrap(); + + let pb_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?") + .bind("Preserve B") + .fetch_one(&pool) + .await + .unwrap(); + + let pc_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?") + .bind("Preserve C") + .fetch_one(&pool) + .await + .unwrap(); + + // Match 1: A vs B (A wins) + let match1 = sqlx::query("INSERT INTO matches (match_type, team1_score, team2_score) VALUES (?, ?, ?)") + .bind("singles").bind(11).bind(5) + .execute(&pool).await.unwrap(); + let match1_id = match1.last_insert_rowid(); + + sqlx::query("INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)") + .bind(match1_id).bind(pa_id.0).bind(1).bind(10.0) + .execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)") + .bind(match1_id).bind(pb_id.0).bind(2).bind(-10.0) + .execute(&pool).await.unwrap(); + + sqlx::query("UPDATE players SET singles_rating = singles_rating + 10 WHERE id = ?") + .bind(pa_id.0).execute(&pool).await.unwrap(); + sqlx::query("UPDATE players SET singles_rating = singles_rating - 10 WHERE id = ?") + .bind(pb_id.0).execute(&pool).await.unwrap(); + + // Match 2: A vs C (A wins) + let match2 = sqlx::query("INSERT INTO matches (match_type, team1_score, team2_score) VALUES (?, ?, ?)") + .bind("singles").bind(11).bind(7) + .execute(&pool).await.unwrap(); + let match2_id = match2.last_insert_rowid(); + + sqlx::query("INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)") + .bind(match2_id).bind(pa_id.0).bind(1).bind(8.0) + .execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)") + .bind(match2_id).bind(pc_id.0).bind(2).bind(-8.0) + .execute(&pool).await.unwrap(); + + sqlx::query("UPDATE players SET singles_rating = singles_rating + 8 WHERE id = ?") + .bind(pa_id.0).execute(&pool).await.unwrap(); + sqlx::query("UPDATE players SET singles_rating = singles_rating - 8 WHERE id = ?") + .bind(pc_id.0).execute(&pool).await.unwrap(); + + // After both matches: A=1518, B=1490, C=1492 + + // Delete ONLY match 1 (A vs B) + let participants: Vec<(i64, f64)> = sqlx::query_as( + "SELECT player_id, rating_change FROM match_participants WHERE match_id = ?" + ) + .bind(match1_id) + .fetch_all(&pool) + .await + .unwrap(); + + for (player_id, change) in participants { + sqlx::query("UPDATE players SET singles_rating = singles_rating - ? WHERE id = ?") + .bind(change) + .bind(player_id) + .execute(&pool) + .await + .unwrap(); + } + + sqlx::query("DELETE FROM match_participants WHERE match_id = ?") + .bind(match1_id) + .execute(&pool) + .await + .unwrap(); + + sqlx::query("DELETE FROM matches WHERE id = ?") + .bind(match1_id) + .execute(&pool) + .await + .unwrap(); + + // After reverting match 1: A=1508, B=1500, C=1492 + // Match 2's effect on A and C should be preserved + + let final_a: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?") + .bind(pa_id.0) + .fetch_one(&pool) + .await + .unwrap(); + assert!((final_a.0 - 1508.0).abs() < 0.01, "A rating wrong: {}", final_a.0); + + let final_b: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?") + .bind(pb_id.0) + .fetch_one(&pool) + .await + .unwrap(); + assert!((final_b.0 - 1500.0).abs() < 0.01, "B rating wrong: {}", final_b.0); + + let final_c: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?") + .bind(pc_id.0) + .fetch_one(&pool) + .await + .unwrap(); + assert!((final_c.0 - 1492.0).abs() < 0.01, "C rating wrong: {}", final_c.0); + + // Match 2 should still exist + let match_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM matches") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(match_count.0, 1); +}