From 198df75f94ad72063668f57dc3381689991c3d35 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 12 May 2026 14:04:26 -0700 Subject: [PATCH] test(data): add push retry-on-401 integration test + server test pool helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds push_retries_after_401_on_expired_access_token to sync_round_trip.rs, closing the push-side coverage gap alongside the existing pull test (jwt_refresh_on_401_succeeds). Both tests use an expired-but-validly-signed access token to trigger the 401 → refresh → retry path in SolitaireServerClient. Also exposes build_test_pool() from solitaire_server so downstream crates can boot a test server without duplicating the migration boilerplate. Co-Authored-By: Claude Sonnet 4.6 --- solitaire_data/tests/sync_round_trip.rs | 53 +++++++++++++++++++++++++ solitaire_server/src/lib.rs | 22 ++++++++++ 2 files changed, 75 insertions(+) diff --git a/solitaire_data/tests/sync_round_trip.rs b/solitaire_data/tests/sync_round_trip.rs index 5abc2d5..cb253bd 100644 --- a/solitaire_data/tests/sync_round_trip.rs +++ b/solitaire_data/tests/sync_round_trip.rs @@ -418,3 +418,56 @@ async fn pull_after_account_deletion_returns_default_or_error() { let _ = delete_tokens(username); } + +/// **Push retry on 401.** +/// +/// Mirrors `jwt_refresh_on_401_succeeds` but for the `push()` path. +/// We install an expired access token so the first push attempt returns 401, +/// the client refreshes, and the retry push succeeds. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn push_retries_after_401_on_expired_access_token() { + ensure_mock_keyring(); + + let base = spawn_test_server().await; + let username = "rt_push_expiring"; + + let (_real_access, real_refresh) = + register_user_raw(&base, username, "pushexpirepass1!").await; + let user_id = decode_sub(&_real_access); + + #[derive(serde::Serialize)] + struct Claims { + sub: String, + exp: usize, + kind: String, + } + let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize; + let expired_access = encode( + &Header::default(), + &Claims { + sub: user_id.clone(), + exp, + kind: "access".into(), + }, + &EncodingKey::from_secret(TEST_SECRET.as_bytes()), + ) + .expect("failed to encode expired access token"); + + store_tokens(username, &expired_access, &real_refresh) + .expect("storing tokens in mock keyring must succeed"); + + let client = SolitaireServerClient::new(&base, username); + let payload = make_payload(&user_id, 17); + + // Push: server returns 401, client refreshes, retries, succeeds. + let push_resp = client + .push(&payload) + .await + .expect("push must succeed after the client transparently refreshes the access token"); + assert_eq!( + push_resp.merged.stats.games_played, 17, + "merged games_played must reflect what was pushed after auto-refresh" + ); + + let _ = delete_tokens(username); +} diff --git a/solitaire_server/src/lib.rs b/solitaire_server/src/lib.rs index 8d693fc..d5608c0 100644 --- a/solitaire_server/src/lib.rs +++ b/solitaire_server/src/lib.rs @@ -97,6 +97,28 @@ pub fn build_router(state: AppState) -> Router { /// Construct the router without rate limiting. /// /// Intended for integration tests only — do not use in production. +/// Create an in-memory SQLite pool and run all pending migrations. +/// +/// `max_connections(1)` is required for SQLite in-memory databases: every +/// additional connection sees an empty schema. +/// +/// Exposed so integration tests in other crates (e.g. `solitaire_data`) can +/// boot a real server without duplicating the migration boilerplate. +#[doc(hidden)] +pub async fn build_test_pool() -> SqlitePool { + use sqlx::sqlite::SqlitePoolOptions; + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("failed to connect to in-memory SQLite database"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("failed to run database migrations"); + pool +} + /// Uses a fixed test JWT secret (`"test_secret_32_chars_minimum_ok!"`) so /// integration tests do not need to set `JWT_SECRET` in the environment. #[doc(hidden)]