test(core,sync,server): add EmptySource, ConflictReport, and roundtrip coverage
- core/game_state.rs: move_from_empty_pile_returns_empty_source covers the EmptySource error path in move_cards() that had no test - sync/merge.rs: four new tests verifying ConflictReport field/value content for win_streak_current and daily_challenge_streak divergence, plus negative cases asserting no report is generated when values are equal - server/tests: register_login_push_pull_full_roundtrip drives the full register → login → push → pull sequence through the test router, confirming that a login-derived JWT can push stats and retrieve them unchanged Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,10 @@
|
|||||||
//!
|
//!
|
||||||
//! # JWT secret
|
//! # JWT secret
|
||||||
//!
|
//!
|
||||||
//! Each test calls [`set_jwt_secret`] before touching any endpoint that reads
|
//! [`build_test_router`] injects a fixed test secret into [`AppState`] so
|
||||||
//! `JWT_SECRET` from the environment. This is safe because `cargo test` runs
|
//! tests do not need to set `JWT_SECRET` in the environment. The constant
|
||||||
//! integration-test binaries single-threaded by default.
|
//! [`TEST_SECRET`] must match the value used by [`build_test_router`] so that
|
||||||
|
//! test-side token decoding works correctly.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
@@ -28,7 +29,9 @@ use tower::ServiceExt;
|
|||||||
// Constants
|
// Constants
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// The JWT secret injected into the environment for all tests.
|
/// JWT secret used by [`build_test_router`] and by test-side token decoding.
|
||||||
|
///
|
||||||
|
/// Must match the value hardcoded in [`solitaire_server::build_test_router`].
|
||||||
const TEST_SECRET: &str = "test_secret_32_chars_minimum_ok!";
|
const TEST_SECRET: &str = "test_secret_32_chars_minimum_ok!";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -53,15 +56,6 @@ async fn test_pool() -> SqlitePool {
|
|||||||
pool
|
pool
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inject `JWT_SECRET` into the process environment so all auth code can read it.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// Only called from test code where tests run sequentially in a single binary.
|
|
||||||
fn set_jwt_secret() {
|
|
||||||
// SAFETY: test-only; integration test binaries are single-threaded.
|
|
||||||
unsafe { std::env::set_var("JWT_SECRET", TEST_SECRET) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fake client IP injected by all test requests so `tower_governor`'s
|
/// Fake client IP injected by all test requests so `tower_governor`'s
|
||||||
/// `SmartIpKeyExtractor` can extract a key without a real peer address.
|
/// `SmartIpKeyExtractor` can extract a key without a real peer address.
|
||||||
const TEST_CLIENT_IP: &str = "127.0.0.1";
|
const TEST_CLIENT_IP: &str = "127.0.0.1";
|
||||||
@@ -202,7 +196,7 @@ fn make_payload(user_id_str: &str, games_played: u32) -> SyncPayload {
|
|||||||
/// `POST /api/auth/register` must return 200 with both tokens.
|
/// `POST /api/auth/register` must return 200 with both tokens.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn register_creates_account_and_returns_tokens() {
|
async fn register_creates_account_and_returns_tokens() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let resp = post_json(
|
let resp = post_json(
|
||||||
@@ -227,7 +221,7 @@ async fn register_creates_account_and_returns_tokens() {
|
|||||||
/// Registering the same username twice must return 409 Conflict on the second attempt.
|
/// Registering the same username twice must return 409 Conflict on the second attempt.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn register_duplicate_username_returns_conflict() {
|
async fn register_duplicate_username_returns_conflict() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let creds = serde_json::json!({ "username": "bob", "password": "s3cr3t!!" });
|
let creds = serde_json::json!({ "username": "bob", "password": "s3cr3t!!" });
|
||||||
|
|
||||||
@@ -247,7 +241,7 @@ async fn register_duplicate_username_returns_conflict() {
|
|||||||
/// Short username (< 3 chars) is rejected with 400.
|
/// Short username (< 3 chars) is rejected with 400.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn register_rejects_short_username() {
|
async fn register_rejects_short_username() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let resp = post_json(
|
let resp = post_json(
|
||||||
app,
|
app,
|
||||||
@@ -261,7 +255,7 @@ async fn register_rejects_short_username() {
|
|||||||
/// Username with disallowed characters is rejected with 400.
|
/// Username with disallowed characters is rejected with 400.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn register_rejects_invalid_username_chars() {
|
async fn register_rejects_invalid_username_chars() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let resp = post_json(
|
let resp = post_json(
|
||||||
app,
|
app,
|
||||||
@@ -275,7 +269,7 @@ async fn register_rejects_invalid_username_chars() {
|
|||||||
/// Password shorter than 8 characters is rejected with 400.
|
/// Password shorter than 8 characters is rejected with 400.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn register_rejects_short_password() {
|
async fn register_rejects_short_password() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let resp = post_json(
|
let resp = post_json(
|
||||||
app,
|
app,
|
||||||
@@ -289,7 +283,7 @@ async fn register_rejects_short_password() {
|
|||||||
/// `POST /api/auth/login` with correct credentials returns 200 with both tokens.
|
/// `POST /api/auth/login` with correct credentials returns 200 with both tokens.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_with_correct_credentials_returns_tokens() {
|
async fn login_with_correct_credentials_returns_tokens() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
// Register first.
|
// Register first.
|
||||||
@@ -312,7 +306,7 @@ async fn login_with_correct_credentials_returns_tokens() {
|
|||||||
/// `POST /api/auth/login` with a wrong password must return 401.
|
/// `POST /api/auth/login` with a wrong password must return 401.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_with_wrong_password_returns_401() {
|
async fn login_with_wrong_password_returns_401() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
// Register a user.
|
// Register a user.
|
||||||
@@ -336,7 +330,7 @@ async fn login_with_wrong_password_returns_401() {
|
|||||||
/// `POST /api/auth/login` for a username that does not exist must return 401.
|
/// `POST /api/auth/login` for a username that does not exist must return 401.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_with_unknown_username_returns_401() {
|
async fn login_with_unknown_username_returns_401() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let resp = post_json(
|
let resp = post_json(
|
||||||
@@ -356,7 +350,7 @@ async fn login_with_unknown_username_returns_401() {
|
|||||||
/// `POST /api/auth/refresh` with a valid refresh token returns 200 with a new access token.
|
/// `POST /api/auth/refresh` with a valid refresh token returns 200 with a new access token.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn refresh_returns_new_access_token() {
|
async fn refresh_returns_new_access_token() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (_access, refresh) = register_user(app.clone(), "eve", "refresh_me").await;
|
let (_access, refresh) = register_user(app.clone(), "eve", "refresh_me").await;
|
||||||
@@ -380,7 +374,7 @@ async fn refresh_returns_new_access_token() {
|
|||||||
/// the `kind` claim will be `"access"`, not `"refresh"`.
|
/// the `kind` claim will be `"access"`, not `"refresh"`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn refresh_with_access_token_returns_401() {
|
async fn refresh_with_access_token_returns_401() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _refresh) = register_user(app.clone(), "frank", "bad_refresh").await;
|
let (access, _refresh) = register_user(app.clone(), "frank", "bad_refresh").await;
|
||||||
@@ -407,7 +401,7 @@ async fn refresh_with_access_token_returns_401() {
|
|||||||
/// Push a payload, then pull — the pulled data must reflect the pushed values.
|
/// Push a payload, then pull — the pulled data must reflect the pushed values.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn push_then_pull_returns_pushed_data() {
|
async fn push_then_pull_returns_pushed_data() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "grace", "sync_pass").await;
|
let (access, _) = register_user(app.clone(), "grace", "sync_pass").await;
|
||||||
@@ -436,10 +430,127 @@ async fn push_then_pull_returns_pushed_data() {
|
|||||||
assert_eq!(games_played, 7, "pulled games_played must match pushed value");
|
assert_eq!(games_played, 7, "pulled games_played must match pushed value");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full register → login → push → pull integration roundtrip.
|
||||||
|
///
|
||||||
|
/// This test drives every auth and sync endpoint in sequence to verify that
|
||||||
|
/// the complete happy-path flow works end-to-end with a fresh in-memory
|
||||||
|
/// database:
|
||||||
|
/// 1. Register a new user — extracts the access token from the response.
|
||||||
|
/// 2. Login with the same credentials — obtains a fresh access token from
|
||||||
|
/// the login endpoint (not reusing the registration token).
|
||||||
|
/// 3. Push a `SyncPayload` with known stats via `POST /api/sync/push`.
|
||||||
|
/// 4. Pull via `GET /api/sync/pull` and assert the pulled payload reflects
|
||||||
|
/// the pushed values.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_login_push_pull_full_roundtrip() {
|
||||||
|
|
||||||
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
|
// --- Step 1: Register ---
|
||||||
|
let reg_resp = post_json(
|
||||||
|
app.clone(),
|
||||||
|
"/api/auth/register",
|
||||||
|
serde_json::json!({ "username": "roundtrip_user", "password": "roundtrip_pass" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
reg_resp.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"registration must return 200"
|
||||||
|
);
|
||||||
|
let reg_body = body_json(reg_resp).await;
|
||||||
|
assert!(
|
||||||
|
reg_body["access_token"].is_string(),
|
||||||
|
"register must return an access_token"
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Step 2: Login (explicit — do not reuse the registration token) ---
|
||||||
|
let login_resp = post_json(
|
||||||
|
app.clone(),
|
||||||
|
"/api/auth/login",
|
||||||
|
serde_json::json!({ "username": "roundtrip_user", "password": "roundtrip_pass" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
login_resp.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"login must return 200"
|
||||||
|
);
|
||||||
|
let login_body = body_json(login_resp).await;
|
||||||
|
let access_token = login_body["access_token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("login must return access_token")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Decode the user UUID from the login JWT so we can construct the payload.
|
||||||
|
let user_id = decode_sub(&access_token);
|
||||||
|
|
||||||
|
// --- Step 3: Push a payload with known values ---
|
||||||
|
let payload = SyncPayload {
|
||||||
|
user_id: uuid::Uuid::parse_str(&user_id)
|
||||||
|
.expect("JWT sub must be a valid UUID"),
|
||||||
|
stats: StatsSnapshot {
|
||||||
|
games_played: 42,
|
||||||
|
games_won: 17,
|
||||||
|
best_single_score: 4_200,
|
||||||
|
fastest_win_seconds: 95,
|
||||||
|
..StatsSnapshot::default()
|
||||||
|
},
|
||||||
|
achievements: vec![],
|
||||||
|
progress: PlayerProgress::default(),
|
||||||
|
last_modified: chrono::Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let push_resp = post_authed(
|
||||||
|
app.clone(),
|
||||||
|
"/api/sync/push",
|
||||||
|
&access_token,
|
||||||
|
serde_json::to_value(&payload).expect("SyncPayload must serialise"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
push_resp.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"push must return 200"
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Step 4: Pull and verify the stored data matches what was pushed ---
|
||||||
|
let pull_resp = get_authed(app, "/api/sync/pull", &access_token).await;
|
||||||
|
assert_eq!(
|
||||||
|
pull_resp.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"pull must return 200"
|
||||||
|
);
|
||||||
|
|
||||||
|
let pull_body = body_json(pull_resp).await;
|
||||||
|
let merged = &pull_body["merged"];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
merged["stats"]["games_played"].as_u64(),
|
||||||
|
Some(42),
|
||||||
|
"pulled games_played must match the pushed value"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
merged["stats"]["games_won"].as_u64(),
|
||||||
|
Some(17),
|
||||||
|
"pulled games_won must match the pushed value"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
merged["stats"]["best_single_score"].as_u64(),
|
||||||
|
Some(4_200),
|
||||||
|
"pulled best_single_score must match the pushed value"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
merged["stats"]["fastest_win_seconds"].as_u64(),
|
||||||
|
Some(95),
|
||||||
|
"pulled fastest_win_seconds must match the pushed value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Pushing a payload whose `user_id` does not match the JWT `sub` must return 400.
|
/// Pushing a payload whose `user_id` does not match the JWT `sub` must return 400.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn push_with_wrong_user_id_returns_400() {
|
async fn push_with_wrong_user_id_returns_400() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "heidi", "sync_pass").await;
|
let (access, _) = register_user(app.clone(), "heidi", "sync_pass").await;
|
||||||
@@ -472,7 +583,7 @@ async fn push_with_wrong_user_id_returns_400() {
|
|||||||
/// A pull before any push returns a default empty payload (200, not 404).
|
/// A pull before any push returns a default empty payload (200, not 404).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pull_before_push_returns_default_payload() {
|
async fn pull_before_push_returns_default_payload() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "ivan", "nopush!!").await;
|
let (access, _) = register_user(app.clone(), "ivan", "nopush!!").await;
|
||||||
@@ -490,7 +601,7 @@ async fn pull_before_push_returns_default_payload() {
|
|||||||
/// Accessing `/api/sync/pull` without a token must return 401.
|
/// Accessing `/api/sync/pull` without a token must return 401.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pull_without_token_returns_401() {
|
async fn pull_without_token_returns_401() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
@@ -517,7 +628,7 @@ async fn pull_without_token_returns_401() {
|
|||||||
/// return 200.
|
/// return 200.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delete_account_succeeds_and_data_is_gone() {
|
async fn delete_account_succeeds_and_data_is_gone() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "judy", "delete_me").await;
|
let (access, _) = register_user(app.clone(), "judy", "delete_me").await;
|
||||||
@@ -570,7 +681,7 @@ async fn delete_account_succeeds_and_data_is_gone() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn health_returns_ok() {
|
async fn health_returns_ok() {
|
||||||
// No JWT needed; set it anyway for consistency.
|
// No JWT needed; set it anyway for consistency.
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
@@ -596,7 +707,7 @@ async fn health_returns_ok() {
|
|||||||
/// `GET /api/daily-challenge` must return 200 with today's UTC date.
|
/// `GET /api/daily-challenge` must return 200 with today's UTC date.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn daily_challenge_returns_goal_for_today() {
|
async fn daily_challenge_returns_goal_for_today() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let today = Utc::now().format("%Y-%m-%d").to_string();
|
let today = Utc::now().format("%Y-%m-%d").to_string();
|
||||||
@@ -625,7 +736,7 @@ async fn daily_challenge_returns_goal_for_today() {
|
|||||||
/// Calling `GET /api/daily-challenge` twice returns the same seed (deterministic).
|
/// Calling `GET /api/daily-challenge` twice returns the same seed (deterministic).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn daily_challenge_is_deterministic() {
|
async fn daily_challenge_is_deterministic() {
|
||||||
set_jwt_secret();
|
|
||||||
// Use the same pool so the second call hits the stored row.
|
// Use the same pool so the second call hits the stored row.
|
||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
|
|
||||||
@@ -668,7 +779,7 @@ async fn daily_challenge_is_deterministic() {
|
|||||||
/// `GET /api/leaderboard` requires authentication — no token returns 401.
|
/// `GET /api/leaderboard` requires authentication — no token returns 401.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn leaderboard_without_token_returns_401() {
|
async fn leaderboard_without_token_returns_401() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
@@ -688,7 +799,7 @@ async fn leaderboard_without_token_returns_401() {
|
|||||||
/// Opting in and then fetching the leaderboard returns the opted-in entry.
|
/// Opting in and then fetching the leaderboard returns the opted-in entry.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn opt_in_then_leaderboard_shows_entry() {
|
async fn opt_in_then_leaderboard_shows_entry() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "karen", "leaderpass").await;
|
let (access, _) = register_user(app.clone(), "karen", "leaderpass").await;
|
||||||
@@ -722,7 +833,7 @@ async fn opt_in_then_leaderboard_shows_entry() {
|
|||||||
/// Pushing sync data after opting in updates the leaderboard best_score.
|
/// Pushing sync data after opting in updates the leaderboard best_score.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn push_after_opt_in_updates_leaderboard_score() {
|
async fn push_after_opt_in_updates_leaderboard_score() {
|
||||||
set_jwt_secret();
|
|
||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
let app = build_test_router(pool);
|
let app = build_test_router(pool);
|
||||||
|
|
||||||
@@ -774,7 +885,7 @@ async fn push_after_opt_in_updates_leaderboard_score() {
|
|||||||
/// Pushing a lower score after a higher one does not overwrite the best.
|
/// Pushing a lower score after a higher one does not overwrite the best.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn push_lower_score_does_not_overwrite_leaderboard_best() {
|
async fn push_lower_score_does_not_overwrite_leaderboard_best() {
|
||||||
set_jwt_secret();
|
|
||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
let app = build_test_router(pool);
|
let app = build_test_router(pool);
|
||||||
|
|
||||||
@@ -822,7 +933,7 @@ async fn push_lower_score_does_not_overwrite_leaderboard_best() {
|
|||||||
/// Opting out hides the player from the leaderboard; opting back in restores them.
|
/// Opting out hides the player from the leaderboard; opting back in restores them.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn opt_out_hides_then_opt_in_restores() {
|
async fn opt_out_hides_then_opt_in_restores() {
|
||||||
set_jwt_secret();
|
|
||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
let app = build_test_router(pool);
|
let app = build_test_router(pool);
|
||||||
|
|
||||||
@@ -877,7 +988,7 @@ async fn opt_out_hides_then_opt_in_restores() {
|
|||||||
/// Opting in with an empty display name returns 400.
|
/// Opting in with an empty display name returns 400.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn opt_in_empty_display_name_returns_400() {
|
async fn opt_in_empty_display_name_returns_400() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let (access, _) = register_user(app.clone(), "empty_name", "pass1234").await;
|
let (access, _) = register_user(app.clone(), "empty_name", "pass1234").await;
|
||||||
|
|
||||||
@@ -898,7 +1009,7 @@ async fn opt_in_empty_display_name_returns_400() {
|
|||||||
/// Opting in with a display name longer than 32 characters returns 400.
|
/// Opting in with a display name longer than 32 characters returns 400.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn opt_in_too_long_display_name_returns_400() {
|
async fn opt_in_too_long_display_name_returns_400() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let (access, _) = register_user(app.clone(), "long_name", "pass1234").await;
|
let (access, _) = register_user(app.clone(), "long_name", "pass1234").await;
|
||||||
|
|
||||||
@@ -920,7 +1031,7 @@ async fn opt_in_too_long_display_name_returns_400() {
|
|||||||
/// Exactly 32 ASCII characters is accepted.
|
/// Exactly 32 ASCII characters is accepted.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn opt_in_exactly_32_char_display_name_succeeds() {
|
async fn opt_in_exactly_32_char_display_name_succeeds() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let (access, _) = register_user(app.clone(), "maxname", "pass1234").await;
|
let (access, _) = register_user(app.clone(), "maxname", "pass1234").await;
|
||||||
|
|
||||||
@@ -943,7 +1054,7 @@ async fn opt_in_exactly_32_char_display_name_succeeds() {
|
|||||||
/// accepted — the limit is character count, not byte count.
|
/// accepted — the limit is character count, not byte count.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn opt_in_32_unicode_chars_display_name_succeeds() {
|
async fn opt_in_32_unicode_chars_display_name_succeeds() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let (access, _) = register_user(app.clone(), "unicode_name", "pass1234").await;
|
let (access, _) = register_user(app.clone(), "unicode_name", "pass1234").await;
|
||||||
|
|
||||||
@@ -967,7 +1078,7 @@ async fn opt_in_32_unicode_chars_display_name_succeeds() {
|
|||||||
/// A display name with 33 Unicode emoji is rejected.
|
/// A display name with 33 Unicode emoji is rejected.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn opt_in_33_unicode_chars_display_name_returns_400() {
|
async fn opt_in_33_unicode_chars_display_name_returns_400() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let (access, _) = register_user(app.clone(), "unicode_long", "pass1234").await;
|
let (access, _) = register_user(app.clone(), "unicode_long", "pass1234").await;
|
||||||
|
|
||||||
@@ -990,7 +1101,7 @@ async fn opt_in_33_unicode_chars_display_name_returns_400() {
|
|||||||
/// the server merges (max wins) rather than blindly replacing.
|
/// the server merges (max wins) rather than blindly replacing.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn second_push_with_lower_stats_preserves_higher_stored_values() {
|
async fn second_push_with_lower_stats_preserves_higher_stored_values() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "merge_test", "merge_pass").await;
|
let (access, _) = register_user(app.clone(), "merge_test", "merge_pass").await;
|
||||||
@@ -1033,7 +1144,7 @@ async fn second_push_with_lower_stats_preserves_higher_stored_values() {
|
|||||||
/// Login with leading/trailing whitespace in the username still succeeds.
|
/// Login with leading/trailing whitespace in the username still succeeds.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_trims_whitespace_from_username() {
|
async fn login_trims_whitespace_from_username() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let _ = register_user(app.clone(), "trimtest", "password1!").await;
|
let _ = register_user(app.clone(), "trimtest", "password1!").await;
|
||||||
@@ -1060,7 +1171,7 @@ async fn login_trims_whitespace_from_username() {
|
|||||||
/// `POST /api/sync/push` with a body exceeding the 1 MB limit must return 413.
|
/// `POST /api/sync/push` with a body exceeding the 1 MB limit must return 413.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn push_oversized_body_returns_413() {
|
async fn push_oversized_body_returns_413() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "sizetest", "password1!").await;
|
let (access, _) = register_user(app.clone(), "sizetest", "password1!").await;
|
||||||
@@ -1090,7 +1201,7 @@ async fn push_oversized_body_returns_413() {
|
|||||||
/// A JWT whose `exp` is in the past must be rejected with 401 on protected routes.
|
/// A JWT whose `exp` is in the past must be rejected with 401 on protected routes.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn expired_access_token_returns_401() {
|
async fn expired_access_token_returns_401() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
// Craft a token that expired 2 hours ago — well past jsonwebtoken's 60 s leeway.
|
// Craft a token that expired 2 hours ago — well past jsonwebtoken's 60 s leeway.
|
||||||
@@ -1123,7 +1234,7 @@ async fn expired_access_token_returns_401() {
|
|||||||
/// A refresh token must be rejected when used as a Bearer token on protected routes.
|
/// A refresh token must be rejected when used as a Bearer token on protected routes.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn refresh_token_rejected_on_protected_routes() {
|
async fn refresh_token_rejected_on_protected_routes() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (_, refresh) = register_user(app.clone(), "kindtest", "password1!").await;
|
let (_, refresh) = register_user(app.clone(), "kindtest", "password1!").await;
|
||||||
|
|||||||
@@ -643,6 +643,95 @@ mod tests {
|
|||||||
assert_eq!(merged.progress.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
|
assert_eq!(merged.progress.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// ConflictReport field population
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conflict_report_win_streak_current_contains_correct_field_and_values() {
|
||||||
|
// Verify that the ConflictReport for win_streak_current carries the exact
|
||||||
|
// field name and the string representations of the diverging values.
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.stats.win_streak_current = 7;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.stats.win_streak_current = 2;
|
||||||
|
|
||||||
|
let (_, conflicts) = merge(&local, &remote);
|
||||||
|
|
||||||
|
let report = conflicts
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.field == "win_streak_current")
|
||||||
|
.expect("ConflictReport for win_streak_current must be present");
|
||||||
|
assert_eq!(
|
||||||
|
report.local_value, "7",
|
||||||
|
"local_value in ConflictReport must be the local streak as a string"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
report.remote_value, "2",
|
||||||
|
"remote_value in ConflictReport must be the remote streak as a string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conflict_report_daily_challenge_streak_contains_correct_field_and_values() {
|
||||||
|
// daily_challenge_streak divergence must also produce a ConflictReport with
|
||||||
|
// the correct field name and human-readable values.
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.progress.daily_challenge_streak = 10;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.progress.daily_challenge_streak = 4;
|
||||||
|
|
||||||
|
let (merged, conflicts) = merge(&local, &remote);
|
||||||
|
|
||||||
|
let report = conflicts
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.field == "daily_challenge_streak")
|
||||||
|
.expect("ConflictReport for daily_challenge_streak must be present");
|
||||||
|
assert_eq!(
|
||||||
|
report.local_value, "10",
|
||||||
|
"local_value must equal the local streak string"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
report.remote_value, "4",
|
||||||
|
"remote_value must equal the remote streak string"
|
||||||
|
);
|
||||||
|
// Best-effort resolution: the higher value is retained.
|
||||||
|
assert_eq!(
|
||||||
|
merged.progress.daily_challenge_streak, 10,
|
||||||
|
"merged streak must take the higher value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_conflict_report_when_win_streak_current_is_equal() {
|
||||||
|
// Identical win_streak_current must not generate any ConflictReport.
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.stats.win_streak_current = 5;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.stats.win_streak_current = 5;
|
||||||
|
|
||||||
|
let (_, conflicts) = merge(&local, &remote);
|
||||||
|
assert!(
|
||||||
|
!conflicts.iter().any(|c| c.field == "win_streak_current"),
|
||||||
|
"equal streaks must produce no conflict"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_conflict_report_when_daily_challenge_streak_is_equal() {
|
||||||
|
// Identical daily_challenge_streak must not generate any ConflictReport.
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.progress.daily_challenge_streak = 3;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.progress.daily_challenge_streak = 3;
|
||||||
|
|
||||||
|
let (_, conflicts) = merge(&local, &remote);
|
||||||
|
assert!(
|
||||||
|
!conflicts.iter().any(|c| c.field == "daily_challenge_streak"),
|
||||||
|
"equal daily challenge streaks must produce no conflict"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fastest_win_both_max_sentinel_stays_max() {
|
fn fastest_win_both_max_sentinel_stays_max() {
|
||||||
// Both sides have u64::MAX (no wins recorded on either) — result must remain MAX,
|
// Both sides have u64::MAX (no wins recorded on either) — result must remain MAX,
|
||||||
|
|||||||
Reference in New Issue
Block a user