diff --git a/solitaire_core/src/achievement.rs b/solitaire_core/src/achievement.rs index d1f9dd8..9ee4345 100644 --- a/solitaire_core/src/achievement.rs +++ b/solitaire_core/src/achievement.rs @@ -476,4 +476,105 @@ mod tests { assert_eq!(achievement_by_id("first_win").map(|d| d.name), Some("First Win")); assert!(achievement_by_id("nonexistent").is_none()); } + + #[test] + fn on_a_roll_requires_streak_of_3() { + let mut c = ctx(); + c.win_streak_current = 2; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"on_a_roll")); + + c.win_streak_current = 3; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"on_a_roll")); + } + + #[test] + fn unstoppable_requires_streak_of_10() { + let mut c = ctx(); + c.win_streak_current = 9; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"unstoppable")); + assert!(ids.contains(&"on_a_roll"), "streak 9 must still satisfy on_a_roll"); + + c.win_streak_current = 10; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"unstoppable")); + assert!(ids.contains(&"on_a_roll"), "streak 10 must also satisfy on_a_roll"); + } + + #[test] + fn century_requires_100_games_played() { + let mut c = ctx(); + c.games_played = 99; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"century")); + + c.games_played = 100; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"century")); + } + + #[test] + fn veteran_requires_500_games_played() { + let mut c = ctx(); + c.games_played = 499; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"veteran")); + assert!(ids.contains(&"century"), "499 games must also satisfy century"); + + c.games_played = 500; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"veteran")); + assert!(ids.contains(&"century"), "500 games must also satisfy century"); + } + + #[test] + fn high_scorer_requires_best_single_score_of_5000() { + let mut c = ctx(); + c.best_single_score = 4_999; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"high_scorer")); + + c.best_single_score = 5_000; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"high_scorer")); + } + + #[test] + fn point_machine_requires_50000_lifetime_score() { + let mut c = ctx(); + c.lifetime_score = 49_999; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"point_machine")); + + c.lifetime_score = 50_000; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"point_machine")); + } + + #[test] + fn draw_three_master_requires_10_draw_three_wins() { + let mut c = ctx(); + c.draw_three_wins = 9; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"draw_three_master")); + + c.draw_three_wins = 10; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"draw_three_master")); + } + + #[test] + fn speed_demon_boundary_at_180_seconds() { + let mut c = ctx(); + c.games_won = 1; + c.last_win_time_seconds = 179; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"speed_demon")); + + c.last_win_time_seconds = 180; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"speed_demon")); + } } diff --git a/solitaire_server/src/leaderboard.rs b/solitaire_server/src/leaderboard.rs index ff95c5f..66879f0 100644 --- a/solitaire_server/src/leaderboard.rs +++ b/solitaire_server/src/leaderboard.rs @@ -119,7 +119,7 @@ pub async fn opt_in( if display_name.is_empty() { return Err(AppError::BadRequest("display_name must not be empty".into())); } - if display_name.len() > DISPLAY_NAME_MAX { + if display_name.chars().count() > DISPLAY_NAME_MAX { return Err(AppError::BadRequest(format!( "display_name must be at most {DISPLAY_NAME_MAX} characters" ))); diff --git a/solitaire_server/tests/server_tests.rs b/solitaire_server/tests/server_tests.rs index 0fcb04a..8c2178d 100644 --- a/solitaire_server/tests/server_tests.rs +++ b/solitaire_server/tests/server_tests.rs @@ -874,6 +874,118 @@ async fn opt_out_hides_then_opt_in_restores() { ); } +/// Opting in with an empty display name returns 400. +#[tokio::test] +async fn opt_in_empty_display_name_returns_400() { + set_jwt_secret(); + let app = build_test_router(test_pool().await); + let (access, _) = register_user(app.clone(), "empty_name", "pass1234").await; + + let resp = post_authed( + app, + "/api/leaderboard/opt-in", + &access, + serde_json::json!({ "display_name": " " }), + ) + .await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "whitespace-only display_name must return 400" + ); +} + +/// Opting in with a display name longer than 32 characters returns 400. +#[tokio::test] +async fn opt_in_too_long_display_name_returns_400() { + set_jwt_secret(); + let app = build_test_router(test_pool().await); + let (access, _) = register_user(app.clone(), "long_name", "pass1234").await; + + let long_name = "a".repeat(33); + let resp = post_authed( + app, + "/api/leaderboard/opt-in", + &access, + serde_json::json!({ "display_name": long_name }), + ) + .await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "33-char display_name must return 400" + ); +} + +/// Exactly 32 ASCII characters is accepted. +#[tokio::test] +async fn opt_in_exactly_32_char_display_name_succeeds() { + set_jwt_secret(); + let app = build_test_router(test_pool().await); + let (access, _) = register_user(app.clone(), "maxname", "pass1234").await; + + let name = "a".repeat(32); + let resp = post_authed( + app, + "/api/leaderboard/opt-in", + &access, + serde_json::json!({ "display_name": name }), + ) + .await; + assert_eq!( + resp.status(), + StatusCode::OK, + "32-char display_name must be accepted" + ); +} + +/// A display name consisting of 32 Unicode emoji (multi-byte chars) must be +/// accepted — the limit is character count, not byte count. +#[tokio::test] +async fn opt_in_32_unicode_chars_display_name_succeeds() { + set_jwt_secret(); + let app = build_test_router(test_pool().await); + let (access, _) = register_user(app.clone(), "unicode_name", "pass1234").await; + + // 32 emoji — each is 4 bytes, so 128 bytes total. + // A byte-length check would incorrectly reject this. + let name = "🎉".repeat(32); + let resp = post_authed( + app, + "/api/leaderboard/opt-in", + &access, + serde_json::json!({ "display_name": name }), + ) + .await; + assert_eq!( + resp.status(), + StatusCode::OK, + "32-emoji display_name (32 chars, 128 bytes) must be accepted" + ); +} + +/// A display name with 33 Unicode emoji is rejected. +#[tokio::test] +async fn opt_in_33_unicode_chars_display_name_returns_400() { + set_jwt_secret(); + let app = build_test_router(test_pool().await); + let (access, _) = register_user(app.clone(), "unicode_long", "pass1234").await; + + let name = "🎉".repeat(33); + let resp = post_authed( + app, + "/api/leaderboard/opt-in", + &access, + serde_json::json!({ "display_name": name }), + ) + .await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "33-emoji display_name must return 400" + ); +} + /// Login with leading/trailing whitespace in the username still succeeds. #[tokio::test] async fn login_trims_whitespace_from_username() {