Compare commits
5 Commits
0e7a34d6bf
...
fe23e89971
| Author | SHA1 | Date | |
|---|---|---|---|
| fe23e89971 | |||
| 34f60e048a | |||
| 87fe51a0d0 | |||
| 0318480ba7 | |||
| adacc40592 |
@@ -386,6 +386,14 @@ mod tests {
|
||||
assert_eq!(acc, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_elapsed_saturates_at_u64_max() {
|
||||
let mut elapsed = u64::MAX;
|
||||
let mut acc = 0.0;
|
||||
advance_elapsed(&mut elapsed, &mut acc, 5.0, false);
|
||||
assert_eq!(elapsed, u64::MAX, "elapsed must not overflow past u64::MAX");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_elapsed_handles_subsecond_deltas_without_skipping() {
|
||||
let mut elapsed = 0;
|
||||
|
||||
@@ -244,4 +244,51 @@ mod tests {
|
||||
types.dedup();
|
||||
assert_eq!(types.len(), 13);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pure-function tests (no Bevy app required)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn all_three_themes_produce_distinct_colours() {
|
||||
let green = theme_colour(&Theme::Green);
|
||||
let blue = theme_colour(&Theme::Blue);
|
||||
let dark = theme_colour(&Theme::Dark);
|
||||
assert_ne!(green, blue, "Green and Blue must differ");
|
||||
assert_ne!(green, dark, "Green and Dark must differ");
|
||||
assert_ne!(blue, dark, "Blue and Dark must differ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_background_index_0_matches_theme_colour() {
|
||||
for theme in [Theme::Green, Theme::Blue, Theme::Dark] {
|
||||
let expected = theme_colour(&theme);
|
||||
let actual = effective_background_colour(&theme, 0);
|
||||
assert_eq!(
|
||||
expected, actual,
|
||||
"index 0 must always return the theme colour for {:?}",
|
||||
theme
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_background_indices_1_through_3_are_distinct_from_theme() {
|
||||
// Non-zero indices override the theme with a fixed colour.
|
||||
let theme_green = theme_colour(&Theme::Green);
|
||||
for idx in 1..=3 {
|
||||
let eff = effective_background_colour(&Theme::Green, idx);
|
||||
assert_ne!(eff, theme_green, "index {idx} must override the theme colour");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_background_index_4_falls_through_to_charcoal() {
|
||||
// All indices ≥ 4 share the same charcoal fallback.
|
||||
let c4 = effective_background_colour(&Theme::Green, 4);
|
||||
let c5 = effective_background_colour(&Theme::Green, 5);
|
||||
let c99 = effective_background_colour(&Theme::Green, 99);
|
||||
assert_eq!(c4, c5, "indices 4 and 5 must share the charcoal fallback");
|
||||
assert_eq!(c4, c99, "index 99 must share the charcoal fallback");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,3 +227,38 @@ pub async fn delete_account(
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn username_chars_ok_accepts_alphanumeric_and_underscore() {
|
||||
assert!(username_chars_ok("alice"));
|
||||
assert!(username_chars_ok("Alice_123"));
|
||||
assert!(username_chars_ok("UPPER_case_99"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_chars_ok_rejects_special_chars() {
|
||||
assert!(!username_chars_ok("ali ce")); // space
|
||||
assert!(!username_chars_ok("ali-ce")); // hyphen
|
||||
assert!(!username_chars_ok("ali.ce")); // dot
|
||||
assert!(!username_chars_ok("ali@ce")); // at
|
||||
assert!(!username_chars_ok("ali!ce")); // exclamation
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_chars_ok_accepts_empty_string() {
|
||||
// The length check in `register` guards against empty usernames;
|
||||
// this function only validates characters, so empty is technically ok.
|
||||
assert!(username_chars_ok(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_chars_ok_rejects_unicode_letters() {
|
||||
// Non-ASCII characters must be rejected even if they look like letters.
|
||||
assert!(!username_chars_ok("héro"));
|
||||
assert!(!username_chars_ok("用户"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,128 @@ pub fn validate_refresh_token(token: &str, secret: &str) -> Result<Claims, AppEr
|
||||
// Axum extractor — allows handlers to receive AuthenticatedUser directly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
use chrono::Utc;
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
|
||||
const SECRET: &str = "test_secret_for_middleware_unit_tests_only";
|
||||
|
||||
fn make_token(user_id: &str, kind: &str, exp_offset_secs: i64) -> String {
|
||||
let exp = (Utc::now() + chrono::Duration::seconds(exp_offset_secs)).timestamp() as usize;
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
exp,
|
||||
kind: kind.to_string(),
|
||||
};
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(SECRET.as_bytes())).unwrap()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// extract_bearer_token
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn extract_bearer_token_returns_token_from_valid_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"Authorization",
|
||||
HeaderValue::from_static("Bearer my.jwt.token"),
|
||||
);
|
||||
assert_eq!(extract_bearer_token(&headers), Some("my.jwt.token".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_bearer_token_returns_none_when_header_missing() {
|
||||
let headers = HeaderMap::new();
|
||||
assert_eq!(extract_bearer_token(&headers), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_bearer_token_returns_none_for_wrong_prefix() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"Authorization",
|
||||
HeaderValue::from_static("Token my.jwt.token"),
|
||||
);
|
||||
assert_eq!(extract_bearer_token(&headers), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_bearer_token_returns_none_for_empty_value() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("Authorization", HeaderValue::from_static(""));
|
||||
assert_eq!(extract_bearer_token(&headers), None);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// validate_access_token
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn validate_access_token_accepts_valid_access_token() {
|
||||
let token = make_token("user-abc", "access", 3600);
|
||||
let claims = validate_access_token(&token, SECRET).expect("should accept valid access token");
|
||||
assert_eq!(claims.sub, "user-abc");
|
||||
assert_eq!(claims.kind, "access");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_access_token_rejects_refresh_token() {
|
||||
let token = make_token("user-abc", "refresh", 3600);
|
||||
let result = validate_access_token(&token, SECRET);
|
||||
assert!(result.is_err(), "refresh token must be rejected by access validator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_access_token_rejects_expired_token() {
|
||||
// Use -7200 (2 hours past) to exceed jsonwebtoken's default 60-second leeway.
|
||||
let token = make_token("user-abc", "access", -7200);
|
||||
let result = validate_access_token(&token, SECRET);
|
||||
assert!(result.is_err(), "expired token must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_access_token_rejects_wrong_secret() {
|
||||
let token = make_token("user-abc", "access", 3600);
|
||||
let result = validate_access_token(&token, "wrong_secret");
|
||||
assert!(result.is_err(), "token signed with different secret must be rejected");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// validate_refresh_token
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn validate_refresh_token_accepts_valid_refresh_token() {
|
||||
let token = make_token("user-xyz", "refresh", 86400);
|
||||
let claims = validate_refresh_token(&token, SECRET).expect("should accept valid refresh token");
|
||||
assert_eq!(claims.sub, "user-xyz");
|
||||
assert_eq!(claims.kind, "refresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_refresh_token_rejects_access_token() {
|
||||
let token = make_token("user-xyz", "access", 86400);
|
||||
let result = validate_refresh_token(&token, SECRET);
|
||||
assert!(result.is_err(), "access token must be rejected by refresh validator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_refresh_token_rejects_expired_token() {
|
||||
// Use -7200 (2 hours past) to exceed jsonwebtoken's default 60-second leeway.
|
||||
let token = make_token("user-xyz", "refresh", -7200);
|
||||
let result = validate_refresh_token(&token, SECRET);
|
||||
assert!(result.is_err(), "expired refresh token must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||
where
|
||||
|
||||
@@ -130,3 +130,197 @@ impl PlayerProgress {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// level_for_xp
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn level_zero_at_zero_xp() {
|
||||
assert_eq!(level_for_xp(0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_one_at_500_xp() {
|
||||
assert_eq!(level_for_xp(500), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_nine_at_4500_xp() {
|
||||
assert_eq!(level_for_xp(4_500), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_ten_at_5000_xp() {
|
||||
assert_eq!(level_for_xp(5_000), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_eleven_at_6000_xp() {
|
||||
assert_eq!(level_for_xp(6_000), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_scales_correctly_above_ten() {
|
||||
// Level 10 + floor((7000 - 5000) / 1000) = 10 + 2 = 12
|
||||
assert_eq!(level_for_xp(7_000), 12);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// add_xp
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn add_xp_increases_total_xp() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.add_xp(300);
|
||||
assert_eq!(p.total_xp, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_xp_returns_previous_level() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.add_xp(400); // still level 0
|
||||
let prev = p.add_xp(200); // crosses into level 1
|
||||
assert_eq!(prev, 0, "returned level should be the pre-call level");
|
||||
assert_eq!(p.level, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_xp_saturates_on_overflow() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.total_xp = u64::MAX;
|
||||
p.add_xp(1);
|
||||
assert_eq!(p.total_xp, u64::MAX);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// leveled_up_from
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn leveled_up_from_returns_true_when_level_increased() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.add_xp(600); // reaches level 1
|
||||
assert!(p.leveled_up_from(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leveled_up_from_returns_false_when_same_level() {
|
||||
let p = PlayerProgress::default();
|
||||
assert!(!p.leveled_up_from(0));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// roll_weekly_goals_if_new_week
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn roll_weekly_goals_clears_progress_for_new_week() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.weekly_goal_week_iso = Some("2026-W16".to_string());
|
||||
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 3);
|
||||
|
||||
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
|
||||
assert!(rolled);
|
||||
assert!(p.weekly_goal_progress.is_empty());
|
||||
assert_eq!(p.weekly_goal_week_iso, Some("2026-W17".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roll_weekly_goals_is_noop_for_same_week() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.weekly_goal_week_iso = Some("2026-W17".to_string());
|
||||
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 2);
|
||||
|
||||
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
|
||||
assert!(!rolled);
|
||||
assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&2));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// record_weekly_progress
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn record_weekly_progress_increments_counter() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.roll_weekly_goals_if_new_week("2026-W17");
|
||||
let done = p.record_weekly_progress("weekly_5_wins", 5);
|
||||
assert!(!done, "1/5 should not be done");
|
||||
assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_weekly_progress_returns_true_on_completion() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.roll_weekly_goals_if_new_week("2026-W17");
|
||||
for _ in 0..4 {
|
||||
p.record_weekly_progress("weekly_5_wins", 5);
|
||||
}
|
||||
let done = p.record_weekly_progress("weekly_5_wins", 5);
|
||||
assert!(done, "5th increment should complete the goal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_weekly_progress_does_not_exceed_target() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.roll_weekly_goals_if_new_week("2026-W17");
|
||||
for _ in 0..10 {
|
||||
p.record_weekly_progress("weekly_5_wins", 5);
|
||||
}
|
||||
// Counter must be capped at target — never go above.
|
||||
assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&5));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// record_daily_completion
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn record_daily_completion_starts_streak_at_one() {
|
||||
let mut p = PlayerProgress::default();
|
||||
let recorded = p.record_daily_completion(date(2026, 4, 20));
|
||||
assert!(recorded);
|
||||
assert_eq!(p.daily_challenge_streak, 1);
|
||||
assert_eq!(p.daily_challenge_last_completed, Some(date(2026, 4, 20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_daily_completion_same_day_is_noop() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.record_daily_completion(date(2026, 4, 20));
|
||||
let recorded = p.record_daily_completion(date(2026, 4, 20));
|
||||
assert!(!recorded);
|
||||
assert_eq!(p.daily_challenge_streak, 1, "streak must not double-count same day");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_daily_completion_consecutive_days_extend_streak() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.record_daily_completion(date(2026, 4, 20));
|
||||
p.record_daily_completion(date(2026, 4, 21));
|
||||
assert_eq!(p.daily_challenge_streak, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_daily_completion_gap_resets_streak_to_one() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.record_daily_completion(date(2026, 4, 20));
|
||||
p.record_daily_completion(date(2026, 4, 22)); // skip the 21st
|
||||
assert_eq!(p.daily_challenge_streak, 1, "gap must reset streak");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,4 +123,31 @@ mod tests {
|
||||
let s = StatsSnapshot::default();
|
||||
assert_eq!(s.fastest_win_seconds, u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_abandoned_increments_played_and_lost() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.record_abandoned();
|
||||
assert_eq!(s.games_played, 1);
|
||||
assert_eq!(s.games_lost, 1);
|
||||
assert_eq!(s.games_won, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_abandoned_resets_win_streak() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.win_streak_current = 5;
|
||||
s.record_abandoned();
|
||||
assert_eq!(s.win_streak_current, 0, "abandoned game must break the win streak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_abandoned_preserves_best_streak() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.win_streak_best = 7;
|
||||
s.win_streak_current = 7;
|
||||
s.record_abandoned();
|
||||
assert_eq!(s.win_streak_best, 7, "best streak must not be reduced on abandon");
|
||||
assert_eq!(s.win_streak_current, 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user