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.
This commit is contained in:
+63
-9
@@ -46,7 +46,34 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
for game in &cfg.dev_content_games {
|
for game in &cfg.dev_content_games {
|
||||||
card_db.load_game_dev(&cfg.data_dir, game)?;
|
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);
|
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<String> =
|
||||||
|
sqlx::query_scalar("SELECT DISTINCT card_id FROM owned_cards")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
let missing: Vec<String> = 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 pack_defs = Arc::new(load_pack_definitions(&cfg.data_dir)?);
|
||||||
let obj_defs = Arc::new(load_objective_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)?);
|
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
|
||||||
@@ -178,7 +205,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
|
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
|
||||||
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
|
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
|
||||||
.route("/objectives", get(routes::objectives::get_objectives))
|
.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(
|
.route(
|
||||||
"/objectives/claim",
|
"/objectives/claim",
|
||||||
post(routes::objectives::post_claim_objective),
|
post(routes::objectives::post_claim_objective),
|
||||||
@@ -196,7 +226,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/market", get(routes::market::get_market))
|
.route("/market", get(routes::market::get_market))
|
||||||
.route("/market/buy", post(routes::market::post_market_buy))
|
.route("/market/buy", post(routes::market::post_market_buy))
|
||||||
.route("/market/sell", post(routes::market::post_market_sell))
|
.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/refresh", post(routes::market::post_market_refresh))
|
||||||
.route("/market/my-listings", get(routes::market::get_my_listings))
|
.route("/market/my-listings", get(routes::market::get_my_listings))
|
||||||
.route(
|
.route(
|
||||||
@@ -211,15 +244,36 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/settings", get(routes::settings::get_settings))
|
.route("/settings", get(routes::settings::get_settings))
|
||||||
.route("/settings", put(routes::settings::put_settings))
|
.route("/settings", put(routes::settings::put_settings))
|
||||||
.route("/division", get(routes::division::get_division))
|
.route("/division", get(routes::division::get_division))
|
||||||
.route("/division/history", get(routes::division::get_division_history))
|
.route(
|
||||||
.route("/division/leaderboard", get(routes::division::get_division_leaderboard))
|
"/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("/achievements", get(routes::achievements::get_achievements))
|
||||||
.route("/notifications", get(routes::notifications::get_notifications))
|
.route(
|
||||||
.route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read))
|
"/notifications",
|
||||||
.route("/notifications/:id/read", patch(routes::notifications::mark_notification_read))
|
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", get(routes::fut_champs::get_fut_champs))
|
||||||
.route("/fut-champs/start", post(routes::fut_champs::post_start_fut_champs))
|
.route(
|
||||||
.route("/fut-champs/history", get(routes::fut_champs::get_champs_history))
|
"/fut-champs/start",
|
||||||
|
post(routes::fut_champs::post_start_fut_champs),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/fut-champs/history",
|
||||||
|
get(routes::fut_champs::get_champs_history),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/fut-champs/:session_id/result",
|
"/fut-champs/:session_id/result",
|
||||||
post(routes::fut_champs::post_champs_result),
|
post(routes::fut_champs::post_champs_result),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@@ -10,6 +11,11 @@ pub struct Config {
|
|||||||
/// loaded IN ADDITION to the default `data/cards` catalog. Empty by default —
|
/// loaded IN ADDITION to the default `data/cards` catalog. Empty by default —
|
||||||
/// default/test content is never affected unless a game is named here.
|
/// default/test content is never affected unless a game is named here.
|
||||||
pub dev_content_games: Vec<String>,
|
pub dev_content_games: Vec<String>,
|
||||||
|
/// 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<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -33,6 +39,16 @@ impl Config {
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default(),
|
.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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub async fn build_app(pool: db::Pool, data_dir: &str) -> Result<Router> {
|
|||||||
data_dir: data_dir.to_string(),
|
data_dir: data_dir.to_string(),
|
||||||
max_connections: 1,
|
max_connections: 1,
|
||||||
dev_content_games: Vec::new(),
|
dev_content_games: Vec::new(),
|
||||||
|
content_packs: Vec::new(),
|
||||||
};
|
};
|
||||||
app::build(pool, cfg).await
|
app::build(pool, cfg).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,23 @@ impl CardDb {
|
|||||||
Ok(n)
|
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<usize> {
|
||||||
|
let content = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("reading content pack {path:?}"))?;
|
||||||
|
let batch: Vec<CardDefinition> =
|
||||||
|
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> {
|
pub fn get(&self, id: &str) -> Option<&CardDefinition> {
|
||||||
self.cards.get(id)
|
self.cards.get(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user