add study flow endpoints
This commit is contained in:
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,33 @@
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
use redis::Client as RedisClient;
|
use redis::Client as RedisClient;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
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::{EventManifestSnapshot, EventSnapshot};
|
||||||
pub use crate::events::{MarketSnapshot, OutcomeSnapshot};
|
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::markets::{OddsVersionSnapshot, OutcomeOddsSnapshot};
|
||||||
|
pub use crate::settlement::SettlementSnapshot;
|
||||||
pub use crate::sessions::{SessionSnapshot, SessionStartRequest};
|
pub use crate::sessions::{SessionSnapshot, SessionStartRequest};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
analytics::AnalyticsStore,
|
||||||
|
bets::{
|
||||||
|
BetsStore, ACCEPTANCE_ACCEPTED, ACCEPTANCE_REJECTED_INVALID_MARKET,
|
||||||
|
ACCEPTANCE_REJECTED_INVALID_SESSION, ACCEPTANCE_REJECTED_TOO_LATE,
|
||||||
|
},
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
error::AppError,
|
error::AppError,
|
||||||
events::{EventsStore},
|
events::{EventsStore},
|
||||||
|
experiments::ExperimentsStore,
|
||||||
|
localization::LocalizationStore,
|
||||||
markets::MarketsStore,
|
markets::MarketsStore,
|
||||||
|
settlement::SettlementsStore,
|
||||||
sessions::{SessionDefaults, SessionsStore},
|
sessions::{SessionDefaults, SessionsStore},
|
||||||
users::{UserCreateRequest, UsersStore},
|
users::{UserCreateRequest, UsersStore},
|
||||||
};
|
};
|
||||||
@@ -25,6 +39,11 @@ pub struct AppState {
|
|||||||
pub database_pool: Option<PgPool>,
|
pub database_pool: Option<PgPool>,
|
||||||
pub redis_client: Option<RedisClient>,
|
pub redis_client: Option<RedisClient>,
|
||||||
events: EventsStore,
|
events: EventsStore,
|
||||||
|
bets: BetsStore,
|
||||||
|
settlements: SettlementsStore,
|
||||||
|
experiments: ExperimentsStore,
|
||||||
|
localization: LocalizationStore,
|
||||||
|
analytics: AnalyticsStore,
|
||||||
markets: MarketsStore,
|
markets: MarketsStore,
|
||||||
users: UsersStore,
|
users: UsersStore,
|
||||||
sessions: SessionsStore,
|
sessions: SessionsStore,
|
||||||
@@ -42,6 +61,11 @@ impl AppState {
|
|||||||
database_pool,
|
database_pool,
|
||||||
redis_client,
|
redis_client,
|
||||||
events: EventsStore::new(),
|
events: EventsStore::new(),
|
||||||
|
bets: BetsStore::new(),
|
||||||
|
settlements: SettlementsStore::new(),
|
||||||
|
experiments: ExperimentsStore::new(),
|
||||||
|
localization: LocalizationStore::new(),
|
||||||
|
analytics: AnalyticsStore::new(),
|
||||||
markets: MarketsStore::new(),
|
markets: MarketsStore::new(),
|
||||||
users: UsersStore::new(),
|
users: UsersStore::new(),
|
||||||
sessions: SessionsStore::new(),
|
sessions: SessionsStore::new(),
|
||||||
@@ -82,6 +106,150 @@ impl AppState {
|
|||||||
self.sessions.current_session().await
|
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> {
|
pub async fn end_session(&self) -> Result<SessionSnapshot, AppError> {
|
||||||
self.sessions
|
self.sessions
|
||||||
.end_session()
|
.end_session()
|
||||||
@@ -101,6 +269,52 @@ impl AppState {
|
|||||||
self.events.get_manifest(event_id).await
|
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>> {
|
pub async fn markets_for_event(&self, event_id: Uuid) -> Option<Vec<MarketSnapshot>> {
|
||||||
self.markets.markets_for_event(event_id).await
|
self.markets.markets_for_event(event_id).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod analytics;
|
||||||
|
pub mod bets;
|
||||||
pub mod app_state;
|
pub mod app_state;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod experiments;
|
||||||
|
pub mod localization;
|
||||||
pub mod markets;
|
pub mod markets;
|
||||||
|
pub mod settlement;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-10
@@ -36,6 +36,7 @@ pub struct MarketsStore {
|
|||||||
struct MarketsState {
|
struct MarketsState {
|
||||||
markets_by_event_id: HashMap<Uuid, Vec<MarketSnapshot>>,
|
markets_by_event_id: HashMap<Uuid, Vec<MarketSnapshot>>,
|
||||||
markets_by_id: HashMap<Uuid, MarketSnapshot>,
|
markets_by_id: HashMap<Uuid, MarketSnapshot>,
|
||||||
|
outcomes_by_id: HashMap<Uuid, OutcomeSnapshot>,
|
||||||
current_odds_by_market_id: HashMap<Uuid, OddsVersionSnapshot>,
|
current_odds_by_market_id: HashMap<Uuid, OddsVersionSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,15 +55,7 @@ impl MarketsStore {
|
|||||||
let home_outcome_id = Uuid::parse_str("44444444-4444-4444-4444-444444444444").expect("valid uuid");
|
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 away_outcome_id = Uuid::parse_str("55555555-5555-5555-5555-555555555555").expect("valid uuid");
|
||||||
|
|
||||||
let market = MarketSnapshot {
|
let outcomes = vec![
|
||||||
id: market_id,
|
|
||||||
event_id,
|
|
||||||
question_key: "market.sample.winner".to_string(),
|
|
||||||
market_type: "winner".to_string(),
|
|
||||||
status: "open".to_string(),
|
|
||||||
lock_at: preview_lock_at,
|
|
||||||
settlement_rule_key: "settle_on_match_winner".to_string(),
|
|
||||||
outcomes: vec![
|
|
||||||
OutcomeSnapshot {
|
OutcomeSnapshot {
|
||||||
id: home_outcome_id,
|
id: home_outcome_id,
|
||||||
market_id,
|
market_id,
|
||||||
@@ -77,7 +70,17 @@ impl MarketsStore {
|
|||||||
label_key: "outcome.away".to_string(),
|
label_key: "outcome.away".to_string(),
|
||||||
sort_order: 2,
|
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: preview_lock_at,
|
||||||
|
settlement_rule_key: "settle_on_match_winner".to_string(),
|
||||||
|
outcomes: outcomes.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let odds_version = OddsVersionSnapshot {
|
let odds_version = OddsVersionSnapshot {
|
||||||
@@ -112,6 +115,11 @@ impl MarketsStore {
|
|||||||
let mut markets_by_id = HashMap::new();
|
let mut markets_by_id = HashMap::new();
|
||||||
markets_by_id.insert(market_id, market);
|
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();
|
let mut current_odds_by_market_id = HashMap::new();
|
||||||
current_odds_by_market_id.insert(market_id, odds_version);
|
current_odds_by_market_id.insert(market_id, odds_version);
|
||||||
|
|
||||||
@@ -119,6 +127,7 @@ impl MarketsStore {
|
|||||||
inner: Arc::new(RwLock::new(MarketsState {
|
inner: Arc::new(RwLock::new(MarketsState {
|
||||||
markets_by_event_id,
|
markets_by_event_id,
|
||||||
markets_by_id,
|
markets_by_id,
|
||||||
|
outcomes_by_id,
|
||||||
current_odds_by_market_id,
|
current_odds_by_market_id,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
@@ -140,6 +149,14 @@ impl MarketsStore {
|
|||||||
Some(market.clone())
|
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> {
|
pub async fn current_odds(&self, market_id: Uuid) -> Option<OddsVersionSnapshot> {
|
||||||
let state = self.inner.read().await;
|
let state = self.inner.read().await;
|
||||||
let Some(odds) = state.current_odds_by_market_id.get(&market_id) else {
|
let Some(odds) = state.current_odds_by_market_id.get(&market_id) else {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod analytics;
|
||||||
|
pub mod bets;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod experiments;
|
||||||
|
pub mod localization;
|
||||||
pub mod markets;
|
pub mod markets;
|
||||||
|
pub mod results;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|
||||||
use axum::{routing::{get, post}, Router};
|
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}", get(events::show))
|
||||||
.route("/api/v1/events/{event_id}/manifest", get(events::manifest))
|
.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}/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/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/start", post(session::start))
|
||||||
.route("/api/v1/session/end", post(session::end))
|
.route("/api/v1/session/end", post(session::end))
|
||||||
.route("/api/v1/session/me", get(session::me))
|
.route("/api/v1/session/me", get(session::me))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use axum::{body::{to_bytes, Body}, http::{Request, StatusCode}};
|
use axum::{body::{to_bytes, Body}, http::{Request, StatusCode}};
|
||||||
|
use chrono::Utc;
|
||||||
use hermes_backend::{app_state::AppState, build_router, config::AppConfig};
|
use hermes_backend::{app_state::AppState, build_router, config::AppConfig};
|
||||||
use serde_json as json;
|
use serde_json as json;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
@@ -130,6 +131,285 @@ async fn feed_next_returns_a_manifestable_event() {
|
|||||||
assert!(manifest["markets"].as_array().unwrap().len() >= 1);
|
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]
|
#[tokio::test]
|
||||||
async fn event_markets_and_current_odds_work() {
|
async fn event_markets_and_current_odds_work() {
|
||||||
let app = build_router(AppState::new(AppConfig::default(), None, None));
|
let app = build_router(AppState::new(AppConfig::default(), None, None));
|
||||||
|
|||||||
Reference in New Issue
Block a user