feat(leaderboard): opt-out support — server endpoint, client method, UI button
- Server: DELETE /api/leaderboard/opt-in sets leaderboard_opt_in=0, hiding the player without deleting their row (scores preserved for re-opt-in) - SyncProvider trait: opt_out_leaderboard() default no-op method + blanket impl - SolitaireServerClient: implements opt_out_leaderboard via DELETE request with JWT refresh - Leaderboard UI: "Opt Out" button (dark red) alongside existing "Opt In" button - Server integration test: opt-out hides, opt-in restores (round-trip verified) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,25 @@ pub async fn get_leaderboard(
|
||||
Ok(Json(entries?))
|
||||
}
|
||||
|
||||
/// `DELETE /api/leaderboard/opt-in` — opt out, hiding the player's entry.
|
||||
///
|
||||
/// Sets `leaderboard_opt_in = 0` on the user row so the entry no longer
|
||||
/// appears in `GET /api/leaderboard`. The leaderboard row itself is kept
|
||||
/// so scores are preserved if the player opts back in later.
|
||||
pub async fn opt_out(
|
||||
State(pool): State<SqlitePool>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?",
|
||||
user.user_id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// `POST /api/leaderboard/opt-in` — opt in and upsert the player's entry.
|
||||
///
|
||||
/// Sets `leaderboard_opt_in = 1` on the user row and creates/updates the
|
||||
|
||||
@@ -44,6 +44,7 @@ fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
|
||||
.route("/api/sync/push", post(sync::push))
|
||||
.route("/api/leaderboard", get(leaderboard::get_leaderboard))
|
||||
.route("/api/leaderboard/opt-in", post(leaderboard::opt_in))
|
||||
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out))
|
||||
.route("/api/account", delete(auth::delete_account))
|
||||
.layer(axum_middleware::from_fn(middleware::require_auth));
|
||||
|
||||
|
||||
@@ -776,3 +776,58 @@ async fn push_lower_score_does_not_overwrite_leaderboard_best() {
|
||||
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");
|
||||
}
|
||||
|
||||
/// Opting out hides the player from the leaderboard; opting back in restores them.
|
||||
#[tokio::test]
|
||||
async fn opt_out_hides_then_opt_in_restores() {
|
||||
set_jwt_secret();
|
||||
let pool = test_pool().await;
|
||||
let app = build_test_router(pool);
|
||||
|
||||
let (access, _) = register_user(app.clone(), "visible", "pass1234").await;
|
||||
|
||||
// Opt in.
|
||||
let resp = post_authed(
|
||||
app.clone(),
|
||||
"/api/leaderboard/opt-in",
|
||||
&access,
|
||||
serde_json::json!({ "display_name": "Visible" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Verify they appear.
|
||||
let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
|
||||
let entries = body_json(lb).await;
|
||||
assert!(
|
||||
entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"),
|
||||
"opted-in user must appear"
|
||||
);
|
||||
|
||||
// Opt out.
|
||||
let resp = delete_authed(app.clone(), "/api/leaderboard/opt-in", &access).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Verify they are hidden.
|
||||
let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
|
||||
let entries = body_json(lb).await;
|
||||
assert!(
|
||||
!entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"),
|
||||
"opted-out user must be hidden"
|
||||
);
|
||||
|
||||
// Opt back in — should restore without losing display name.
|
||||
post_authed(
|
||||
app.clone(),
|
||||
"/api/leaderboard/opt-in",
|
||||
&access,
|
||||
serde_json::json!({ "display_name": "Visible" }),
|
||||
)
|
||||
.await;
|
||||
let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
|
||||
let entries = body_json(lb).await;
|
||||
assert!(
|
||||
entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"),
|
||||
"re-opted-in user must appear again"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user