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
|
||||
//!
|
||||
//! Each test calls [`set_jwt_secret`] before touching any endpoint that reads
|
||||
//! `JWT_SECRET` from the environment. This is safe because `cargo test` runs
|
||||
//! integration-test binaries single-threaded by default.
|
||||
//! [`build_test_router`] injects a fixed test secret into [`AppState`] so
|
||||
//! tests do not need to set `JWT_SECRET` in the environment. The constant
|
||||
//! [`TEST_SECRET`] must match the value used by [`build_test_router`] so that
|
||||
//! test-side token decoding works correctly.
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
@@ -28,7 +29,9 @@ use tower::ServiceExt;
|
||||
// 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!";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -53,15 +56,6 @@ async fn test_pool() -> SqlitePool {
|
||||
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
|
||||
/// `SmartIpKeyExtractor` can extract a key without a real peer address.
|
||||
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.
|
||||
#[tokio::test]
|
||||
async fn register_creates_account_and_returns_tokens() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
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.
|
||||
#[tokio::test]
|
||||
async fn register_duplicate_username_returns_conflict() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
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.
|
||||
#[tokio::test]
|
||||
async fn register_rejects_short_username() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
let resp = post_json(
|
||||
app,
|
||||
@@ -261,7 +255,7 @@ async fn register_rejects_short_username() {
|
||||
/// Username with disallowed characters is rejected with 400.
|
||||
#[tokio::test]
|
||||
async fn register_rejects_invalid_username_chars() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
let resp = post_json(
|
||||
app,
|
||||
@@ -275,7 +269,7 @@ async fn register_rejects_invalid_username_chars() {
|
||||
/// Password shorter than 8 characters is rejected with 400.
|
||||
#[tokio::test]
|
||||
async fn register_rejects_short_password() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
let resp = post_json(
|
||||
app,
|
||||
@@ -289,7 +283,7 @@ async fn register_rejects_short_password() {
|
||||
/// `POST /api/auth/login` with correct credentials returns 200 with both tokens.
|
||||
#[tokio::test]
|
||||
async fn login_with_correct_credentials_returns_tokens() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
// 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.
|
||||
#[tokio::test]
|
||||
async fn login_with_wrong_password_returns_401() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
// 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.
|
||||
#[tokio::test]
|
||||
async fn login_with_unknown_username_returns_401() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
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.
|
||||
#[tokio::test]
|
||||
async fn refresh_returns_new_access_token() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().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"`.
|
||||
#[tokio::test]
|
||||
async fn refresh_with_access_token_returns_401() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().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.
|
||||
#[tokio::test]
|
||||
async fn push_then_pull_returns_pushed_data() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().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");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[tokio::test]
|
||||
async fn push_with_wrong_user_id_returns_400() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().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).
|
||||
#[tokio::test]
|
||||
async fn pull_before_push_returns_default_payload() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().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.
|
||||
#[tokio::test]
|
||||
async fn pull_without_token_returns_401() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let req = Request::builder()
|
||||
@@ -517,7 +628,7 @@ async fn pull_without_token_returns_401() {
|
||||
/// return 200.
|
||||
#[tokio::test]
|
||||
async fn delete_account_succeeds_and_data_is_gone() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().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]
|
||||
async fn health_returns_ok() {
|
||||
// No JWT needed; set it anyway for consistency.
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
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.
|
||||
#[tokio::test]
|
||||
async fn daily_challenge_returns_goal_for_today() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
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).
|
||||
#[tokio::test]
|
||||
async fn daily_challenge_is_deterministic() {
|
||||
set_jwt_secret();
|
||||
|
||||
// Use the same pool so the second call hits the stored row.
|
||||
let pool = test_pool().await;
|
||||
|
||||
@@ -668,7 +779,7 @@ async fn daily_challenge_is_deterministic() {
|
||||
/// `GET /api/leaderboard` requires authentication — no token returns 401.
|
||||
#[tokio::test]
|
||||
async fn leaderboard_without_token_returns_401() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
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.
|
||||
#[tokio::test]
|
||||
async fn opt_in_then_leaderboard_shows_entry() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().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.
|
||||
#[tokio::test]
|
||||
async fn push_after_opt_in_updates_leaderboard_score() {
|
||||
set_jwt_secret();
|
||||
|
||||
let pool = test_pool().await;
|
||||
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.
|
||||
#[tokio::test]
|
||||
async fn push_lower_score_does_not_overwrite_leaderboard_best() {
|
||||
set_jwt_secret();
|
||||
|
||||
let pool = test_pool().await;
|
||||
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.
|
||||
#[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);
|
||||
|
||||
@@ -877,7 +988,7 @@ 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;
|
||||
|
||||
@@ -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.
|
||||
#[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;
|
||||
|
||||
@@ -920,7 +1031,7 @@ async fn opt_in_too_long_display_name_returns_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;
|
||||
|
||||
@@ -943,7 +1054,7 @@ async fn opt_in_exactly_32_char_display_name_succeeds() {
|
||||
/// 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;
|
||||
|
||||
@@ -967,7 +1078,7 @@ async fn opt_in_32_unicode_chars_display_name_succeeds() {
|
||||
/// 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;
|
||||
|
||||
@@ -990,7 +1101,7 @@ async fn opt_in_33_unicode_chars_display_name_returns_400() {
|
||||
/// the server merges (max wins) rather than blindly replacing.
|
||||
#[tokio::test]
|
||||
async fn second_push_with_lower_stats_preserves_higher_stored_values() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().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.
|
||||
#[tokio::test]
|
||||
async fn login_trims_whitespace_from_username() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().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.
|
||||
#[tokio::test]
|
||||
async fn push_oversized_body_returns_413() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().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.
|
||||
#[tokio::test]
|
||||
async fn expired_access_token_returns_401() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
// 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.
|
||||
#[tokio::test]
|
||||
async fn refresh_token_rejected_on_protected_routes() {
|
||||
set_jwt_secret();
|
||||
|
||||
let app = build_test_router(test_pool().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));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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]
|
||||
fn fastest_win_both_max_sentinel_stays_max() {
|
||||
// Both sides have u64::MAX (no wins recorded on either) — result must remain MAX,
|
||||
|
||||
Reference in New Issue
Block a user