diff --git a/backend/src/admin/mod.rs b/backend/src/admin/mod.rs new file mode 100644 index 0000000..0e4b78e --- /dev/null +++ b/backend/src/admin/mod.rs @@ -0,0 +1,5 @@ +pub use crate::events::{ + EventManifestSnapshot, EventMediaSnapshot, EventSnapshot, MarketSnapshot, OutcomeSnapshot, +}; +pub use crate::markets::{OddsVersionSnapshot, OutcomeOddsSnapshot}; +pub use crate::settlement::SettlementSnapshot; diff --git a/backend/src/app_state.rs b/backend/src/app_state.rs index 4e95bf5..3ae56d6 100644 --- a/backend/src/app_state.rs +++ b/backend/src/app_state.rs @@ -9,10 +9,9 @@ pub use crate::analytics::{AnalyticsBatchRequest, AnalyticsEventInput, Attribute pub use crate::bets::{BetIntentRecord, BetIntentRequest, BetIntentResponse}; pub use crate::events::{EventManifestSnapshot, EventSnapshot}; pub use crate::events::{MarketSnapshot, OutcomeSnapshot}; +pub use crate::admin::{EventMediaSnapshot, OddsVersionSnapshot, OutcomeOddsSnapshot, SettlementSnapshot}; pub use crate::experiments::ExperimentConfigSnapshot; pub use crate::localization::LocalizationBundleSnapshot; -pub use crate::markets::{OddsVersionSnapshot, OutcomeOddsSnapshot}; -pub use crate::settlement::SettlementSnapshot; pub use crate::sessions::{SessionSnapshot, SessionStartRequest}; use crate::{ @@ -257,6 +256,54 @@ impl AppState { .map_err(|_| AppError::not_found("No active session")) } + pub async fn admin_create_event_manifest( + &self, + manifest: EventManifestSnapshot, + ) -> Result { + let created = self.events.insert_manifest(manifest.clone()).await; + + for market in &created.markets { + self.markets.insert_market(market.clone()).await; + } + + Ok(created) + } + + pub async fn admin_create_market( + &self, + market: MarketSnapshot, + ) -> Result { + let Some(_) = self.event(market.event_id).await else { + return Err(AppError::not_found("Event not found")); + }; + + self.markets.insert_market(market.clone()).await; + let _ = self.events.insert_market(market.event_id, market.clone()).await; + Ok(market) + } + + pub async fn admin_publish_odds( + &self, + odds: OddsVersionSnapshot, + ) -> Result { + let Some(_) = self.markets.market(odds.market_id).await else { + return Err(AppError::not_found("Market not found")); + }; + + Ok(self.markets.publish_odds(odds).await) + } + + pub async fn admin_publish_settlement( + &self, + settlement: SettlementSnapshot, + ) -> Result { + let Some(market) = self.markets.market(settlement.market_id).await else { + return Err(AppError::not_found("Market not found")); + }; + + Ok(self.settlements.upsert(market.event_id, settlement).await) + } + pub async fn next_event(&self) -> Option { self.events.next_event().await } diff --git a/backend/src/bin/generate_test_event.rs b/backend/src/bin/generate_test_event.rs new file mode 100644 index 0000000..7635aab --- /dev/null +++ b/backend/src/bin/generate_test_event.rs @@ -0,0 +1,9 @@ +#![forbid(unsafe_code)] + +use serde_json as json; + +fn main() { + let fixture = hermes_backend::fixtures::build_sample_study_fixture(); + let output = json::to_string_pretty(&fixture).expect("valid test event fixture"); + println!("{output}"); +} diff --git a/backend/src/events/mod.rs b/backend/src/events/mod.rs index 760514f..c8495c9 100644 --- a/backend/src/events/mod.rs +++ b/backend/src/events/mod.rs @@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use uuid::Uuid; +use crate::fixtures::SampleStudyFixture; + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct EventSnapshot { pub id: Uuid, @@ -76,9 +78,22 @@ struct EventsState { impl EventsStore { pub fn new() -> Self { - Self::with_sample_data() + Self::from_fixture(&crate::fixtures::load_sample_study_fixture()) } + pub fn from_fixture(fixture: &SampleStudyFixture) -> Self { + let mut manifests_by_event_id = HashMap::new(); + manifests_by_event_id.insert(fixture.manifest.event.id, fixture.manifest.clone()); + + Self { + inner: Arc::new(RwLock::new(EventsState { + feed_order: vec![fixture.manifest.event.id], + manifests_by_event_id, + })), + } + } + + #[allow(dead_code, unused_variables)] pub fn with_sample_data() -> Self { let event_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").expect("valid uuid"); let market_id = Uuid::parse_str("22222222-2222-2222-2222-222222222222").expect("valid uuid"); @@ -152,12 +167,32 @@ impl EventsStore { let mut manifests_by_event_id = HashMap::new(); manifests_by_event_id.insert(event_id, manifest); - Self { - inner: Arc::new(RwLock::new(EventsState { - feed_order: vec![event_id], - manifests_by_event_id, - })), + Self::from_fixture(&crate::fixtures::load_sample_study_fixture()) + } + + pub async fn insert_manifest(&self, manifest: EventManifestSnapshot) -> EventManifestSnapshot { + let mut state = self.inner.write().await; + let event_id = manifest.event.id; + if !state.manifests_by_event_id.contains_key(&event_id) { + state.feed_order.push(event_id); } + state.manifests_by_event_id.insert(event_id, manifest.clone()); + manifest + } + + pub async fn insert_market(&self, event_id: Uuid, market: MarketSnapshot) -> bool { + let mut state = self.inner.write().await; + let Some(manifest) = state.manifests_by_event_id.get_mut(&event_id) else { + return false; + }; + + let Some(existing) = manifest.markets.iter_mut().find(|existing| existing.id == market.id) else { + manifest.markets.push(market); + return true; + }; + + *existing = market; + true } pub async fn next_event(&self) -> Option { diff --git a/backend/src/fixtures/mod.rs b/backend/src/fixtures/mod.rs new file mode 100644 index 0000000..1f0220e --- /dev/null +++ b/backend/src/fixtures/mod.rs @@ -0,0 +1,153 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json as json; +use uuid::Uuid; + +use crate::{ + events::{ + EventManifestSnapshot, EventMediaSnapshot, EventSnapshot, MarketSnapshot, OutcomeSnapshot, + }, + markets::{OddsVersionSnapshot, OutcomeOddsSnapshot}, + settlement::SettlementSnapshot, +}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SettlementFixture { + pub event_id: Uuid, + pub settlement: SettlementSnapshot, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SampleStudyFixture { + pub manifest: EventManifestSnapshot, + pub odds_versions: Vec, + pub settlement: SettlementFixture, +} + +pub fn load_sample_study_fixture() -> SampleStudyFixture { + let raw = include_str!("../../../fixtures/manifests/sample_event.json"); + json::from_str(raw).expect("valid sample study fixture") +} + +pub fn build_sample_study_fixture() -> SampleStudyFixture { + let event_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").expect("valid uuid"); + let market_id = Uuid::parse_str("22222222-2222-2222-2222-222222222222").expect("valid uuid"); + let odds_version_id = + Uuid::parse_str("66666666-6666-6666-6666-666666666666").expect("valid uuid"); + let lock_at = parse_ts("2099-01-01T00:10:00Z"); + let settle_at = parse_ts("2099-01-01T00:25:00Z"); + let created_at = parse_ts("2099-01-01T00:00:00Z"); + + let home_outcome_id = + Uuid::parse_str("44444444-4444-4444-4444-444444444444").expect("valid uuid"); + let away_outcome_id = + Uuid::parse_str("55555555-5555-5555-5555-555555555555").expect("valid uuid"); + + let event = EventSnapshot { + id: event_id, + sport_type: "football".to_string(), + source_ref: "sample-event-001".to_string(), + title_en: "Late winner chance".to_string(), + title_sv: "Möjlighet till segermål".to_string(), + status: "prefetch_ready".to_string(), + preview_start_ms: 0, + preview_end_ms: 45_000, + reveal_start_ms: 50_000, + reveal_end_ms: 90_000, + lock_at, + settle_at, + }; + + let media = EventMediaSnapshot { + id: Uuid::parse_str("33333333-3333-3333-3333-333333333333").expect("valid uuid"), + event_id, + media_type: "hls_main".to_string(), + hls_master_url: "https://cdn.example.com/hermes/sample-event/master.m3u8".to_string(), + poster_url: Some("https://cdn.example.com/hermes/sample-event/poster.jpg".to_string()), + duration_ms: 90_000, + preview_start_ms: 0, + preview_end_ms: 45_000, + reveal_start_ms: 50_000, + reveal_end_ms: 90_000, + }; + + let outcomes = vec![ + OutcomeSnapshot { + id: home_outcome_id, + market_id, + outcome_code: "home".to_string(), + label_key: "outcome.home".to_string(), + sort_order: 1, + }, + OutcomeSnapshot { + id: away_outcome_id, + market_id, + outcome_code: "away".to_string(), + label_key: "outcome.away".to_string(), + sort_order: 2, + }, + ]; + + let market = MarketSnapshot { + id: market_id, + event_id, + question_key: "market.sample.winner".to_string(), + market_type: "winner".to_string(), + status: "open".to_string(), + lock_at, + settlement_rule_key: "settle_on_match_winner".to_string(), + outcomes, + }; + + let odds_version = OddsVersionSnapshot { + id: odds_version_id, + market_id, + version_no: 1, + created_at, + is_current: true, + odds: vec![ + OutcomeOddsSnapshot { + id: Uuid::parse_str("77777777-7777-7777-7777-777777777777").expect("valid uuid"), + odds_version_id, + outcome_id: home_outcome_id, + decimal_odds: 1.85, + fractional_num: 17, + fractional_den: 20, + }, + OutcomeOddsSnapshot { + id: Uuid::parse_str("88888888-8888-8888-8888-888888888888").expect("valid uuid"), + odds_version_id, + outcome_id: away_outcome_id, + decimal_odds: 2.05, + fractional_num: 21, + fractional_den: 20, + }, + ], + }; + + let settlement = SettlementFixture { + event_id, + settlement: SettlementSnapshot { + id: Uuid::parse_str("99999999-9999-9999-9999-999999999999").expect("valid uuid"), + market_id, + settled_at: settle_at, + winning_outcome_id: home_outcome_id, + }, + }; + + SampleStudyFixture { + manifest: EventManifestSnapshot { + event, + media: vec![media], + markets: vec![market], + }, + odds_versions: vec![odds_version], + settlement, + } +} + +fn parse_ts(value: &str) -> DateTime { + DateTime::parse_from_rfc3339(value) + .expect("valid timestamp") + .with_timezone(&Utc) +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 253b861..88d6d62 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] pub mod analytics; +pub mod admin; pub mod bets; pub mod app_state; pub mod config; @@ -8,6 +9,7 @@ pub mod db; pub mod events; pub mod error; pub mod experiments; +pub mod fixtures; pub mod localization; pub mod markets; pub mod settlement; diff --git a/backend/src/markets/mod.rs b/backend/src/markets/mod.rs index c6a0883..07488a3 100644 --- a/backend/src/markets/mod.rs +++ b/backend/src/markets/mod.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use uuid::Uuid; +use crate::fixtures::SampleStudyFixture; use crate::events::{MarketSnapshot, OutcomeSnapshot}; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -42,9 +43,46 @@ struct MarketsState { impl MarketsStore { pub fn new() -> Self { - Self::with_sample_data() + Self::from_fixture(&crate::fixtures::load_sample_study_fixture()) } + pub fn from_fixture(fixture: &SampleStudyFixture) -> Self { + let mut markets_by_event_id: HashMap> = HashMap::new(); + let mut markets_by_id = HashMap::new(); + let mut outcomes_by_id = HashMap::new(); + let mut current_odds_by_market_id = HashMap::new(); + + for market_fixture in &fixture.manifest.markets { + let market = market_fixture.clone(); + markets_by_event_id.entry(market.event_id).or_default().push(market.clone()); + markets_by_id.insert(market.id, market.clone()); + + for outcome in &market.outcomes { + outcomes_by_id.insert(outcome.id, outcome.clone()); + } + + let Some(odds_version) = fixture + .odds_versions + .iter() + .find(|odds_version| odds_version.market_id == market.id) + .cloned() else { + continue; + }; + + current_odds_by_market_id.insert(market.id, odds_version); + } + + Self { + inner: Arc::new(RwLock::new(MarketsState { + markets_by_event_id, + markets_by_id, + outcomes_by_id, + current_odds_by_market_id, + })), + } + } + + #[allow(dead_code, unused_variables)] pub fn with_sample_data() -> Self { let event_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").expect("valid uuid"); let market_id = Uuid::parse_str("22222222-2222-2222-2222-222222222222").expect("valid uuid"); @@ -123,14 +161,35 @@ impl MarketsStore { let mut current_odds_by_market_id = HashMap::new(); current_odds_by_market_id.insert(market_id, odds_version); - Self { - inner: Arc::new(RwLock::new(MarketsState { - markets_by_event_id, - markets_by_id, - outcomes_by_id, - current_odds_by_market_id, - })), + Self::from_fixture(&crate::fixtures::load_sample_study_fixture()) + } + + pub async fn insert_market(&self, market: MarketSnapshot) -> MarketSnapshot { + let mut state = self.inner.write().await; + + state + .markets_by_event_id + .entry(market.event_id) + .or_default() + .retain(|existing| existing.id != market.id); + state + .markets_by_event_id + .entry(market.event_id) + .or_default() + .push(market.clone()); + state.markets_by_id.insert(market.id, market.clone()); + + for outcome in &market.outcomes { + state.outcomes_by_id.insert(outcome.id, outcome.clone()); } + + market + } + + pub async fn publish_odds(&self, odds: OddsVersionSnapshot) -> OddsVersionSnapshot { + let mut state = self.inner.write().await; + state.current_odds_by_market_id.insert(odds.market_id, odds.clone()); + odds } pub async fn markets_for_event(&self, event_id: Uuid) -> Option> { diff --git a/backend/src/routes/admin.rs b/backend/src/routes/admin.rs new file mode 100644 index 0000000..b043580 --- /dev/null +++ b/backend/src/routes/admin.rs @@ -0,0 +1,38 @@ +use axum::{extract::{Extension, Json}, http::StatusCode}; + +use crate::{ + app_state::{AppState, EventManifestSnapshot, MarketSnapshot, OddsVersionSnapshot, SettlementSnapshot}, + error::AppError, +}; + +pub async fn create_event( + Extension(state): Extension, + Json(payload): Json, +) -> Result<(StatusCode, Json), AppError> { + let created = state.admin_create_event_manifest(payload).await?; + Ok((StatusCode::CREATED, Json(created))) +} + +pub async fn create_market( + Extension(state): Extension, + Json(payload): Json, +) -> Result<(StatusCode, Json), AppError> { + let created = state.admin_create_market(payload).await?; + Ok((StatusCode::CREATED, Json(created))) +} + +pub async fn create_odds( + Extension(state): Extension, + Json(payload): Json, +) -> Result<(StatusCode, Json), AppError> { + let created = state.admin_publish_odds(payload).await?; + Ok((StatusCode::CREATED, Json(created))) +} + +pub async fn create_settlement( + Extension(state): Extension, + Json(payload): Json, +) -> Result<(StatusCode, Json), AppError> { + let created = state.admin_publish_settlement(payload).await?; + Ok((StatusCode::CREATED, Json(created))) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 13642ea..784637a 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1,4 +1,5 @@ pub mod health; +pub mod admin; pub mod analytics; pub mod bets; pub mod events; @@ -19,6 +20,10 @@ pub fn router() -> Router { .route("/api/v1/events/{event_id}/markets", get(markets::list_for_event)) .route("/api/v1/events/{event_id}/result", get(results::show)) .route("/api/v1/markets/{market_id}/odds/current", get(markets::current_odds)) + .route("/api/v1/admin/events", post(admin::create_event)) + .route("/api/v1/admin/markets", post(admin::create_market)) + .route("/api/v1/admin/odds", post(admin::create_odds)) + .route("/api/v1/admin/settlements", post(admin::create_settlement)) .route("/api/v1/bets/intent", post(bets::submit)) .route("/api/v1/bets/{bet_intent_id}", get(bets::show)) .route("/api/v1/analytics/batch", post(analytics::batch)) diff --git a/backend/src/settlement/mod.rs b/backend/src/settlement/mod.rs index bf953f7..b767ee4 100644 --- a/backend/src/settlement/mod.rs +++ b/backend/src/settlement/mod.rs @@ -1,9 +1,12 @@ -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use chrono::{DateTime, Duration as ChronoDuration, Utc}; use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; use uuid::Uuid; +use crate::fixtures::SampleStudyFixture; + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SettlementSnapshot { pub id: Uuid, @@ -14,15 +17,39 @@ pub struct SettlementSnapshot { #[derive(Clone, Default)] pub struct SettlementsStore { + inner: Arc>, +} + +#[derive(Default)] +struct SettlementsState { settlements_by_event_id: HashMap, settlements_by_market_id: HashMap, } impl SettlementsStore { pub fn new() -> Self { - Self::with_sample_data() + Self::from_fixture(&crate::fixtures::load_sample_study_fixture()) } + pub fn from_fixture(fixture: &SampleStudyFixture) -> Self { + let mut settlements_by_event_id = HashMap::new(); + settlements_by_event_id.insert(fixture.settlement.event_id, fixture.settlement.settlement.clone()); + + let mut settlements_by_market_id = HashMap::new(); + settlements_by_market_id.insert( + fixture.settlement.settlement.market_id, + fixture.settlement.settlement.clone(), + ); + + Self { + inner: Arc::new(RwLock::new(SettlementsState { + settlements_by_event_id, + settlements_by_market_id, + })), + } + } + + #[allow(dead_code, unused_variables)] pub fn with_sample_data() -> Self { let event_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").expect("valid uuid"); let market_id = Uuid::parse_str("22222222-2222-2222-2222-222222222222").expect("valid uuid"); @@ -41,23 +68,31 @@ impl SettlementsStore { let mut settlements_by_market_id = HashMap::new(); settlements_by_market_id.insert(market_id, settlement.clone()); - Self { - settlements_by_event_id, - settlements_by_market_id, - } + Self::from_fixture(&crate::fixtures::load_sample_study_fixture()) } pub async fn settlement_for_event(&self, event_id: Uuid) -> Option { - let Some(settlement) = self.settlements_by_event_id.get(&event_id) else { + let state = self.inner.read().await; + let Some(settlement) = state.settlements_by_event_id.get(&event_id) else { return None; }; Some(settlement.clone()) } pub async fn settlement_for_market(&self, market_id: Uuid) -> Option { - let Some(settlement) = self.settlements_by_market_id.get(&market_id) else { + let state = self.inner.read().await; + let Some(settlement) = state.settlements_by_market_id.get(&market_id) else { return None; }; Some(settlement.clone()) } + + pub async fn upsert(&self, event_id: Uuid, settlement: SettlementSnapshot) -> SettlementSnapshot { + let mut state = self.inner.write().await; + state.settlements_by_event_id.insert(event_id, settlement.clone()); + state + .settlements_by_market_id + .insert(settlement.market_id, settlement.clone()); + settlement + } } diff --git a/backend/tests/api_smoke.rs b/backend/tests/api_smoke.rs index 086f196..5cd1b63 100644 --- a/backend/tests/api_smoke.rs +++ b/backend/tests/api_smoke.rs @@ -2,6 +2,7 @@ use axum::{body::{to_bytes, Body}, http::{Request, StatusCode}}; use chrono::Utc; use hermes_backend::{app_state::AppState, build_router, config::AppConfig}; use serde_json as json; +use uuid::Uuid; use tower::ServiceExt; #[tokio::test] @@ -410,6 +411,223 @@ async fn analytics_batch_is_recorded() { assert_eq!(attribute_count, 1); } +#[tokio::test] +async fn admin_endpoints_publish_round_data() { + let app = build_router(AppState::new(AppConfig::default(), None, None)); + + 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 odds_version_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": "admin-event-001", + "title_en": "Admin created event", + "title_sv": "Adminskapad händelse", + "status": "scheduled", + "preview_start_ms": 0, + "preview_end_ms": 1000, + "reveal_start_ms": 2000, + "reveal_end_ms": 3000, + "lock_at": "2099-01-01T01:10:00Z", + "settle_at": "2099-01-01T01:25:00Z" + }, + "media": [ + { + "id": Uuid::new_v4(), + "event_id": event_id, + "media_type": "hls_main", + "hls_master_url": "https://cdn.example.com/admin/master.m3u8", + "poster_url": "https://cdn.example.com/admin/poster.jpg", + "duration_ms": 3000, + "preview_start_ms": 0, + "preview_end_ms": 1000, + "reveal_start_ms": 2000, + "reveal_end_ms": 3000 + } + ], + "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.admin.winner", + "market_type": "winner", + "status": "open", + "lock_at": "2099-01-01T01:10: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 odds_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/admin/odds") + .header("content-type", "application/json") + .body(Body::from( + json::json!({ + "id": odds_version_id, + "market_id": market_id, + "version_no": 1, + "created_at": "2099-01-01T01:00:00Z", + "is_current": true, + "odds": [ + { + "id": Uuid::new_v4(), + "odds_version_id": odds_version_id, + "outcome_id": home_outcome_id, + "decimal_odds": 1.9, + "fractional_num": 9, + "fractional_den": 10 + }, + { + "id": Uuid::new_v4(), + "odds_version_id": odds_version_id, + "outcome_id": away_outcome_id, + "decimal_odds": 2.1, + "fractional_num": 11, + "fractional_den": 10 + } + ] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(odds_response.status(), StatusCode::CREATED); + + let 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!(settlement_response.status(), StatusCode::CREATED); + + let manifest_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/events/{event_id}/manifest")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(manifest_response.status(), StatusCode::OK); + + let manifest_body = to_bytes(manifest_response.into_body(), usize::MAX).await.unwrap(); + let manifest_json: json::Value = json::from_slice(&manifest_body).unwrap(); + assert_eq!(manifest_json["markets"].as_array().unwrap().len(), 1); + + let odds_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/markets/{market_id}/odds/current")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(odds_response.status(), StatusCode::OK); + + let odds_body = to_bytes(odds_response.into_body(), usize::MAX).await.unwrap(); + let odds_json: json::Value = json::from_slice(&odds_body).unwrap(); + assert_eq!(odds_json["id"], odds_version_id.to_string()); + + 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.to_string()); + assert_eq!(result_json["winning_outcome_id"], home_outcome_id.to_string()); +} + #[tokio::test] async fn event_markets_and_current_odds_work() { let app = build_router(AppState::new(AppConfig::default(), None, None)); diff --git a/fixtures/manifests/sample_event.json b/fixtures/manifests/sample_event.json new file mode 100644 index 0000000..0f57967 --- /dev/null +++ b/fixtures/manifests/sample_event.json @@ -0,0 +1,95 @@ +{ + "manifest": { + "event": { + "id": "11111111-1111-1111-1111-111111111111", + "sport_type": "football", + "source_ref": "sample-event-001", + "title_en": "Late winner chance", + "title_sv": "Möjlighet till segermål", + "status": "prefetch_ready", + "preview_start_ms": 0, + "preview_end_ms": 45000, + "reveal_start_ms": 50000, + "reveal_end_ms": 90000, + "lock_at": "2099-01-01T00:10:00Z", + "settle_at": "2099-01-01T00:25:00Z" + }, + "media": [ + { + "id": "33333333-3333-3333-3333-333333333333", + "event_id": "11111111-1111-1111-1111-111111111111", + "media_type": "hls_main", + "hls_master_url": "https://cdn.example.com/hermes/sample-event/master.m3u8", + "poster_url": "https://cdn.example.com/hermes/sample-event/poster.jpg", + "duration_ms": 90000, + "preview_start_ms": 0, + "preview_end_ms": 45000, + "reveal_start_ms": 50000, + "reveal_end_ms": 90000 + } + ], + "markets": [ + { + "id": "22222222-2222-2222-2222-222222222222", + "event_id": "11111111-1111-1111-1111-111111111111", + "question_key": "market.sample.winner", + "market_type": "winner", + "status": "open", + "lock_at": "2099-01-01T00:10:00Z", + "settlement_rule_key": "settle_on_match_winner", + "outcomes": [ + { + "id": "44444444-4444-4444-4444-444444444444", + "market_id": "22222222-2222-2222-2222-222222222222", + "outcome_code": "home", + "label_key": "outcome.home", + "sort_order": 1 + }, + { + "id": "55555555-5555-5555-5555-555555555555", + "market_id": "22222222-2222-2222-2222-222222222222", + "outcome_code": "away", + "label_key": "outcome.away", + "sort_order": 2 + } + ] + } + ] + }, + "odds_versions": [ + { + "id": "66666666-6666-6666-6666-666666666666", + "market_id": "22222222-2222-2222-2222-222222222222", + "version_no": 1, + "created_at": "2099-01-01T00:00:00Z", + "is_current": true, + "odds": [ + { + "id": "77777777-7777-7777-7777-777777777777", + "odds_version_id": "66666666-6666-6666-6666-666666666666", + "outcome_id": "44444444-4444-4444-4444-444444444444", + "decimal_odds": 1.85, + "fractional_num": 17, + "fractional_den": 20 + }, + { + "id": "88888888-8888-8888-8888-888888888888", + "odds_version_id": "66666666-6666-6666-6666-666666666666", + "outcome_id": "55555555-5555-5555-5555-555555555555", + "decimal_odds": 2.05, + "fractional_num": 21, + "fractional_den": 20 + } + ] + } + ], + "settlement": { + "event_id": "11111111-1111-1111-1111-111111111111", + "settlement": { + "id": "99999999-9999-9999-9999-999999999999", + "market_id": "22222222-2222-2222-2222-222222222222", + "settled_at": "2099-01-01T00:25:00Z", + "winning_outcome_id": "44444444-4444-4444-4444-444444444444" + } + } +} diff --git a/mobile/ios-app/App/HermesApp.swift b/mobile/ios-app/App/HermesApp.swift new file mode 100644 index 0000000..cd5e24a --- /dev/null +++ b/mobile/ios-app/App/HermesApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct HermesApp: App { + var body: some Scene { + WindowGroup { + RootView() + } + } +} diff --git a/mobile/ios-app/App/RootView.swift b/mobile/ios-app/App/RootView.swift new file mode 100644 index 0000000..99c6851 --- /dev/null +++ b/mobile/ios-app/App/RootView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct RootView: View { + var body: some View { + NavigationStack { + VStack(spacing: 16) { + Text("Hermes") + .font(.largeTitle.bold()) + Text("Native study app scaffold") + .font(.body) + .foregroundStyle(.secondary) + OnboardingView() + } + .padding() + .navigationTitle("Hermes") + } + } +} diff --git a/mobile/ios-app/Core/Analytics/AnalyticsClient.swift b/mobile/ios-app/Core/Analytics/AnalyticsClient.swift new file mode 100644 index 0000000..d2b51c4 --- /dev/null +++ b/mobile/ios-app/Core/Analytics/AnalyticsClient.swift @@ -0,0 +1,13 @@ +import Foundation + +protocol AnalyticsTracking { + func track(_ event: String, attributes: [String: String]) +} + +final class HermesAnalyticsClient: AnalyticsTracking { + func track(_ event: String, attributes: [String: String]) { + // Scaffold implementation. + _ = event + _ = attributes + } +} diff --git a/mobile/ios-app/Core/DesignSystem/Theme.swift b/mobile/ios-app/Core/DesignSystem/Theme.swift new file mode 100644 index 0000000..d68bf65 --- /dev/null +++ b/mobile/ios-app/Core/DesignSystem/Theme.swift @@ -0,0 +1,9 @@ +import SwiftUI + +enum HermesTheme { + static let background = Color.black + static let surface = Color(red: 0.12, green: 0.12, blue: 0.14) + static let accent = Color(red: 0.87, green: 0.74, blue: 0.34) + static let textPrimary = Color.white + static let textSecondary = Color.white.opacity(0.72) +} diff --git a/mobile/ios-app/Core/Gestures/GestureHandlers.swift b/mobile/ios-app/Core/Gestures/GestureHandlers.swift new file mode 100644 index 0000000..e21f19a --- /dev/null +++ b/mobile/ios-app/Core/Gestures/GestureHandlers.swift @@ -0,0 +1,6 @@ +import Foundation + +struct GestureHandlers { + func handleTap() {} + func handleSwipeDown() {} +} diff --git a/mobile/ios-app/Core/Haptics/HapticsController.swift b/mobile/ios-app/Core/Haptics/HapticsController.swift new file mode 100644 index 0000000..1bbab55 --- /dev/null +++ b/mobile/ios-app/Core/Haptics/HapticsController.swift @@ -0,0 +1,6 @@ +import Foundation + +final class HapticsController { + func selectionAccepted() {} + func marketLocked() {} +} diff --git a/mobile/ios-app/Core/Localization/LocalizationStore.swift b/mobile/ios-app/Core/Localization/LocalizationStore.swift new file mode 100644 index 0000000..93c5a46 --- /dev/null +++ b/mobile/ios-app/Core/Localization/LocalizationStore.swift @@ -0,0 +1,18 @@ +import Foundation + +final class LocalizationStore { + private let bundle: Bundle + + init(bundle: Bundle = .main) { + self.bundle = bundle + } + + func string(for key: String, locale: String) -> String { + guard let path = bundle.path(forResource: locale, ofType: "lproj"), + let localizedBundle = Bundle(path: path) else { + return bundle.localizedString(forKey: key, value: nil, table: nil) + } + + return localizedBundle.localizedString(forKey: key, value: nil, table: nil) + } +} diff --git a/mobile/ios-app/Core/Media/PlayerCoordinator.swift b/mobile/ios-app/Core/Media/PlayerCoordinator.swift new file mode 100644 index 0000000..5542bbf --- /dev/null +++ b/mobile/ios-app/Core/Media/PlayerCoordinator.swift @@ -0,0 +1,6 @@ +import Foundation + +final class PlayerCoordinator: ObservableObject { + @Published var isPlaying = false + @Published var playbackPositionMs: Int = 0 +} diff --git a/mobile/ios-app/Core/Networking/APIClient.swift b/mobile/ios-app/Core/Networking/APIClient.swift new file mode 100644 index 0000000..1f77316 --- /dev/null +++ b/mobile/ios-app/Core/Networking/APIClient.swift @@ -0,0 +1,24 @@ +import Foundation + +struct APIEnvironment { + let baseURL: URL +} + +struct HermesAPIClient { + let environment: APIEnvironment + let session: URLSession + + init(environment: APIEnvironment, session: URLSession = .shared) { + self.environment = environment + self.session = session + } + + func get(path: String) async throws -> (Data, HTTPURLResponse) { + let url = environment.baseURL.appendingPathComponent(path) + let (data, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + return (data, httpResponse) + } +} diff --git a/mobile/ios-app/Features/Feed/FeedView.swift b/mobile/ios-app/Features/Feed/FeedView.swift new file mode 100644 index 0000000..66a2d86 --- /dev/null +++ b/mobile/ios-app/Features/Feed/FeedView.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct FeedView: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Next round") + .font(.headline) + Text("A new clip is ready for review.") + .foregroundStyle(.secondary) + } + .padding() + .background(HermesTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } +} diff --git a/mobile/ios-app/Features/Onboarding/OnboardingView.swift b/mobile/ios-app/Features/Onboarding/OnboardingView.swift new file mode 100644 index 0000000..944e12d --- /dev/null +++ b/mobile/ios-app/Features/Onboarding/OnboardingView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct OnboardingView: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Study intro") + .font(.headline) + Text("Watch the clip, make your choice before lock, then see the reveal.") + .foregroundStyle(.secondary) + Button("Start session") {} + .buttonStyle(.borderedProminent) + } + .padding() + .background(HermesTheme.surface) + .foregroundStyle(HermesTheme.textPrimary) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } +} diff --git a/mobile/ios-app/Features/Result/ResultView.swift b/mobile/ios-app/Features/Result/ResultView.swift new file mode 100644 index 0000000..e975548 --- /dev/null +++ b/mobile/ios-app/Features/Result/ResultView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct ResultView: View { + var body: some View { + Text("Result scaffold") + } +} diff --git a/mobile/ios-app/Features/Reveal/RevealView.swift b/mobile/ios-app/Features/Reveal/RevealView.swift new file mode 100644 index 0000000..193091e --- /dev/null +++ b/mobile/ios-app/Features/Reveal/RevealView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct RevealView: View { + var body: some View { + Text("Reveal scaffold") + } +} diff --git a/mobile/ios-app/Features/Round/RoundView.swift b/mobile/ios-app/Features/Round/RoundView.swift new file mode 100644 index 0000000..908087a --- /dev/null +++ b/mobile/ios-app/Features/Round/RoundView.swift @@ -0,0 +1,8 @@ +import SwiftUI + +struct RoundView: View { + var body: some View { + Text("Round scaffold") + .padding() + } +} diff --git a/mobile/ios-app/Features/Selection/SelectionView.swift b/mobile/ios-app/Features/Selection/SelectionView.swift new file mode 100644 index 0000000..f901880 --- /dev/null +++ b/mobile/ios-app/Features/Selection/SelectionView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct SelectionView: View { + var body: some View { + Text("Selection scaffold") + } +} diff --git a/mobile/ios-app/Features/Session/SessionView.swift b/mobile/ios-app/Features/Session/SessionView.swift new file mode 100644 index 0000000..57152ae --- /dev/null +++ b/mobile/ios-app/Features/Session/SessionView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct SessionView: View { + var body: some View { + Text("Session scaffold") + } +} diff --git a/mobile/ios-app/Features/Settings/SettingsView.swift b/mobile/ios-app/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..04bdbde --- /dev/null +++ b/mobile/ios-app/Features/Settings/SettingsView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct SettingsView: View { + var body: some View { + Text("Settings scaffold") + } +} diff --git a/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings b/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings new file mode 100644 index 0000000..b024e2e --- /dev/null +++ b/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings @@ -0,0 +1,4 @@ +"app.name" = "Hermes"; +"onboarding.title" = "Study intro"; +"onboarding.start_session" = "Start session"; +"feed.next_round_title" = "Next round"; diff --git a/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings b/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings new file mode 100644 index 0000000..ab6e136 --- /dev/null +++ b/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings @@ -0,0 +1,4 @@ +"app.name" = "Hermes"; +"onboarding.title" = "Studieintro"; +"onboarding.start_session" = "Starta session"; +"feed.next_round_title" = "Nästa runda"; diff --git a/scripts/generate_test_event.sh b/scripts/generate_test_event.sh new file mode 100755 index 0000000..bc6aeb5 --- /dev/null +++ b/scripts/generate_test_event.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +cargo run --quiet --manifest-path "backend/Cargo.toml" --bin generate_test_event diff --git a/scripts/seed_fixtures.sh b/scripts/seed_fixtures.sh new file mode 100755 index 0000000..883c35e --- /dev/null +++ b/scripts/seed_fixtures.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +./scripts/generate_test_event.sh > fixtures/manifests/sample_event.json