diff --git a/PLAN.md b/PLAN.md index 7bd10d2..92e0d8f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -15,6 +15,11 @@ This is the canonical working plan and progress log for the project. Use this fi - iOS source was updated for the backend-backed session and round flow, including the real preview cue points and localized session strings. - Android debug build passes with `./gradlew :app:assembleDebug`. - Backend tests pass with `cargo test`. +- Backend localization bundles now have a contract test, and the localization catalog matches the shipped English and Swedish keys. +- The research export path is documented under `docs/research/export-path.md`. +- Backend bet coverage now includes a direct regression for late-lock rejection. +- Backend bet coverage now includes invalid-session and invalid-market rejection regressions, plus settlement override coverage. +- Backend analytics coverage now includes a representative key user flow batch. ### Still Open diff --git a/backend/tests/api_smoke.rs b/backend/tests/api_smoke.rs index 635fcac..6251551 100644 --- a/backend/tests/api_smoke.rs +++ b/backend/tests/api_smoke.rs @@ -761,3 +761,477 @@ async fn event_markets_and_current_odds_work() { assert_eq!(odds["is_current"], true); assert_eq!(odds["odds"].as_array().unwrap().len(), 2); } + +#[tokio::test] +async fn bets_reject_after_market_lock() { + let app = build_router(AppState::new(AppConfig::default(), None, None)); + + let session_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/session/start") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + let session_body = to_bytes(session_response.into_body(), usize::MAX).await.unwrap(); + let session_json: json::Value = json::from_slice(&session_body).unwrap(); + let session_id = session_json["session_id"].as_str().unwrap().to_string(); + + let event_id = Uuid::new_v4(); + let market_id = Uuid::new_v4(); + let home_outcome_id = Uuid::new_v4(); + let away_outcome_id = Uuid::new_v4(); + + let event_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/admin/events") + .header("content-type", "application/json") + .body(Body::from( + json::json!({ + "event": { + "id": event_id, + "sport_type": "football", + "source_ref": "locked-event-001", + "title_en": "Locked event", + "title_sv": "Låst händelse", + "status": "scheduled", + "preview_start_ms": 0, + "preview_end_ms": 1000, + "reveal_start_ms": 2000, + "reveal_end_ms": 3000, + "lock_at": "2000-01-01T00:00:00Z", + "settle_at": "2000-01-01T00:15:00Z" + }, + "media": [], + "markets": [] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(event_response.status(), StatusCode::CREATED); + + let market_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/admin/markets") + .header("content-type", "application/json") + .body(Body::from( + json::json!({ + "id": market_id, + "event_id": event_id, + "question_key": "market.locked.winner", + "market_type": "winner", + "status": "locked", + "lock_at": "2000-01-01T00:00:00Z", + "settlement_rule_key": "settle_on_match_winner", + "outcomes": [ + { + "id": home_outcome_id, + "market_id": market_id, + "outcome_code": "home", + "label_key": "outcome.home", + "sort_order": 1 + }, + { + "id": away_outcome_id, + "market_id": market_id, + "outcome_code": "away", + "label_key": "outcome.away", + "sort_order": 2 + } + ] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(market_response.status(), StatusCode::CREATED); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/bets/intent") + .header("content-type", "application/json") + .body(Body::from( + json::json!({ + "session_id": session_id, + "event_id": event_id, + "market_id": market_id, + "outcome_id": home_outcome_id, + "idempotency_key": "late-bet-001", + "client_sent_at": Utc::now().to_rfc3339(), + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let bet_json: json::Value = json::from_slice(&body).unwrap(); + + assert_eq!(bet_json["accepted"], false); + assert_eq!(bet_json["acceptance_code"], "rejected_too_late"); + assert!(bet_json["accepted_odds_version_id"].is_null()); +} + +#[tokio::test] +async fn bets_reject_with_invalid_session() { + let app = build_router(AppState::new(AppConfig::default(), None, None)); + + let session_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/session/start") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(session_response.status(), StatusCode::CREATED); + + let event_response = app + .clone() + .oneshot(Request::builder().uri("/api/v1/feed/next").body(Body::empty()).unwrap()) + .await + .unwrap(); + + let event_body = to_bytes(event_response.into_body(), usize::MAX).await.unwrap(); + let event_json: json::Value = json::from_slice(&event_body).unwrap(); + let event_id = event_json["id"].as_str().unwrap().to_string(); + + let markets_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/events/{event_id}/markets")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let markets_body = to_bytes(markets_response.into_body(), usize::MAX).await.unwrap(); + let markets_json: json::Value = json::from_slice(&markets_body).unwrap(); + let market_id = markets_json[0]["id"].as_str().unwrap().to_string(); + let outcome_id = markets_json[0]["outcomes"][0]["id"].as_str().unwrap().to_string(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/bets/intent") + .header("content-type", "application/json") + .body(Body::from( + json::json!({ + "session_id": Uuid::new_v4(), + "event_id": event_id, + "market_id": market_id, + "outcome_id": outcome_id, + "idempotency_key": "invalid-session-001", + "client_sent_at": Utc::now().to_rfc3339(), + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let bet_json: json::Value = json::from_slice(&body).unwrap(); + + assert_eq!(bet_json["accepted"], false); + assert_eq!(bet_json["acceptance_code"], "rejected_invalid_session"); + assert!(bet_json["accepted_odds_version_id"].is_null()); +} + +#[tokio::test] +async fn bets_reject_with_invalid_market() { + let app = build_router(AppState::new(AppConfig::default(), None, None)); + + let session_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/session/start") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(session_response.status(), StatusCode::CREATED); + + let session_body = to_bytes(session_response.into_body(), usize::MAX).await.unwrap(); + let session_json: json::Value = json::from_slice(&session_body).unwrap(); + let session_id = session_json["session_id"].as_str().unwrap().to_string(); + + let event_response = app + .clone() + .oneshot(Request::builder().uri("/api/v1/feed/next").body(Body::empty()).unwrap()) + .await + .unwrap(); + + let event_body = to_bytes(event_response.into_body(), usize::MAX).await.unwrap(); + let event_json: json::Value = json::from_slice(&event_body).unwrap(); + let event_id = event_json["id"].as_str().unwrap().to_string(); + + let markets_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/events/{event_id}/markets")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let markets_body = to_bytes(markets_response.into_body(), usize::MAX).await.unwrap(); + let markets_json: json::Value = json::from_slice(&markets_body).unwrap(); + let outcome_id = markets_json[0]["outcomes"][0]["id"].as_str().unwrap().to_string(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/bets/intent") + .header("content-type", "application/json") + .body(Body::from( + json::json!({ + "session_id": session_id, + "event_id": event_id, + "market_id": Uuid::new_v4(), + "outcome_id": outcome_id, + "idempotency_key": "invalid-market-001", + "client_sent_at": Utc::now().to_rfc3339(), + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let bet_json: json::Value = json::from_slice(&body).unwrap(); + + assert_eq!(bet_json["accepted"], false); + assert_eq!(bet_json["acceptance_code"], "rejected_invalid_market"); + assert!(bet_json["accepted_odds_version_id"].is_null()); +} + +#[tokio::test] +async fn settlement_override_is_reflected_in_results() { + let app = build_router(AppState::new(AppConfig::default(), None, None)); + + let event_response = app + .clone() + .oneshot(Request::builder().uri("/api/v1/feed/next").body(Body::empty()).unwrap()) + .await + .unwrap(); + + let event_body = to_bytes(event_response.into_body(), usize::MAX).await.unwrap(); + let event_json: json::Value = json::from_slice(&event_body).unwrap(); + let event_id = event_json["id"].as_str().unwrap().to_string(); + + let markets_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/events/{event_id}/markets")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let markets_body = to_bytes(markets_response.into_body(), usize::MAX).await.unwrap(); + let markets_json: json::Value = json::from_slice(&markets_body).unwrap(); + let market_id = markets_json[0]["id"].as_str().unwrap().to_string(); + let home_outcome_id = markets_json[0]["outcomes"][0]["id"].as_str().unwrap().to_string(); + let away_outcome_id = markets_json[0]["outcomes"][1]["id"].as_str().unwrap().to_string(); + + let first_settlement_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/admin/settlements") + .header("content-type", "application/json") + .body(Body::from( + json::json!({ + "id": Uuid::new_v4(), + "market_id": market_id, + "settled_at": "2099-01-01T01:25:00Z", + "winning_outcome_id": home_outcome_id + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(first_settlement_response.status(), StatusCode::CREATED); + + let second_settlement_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/admin/settlements") + .header("content-type", "application/json") + .body(Body::from( + json::json!({ + "id": Uuid::new_v4(), + "market_id": market_id, + "settled_at": "2099-01-01T01:30:00Z", + "winning_outcome_id": away_outcome_id + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(second_settlement_response.status(), StatusCode::CREATED); + + let result_response = app + .oneshot( + Request::builder() + .uri(format!("/api/v1/events/{event_id}/result")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(result_response.status(), StatusCode::OK); + + let result_body = to_bytes(result_response.into_body(), usize::MAX).await.unwrap(); + let result_json: json::Value = json::from_slice(&result_body).unwrap(); + + assert_eq!(result_json["market_id"], market_id); + assert_eq!(result_json["winning_outcome_id"], away_outcome_id); +} + +#[tokio::test] +async fn analytics_records_key_user_flow_events() { + let state = AppState::new(AppConfig::default(), None, None); + let app = build_router(state.clone()); + + let session_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/session/start") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(session_response.status(), StatusCode::CREATED); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/analytics/batch") + .header("content-type", "application/json") + .body(Body::from( + json::json!({ + "events": [ + { + "event_name": "session_started", + "occurred_at": Utc::now().to_rfc3339(), + "attributes": [ + {"key": "locale_code", "value": "en"}, + {"key": "experiment_variant", "value": "modern"} + ] + }, + { + "event_name": "feed_viewed", + "occurred_at": Utc::now().to_rfc3339(), + "attributes": [ + {"key": "screen_name", "value": "feed"} + ] + }, + { + "event_name": "round_loaded", + "occurred_at": Utc::now().to_rfc3339(), + "attributes": [ + {"key": "event_id", "value": Uuid::new_v4().to_string()} + ] + }, + { + "event_name": "outcome_selected", + "occurred_at": Utc::now().to_rfc3339(), + "attributes": [ + {"key": "market_id", "value": Uuid::new_v4().to_string()}, + {"key": "outcome_id", "value": Uuid::new_v4().to_string()} + ] + }, + { + "event_name": "selection_submitted", + "occurred_at": Utc::now().to_rfc3339() + }, + { + "event_name": "result_viewed", + "occurred_at": Utc::now().to_rfc3339(), + "attributes": [ + {"key": "screen_name", "value": "result"} + ] + } + ] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let (event_count, attribute_count) = state.analytics_counts().await; + assert_eq!(event_count, 6); + assert_eq!(attribute_count, 7); +} diff --git a/backend/tests/localization_contract.rs b/backend/tests/localization_contract.rs new file mode 100644 index 0000000..54298ec --- /dev/null +++ b/backend/tests/localization_contract.rs @@ -0,0 +1,28 @@ +use std::collections::BTreeSet; + +use serde_json as json; + +#[test] +fn english_and_swedish_localization_bundles_share_the_same_keys() { + let en: json::Value = + json::from_str(include_str!("../../contracts/localization/en.json")).unwrap(); + let sv: json::Value = + json::from_str(include_str!("../../contracts/localization/sv.json")).unwrap(); + + let en_keys: BTreeSet<_> = en.as_object().unwrap().keys().cloned().collect(); + let sv_keys: BTreeSet<_> = sv.as_object().unwrap().keys().cloned().collect(); + + assert_eq!(en_keys, sv_keys); + assert!(!en_keys.is_empty()); + + for key in &en_keys { + assert!( + !en[key].as_str().unwrap().trim().is_empty(), + "missing English text for {key}" + ); + assert!( + !sv[key].as_str().unwrap().trim().is_empty(), + "missing Swedish text for {key}" + ); + } +} diff --git a/docs/localization-catalog.md b/docs/localization-catalog.md index 8f6ec44..3eb25c4 100644 --- a/docs/localization-catalog.md +++ b/docs/localization-catalog.md @@ -30,41 +30,18 @@ | `feed.next_round_body` | Next round body | | `feed.watch_preview` | Watch preview CTA | | `feed.round_ready` | Round ready label | -| `feed.title` | Feed title | -| `feed.subtitle` | Feed subtitle | -| `feed.hero_title` | Feed hero title | -| `feed.hero_subtitle` | Feed hero subtitle | -| `feed.lock_label` | Feed lock label | -| `feed.odds_label` | Feed odds label | -| `feed.cta` | Feed CTA | | `round.countdown_label` | Countdown label | | `round.locked_label` | Locked label | | `round.selection_prompt` | Selection prompt | | `round.selection_confirmed` | Selection accepted | | `round.selection_submitting` | Selection submitting | | `round.odds_label` | Odds label | -| `round.title` | Round title | -| `round.subtitle` | Round subtitle | -| `round.video_placeholder` | Video placeholder | -| `round.preview_label` | Preview label | -| `round.primary_cta` | Round confirm CTA | -| `round.home` | Home outcome label | -| `round.away` | Away outcome label | | `reveal.title` | Reveal title | | `reveal.subtitle` | Reveal subtitle | -| `reveal.status` | Reveal status | -| `reveal.cta` | Reveal CTA | | `result.title` | Result title | -| `result.selection_label` | Result selection label | -| `result.outcome_label` | Result outcome label | -| `result.win` | Winning result label | -| `result.lose` | Losing result label | +| `result.user_selection` | Result selection label | +| `result.outcome` | Result outcome label | | `result.next_round` | Next round CTA | -| `result.subtitle` | Result subtitle | -| `result.selection_label` | Result selection label | -| `result.outcome_label` | Result outcome label | -| `result.win` | Winning result label | -| `result.lose` | Losing result label | | `settings.title` | Settings title | | `settings.language` | Language setting | | `settings.haptics` | Haptics setting | @@ -73,3 +50,8 @@ | `errors.network` | Network error copy | | `errors.playback` | Playback error copy | | `errors.session_expired` | Session expired copy | + +## Validation + +- English and Swedish bundles must contain the same keys +- Empty values are not allowed diff --git a/docs/research/export-path.md b/docs/research/export-path.md new file mode 100644 index 0000000..b9c47d7 --- /dev/null +++ b/docs/research/export-path.md @@ -0,0 +1,38 @@ +# Research Export Path + +## Goal + +Export study data without adding JSONB or ad hoc blobs. + +## Source Tables + +- `sessions` +- `experiment_assignments` +- `bet_intents` +- `settlements` +- `analytics_events` +- `analytics_event_attributes` + +## Recommended Export Shape + +- `session_id` +- `user_id` +- `experiment_variant` +- `locale_code` +- `event_name` +- `occurred_at` +- `attribute_key` +- `attribute_value` + +## Export Flow + +1. Join relational tables into a flat study dataset. +2. Filter by session, date range, or experiment variant. +3. Write CSV for quick review or Parquet for downstream analysis. +4. Keep the export job read-only and repeatable. + +## Notes + +- Use the server clock for ordering and latency calculations. +- Prefer one row per analytics event with child attribute expansion. +- Keep locale and variant fields in every export.