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
}
}