Files
Ferrous-Solitaire/solitaire_server/src/leaderboard.rs
T
funman300 9bfca929cb chore(workspace): satisfy clippy --all-targets in test code
Five test-only lints surfaced by --all-targets were blocking CI under
-D warnings: a useless vec! in a leaderboard sort test, a
field_reassign_with_default in tuning tests, and three
assertions_on_constants in card_plugin sanity tests. The constant
assertions are now wrapped in const blocks so they run at compile time;
the runtime-formatted values were dropped from their messages because
const-block assert messages must be string literals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 19:54:40 +00:00

223 lines
7.9 KiB
Rust

//! Leaderboard endpoints.
//!
//! `GET /api/leaderboard` — list all opted-in entries (requires auth).
//! `POST /api/leaderboard/opt-in` — opt in and set / update display name.
use axum::{extract::State, Json};
use chrono::Utc;
use serde::Deserialize;
use solitaire_sync::LeaderboardEntry;
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
// ---------------------------------------------------------------------------
// Request shapes
// ---------------------------------------------------------------------------
/// Body for `POST /api/leaderboard/opt-in`.
#[derive(Debug, Deserialize)]
pub struct OptInRequest {
/// The display name the player wants shown on the leaderboard.
pub display_name: String,
}
// ---------------------------------------------------------------------------
// Database row helper
// ---------------------------------------------------------------------------
struct LeaderboardRow {
display_name: Option<String>,
best_score: Option<i64>,
best_time_secs: Option<i64>,
recorded_at: Option<String>,
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// `GET /api/leaderboard` — return all opted-in leaderboard entries.
///
/// Returns entries sorted by `best_score` descending (nulls last).
pub async fn get_leaderboard(
State(state): State<AppState>,
_user: AuthenticatedUser,
) -> Result<Json<Vec<LeaderboardEntry>>, AppError> {
let rows = sqlx::query_as!(
LeaderboardRow,
r#"SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at
FROM leaderboard l
JOIN users u ON u.id = l.user_id
WHERE u.leaderboard_opt_in = 1
ORDER BY
CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,
l.best_score DESC,
CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,
l.best_time_secs ASC"#
)
.fetch_all(&state.pool)
.await?;
let entries: Result<Vec<LeaderboardEntry>, AppError> = rows
.into_iter()
.map(|r| -> Result<LeaderboardEntry, AppError> {
let display_name = r
.display_name
.ok_or_else(|| AppError::Internal("missing display_name".into()))?;
let recorded_at_str = r
.recorded_at
.ok_or_else(|| AppError::Internal("missing recorded_at".into()))?;
let recorded_at = recorded_at_str
.parse::<chrono::DateTime<Utc>>()
.map_err(|e| AppError::Internal(format!("invalid recorded_at: {e}")))?;
Ok(LeaderboardEntry {
display_name,
best_score: r.best_score.map(|v| v as i32),
best_time_secs: r.best_time_secs.map(|v| v as u64),
recorded_at,
})
})
.collect();
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(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<serde_json::Value>, AppError> {
sqlx::query!(
"UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?",
user.user_id
)
.execute(&state.pool)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
/// Maximum allowed character count for a leaderboard display name (32 chars).
/// Keeps names readable in the leaderboard UI while allowing reasonable creativity.
const DISPLAY_NAME_MAX: usize = 32;
/// `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
/// leaderboard entry with the supplied display name.
pub async fn opt_in(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(body): Json<OptInRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let display_name = body.display_name.trim();
if display_name.is_empty() {
return Err(AppError::BadRequest("display_name must not be empty".into()));
}
if display_name.chars().count() > DISPLAY_NAME_MAX {
return Err(AppError::BadRequest(format!(
"display_name must be at most {DISPLAY_NAME_MAX} characters"
)));
}
let display_name = display_name.to_string();
// Mark the user as opted in.
sqlx::query!(
"UPDATE users SET leaderboard_opt_in = 1 WHERE id = ?",
user.user_id
)
.execute(&state.pool)
.await?;
let now = Utc::now().to_rfc3339();
// Upsert leaderboard row (preserve best_score / best_time if already present).
sqlx::query!(
r#"INSERT INTO leaderboard (user_id, display_name, recorded_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
display_name = excluded.display_name,
recorded_at = excluded.recorded_at"#,
user.user_id,
display_name,
now
)
.execute(&state.pool)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
// ---------------------------------------------------------------------------
// Tests — data shape and display-name logic; no database required
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use chrono::Utc;
use solitaire_sync::LeaderboardEntry;
/// Helper that constructs a `LeaderboardEntry` with the given display name
/// and best score. `best_time_secs` is left as `None`.
fn entry(display_name: &str, best_score: Option<i32>) -> LeaderboardEntry {
LeaderboardEntry {
display_name: display_name.to_string(),
best_score,
best_time_secs: None,
recorded_at: Utc::now(),
}
}
// -----------------------------------------------------------------------
// 1. A LeaderboardEntry always carries a non-empty display_name.
// -----------------------------------------------------------------------
#[test]
fn leaderboard_entry_has_display_name() {
let e = entry("Alice", Some(4_500));
assert!(
!e.display_name.is_empty(),
"display_name must not be empty for a valid leaderboard entry"
);
assert_eq!(e.display_name, "Alice");
}
// -----------------------------------------------------------------------
// 2. A Vec of entries sorts by best_score descending (matching the SQL
// ORDER BY used in get_leaderboard).
// -----------------------------------------------------------------------
#[test]
fn leaderboard_entries_sorted_by_score_descending() {
let mut entries = [
entry("Charlie", Some(1_200)),
entry("Alice", Some(8_000)),
entry("Bob", Some(3_500)),
entry("Dave", None), // no score — should rank last
];
// Mirrors the SQL sort:
// CASE WHEN best_score IS NULL THEN 1 ELSE 0 END ASC,
// best_score DESC
entries.sort_by(|a, b| {
let a_null = a.best_score.is_none() as u8;
let b_null = b.best_score.is_none() as u8;
a_null
.cmp(&b_null)
.then_with(|| b.best_score.cmp(&a.best_score))
});
// Scored entries first, in descending order.
assert_eq!(entries[0].display_name, "Alice", "highest scorer must be first");
assert_eq!(entries[1].display_name, "Bob", "second-highest scorer must be second");
assert_eq!(entries[2].display_name, "Charlie", "lowest scorer must be third");
// Null-score entry sinks to the bottom.
assert_eq!(entries[3].display_name, "Dave", "entry with no score must rank last");
}
}