add fixture-backed admin and ios scaffold

This commit is contained in:
2026-04-09 15:30:00 +02:00
parent a1dcebaec1
commit 87f152a232
33 changed files with 927 additions and 24 deletions
+5
View File
@@ -0,0 +1,5 @@
pub use crate::events::{
EventManifestSnapshot, EventMediaSnapshot, EventSnapshot, MarketSnapshot, OutcomeSnapshot,
};
pub use crate::markets::{OddsVersionSnapshot, OutcomeOddsSnapshot};
pub use crate::settlement::SettlementSnapshot;
+49 -2
View File
@@ -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<EventManifestSnapshot, AppError> {
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<MarketSnapshot, AppError> {
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<OddsVersionSnapshot, AppError> {
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<SettlementSnapshot, AppError> {
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<EventSnapshot> {
self.events.next_event().await
}
+9
View File
@@ -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}");
}
+41 -6
View File
@@ -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<EventSnapshot> {
+153
View File
@@ -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<OddsVersionSnapshot>,
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<Utc> {
DateTime::parse_from_rfc3339(value)
.expect("valid timestamp")
.with_timezone(&Utc)
}
+2
View File
@@ -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;
+67 -8
View File
@@ -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<Uuid, Vec<MarketSnapshot>> = 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<Vec<MarketSnapshot>> {
+38
View File
@@ -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<AppState>,
Json(payload): Json<EventManifestSnapshot>,
) -> Result<(StatusCode, Json<EventManifestSnapshot>), AppError> {
let created = state.admin_create_event_manifest(payload).await?;
Ok((StatusCode::CREATED, Json(created)))
}
pub async fn create_market(
Extension(state): Extension<AppState>,
Json(payload): Json<MarketSnapshot>,
) -> Result<(StatusCode, Json<MarketSnapshot>), AppError> {
let created = state.admin_create_market(payload).await?;
Ok((StatusCode::CREATED, Json(created)))
}
pub async fn create_odds(
Extension(state): Extension<AppState>,
Json(payload): Json<OddsVersionSnapshot>,
) -> Result<(StatusCode, Json<OddsVersionSnapshot>), AppError> {
let created = state.admin_publish_odds(payload).await?;
Ok((StatusCode::CREATED, Json(created)))
}
pub async fn create_settlement(
Extension(state): Extension<AppState>,
Json(payload): Json<SettlementSnapshot>,
) -> Result<(StatusCode, Json<SettlementSnapshot>), AppError> {
let created = state.admin_publish_settlement(payload).await?;
Ok((StatusCode::CREATED, Json(created)))
}
+5
View File
@@ -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))
+43 -8
View File
@@ -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<RwLock<SettlementsState>>,
}
#[derive(Default)]
struct SettlementsState {
settlements_by_event_id: HashMap<Uuid, SettlementSnapshot>,
settlements_by_market_id: HashMap<Uuid, SettlementSnapshot>,
}
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<SettlementSnapshot> {
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<SettlementSnapshot> {
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
}
}
+218
View File
@@ -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));
+95
View File
@@ -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"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import SwiftUI
@main
struct HermesApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
}
}
+18
View File
@@ -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")
}
}
}
@@ -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
}
}
@@ -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)
}
@@ -0,0 +1,6 @@
import Foundation
struct GestureHandlers {
func handleTap() {}
func handleSwipeDown() {}
}
@@ -0,0 +1,6 @@
import Foundation
final class HapticsController {
func selectionAccepted() {}
func marketLocked() {}
}
@@ -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)
}
}
@@ -0,0 +1,6 @@
import Foundation
final class PlayerCoordinator: ObservableObject {
@Published var isPlaying = false
@Published var playbackPositionMs: Int = 0
}
@@ -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)
}
}
@@ -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))
}
}
@@ -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))
}
}
@@ -0,0 +1,7 @@
import SwiftUI
struct ResultView: View {
var body: some View {
Text("Result scaffold")
}
}
@@ -0,0 +1,7 @@
import SwiftUI
struct RevealView: View {
var body: some View {
Text("Reveal scaffold")
}
}
@@ -0,0 +1,8 @@
import SwiftUI
struct RoundView: View {
var body: some View {
Text("Round scaffold")
.padding()
}
}
@@ -0,0 +1,7 @@
import SwiftUI
struct SelectionView: View {
var body: some View {
Text("Selection scaffold")
}
}
@@ -0,0 +1,7 @@
import SwiftUI
struct SessionView: View {
var body: some View {
Text("Session scaffold")
}
}
@@ -0,0 +1,7 @@
import SwiftUI
struct SettingsView: View {
var body: some View {
Text("Settings scaffold")
}
}
@@ -0,0 +1,4 @@
"app.name" = "Hermes";
"onboarding.title" = "Study intro";
"onboarding.start_session" = "Start session";
"feed.next_round_title" = "Next round";
@@ -0,0 +1,4 @@
"app.name" = "Hermes";
"onboarding.title" = "Studieintro";
"onboarding.start_session" = "Starta session";
"feed.next_round_title" = "Nästa runda";
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cargo run --quiet --manifest-path "backend/Cargo.toml" --bin generate_test_event
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
./scripts/generate_test_event.sh > fixtures/manifests/sample_event.json