From 3081505a3d76f1b48f5a61e93778db01f29a29c2 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 5 May 2026 18:58:54 +0000 Subject: [PATCH] =?UTF-8?q?test(server):=20E2E=20coverage=20for=20replay?= =?UTF-8?q?=20upload=20=E2=86=92=20fetch=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five new integration tests against the in-process Axum router + in-memory SQLite, covering the full HTTP transport + database layer that the web replay viewer depends on: - `replay_upload_then_fetch_round_trips_payload` — register → POST → GET → assert the fetched JSON matches the upload byte-for-byte. Canonical "the web viewer can play back what the desktop client uploaded" coverage. - `replay_fetch_unknown_id_returns_404` — exercises the `AppError::NotFound` mapping (not a 500). - `replay_recent_lists_newest_first_with_username` — two uploads, asserts received_at DESC ordering and that the username join populates the `username` field. - `replay_upload_without_auth_returns_401` — guards against the upload endpoint accidentally accepting anonymous inserts. - `replay_upload_malformed_body_returns_400` — header projector rejects payloads missing required fields with 400, not 500. Schema-correctness (round-trip, version gate, atomic write) is still covered by `solitaire_data::replay`'s unit tests; this file is strictly for the HTTP transport. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_server/tests/server_tests.rs | 147 +++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/solitaire_server/tests/server_tests.rs b/solitaire_server/tests/server_tests.rs index 8e79284..df1a29e 100644 --- a/solitaire_server/tests/server_tests.rs +++ b/solitaire_server/tests/server_tests.rs @@ -1447,3 +1447,150 @@ async fn auth_rate_limit_returns_429_on_11th_request() { "11th request must be rate-limited with 429" ); } + +// --------------------------------------------------------------------------- +// Replay endpoints +// +// End-to-end coverage for the upload → fetch → render path that powers +// the web replay viewer. Each test boots the full router against an +// in-memory SQLite, registers a user, and exercises one of the three +// replay endpoints. The schema-correctness tests (storage round-trip, +// version gate, atomic write) live in `solitaire_data::replay`; here we +// only verify the HTTP transport + database layer. +// --------------------------------------------------------------------------- + +/// Build a minimal v2 replay JSON the upload endpoint will accept. +/// +/// Uses the same field shape `solitaire_data::Replay` produces — kept +/// in sync by hand because the server crate intentionally does not +/// depend on `solitaire_data` (which carries dirs/keyring/reqwest). +fn sample_replay_payload(seed: u64, score: i32) -> Value { + serde_json::json!({ + "schema_version": 2, + "seed": seed, + "draw_mode": "DrawOne", + "mode": "Classic", + "time_seconds": 134, + "final_score": score, + "recorded_at": "2026-05-02", + "moves": [ + "StockClick", + { "Move": { "from": "Waste", "to": { "Tableau": 3 }, "count": 1 } } + ] + }) +} + +/// Round-trip: register → upload → fetch → assert the payload returned +/// by `GET /api/replays/:id` matches what was uploaded byte-for-byte. +/// This is the canonical "the web viewer can play back what the +/// desktop client uploaded" test. +#[tokio::test] +async fn replay_upload_then_fetch_round_trips_payload() { + let pool = test_pool().await; + let app = build_test_router(pool); + let (token, _) = register_user(app.clone(), "replay_round_trip_user", "p4ssword!").await; + + let payload = sample_replay_payload(7654, 4321); + let resp = post_authed(app.clone(), "/api/replays", &token, payload.clone()).await; + assert_eq!(resp.status(), StatusCode::OK, "upload must return 200"); + let id = body_json(resp).await["id"] + .as_str() + .expect("upload response missing `id`") + .to_string(); + assert!(uuid::Uuid::parse_str(&id).is_ok(), "id must be a UUID"); + + // Fetch is public — no auth required, exercising the path the + // logged-out web viewer takes. + let req = Request::builder() + .method("GET") + .uri(format!("/api/replays/{id}")) + .header("x-forwarded-for", TEST_CLIENT_IP) + .body(Body::empty()) + .expect("fetch request"); + let resp = app.clone().oneshot(req).await.expect("oneshot"); + assert_eq!(resp.status(), StatusCode::OK, "fetch must return 200"); + let fetched = body_json(resp).await; + assert_eq!( + fetched, payload, + "fetched payload must match what was uploaded byte-for-byte", + ); +} + +/// `GET /api/replays/:id` for an id that was never uploaded must +/// return 404 (not 500). Exercises the `AppError::NotFound` mapping +/// added in the server commit. +#[tokio::test] +async fn replay_fetch_unknown_id_returns_404() { + let pool = test_pool().await; + let app = build_test_router(pool); + let req = Request::builder() + .method("GET") + .uri("/api/replays/nonexistent-id-1234") + .header("x-forwarded-for", TEST_CLIENT_IP) + .body(Body::empty()) + .expect("fetch request"); + let resp = app.oneshot(req).await.expect("oneshot"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +/// Two uploads, then `GET /api/replays/recent` — the more recent +/// upload must come first and the response must include the +/// uploader's username (joined from the `users` table). +#[tokio::test] +async fn replay_recent_lists_newest_first_with_username() { + let pool = test_pool().await; + let app = build_test_router(pool); + let (token, _) = register_user(app.clone(), "replay_recent_user", "p4ssword!").await; + + let _ = post_authed(app.clone(), "/api/replays", &token, sample_replay_payload(1, 100)).await; + let _ = post_authed(app.clone(), "/api/replays", &token, sample_replay_payload(2, 200)).await; + + let req = Request::builder() + .method("GET") + .uri("/api/replays/recent") + .header("x-forwarded-for", TEST_CLIENT_IP) + .body(Body::empty()) + .expect("recent request"); + let resp = app.oneshot(req).await.expect("oneshot"); + assert_eq!(resp.status(), StatusCode::OK); + + let entries = body_json(resp).await; + let array = entries.as_array().expect("recent should return an array"); + assert!(array.len() >= 2, "two uploads should yield two list entries"); + // Newer upload (seed = 2) must appear before older one (seed = 1). + let seeds: Vec = array + .iter() + .map(|e| e["seed"].as_i64().expect("seed should be an integer")) + .collect(); + assert_eq!( + seeds, [2, 1], + "received_at DESC: most recent upload first", + ); + assert_eq!( + array[0]["username"].as_str(), + Some("replay_recent_user"), + "username must be joined into the response", + ); +} + +/// `POST /api/replays` without an `Authorization` header must return +/// 401, not silently insert as an anonymous user. +#[tokio::test] +async fn replay_upload_without_auth_returns_401() { + let pool = test_pool().await; + let app = build_test_router(pool); + let resp = post_json(app, "/api/replays", sample_replay_payload(99, 50)).await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +/// `POST /api/replays` with a malformed body (missing fields the +/// header projector needs) must return 400, not 500. +#[tokio::test] +async fn replay_upload_malformed_body_returns_400() { + let pool = test_pool().await; + let app = build_test_router(pool); + let (token, _) = register_user(app.clone(), "replay_bad_body_user", "p4ssword!").await; + let bad = serde_json::json!({ "schema_version": 2, "missing_required_fields": true }); + let resp = post_authed(app, "/api/replays", &token, bad).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +}