add study flow endpoints

This commit is contained in:
2026-04-09 15:17:08 +02:00
parent d45202770e
commit a1dcebaec1
15 changed files with 992 additions and 16 deletions
+109
View File
@@ -0,0 +1,109 @@
use std::{collections::HashMap, sync::Arc};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::sessions::SessionSnapshot;
#[derive(Clone, Debug, Deserialize)]
pub struct AnalyticsBatchRequest {
pub events: Vec<AnalyticsEventInput>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AnalyticsEventInput {
pub event_name: String,
pub occurred_at: DateTime<Utc>,
pub attributes: Option<Vec<AttributeInput>>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AttributeInput {
pub key: String,
pub value: String,
}
#[derive(Clone)]
pub struct AnalyticsStore {
inner: Arc<RwLock<AnalyticsState>>,
}
#[derive(Default)]
struct AnalyticsState {
event_types_by_name: HashMap<String, Uuid>,
events: Vec<AnalyticsEventSnapshot>,
attributes: Vec<AnalyticsEventAttributeSnapshot>,
}
#[derive(Clone)]
#[allow(dead_code)]
struct AnalyticsEventSnapshot {
id: Uuid,
analytics_event_type_id: Uuid,
session_id: Uuid,
user_id: Uuid,
occurred_at: DateTime<Utc>,
}
#[derive(Clone)]
#[allow(dead_code)]
struct AnalyticsEventAttributeSnapshot {
id: Uuid,
analytics_event_id: Uuid,
attribute_key: String,
attribute_value: String,
}
impl AnalyticsStore {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(AnalyticsState::default())),
}
}
pub async fn ingest(&self, session: &SessionSnapshot, request: AnalyticsBatchRequest) -> usize {
let mut state = self.inner.write().await;
let mut inserted = 0usize;
for event in request.events {
let analytics_event_type_id = *state
.event_types_by_name
.entry(event.event_name.clone())
.or_insert_with(Uuid::new_v4);
let analytics_event_id = Uuid::new_v4();
state.events.push(AnalyticsEventSnapshot {
id: analytics_event_id,
analytics_event_type_id,
session_id: session.session_id,
user_id: session.user_id,
occurred_at: event.occurred_at,
});
let Some(attributes) = event.attributes else {
inserted += 1;
continue;
};
for attribute in attributes {
state.attributes.push(AnalyticsEventAttributeSnapshot {
id: Uuid::new_v4(),
analytics_event_id,
attribute_key: attribute.key,
attribute_value: attribute.value,
});
}
inserted += 1;
}
inserted
}
pub async fn counts(&self) -> (usize, usize) {
let state = self.inner.read().await;
(state.events.len(), state.attributes.len())
}
}
+214
View File
@@ -1,19 +1,33 @@
use std::time::Instant;
use chrono::Utc;
use redis::Client as RedisClient;
use sqlx::PgPool;
use uuid::Uuid;
pub use crate::analytics::{AnalyticsBatchRequest, AnalyticsEventInput, AttributeInput};
pub use crate::bets::{BetIntentRecord, BetIntentRequest, BetIntentResponse};
pub use crate::events::{EventManifestSnapshot, EventSnapshot};
pub use crate::events::{MarketSnapshot, OutcomeSnapshot};
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::{
analytics::AnalyticsStore,
bets::{
BetsStore, ACCEPTANCE_ACCEPTED, ACCEPTANCE_REJECTED_INVALID_MARKET,
ACCEPTANCE_REJECTED_INVALID_SESSION, ACCEPTANCE_REJECTED_TOO_LATE,
},
config::AppConfig,
error::AppError,
events::{EventsStore},
experiments::ExperimentsStore,
localization::LocalizationStore,
markets::MarketsStore,
settlement::SettlementsStore,
sessions::{SessionDefaults, SessionsStore},
users::{UserCreateRequest, UsersStore},
};
@@ -25,6 +39,11 @@ pub struct AppState {
pub database_pool: Option<PgPool>,
pub redis_client: Option<RedisClient>,
events: EventsStore,
bets: BetsStore,
settlements: SettlementsStore,
experiments: ExperimentsStore,
localization: LocalizationStore,
analytics: AnalyticsStore,
markets: MarketsStore,
users: UsersStore,
sessions: SessionsStore,
@@ -42,6 +61,11 @@ impl AppState {
database_pool,
redis_client,
events: EventsStore::new(),
bets: BetsStore::new(),
settlements: SettlementsStore::new(),
experiments: ExperimentsStore::new(),
localization: LocalizationStore::new(),
analytics: AnalyticsStore::new(),
markets: MarketsStore::new(),
users: UsersStore::new(),
sessions: SessionsStore::new(),
@@ -82,6 +106,150 @@ impl AppState {
self.sessions.current_session().await
}
fn build_bet_intent_record(
&self,
request: &BetIntentRequest,
user_id: Option<Uuid>,
accepted: bool,
acceptance_code: &str,
accepted_odds_version_id: Option<Uuid>,
) -> BetIntentRecord {
BetIntentRecord {
id: Uuid::new_v4(),
session_id: request.session_id,
user_id,
event_id: request.event_id,
market_id: request.market_id,
outcome_id: request.outcome_id,
idempotency_key: request.idempotency_key.clone(),
client_sent_at: request.client_sent_at,
server_received_at: Utc::now(),
accepted,
acceptance_code: acceptance_code.to_string(),
accepted_odds_version_id,
}
}
pub async fn submit_bet_intent(&self, request: BetIntentRequest) -> BetIntentResponse {
let Some(existing) = self
.bets
.get_by_idempotency_key(&request.idempotency_key)
.await
else {
return self.submit_bet_intent_fresh(request).await;
};
existing
}
async fn submit_bet_intent_fresh(&self, request: BetIntentRequest) -> BetIntentResponse {
let Some(session) = self.current_session().await else {
return self
.bets
.record(self.build_bet_intent_record(
&request,
None,
false,
ACCEPTANCE_REJECTED_INVALID_SESSION,
None,
))
.await;
};
if session.ended_at.is_some() || session.session_id != request.session_id {
return self
.bets
.record(self.build_bet_intent_record(
&request,
Some(session.user_id),
false,
ACCEPTANCE_REJECTED_INVALID_SESSION,
None,
))
.await;
}
let Some(market) = self.markets.market(request.market_id).await else {
return self
.bets
.record(self.build_bet_intent_record(
&request,
Some(session.user_id),
false,
ACCEPTANCE_REJECTED_INVALID_MARKET,
None,
))
.await;
};
if market.event_id != request.event_id {
return self
.bets
.record(self.build_bet_intent_record(
&request,
Some(session.user_id),
false,
ACCEPTANCE_REJECTED_INVALID_MARKET,
None,
))
.await;
}
let Some(outcome) = self.markets.outcome(request.outcome_id).await else {
return self
.bets
.record(self.build_bet_intent_record(
&request,
Some(session.user_id),
false,
ACCEPTANCE_REJECTED_INVALID_MARKET,
None,
))
.await;
};
if outcome.market_id != market.id || request.event_id != market.event_id {
return self
.bets
.record(self.build_bet_intent_record(
&request,
Some(session.user_id),
false,
ACCEPTANCE_REJECTED_INVALID_MARKET,
None,
))
.await;
}
if Utc::now() >= market.lock_at {
return self
.bets
.record(self.build_bet_intent_record(
&request,
Some(session.user_id),
false,
ACCEPTANCE_REJECTED_TOO_LATE,
None,
))
.await;
}
let accepted_odds_version_id = self.current_odds(market.id).await.map(|odds| odds.id);
self.bets
.record(self.build_bet_intent_record(
&request,
Some(session.user_id),
true,
ACCEPTANCE_ACCEPTED,
accepted_odds_version_id,
))
.await
}
pub async fn bet_intent(&self, bet_intent_id: Uuid) -> Option<BetIntentResponse> {
self.bets.get(bet_intent_id).await
}
pub async fn end_session(&self) -> Result<SessionSnapshot, AppError> {
self.sessions
.end_session()
@@ -101,6 +269,52 @@ impl AppState {
self.events.get_manifest(event_id).await
}
pub async fn settlement_for_event(&self, event_id: Uuid) -> Option<SettlementSnapshot> {
self.settlements.settlement_for_event(event_id).await
}
pub async fn experiment_config(&self) -> ExperimentConfigSnapshot {
let Some(session) = self.current_session().await else {
return self
.experiments
.config_for_variant(&self.config.default_experiment_variant);
};
if session.ended_at.is_some() {
return self
.experiments
.config_for_variant(&self.config.default_experiment_variant);
}
self.experiments.config_for_variant(&session.experiment_variant)
}
pub async fn localization_bundle(
&self,
locale_code: &str,
) -> Option<LocalizationBundleSnapshot> {
self.localization.bundle(locale_code)
}
pub async fn ingest_analytics_batch(
&self,
request: AnalyticsBatchRequest,
) -> Result<usize, AppError> {
let Some(session) = self.current_session().await else {
return Err(AppError::not_found("No active session"));
};
if session.ended_at.is_some() {
return Err(AppError::not_found("No active session"));
}
Ok(self.analytics.ingest(&session, request).await)
}
pub async fn analytics_counts(&self) -> (usize, usize) {
self.analytics.counts().await
}
pub async fn markets_for_event(&self, event_id: Uuid) -> Option<Vec<MarketSnapshot>> {
self.markets.markets_for_event(event_id).await
}
+116
View File
@@ -0,0 +1,116 @@
use std::{collections::HashMap, sync::Arc};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use uuid::Uuid;
pub const ACCEPTANCE_ACCEPTED: &str = "accepted";
pub const ACCEPTANCE_REJECTED_TOO_LATE: &str = "rejected_too_late";
pub const ACCEPTANCE_REJECTED_INVALID_MARKET: &str = "rejected_invalid_market";
pub const ACCEPTANCE_REJECTED_INVALID_SESSION: &str = "rejected_invalid_session";
pub const ACCEPTANCE_REJECTED_DUPLICATE: &str = "rejected_duplicate";
#[derive(Clone, Debug, Deserialize)]
pub struct BetIntentRequest {
pub session_id: Uuid,
pub event_id: Uuid,
pub market_id: Uuid,
pub outcome_id: Uuid,
pub idempotency_key: String,
pub client_sent_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BetIntentResponse {
pub id: Uuid,
pub accepted: bool,
pub acceptance_code: String,
pub accepted_odds_version_id: Option<Uuid>,
pub server_received_at: DateTime<Utc>,
}
#[derive(Clone, Debug)]
pub struct BetIntentRecord {
pub id: Uuid,
pub session_id: Uuid,
pub user_id: Option<Uuid>,
pub event_id: Uuid,
pub market_id: Uuid,
pub outcome_id: Uuid,
pub idempotency_key: String,
pub client_sent_at: DateTime<Utc>,
pub server_received_at: DateTime<Utc>,
pub accepted: bool,
pub acceptance_code: String,
pub accepted_odds_version_id: Option<Uuid>,
}
impl BetIntentRecord {
pub fn response(&self) -> BetIntentResponse {
BetIntentResponse {
id: self.id,
accepted: self.accepted,
acceptance_code: self.acceptance_code.clone(),
accepted_odds_version_id: self.accepted_odds_version_id,
server_received_at: self.server_received_at,
}
}
}
#[derive(Clone, Default)]
pub struct BetsStore {
inner: Arc<RwLock<BetsState>>,
}
#[derive(Default)]
struct BetsState {
intents_by_id: HashMap<Uuid, BetIntentRecord>,
intents_by_idempotency_key: HashMap<String, Uuid>,
}
impl BetsStore {
pub fn new() -> Self {
Self::default()
}
pub async fn get(&self, bet_intent_id: Uuid) -> Option<BetIntentResponse> {
let state = self.inner.read().await;
let Some(record) = state.intents_by_id.get(&bet_intent_id) else {
return None;
};
Some(record.response())
}
pub async fn get_by_idempotency_key(&self, idempotency_key: &str) -> Option<BetIntentResponse> {
let state = self.inner.read().await;
let Some(bet_intent_id) = state.intents_by_idempotency_key.get(idempotency_key).copied() else {
return None;
};
let Some(record) = state.intents_by_id.get(&bet_intent_id) else {
return None;
};
Some(record.response())
}
pub async fn record(&self, record: BetIntentRecord) -> BetIntentResponse {
let mut state = self.inner.write().await;
if let Some(existing_id) = state.intents_by_idempotency_key.get(&record.idempotency_key).copied() {
if let Some(existing) = state.intents_by_id.get(&existing_id) {
return existing.response();
}
}
let response = record.response();
state
.intents_by_idempotency_key
.insert(record.idempotency_key.clone(), record.id);
state.intents_by_id.insert(record.id, record);
response
}
pub async fn counts(&self) -> usize {
self.inner.read().await.intents_by_id.len()
}
}
+40
View File
@@ -0,0 +1,40 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExperimentConfigSnapshot {
pub variant: String,
pub feature_flags: HashMap<String, bool>,
}
#[derive(Clone, Default)]
pub struct ExperimentsStore;
impl ExperimentsStore {
pub fn new() -> Self {
Self
}
pub fn config_for_variant(&self, variant: &str) -> ExperimentConfigSnapshot {
ExperimentConfigSnapshot {
variant: variant.to_string(),
feature_flags: feature_flags_for_variant(variant),
}
}
}
fn feature_flags_for_variant(variant: &str) -> HashMap<String, bool> {
let modern = variant == "modern";
let mut flags = HashMap::new();
flags.insert("control_mode".to_string(), !modern);
flags.insert("modern_mode".to_string(), modern);
flags.insert("animation_intensity_high".to_string(), modern);
flags.insert("haptics_enabled".to_string(), true);
flags.insert("autoplay_next_enabled".to_string(), true);
flags.insert("countdown_style_compact".to_string(), modern);
flags.insert("odds_update_visualization_enabled".to_string(), modern);
flags.insert("result_card_style_modern".to_string(), modern);
flags.insert("localization_source_contracts".to_string(), true);
flags
}
+5
View File
@@ -1,11 +1,16 @@
#![forbid(unsafe_code)]
pub mod analytics;
pub mod bets;
pub mod app_state;
pub mod config;
pub mod db;
pub mod events;
pub mod error;
pub mod experiments;
pub mod localization;
pub mod markets;
pub mod settlement;
pub mod sessions;
pub mod routes;
pub mod users;
+51
View File
@@ -0,0 +1,51 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json as json;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LocalizationBundleSnapshot {
pub locale_code: String,
pub values: HashMap<String, String>,
}
#[derive(Clone)]
pub struct LocalizationStore {
bundles: HashMap<String, LocalizationBundleSnapshot>,
}
impl LocalizationStore {
pub fn new() -> Self {
let mut bundles = HashMap::new();
bundles.insert(
"en".to_string(),
load_bundle(
"en",
include_str!("../../../contracts/localization/en.json"),
),
);
bundles.insert(
"sv".to_string(),
load_bundle(
"sv",
include_str!("../../../contracts/localization/sv.json"),
),
);
Self { bundles }
}
pub fn bundle(&self, locale_code: &str) -> Option<LocalizationBundleSnapshot> {
let Some(bundle) = self.bundles.get(locale_code) else {
return None;
};
Some(bundle.clone())
}
}
fn load_bundle(locale_code: &str, raw: &str) -> LocalizationBundleSnapshot {
let values: HashMap<String, String> = json::from_str(raw).expect("valid localization bundle");
LocalizationBundleSnapshot {
locale_code: locale_code.to_string(),
values,
}
}
+33 -16
View File
@@ -36,6 +36,7 @@ pub struct MarketsStore {
struct MarketsState {
markets_by_event_id: HashMap<Uuid, Vec<MarketSnapshot>>,
markets_by_id: HashMap<Uuid, MarketSnapshot>,
outcomes_by_id: HashMap<Uuid, OutcomeSnapshot>,
current_odds_by_market_id: HashMap<Uuid, OddsVersionSnapshot>,
}
@@ -54,6 +55,23 @@ impl MarketsStore {
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 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,
@@ -62,22 +80,7 @@ impl MarketsStore {
status: "open".to_string(),
lock_at: preview_lock_at,
settlement_rule_key: "settle_on_match_winner".to_string(),
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,
},
],
outcomes: outcomes.clone(),
};
let odds_version = OddsVersionSnapshot {
@@ -112,6 +115,11 @@ impl MarketsStore {
let mut markets_by_id = HashMap::new();
markets_by_id.insert(market_id, market);
let mut outcomes_by_id = HashMap::new();
for outcome in outcomes {
outcomes_by_id.insert(outcome.id, outcome);
}
let mut current_odds_by_market_id = HashMap::new();
current_odds_by_market_id.insert(market_id, odds_version);
@@ -119,6 +127,7 @@ impl MarketsStore {
inner: Arc::new(RwLock::new(MarketsState {
markets_by_event_id,
markets_by_id,
outcomes_by_id,
current_odds_by_market_id,
})),
}
@@ -140,6 +149,14 @@ impl MarketsStore {
Some(market.clone())
}
pub async fn outcome(&self, outcome_id: Uuid) -> Option<OutcomeSnapshot> {
let state = self.inner.read().await;
let Some(outcome) = state.outcomes_by_id.get(&outcome_id) else {
return None;
};
Some(outcome.clone())
}
pub async fn current_odds(&self, market_id: Uuid) -> Option<OddsVersionSnapshot> {
let state = self.inner.read().await;
let Some(odds) = state.current_odds_by_market_id.get(&market_id) else {
+11
View File
@@ -0,0 +1,11 @@
use axum::{extract::Extension, http::StatusCode, Json};
use crate::{app_state::{AnalyticsBatchRequest, AppState}, error::AppError};
pub async fn batch(
Extension(state): Extension<AppState>,
Json(payload): Json<AnalyticsBatchRequest>,
) -> Result<StatusCode, AppError> {
state.ingest_analytics_batch(payload).await?;
Ok(StatusCode::ACCEPTED)
}
+23
View File
@@ -0,0 +1,23 @@
use axum::{extract::{Extension, Path}, http::StatusCode, Json};
use uuid::Uuid;
use crate::{app_state::{AppState, BetIntentRequest, BetIntentResponse}, error::AppError};
pub async fn submit(
Extension(state): Extension<AppState>,
Json(payload): Json<BetIntentRequest>,
) -> Result<(StatusCode, Json<BetIntentResponse>), AppError> {
let response = state.submit_bet_intent(payload).await;
Ok((StatusCode::CREATED, Json(response)))
}
pub async fn show(
Path(bet_intent_id): Path<Uuid>,
Extension(state): Extension<AppState>,
) -> Result<Json<BetIntentResponse>, AppError> {
let Some(intent) = state.bet_intent(bet_intent_id).await else {
return Err(AppError::not_found("Bet intent not found"));
};
Ok(Json(intent))
}
+7
View File
@@ -0,0 +1,7 @@
use axum::{extract::Extension, Json};
use crate::{app_state::{AppState, ExperimentConfigSnapshot}, error::AppError};
pub async fn config(Extension(state): Extension<AppState>) -> Result<Json<ExperimentConfigSnapshot>, AppError> {
Ok(Json(state.experiment_config().await))
}
+14
View File
@@ -0,0 +1,14 @@
use axum::{extract::{Extension, Path}, Json};
use crate::{app_state::{AppState, LocalizationBundleSnapshot}, error::AppError};
pub async fn bundle(
Path(locale_code): Path<String>,
Extension(state): Extension<AppState>,
) -> Result<Json<LocalizationBundleSnapshot>, AppError> {
let Some(bundle) = state.localization_bundle(&locale_code).await else {
return Err(AppError::not_found("Localization bundle not found"));
};
Ok(Json(bundle))
}
+11
View File
@@ -1,6 +1,11 @@
pub mod health;
pub mod analytics;
pub mod bets;
pub mod events;
pub mod experiments;
pub mod localization;
pub mod markets;
pub mod results;
pub mod session;
use axum::{routing::{get, post}, Router};
@@ -12,7 +17,13 @@ pub fn router() -> Router {
.route("/api/v1/events/{event_id}", get(events::show))
.route("/api/v1/events/{event_id}/manifest", get(events::manifest))
.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/bets/intent", post(bets::submit))
.route("/api/v1/bets/{bet_intent_id}", get(bets::show))
.route("/api/v1/analytics/batch", post(analytics::batch))
.route("/api/v1/experiments/config", get(experiments::config))
.route("/api/v1/localization/{locale_code}", get(localization::bundle))
.route("/api/v1/session/start", post(session::start))
.route("/api/v1/session/end", post(session::end))
.route("/api/v1/session/me", get(session::me))
+15
View File
@@ -0,0 +1,15 @@
use axum::{extract::{Extension, Path}, Json};
use uuid::Uuid;
use crate::{app_state::{AppState, SettlementSnapshot}, error::AppError};
pub async fn show(
Path(event_id): Path<Uuid>,
Extension(state): Extension<AppState>,
) -> Result<Json<SettlementSnapshot>, AppError> {
let Some(settlement) = state.settlement_for_event(event_id).await else {
return Err(AppError::not_found("Settlement not found"));
};
Ok(Json(settlement))
}
+63
View File
@@ -0,0 +1,63 @@
use std::collections::HashMap;
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SettlementSnapshot {
pub id: Uuid,
pub market_id: Uuid,
pub settled_at: DateTime<Utc>,
pub winning_outcome_id: Uuid,
}
#[derive(Clone, Default)]
pub struct SettlementsStore {
settlements_by_event_id: HashMap<Uuid, SettlementSnapshot>,
settlements_by_market_id: HashMap<Uuid, SettlementSnapshot>,
}
impl SettlementsStore {
pub fn new() -> Self {
Self::with_sample_data()
}
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");
let winning_outcome_id = Uuid::parse_str("44444444-4444-4444-4444-444444444444").expect("valid uuid");
let settlement = SettlementSnapshot {
id: Uuid::parse_str("99999999-9999-9999-9999-999999999999").expect("valid uuid"),
market_id,
settled_at: Utc::now() - ChronoDuration::minutes(5),
winning_outcome_id,
};
let mut settlements_by_event_id = HashMap::new();
settlements_by_event_id.insert(event_id, settlement.clone());
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,
}
}
pub async fn settlement_for_event(&self, event_id: Uuid) -> Option<SettlementSnapshot> {
let Some(settlement) = self.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 {
return None;
};
Some(settlement.clone())
}
}
+280
View File
@@ -1,4 +1,5 @@
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 tower::ServiceExt;
@@ -130,6 +131,285 @@ async fn feed_next_returns_a_manifestable_event() {
assert!(manifest["markets"].as_array().unwrap().len() >= 1);
}
#[tokio::test]
async fn bet_intent_accepts_and_is_idempotent() {
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_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 request = json::json!({
"session_id": session_id,
"event_id": event_id,
"market_id": market_id,
"outcome_id": outcome_id,
"idempotency_key": "bet-001",
"client_sent_at": Utc::now().to_rfc3339(),
})
.to_string();
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/bets/intent")
.header("content-type", "application/json")
.body(Body::from(request.clone()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let first_json: json::Value = json::from_slice(&body).unwrap();
let bet_id = first_json["id"].as_str().unwrap().to_string();
assert_eq!(first_json["accepted"], true);
assert_eq!(first_json["acceptance_code"], "accepted");
let response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/bets/{bet_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let lookup_json: json::Value = json::from_slice(&body).unwrap();
assert_eq!(lookup_json["id"], bet_id);
assert_eq!(lookup_json["accepted"], true);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/bets/intent")
.header("content-type", "application/json")
.body(Body::from(request))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let duplicate_json: json::Value = json::from_slice(&body).unwrap();
assert_eq!(duplicate_json["id"], bet_id);
assert_eq!(duplicate_json["accepted"], true);
}
#[tokio::test]
async fn event_result_returns_settlement() {
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 winning_outcome_id = markets_json[0]["outcomes"][0]["id"].as_str().unwrap().to_string();
let response = app
.oneshot(
Request::builder()
.uri(format!("/api/v1/events/{event_id}/result"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let result_json: json::Value = json::from_slice(&body).unwrap();
assert_eq!(result_json["market_id"], market_id);
assert_eq!(result_json["winning_outcome_id"], winning_outcome_id);
}
#[tokio::test]
async fn experiments_and_localization_work() {
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(
json::json!({
"experiment_variant": "modern"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(session_response.status(), StatusCode::CREATED);
let config_response = app
.clone()
.oneshot(Request::builder().uri("/api/v1/experiments/config").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(config_response.status(), StatusCode::OK);
let config_body = to_bytes(config_response.into_body(), usize::MAX).await.unwrap();
let config_json: json::Value = json::from_slice(&config_body).unwrap();
assert_eq!(config_json["variant"], "modern");
assert_eq!(config_json["feature_flags"]["modern_mode"], true);
let localization_response = app
.clone()
.oneshot(Request::builder().uri("/api/v1/localization/sv").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(localization_response.status(), StatusCode::OK);
let localization_body = to_bytes(localization_response.into_body(), usize::MAX).await.unwrap();
let localization_json: json::Value = json::from_slice(&localization_body).unwrap();
assert_eq!(localization_json["locale_code"], "sv");
assert_eq!(localization_json["values"]["common.continue"], "Fortsätt");
let localization_en_response = app
.oneshot(Request::builder().uri("/api/v1/localization/en").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(localization_en_response.status(), StatusCode::OK);
}
#[tokio::test]
async fn analytics_batch_is_recorded() {
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": "screen_viewed",
"occurred_at": Utc::now().to_rfc3339(),
"attributes": [
{"key": "screen_name", "value": "feed"}
]
},
{
"event_name": "cta_pressed",
"occurred_at": Utc::now().to_rfc3339()
}
]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
let (event_count, attribute_count) = state.analytics_counts().await;
assert_eq!(event_count, 2);
assert_eq!(attribute_count, 1);
}
#[tokio::test]
async fn event_markets_and_current_odds_work() {
let app = build_router(AppState::new(AppConfig::default(), None, None));