From 352ad11bc49f5a7d66e321b3e180c4a890a2b0ed Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 12 Aug 2026 19:49:14 +0000 Subject: [PATCH] feat(content): production content-pack loader + referenced-definition preflight Production real-profile content is loaded via an explicit path, NOT the dev-only OPENFUT_DEV_CONTENT_GAMES gate: - Config.content_packs from env OPENFUT_CONTENT_PACKS (comma-sep file paths). - CardDb::load_pack(path): merge an explicit CardDefinition[] production pack. - app::build loads dev packs then production packs. Preflight (app::build, always on): every owned_cards.card_id MUST resolve to a loaded CardDefinition. A real profile with owned players but even ONE missing definition fails LOUDLY instead of silently serving an empty /collection; an empty owned_cards table (fresh DB / tests) passes. 2 preflight integration tests (missing def fails, loaded def passes). clippy -D warnings clean; full suite 151 tests green. --- src/app.rs | 72 +++++++++++++++++++--- src/config.rs | 16 +++++ src/lib.rs | 1 + src/services/card_db.rs | 17 ++++++ tests/content_preflight_test.rs | 104 ++++++++++++++++++++++++++++++++ 5 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 tests/content_preflight_test.rs diff --git a/src/app.rs b/src/app.rs index eddfa94..5ebb561 100644 --- a/src/app.rs +++ b/src/app.rs @@ -46,7 +46,34 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { for game in &cfg.dev_content_games { card_db.load_game_dev(&cfg.data_dir, game)?; } + for pack in &cfg.content_packs { + card_db.load_pack(pack)?; + } let card_db = Arc::new(card_db); + + // Content preflight: every owned card MUST reference a loaded CardDefinition. + // A real profile with owned players but missing definitions fails LOUDLY here + // rather than silently serving an empty /collection. Empty owned_cards (fresh + // DB, tests) passes. A SINGLE missing definition is caught, not only the + // zero-loaded case. + { + let referenced: Vec = + sqlx::query_scalar("SELECT DISTINCT card_id FROM owned_cards") + .fetch_all(&pool) + .await?; + let missing: Vec = referenced + .into_iter() + .filter(|id| card_db.get(id).is_none()) + .collect(); + if !missing.is_empty() { + let sample: Vec<&String> = missing.iter().take(5).collect(); + anyhow::bail!( + "content preflight failed: {} owned card(s) reference CardDefinitionId(s) not loaded (e.g. {:?}). Load the production content pack via OPENFUT_CONTENT_PACKS.", + missing.len(), + sample + ); + } + } let pack_defs = Arc::new(load_pack_definitions(&cfg.data_dir)?); let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?); let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?); @@ -178,7 +205,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/squads/:squad_id", get(routes::squad::get_squad_by_id)) .route("/squads/:squad_id", delete(routes::squad::delete_squad)) .route("/objectives", get(routes::objectives::get_objectives)) - .route("/objectives/:objective_id", get(routes::objectives::get_objective)) + .route( + "/objectives/:objective_id", + get(routes::objectives::get_objective), + ) .route( "/objectives/claim", post(routes::objectives::post_claim_objective), @@ -196,7 +226,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/market", get(routes::market::get_market)) .route("/market/buy", post(routes::market::post_market_buy)) .route("/market/sell", post(routes::market::post_market_sell)) - .route("/market/trade-history", get(routes::market::get_trade_history)) + .route( + "/market/trade-history", + get(routes::market::get_trade_history), + ) .route("/market/refresh", post(routes::market::post_market_refresh)) .route("/market/my-listings", get(routes::market::get_my_listings)) .route( @@ -211,15 +244,36 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/settings", get(routes::settings::get_settings)) .route("/settings", put(routes::settings::put_settings)) .route("/division", get(routes::division::get_division)) - .route("/division/history", get(routes::division::get_division_history)) - .route("/division/leaderboard", get(routes::division::get_division_leaderboard)) + .route( + "/division/history", + get(routes::division::get_division_history), + ) + .route( + "/division/leaderboard", + get(routes::division::get_division_leaderboard), + ) .route("/achievements", get(routes::achievements::get_achievements)) - .route("/notifications", get(routes::notifications::get_notifications)) - .route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read)) - .route("/notifications/:id/read", patch(routes::notifications::mark_notification_read)) + .route( + "/notifications", + get(routes::notifications::get_notifications), + ) + .route( + "/notifications/read-all", + post(routes::notifications::mark_all_notifications_read), + ) + .route( + "/notifications/:id/read", + patch(routes::notifications::mark_notification_read), + ) .route("/fut-champs", get(routes::fut_champs::get_fut_champs)) - .route("/fut-champs/start", post(routes::fut_champs::post_start_fut_champs)) - .route("/fut-champs/history", get(routes::fut_champs::get_champs_history)) + .route( + "/fut-champs/start", + post(routes::fut_champs::post_start_fut_champs), + ) + .route( + "/fut-champs/history", + get(routes::fut_champs::get_champs_history), + ) .route( "/fut-champs/:session_id/result", post(routes::fut_champs::post_champs_result), diff --git a/src/config.rs b/src/config.rs index d24c00c..902ee7f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use std::path::PathBuf; #[derive(Debug, Clone)] pub struct Config { @@ -10,6 +11,11 @@ pub struct Config { /// loaded IN ADDITION to the default `data/cards` catalog. Empty by default — /// default/test content is never affected unless a game is named here. pub dev_content_games: Vec, + /// Explicit PRODUCTION content pack file paths (each a `CardDefinition[]` + /// JSON), loaded IN ADDITION to `data/cards` and any dev pack. This is the + /// production real-profile content path — deliberately NOT gated behind the + /// dev-only `dev_content_games`. + pub content_packs: Vec, } impl Config { @@ -33,6 +39,16 @@ impl Config { .collect() }) .unwrap_or_default(), + content_packs: std::env::var("OPENFUT_CONTENT_PACKS") + .ok() + .map(|v| { + v.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .collect() + }) + .unwrap_or_default(), }) } } diff --git a/src/lib.rs b/src/lib.rs index 5607d43..f59f019 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,7 @@ pub async fn build_app(pool: db::Pool, data_dir: &str) -> Result { data_dir: data_dir.to_string(), max_connections: 1, dev_content_games: Vec::new(), + content_packs: Vec::new(), }; app::build(pool, cfg).await } diff --git a/src/services/card_db.rs b/src/services/card_db.rs index 45c3374..0ee20dd 100644 --- a/src/services/card_db.rs +++ b/src/services/card_db.rs @@ -62,6 +62,23 @@ impl CardDb { Ok(n) } + /// Merge an explicit PRODUCTION content pack file (a single + /// `CardDefinition[]`). Unlike [`CardDb::load_game_dev`] this takes a direct + /// path (the real-profile import emits one) and is the production content + /// path — not gated behind dev content. Returns the number merged. + pub fn load_pack(&mut self, path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("reading content pack {path:?}"))?; + let batch: Vec = + serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?; + let n = batch.len(); + for card in batch { + self.cards.insert(card.id.clone(), card); + } + tracing::info!("Loaded {} production card definitions from {:?}", n, path); + Ok(n) + } + pub fn get(&self, id: &str) -> Option<&CardDefinition> { self.cards.get(id) } diff --git a/tests/content_preflight_test.rs b/tests/content_preflight_test.rs new file mode 100644 index 0000000..173f24e --- /dev/null +++ b/tests/content_preflight_test.rs @@ -0,0 +1,104 @@ +//! Content preflight: a real profile with owned players but a missing +//! CardDefinition must fail startup LOUDLY, never serve a silent empty club. + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use openfut_core::services::card_db::CardDb; +use tower::ServiceExt; + +async fn fresh_pool() -> sqlx::SqlitePool { + let p = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!("./migrations") + .run(&p) + .await + .expect("migrations"); + p +} + +async fn create_profile(app: &axum::Router) { + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/local") + .header("content-type", "application/json") + .body(Body::from(r#"{"username":"CAGE"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "auth/local should create a profile+club" + ); +} + +async fn insert_owned(pool: &sqlx::SqlitePool, id: &str, club_id: &str, card_id: &str) { + sqlx::query( + "INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \ + VALUES (?, ?, ?, 0, NULL, ?)", + ) + .bind(id) + .bind(club_id) + .bind(card_id) + .bind("2026-01-01T00:00:00Z") + .execute(pool) + .await + .unwrap(); +} + +#[tokio::test] +async fn preflight_fails_on_owned_card_missing_definition() { + let pool = fresh_pool().await; + // first build is fine: no owned cards yet. + let app = openfut_core::build_app(pool.clone(), "data").await.unwrap(); + create_profile(&app).await; + let club: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1") + .fetch_one(&pool) + .await + .unwrap(); + insert_owned(&pool, "oc-bogus", &club, "fifa17_definitely_missing_999999").await; + + // second build must now fail preflight: one owned card references a def that + // is not loaded — must not silently serve an empty collection. + let err = openfut_core::build_app(pool.clone(), "data") + .await + .expect_err("preflight must fail on a missing definition"); + let msg = format!("{err:#}"); + assert!( + msg.contains("content preflight failed"), + "unexpected error: {msg}" + ); +} + +#[tokio::test] +async fn preflight_passes_when_owned_card_definition_is_loaded() { + let pool = fresh_pool().await; + // pick a definition that IS in the default data/cards catalog. + let valid_id = CardDb::load("data") + .unwrap() + .all() + .first() + .map(|c| c.id.clone()) + .expect("data/cards must be non-empty"); + + let app = openfut_core::build_app(pool.clone(), "data").await.unwrap(); + create_profile(&app).await; + let club: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1") + .fetch_one(&pool) + .await + .unwrap(); + insert_owned(&pool, "oc-valid", &club, &valid_id).await; + + let _app = openfut_core::build_app(pool.clone(), "data") + .await + .expect("preflight passes when the owned card's definition is loaded"); +}