Compare commits
3 Commits
08f74d1e25
...
566b112d9e
| Author | SHA1 | Date | |
|---|---|---|---|
| 566b112d9e | |||
| 198df75f94 | |||
| 40d07122ba |
+10
-8
@@ -1,6 +1,6 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-12 — Sync rate limiting + mirror_achievement removal + theme import scan shipped (`6e6f3ef`). HEAD locally: `6e6f3ef`. Push pending.
|
||||
**Last updated:** 2026-05-12 — WASM build script + push-retry test shipped (`198df75`). HEAD locally: `198df75`. Push pending.
|
||||
|
||||
Phase 8 closes the self-hosted-server connection arc end-to-end: login/register
|
||||
modal, re-auth on token expiry, account deletion flow, server deployment
|
||||
@@ -12,8 +12,8 @@ and full server integration tests.
|
||||
|
||||
## Current state
|
||||
|
||||
- **HEAD locally:** `6e6f3ef` (feat: sync rate limiting).
|
||||
- **HEAD on origin:** `b129664` (pushed — 4 commits ahead).
|
||||
- **HEAD locally:** `198df75` (test: push retry + build_test_pool).
|
||||
- **HEAD on origin:** `08f74d1` (pushed — 3 commits ahead).
|
||||
- **Working tree:** `SESSION_HANDOFF.md` modified, uncommitted.
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||
- **Tests:** **1300+ passing / 0 failing** across the workspace.
|
||||
@@ -83,16 +83,18 @@ Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG):
|
||||
- [x] **`mirror_achievement` removed.** Done (`549a817`): method was a no-op
|
||||
default never overridden and never called; achievements already sync via
|
||||
`SyncPayload` push. Deleted from trait and blanket impl.
|
||||
- **WASM build script.** `web/pkg/` contains compiled WASM committed to git.
|
||||
Need a `build_wasm.sh` or Makefile target documenting the `wasm-pack build`
|
||||
invocation to regenerate it.
|
||||
- [x] **WASM build script.** Done (`40d0712`): `build_wasm.sh` at repo root
|
||||
documents `wasm-pack build --target web`, cleans up pkg metadata files,
|
||||
includes dependency guard + install instructions.
|
||||
- **Server password reset.** No admin endpoint or CLI tool for resetting a
|
||||
user's password. Self-hosters have no recovery path short of direct SQLite
|
||||
edits.
|
||||
|
||||
### 6. Testing gaps
|
||||
- **Server 401 → refresh → retry path** — the `pull`/`push` retry logic in
|
||||
`SolitaireServerClient` has no integration test.
|
||||
- [x] **Server 401 → refresh → retry path.** Done (`198df75`): both
|
||||
`jwt_refresh_on_401_succeeds` (pull) and
|
||||
`push_retries_after_401_on_expired_access_token` (push) in
|
||||
`solitaire_data/tests/sync_round_trip.rs`.
|
||||
- **WASM winning-replay step-through** — current tests cover 2 stock clicks;
|
||||
a test stepping through a full winning sequence would catch
|
||||
`GameState`/`ReplayMove` compatibility regressions.
|
||||
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rebuild the solitaire_wasm crate and install the output into
|
||||
# solitaire_server/web/pkg/ so the server can serve the replay viewer.
|
||||
#
|
||||
# Prerequisites:
|
||||
# cargo install wasm-pack
|
||||
# rustup target add wasm32-unknown-unknown
|
||||
#
|
||||
# Run from the repo root:
|
||||
# ./build_wasm.sh
|
||||
#
|
||||
# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are
|
||||
# committed to git so self-hosters who don't touch the WASM crate can
|
||||
# skip this step. Regenerate after any change to solitaire_wasm/ or
|
||||
# solitaire_core/.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
|
||||
|
||||
if ! command -v wasm-pack &> /dev/null; then
|
||||
echo "error: wasm-pack not found." >&2
|
||||
echo " Install with: cargo install wasm-pack" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building solitaire_wasm (target: web)..."
|
||||
wasm-pack build \
|
||||
--target web \
|
||||
--out-dir "$OUT_DIR" \
|
||||
--no-typescript \
|
||||
"$REPO_ROOT/solitaire_wasm"
|
||||
|
||||
# wasm-pack writes a package.json and .gitignore into the output dir.
|
||||
# Remove them — we manage the output directory ourselves.
|
||||
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
|
||||
|
||||
echo "Done. Output:"
|
||||
ls -lh "$OUT_DIR"
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user