steg 2
This commit is contained in:
@@ -17,11 +17,17 @@ authoritative for timing, odds, settlement, and analytics.
|
||||
- `backend/` Rust API and migrations
|
||||
- `contracts/` OpenAPI and localization contracts
|
||||
- `docs/` architecture, schema, and state-machine docs
|
||||
- `docs/conventions.md` Rust style notes
|
||||
- `infra/` local environment and deployment assets
|
||||
- `mobile/` native app scaffolds
|
||||
- `fixtures/` sample media and test data
|
||||
- `scripts/` helper automation
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
- JSON-heavy Rust files should import `serde_json as json`
|
||||
- Prefer happy-path functions and early `let Some(...) = ... else { ... }` exits
|
||||
|
||||
## Status
|
||||
|
||||
The repository has been initialized with the first planning and backend
|
||||
|
||||
+60
-63
@@ -1,13 +1,19 @@
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use redis::Client as RedisClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{config::AppConfig, error::AppError};
|
||||
pub use crate::events::{EventManifestSnapshot, EventSnapshot};
|
||||
pub use crate::sessions::{SessionSnapshot, SessionStartRequest};
|
||||
|
||||
use crate::{
|
||||
config::AppConfig,
|
||||
error::AppError,
|
||||
events::{EventsStore},
|
||||
sessions::{SessionDefaults, SessionsStore},
|
||||
users::{UserCreateRequest, UsersStore},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
@@ -15,32 +21,9 @@ pub struct AppState {
|
||||
pub started_at: Instant,
|
||||
pub database_pool: Option<PgPool>,
|
||||
pub redis_client: Option<RedisClient>,
|
||||
current_user_id: Uuid,
|
||||
session: Arc<RwLock<Option<SessionSnapshot>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SessionStartRequest {
|
||||
pub locale_code: Option<String>,
|
||||
pub device_platform: Option<String>,
|
||||
pub device_model: Option<String>,
|
||||
pub os_version: Option<String>,
|
||||
pub app_version: Option<String>,
|
||||
pub experiment_variant: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SessionSnapshot {
|
||||
pub session_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub ended_at: Option<DateTime<Utc>>,
|
||||
pub experiment_variant: String,
|
||||
pub app_version: String,
|
||||
pub device_model: Option<String>,
|
||||
pub os_version: Option<String>,
|
||||
pub locale_code: String,
|
||||
pub device_platform: String,
|
||||
events: EventsStore,
|
||||
users: UsersStore,
|
||||
sessions: SessionsStore,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -54,49 +37,63 @@ impl AppState {
|
||||
started_at: Instant::now(),
|
||||
database_pool,
|
||||
redis_client,
|
||||
current_user_id: Uuid::new_v4(),
|
||||
session: Arc::new(RwLock::new(None)),
|
||||
events: EventsStore::new(),
|
||||
users: UsersStore::new(),
|
||||
sessions: SessionsStore::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_session(&self, request: SessionStartRequest) -> SessionSnapshot {
|
||||
let session = SessionSnapshot {
|
||||
session_id: Uuid::new_v4(),
|
||||
user_id: self.current_user_id,
|
||||
started_at: Utc::now(),
|
||||
ended_at: None,
|
||||
experiment_variant: request
|
||||
.experiment_variant
|
||||
.unwrap_or_else(|| self.config.default_experiment_variant.clone()),
|
||||
app_version: request
|
||||
.app_version
|
||||
.unwrap_or_else(|| self.config.app_version.clone()),
|
||||
device_model: request.device_model,
|
||||
os_version: request.os_version,
|
||||
locale_code: request
|
||||
.locale_code
|
||||
.unwrap_or_else(|| self.config.default_locale.clone()),
|
||||
device_platform: request
|
||||
.device_platform
|
||||
.unwrap_or_else(|| self.config.default_device_platform.clone()),
|
||||
};
|
||||
let user = self
|
||||
.users
|
||||
.upsert(UserCreateRequest {
|
||||
external_ref: request.external_ref.clone(),
|
||||
preferred_language: request
|
||||
.locale_code
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.config.default_locale.clone()),
|
||||
device_platform: request
|
||||
.device_platform
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.config.default_device_platform.clone()),
|
||||
})
|
||||
.await;
|
||||
|
||||
*self.session.write().await = Some(session.clone());
|
||||
session
|
||||
self.sessions
|
||||
.start_session(
|
||||
user.id,
|
||||
request,
|
||||
SessionDefaults {
|
||||
default_locale: self.config.default_locale.clone(),
|
||||
default_device_platform: self.config.default_device_platform.clone(),
|
||||
default_app_version: self.config.app_version.clone(),
|
||||
default_experiment_variant: self.config.default_experiment_variant.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn current_session(&self) -> Option<SessionSnapshot> {
|
||||
self.session.read().await.clone()
|
||||
self.sessions.current_session().await
|
||||
}
|
||||
|
||||
pub async fn end_session(&self) -> Result<SessionSnapshot, AppError> {
|
||||
let mut guard = self.session.write().await;
|
||||
let mut session = guard
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::not_found("No active session"))?;
|
||||
session.ended_at = Some(Utc::now());
|
||||
*guard = Some(session.clone());
|
||||
Ok(session)
|
||||
self.sessions
|
||||
.end_session()
|
||||
.await
|
||||
.map_err(|_| AppError::not_found("No active session"))
|
||||
}
|
||||
|
||||
pub async fn next_event(&self) -> Option<EventSnapshot> {
|
||||
self.events.next_event().await
|
||||
}
|
||||
|
||||
pub async fn event(&self, event_id: Uuid) -> Option<EventSnapshot> {
|
||||
self.events.get_event(event_id).await
|
||||
}
|
||||
|
||||
pub async fn event_manifest(&self, event_id: Uuid) -> Option<EventManifestSnapshot> {
|
||||
self.events.get_manifest(event_id).await
|
||||
}
|
||||
|
||||
pub fn database_ready(&self) -> bool {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct EventSnapshot {
|
||||
pub id: Uuid,
|
||||
pub sport_type: String,
|
||||
pub source_ref: String,
|
||||
pub title_en: String,
|
||||
pub title_sv: String,
|
||||
pub status: String,
|
||||
pub preview_start_ms: i64,
|
||||
pub preview_end_ms: i64,
|
||||
pub reveal_start_ms: i64,
|
||||
pub reveal_end_ms: i64,
|
||||
pub lock_at: DateTime<Utc>,
|
||||
pub settle_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct EventMediaSnapshot {
|
||||
pub id: Uuid,
|
||||
pub event_id: Uuid,
|
||||
pub media_type: String,
|
||||
pub hls_master_url: String,
|
||||
pub poster_url: Option<String>,
|
||||
pub duration_ms: i64,
|
||||
pub preview_start_ms: i64,
|
||||
pub preview_end_ms: i64,
|
||||
pub reveal_start_ms: i64,
|
||||
pub reveal_end_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OutcomeSnapshot {
|
||||
pub id: Uuid,
|
||||
pub market_id: Uuid,
|
||||
pub outcome_code: String,
|
||||
pub label_key: String,
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct MarketSnapshot {
|
||||
pub id: Uuid,
|
||||
pub event_id: Uuid,
|
||||
pub question_key: String,
|
||||
pub market_type: String,
|
||||
pub status: String,
|
||||
pub lock_at: DateTime<Utc>,
|
||||
pub settlement_rule_key: String,
|
||||
pub outcomes: Vec<OutcomeSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct EventManifestSnapshot {
|
||||
pub event: EventSnapshot,
|
||||
pub media: Vec<EventMediaSnapshot>,
|
||||
pub markets: Vec<MarketSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct EventsStore {
|
||||
inner: Arc<RwLock<EventsState>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct EventsState {
|
||||
feed_order: Vec<Uuid>,
|
||||
manifests_by_event_id: HashMap<Uuid, EventManifestSnapshot>,
|
||||
}
|
||||
|
||||
impl EventsStore {
|
||||
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 preview_start = 0;
|
||||
let preview_end = 45_000;
|
||||
let reveal_start = 50_000;
|
||||
let reveal_end = 90_000;
|
||||
let lock_at = Utc::now() + ChronoDuration::minutes(10);
|
||||
let settle_at = Utc::now() + ChronoDuration::minutes(25);
|
||||
|
||||
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: preview_start,
|
||||
preview_end_ms: preview_end,
|
||||
reveal_start_ms: reveal_start,
|
||||
reveal_end_ms: reveal_end,
|
||||
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: preview_start,
|
||||
preview_end_ms: preview_end,
|
||||
reveal_start_ms: reveal_start,
|
||||
reveal_end_ms: reveal_end,
|
||||
};
|
||||
|
||||
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: vec![
|
||||
OutcomeSnapshot {
|
||||
id: Uuid::parse_str("44444444-4444-4444-4444-444444444444").expect("valid uuid"),
|
||||
market_id,
|
||||
outcome_code: "home".to_string(),
|
||||
label_key: "outcome.home".to_string(),
|
||||
sort_order: 1,
|
||||
},
|
||||
OutcomeSnapshot {
|
||||
id: Uuid::parse_str("55555555-5555-5555-5555-555555555555").expect("valid uuid"),
|
||||
market_id,
|
||||
outcome_code: "away".to_string(),
|
||||
label_key: "outcome.away".to_string(),
|
||||
sort_order: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let manifest = EventManifestSnapshot {
|
||||
event,
|
||||
media: vec![media],
|
||||
markets: vec![market],
|
||||
};
|
||||
|
||||
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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn next_event(&self) -> Option<EventSnapshot> {
|
||||
let state = self.inner.read().await;
|
||||
let Some(event_id) = state.feed_order.first() else {
|
||||
return None;
|
||||
};
|
||||
let Some(manifest) = state.manifests_by_event_id.get(event_id) else {
|
||||
return None;
|
||||
};
|
||||
Some(manifest.event.clone())
|
||||
}
|
||||
|
||||
pub async fn get_event(&self, event_id: Uuid) -> Option<EventSnapshot> {
|
||||
let state = self.inner.read().await;
|
||||
let Some(manifest) = state.manifests_by_event_id.get(&event_id) else {
|
||||
return None;
|
||||
};
|
||||
Some(manifest.event.clone())
|
||||
}
|
||||
|
||||
pub async fn get_manifest(&self, event_id: Uuid) -> Option<EventManifestSnapshot> {
|
||||
let state = self.inner.read().await;
|
||||
let Some(manifest) = state.manifests_by_event_id.get(&event_id) else {
|
||||
return None;
|
||||
};
|
||||
Some(manifest.clone())
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@
|
||||
pub mod app_state;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod events;
|
||||
pub mod error;
|
||||
pub mod sessions;
|
||||
pub mod routes;
|
||||
pub mod users;
|
||||
pub mod telemetry;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
use axum::{extract::{Extension, Path}, Json};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{app_state::{AppState, EventManifestSnapshot, EventSnapshot}, error::AppError};
|
||||
|
||||
pub async fn next(Extension(state): Extension<AppState>) -> Result<Json<EventSnapshot>, AppError> {
|
||||
let Some(event) = state.next_event().await else {
|
||||
return Err(AppError::not_found("No event available"));
|
||||
};
|
||||
Ok(Json(event))
|
||||
}
|
||||
|
||||
pub async fn show(
|
||||
Path(event_id): Path<Uuid>,
|
||||
Extension(state): Extension<AppState>,
|
||||
) -> Result<Json<EventSnapshot>, AppError> {
|
||||
let Some(event) = state.event(event_id).await else {
|
||||
return Err(AppError::not_found("Event not found"));
|
||||
};
|
||||
Ok(Json(event))
|
||||
}
|
||||
|
||||
pub async fn manifest(
|
||||
Path(event_id): Path<Uuid>,
|
||||
Extension(state): Extension<AppState>,
|
||||
) -> Result<Json<EventManifestSnapshot>, AppError> {
|
||||
let Some(manifest) = state.event_manifest(event_id).await else {
|
||||
return Err(AppError::not_found("Event manifest not found"));
|
||||
};
|
||||
Ok(Json(manifest))
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod health;
|
||||
pub mod events;
|
||||
pub mod session;
|
||||
|
||||
use axum::{routing::{get, post}, Router};
|
||||
@@ -6,6 +7,9 @@ use axum::{routing::{get, post}, Router};
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health::handler))
|
||||
.route("/api/v1/feed/next", get(events::next))
|
||||
.route("/api/v1/events/{event_id}", get(events::show))
|
||||
.route("/api/v1/events/{event_id}/manifest", get(events::manifest))
|
||||
.route("/api/v1/session/start", post(session::start))
|
||||
.route("/api/v1/session/end", post(session::end))
|
||||
.route("/api/v1/session/me", get(session::me))
|
||||
|
||||
@@ -16,9 +16,8 @@ pub async fn end(Extension(state): Extension<AppState>) -> Result<Json<SessionSn
|
||||
}
|
||||
|
||||
pub async fn me(Extension(state): Extension<AppState>) -> Result<Json<SessionSnapshot>, AppError> {
|
||||
let session = state
|
||||
.current_session()
|
||||
.await
|
||||
.ok_or_else(|| AppError::not_found("No active session"))?;
|
||||
let Some(session) = state.current_session().await else {
|
||||
return Err(AppError::not_found("No active session"));
|
||||
};
|
||||
Ok(Json(session))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SessionStartRequest {
|
||||
pub locale_code: Option<String>,
|
||||
pub device_platform: Option<String>,
|
||||
pub device_model: Option<String>,
|
||||
pub os_version: Option<String>,
|
||||
pub app_version: Option<String>,
|
||||
pub experiment_variant: Option<String>,
|
||||
pub external_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SessionDefaults {
|
||||
pub default_locale: String,
|
||||
pub default_device_platform: String,
|
||||
pub default_app_version: String,
|
||||
pub default_experiment_variant: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SessionSnapshot {
|
||||
pub session_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub ended_at: Option<DateTime<Utc>>,
|
||||
pub experiment_variant: String,
|
||||
pub app_version: String,
|
||||
pub device_model: Option<String>,
|
||||
pub os_version: Option<String>,
|
||||
pub locale_code: String,
|
||||
pub device_platform: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SessionsStore {
|
||||
inner: Arc<RwLock<SessionsState>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SessionsState {
|
||||
sessions_by_id: HashMap<Uuid, SessionSnapshot>,
|
||||
current_session_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SessionStoreError {
|
||||
#[error("no active session")]
|
||||
NoActiveSession,
|
||||
}
|
||||
|
||||
impl SessionsStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub async fn start_session(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
request: SessionStartRequest,
|
||||
defaults: SessionDefaults,
|
||||
) -> SessionSnapshot {
|
||||
let snapshot = SessionSnapshot {
|
||||
session_id: Uuid::new_v4(),
|
||||
user_id,
|
||||
started_at: Utc::now(),
|
||||
ended_at: None,
|
||||
experiment_variant: request
|
||||
.experiment_variant
|
||||
.unwrap_or(defaults.default_experiment_variant),
|
||||
app_version: request.app_version.unwrap_or(defaults.default_app_version),
|
||||
device_model: request.device_model,
|
||||
os_version: request.os_version,
|
||||
locale_code: request.locale_code.unwrap_or(defaults.default_locale),
|
||||
device_platform: request
|
||||
.device_platform
|
||||
.unwrap_or(defaults.default_device_platform),
|
||||
};
|
||||
|
||||
let mut state = self.inner.write().await;
|
||||
state.current_session_id = Some(snapshot.session_id);
|
||||
state.sessions_by_id.insert(snapshot.session_id, snapshot.clone());
|
||||
snapshot
|
||||
}
|
||||
|
||||
pub async fn current_session(&self) -> Option<SessionSnapshot> {
|
||||
let state = self.inner.read().await;
|
||||
let Some(session_id) = state.current_session_id else {
|
||||
return None;
|
||||
};
|
||||
state.sessions_by_id.get(&session_id).cloned()
|
||||
}
|
||||
|
||||
pub async fn end_session(&self) -> Result<SessionSnapshot, SessionStoreError> {
|
||||
let mut state = self.inner.write().await;
|
||||
let Some(session_id) = state.current_session_id else {
|
||||
return Err(SessionStoreError::NoActiveSession);
|
||||
};
|
||||
|
||||
let Some(snapshot) = state.sessions_by_id.get_mut(&session_id) else {
|
||||
return Err(SessionStoreError::NoActiveSession);
|
||||
};
|
||||
snapshot.ended_at = Some(Utc::now());
|
||||
Ok(snapshot.clone())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct UserCreateRequest {
|
||||
pub external_ref: Option<String>,
|
||||
pub preferred_language: String,
|
||||
pub device_platform: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct UserSnapshot {
|
||||
pub id: Uuid,
|
||||
pub external_ref: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub preferred_language: String,
|
||||
pub device_platform: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct UsersStore {
|
||||
inner: Arc<RwLock<UsersState>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct UsersState {
|
||||
users_by_id: HashMap<Uuid, UserSnapshot>,
|
||||
user_ids_by_external_ref: HashMap<String, Uuid>,
|
||||
}
|
||||
|
||||
impl UsersStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn insert_user(
|
||||
state: &mut UsersState,
|
||||
external_ref: String,
|
||||
preferred_language: String,
|
||||
device_platform: String,
|
||||
) -> UserSnapshot {
|
||||
let snapshot = UserSnapshot {
|
||||
id: Uuid::new_v4(),
|
||||
external_ref: external_ref.clone(),
|
||||
created_at: Utc::now(),
|
||||
preferred_language,
|
||||
device_platform,
|
||||
};
|
||||
|
||||
state.user_ids_by_external_ref.insert(external_ref, snapshot.id);
|
||||
state.users_by_id.insert(snapshot.id, snapshot.clone());
|
||||
snapshot
|
||||
}
|
||||
|
||||
pub async fn upsert(&self, request: UserCreateRequest) -> UserSnapshot {
|
||||
let UserCreateRequest {
|
||||
external_ref,
|
||||
preferred_language,
|
||||
device_platform,
|
||||
} = request;
|
||||
|
||||
let external_ref = external_ref.unwrap_or_else(|| format!("anon_{}", Uuid::new_v4()));
|
||||
let mut state = self.inner.write().await;
|
||||
|
||||
let Some(user_id) = state.user_ids_by_external_ref.get(&external_ref).copied() else {
|
||||
return Self::insert_user(&mut state, external_ref, preferred_language, device_platform);
|
||||
};
|
||||
|
||||
let Some(existing) = state.users_by_id.get_mut(&user_id) else {
|
||||
return Self::insert_user(&mut state, external_ref, preferred_language, device_platform);
|
||||
};
|
||||
|
||||
existing.preferred_language = preferred_language;
|
||||
existing.device_platform = device_platform;
|
||||
existing.clone()
|
||||
}
|
||||
|
||||
pub async fn get(&self, user_id: Uuid) -> Option<UserSnapshot> {
|
||||
self.inner.read().await.users_by_id.get(&user_id).cloned()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use axum::{body::{to_bytes, Body}, http::{Request, StatusCode}};
|
||||
use hermes_backend::{app_state::AppState, build_router, config::AppConfig};
|
||||
use serde_json as json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -34,7 +35,7 @@ async fn session_start_and_me_work() {
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let json: json::Value = json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["locale_code"], "en");
|
||||
|
||||
let response = app
|
||||
@@ -44,3 +45,87 @@ async fn session_start_and_me_work() {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn participant_ref_reuses_the_same_user() {
|
||||
let app = build_router(AppState::new(AppConfig::default(), None, None));
|
||||
|
||||
let body = json::json!({
|
||||
"external_ref": "participant-001",
|
||||
"locale_code": "sv"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let first = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/session/start")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.clone()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let first_body = to_bytes(first.into_body(), usize::MAX).await.unwrap();
|
||||
let first_json: json::Value = json::from_slice(&first_body).unwrap();
|
||||
|
||||
let second = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/session/start")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let second_body = to_bytes(second.into_body(), usize::MAX).await.unwrap();
|
||||
let second_json: json::Value = json::from_slice(&second_body).unwrap();
|
||||
|
||||
assert_eq!(first_json["user_id"], second_json["user_id"]);
|
||||
assert_eq!(first_json["locale_code"], "sv");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn feed_next_returns_a_manifestable_event() {
|
||||
let app = build_router(AppState::new(AppConfig::default(), None, None));
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri("/api/v1/feed/next").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let event: json::Value = json::from_slice(&body).unwrap();
|
||||
let event_id = event["id"].as_str().unwrap().to_string();
|
||||
|
||||
assert_eq!(event["status"], "prefetch_ready");
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/events/{event_id}/manifest"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let manifest: json::Value = json::from_slice(&body).unwrap();
|
||||
|
||||
assert_eq!(manifest["event"]["id"], event_id);
|
||||
assert!(manifest["media"].as_array().unwrap().len() >= 1);
|
||||
assert!(manifest["markets"].as_array().unwrap().len() >= 1);
|
||||
}
|
||||
|
||||
@@ -278,6 +278,8 @@ components:
|
||||
SessionStartRequest:
|
||||
type: object
|
||||
properties:
|
||||
external_ref:
|
||||
type: string
|
||||
locale_code:
|
||||
type: string
|
||||
device_platform:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Conventions
|
||||
|
||||
## Rust Style
|
||||
|
||||
- Prefer `use serde_json as json;` whenever a file works with JSON values or macros.
|
||||
- Keep functions on the happy path.
|
||||
- Use early exits for optional values with `let Some(x) = maybe else { return ...; }`.
|
||||
- Avoid deep nesting when an early return makes the flow clearer.
|
||||
Reference in New Issue
Block a user