diff --git a/.sqlx/query-765c87463905b2edbf37f7416d5bc38e639c7e4c5feb0f5a9d8d6fb724e7a0a8.json b/.sqlx/query-765c87463905b2edbf37f7416d5bc38e639c7e4c5feb0f5a9d8d6fb724e7a0a8.json new file mode 100644 index 0000000..9698123 --- /dev/null +++ b/.sqlx/query-765c87463905b2edbf37f7416d5bc38e639c7e4c5feb0f5a9d8d6fb724e7a0a8.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT leaderboard_opt_in FROM users WHERE id = ?", + "describe": { + "columns": [ + { + "name": "leaderboard_opt_in", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "765c87463905b2edbf37f7416d5bc38e639c7e4c5feb0f5a9d8d6fb724e7a0a8" +} diff --git a/.sqlx/query-a931c228c46dc06a5657d419cd5dfa5a810bbce9501845c92c6619d29806d70c.json b/.sqlx/query-a931c228c46dc06a5657d419cd5dfa5a810bbce9501845c92c6619d29806d70c.json new file mode 100644 index 0000000..64c7138 --- /dev/null +++ b/.sqlx/query-a931c228c46dc06a5657d419cd5dfa5a810bbce9501845c92c6619d29806d70c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE leaderboard\n SET best_score = MAX(COALESCE(best_score, 0), ?),\n best_time_secs = CASE\n WHEN ? IS NULL THEN best_time_secs\n WHEN best_time_secs IS NULL THEN ?\n ELSE MIN(best_time_secs, ?)\n END,\n recorded_at = ?\n WHERE user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "a931c228c46dc06a5657d419cd5dfa5a810bbce9501845c92c6619d29806d70c" +} diff --git a/solitaire_server/src/sync.rs b/solitaire_server/src/sync.rs index 98988ed..9730ea8 100644 --- a/solitaire_server/src/sync.rs +++ b/solitaire_server/src/sync.rs @@ -129,6 +129,10 @@ pub async fn pull( /// `POST /api/sync/push` — merge the client's payload with the server's /// stored payload, persist the result, and return it. +/// +/// If the user has opted in to the leaderboard, the leaderboard row is also +/// updated with the merged `best_single_score` and `fastest_win_seconds` so +/// scores stay in sync without a separate submission step. pub async fn push( State(pool): State, user: AuthenticatedUser, @@ -144,6 +148,7 @@ pub async fn push( None => { // First push — nothing to merge against; store directly. store_payload(&pool, &user.user_id, &client_payload).await?; + update_leaderboard_if_opted_in(&pool, &user.user_id, &client_payload).await?; return Ok(Json(SyncResponse { merged: client_payload, server_time: Utc::now(), @@ -155,6 +160,7 @@ pub async fn push( let (merged, conflicts) = merge(&client_payload, &server_payload); store_payload(&pool, &user.user_id, &merged).await?; + update_leaderboard_if_opted_in(&pool, &user.user_id, &merged).await?; Ok(Json(SyncResponse { merged, @@ -162,3 +168,55 @@ pub async fn push( conflicts, })) } + +/// If the user is opted in to the leaderboard, update their row with the +/// better of the stored and incoming `best_single_score` / `fastest_win_seconds`. +/// +/// Uses SQLite `MIN`/`MAX` in the UPDATE so the database never regresses +/// a score even if the client sends stale data. +async fn update_leaderboard_if_opted_in( + pool: &SqlitePool, + user_id: &str, + payload: &SyncPayload, +) -> Result<(), AppError> { + // Only update if the user has opted in (leaderboard row exists). + let opted_in: Option = sqlx::query_scalar!( + "SELECT leaderboard_opt_in FROM users WHERE id = ?", + user_id + ) + .fetch_optional(pool) + .await?; + + if opted_in != Some(1) { + return Ok(()); + } + + let best_score = payload.stats.best_single_score as i64; + let fastest = if payload.stats.fastest_win_seconds == u64::MAX { + // Sentinel "never won" value — don't store. + None:: + } else { + Some(payload.stats.fastest_win_seconds as i64) + }; + let now = Utc::now().to_rfc3339(); + + sqlx::query!( + r#"UPDATE leaderboard + SET best_score = MAX(COALESCE(best_score, 0), ?), + best_time_secs = CASE + WHEN ? IS NULL THEN best_time_secs + WHEN best_time_secs IS NULL THEN ? + ELSE MIN(best_time_secs, ?) + END, + recorded_at = ? + WHERE user_id = ?"#, + best_score, + fastest, fastest, fastest, + now, + user_id + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/solitaire_server/tests/server_tests.rs b/solitaire_server/tests/server_tests.rs index e1c4e67..1d86038 100644 --- a/solitaire_server/tests/server_tests.rs +++ b/solitaire_server/tests/server_tests.rs @@ -676,3 +676,103 @@ async fn opt_in_then_leaderboard_shows_entry() { .any(|e| e["display_name"] == "KarenTheGreat"); assert!(found, "opted-in user must appear in leaderboard"); } + +/// Pushing sync data after opting in updates the leaderboard best_score. +#[tokio::test] +async fn push_after_opt_in_updates_leaderboard_score() { + set_jwt_secret(); + let pool = test_pool().await; + let app = build_test_router(pool); + + let (access, _) = register_user(app.clone(), "scorer", "scorepass").await; + let user_id = decode_sub(&access); + + // Opt in. + post_authed( + app.clone(), + "/api/leaderboard/opt-in", + &access, + serde_json::json!({ "display_name": "Scorer" }), + ) + .await; + + // Build a payload with a known best_single_score. + let payload = SyncPayload { + user_id: uuid::Uuid::parse_str(&user_id).unwrap(), + stats: StatsSnapshot { + best_single_score: 3_500, + fastest_win_seconds: 180, + games_won: 1, + games_played: 1, + ..StatsSnapshot::default() + }, + achievements: vec![], + progress: PlayerProgress::default(), + last_modified: Utc::now(), + }; + + let push_resp = post_authed( + app.clone(), + "/api/sync/push", + &access, + serde_json::to_value(&payload).unwrap(), + ) + .await; + assert_eq!(push_resp.status(), StatusCode::OK, "push must return 200"); + + // Leaderboard should reflect the pushed score. + let lb_resp = get_authed(app, "/api/leaderboard", &access).await; + let body = body_json(lb_resp).await; + let entries = body.as_array().unwrap(); + let entry = entries.iter().find(|e| e["display_name"] == "Scorer").expect("entry missing"); + assert_eq!(entry["best_score"], 3_500, "best_score must be updated from push"); + assert_eq!(entry["best_time_secs"], 180, "best_time_secs must be updated from push"); +} + +/// Pushing a lower score after a higher one does not overwrite the best. +#[tokio::test] +async fn push_lower_score_does_not_overwrite_leaderboard_best() { + set_jwt_secret(); + let pool = test_pool().await; + let app = build_test_router(pool); + + let (access, _) = register_user(app.clone(), "champ", "champpass").await; + let user_id = decode_sub(&access); + + post_authed( + app.clone(), + "/api/leaderboard/opt-in", + &access, + serde_json::json!({ "display_name": "Champ" }), + ) + .await; + + let make = |score: u32, time: u64| SyncPayload { + user_id: uuid::Uuid::parse_str(&user_id).unwrap(), + stats: StatsSnapshot { + best_single_score: score, + fastest_win_seconds: time, + games_won: 1, + games_played: 1, + ..StatsSnapshot::default() + }, + achievements: vec![], + progress: PlayerProgress::default(), + last_modified: Utc::now(), + }; + + // First push: high score. + post_authed(app.clone(), "/api/sync/push", &access, + serde_json::to_value(make(5_000, 120)).unwrap()).await; + + // Second push: lower score and slower time. + post_authed(app.clone(), "/api/sync/push", &access, + serde_json::to_value(make(1_000, 600)).unwrap()).await; + + let lb_resp = get_authed(app, "/api/leaderboard", &access).await; + let body = body_json(lb_resp).await; + let entries = body.as_array().unwrap(); + let entry = entries.iter().find(|e| e["display_name"] == "Champ").unwrap(); + assert_eq!(entry["best_score"], 5_000, "best_score must not regress"); + assert_eq!(entry["best_time_secs"], 120, "best_time_secs must stay at fastest"); +}