//! 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"); } /// The LOAD-BEARING backwards-compatibility property: `source_rating` was added /// to `CardDefinition` long after packs shipped, and `CardDefinition` has no /// `#[serde(default)]`. Every already-emitted pack omits the key, so a pack /// without it MUST still parse — and land as `None`, never as a fabricated 0 /// that a tier rule would read as bronze. #[test] fn content_pack_without_source_rating_still_parses() { let dir = tempfile::tempdir().unwrap(); let pack = dir.path().join("legacy-pack.json"); std::fs::write( &pack, r#"[{"id":"legacy_1","name":"Legacy Player","overall":84,"position":"ST", "nation":"Nation","league":"League","club":"Club","pace":80, "shooting":85,"passing":70,"dribbling":82,"defending":40, "physical":75,"rarity":"gold","image_path":null}]"#, ) .unwrap(); let mut db = CardDb { cards: Default::default(), }; assert_eq!(db.load_pack(&pack).expect("legacy pack must load"), 1); let def = db.get("legacy_1").expect("definition merged"); assert_eq!(def.overall, 84); assert!( def.source_rating.is_none(), "a missing key is None, not a substituted 0" ); } /// A pack that DOES carry `source_rating` must round-trip through `CardDb` and /// surface on `/collection` as `card.source_rating` — that envelope field is the /// only authoritative staff/manager tier source a game host has. `overall` stays /// 0 for the non-player because it feeds pricing and squad projection. #[tokio::test] async fn collection_surfaces_source_rating_for_a_non_player() { let dir = tempfile::tempdir().unwrap(); let pack = dir.path().join("staff-pack.json"); std::fs::write( &pack, r#"[{"id":"fifa17_3000083","name":"Manager","overall":0,"position":"", "nation":"","league":"","club":"","pace":0,"shooting":0,"passing":0, "dribbling":0,"defending":0,"physical":0,"rarity":"bronze", "image_path":null,"source_rating":88}]"#, ) .unwrap(); let pool = fresh_pool().await; let cfg = openfut_core::config::Config { listen_addr: "127.0.0.1:0".into(), database_url: "sqlite::memory:".into(), data_dir: "data".into(), max_connections: 1, dev_content_games: Vec::new(), content_packs: vec![pack.clone()], }; let app = openfut_core::app::build(pool.clone(), cfg.clone()) .await .expect("app build with the staff pack"); 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-manager", &club, "fifa17_3000083").await; // Rebuild so preflight sees the owned row, then read the envelope. let app = openfut_core::app::build(pool.clone(), cfg) .await .expect("preflight passes: the pack carries the definition"); let resp = app .oneshot( Request::builder() .uri("/collection") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); let coll: serde_json::Value = serde_json::from_slice(&body).unwrap(); let entry = coll["collection"] .as_array() .unwrap() .iter() .find(|c| c["owned_card_id"] == serde_json::json!("oc-manager")) .expect("the owned manager must project"); assert_eq!(entry["card"]["source_rating"], serde_json::json!(88)); assert_eq!( entry["card"]["overall"], serde_json::json!(0), "overall stays 0 for a non-player: it feeds pricing and projection" ); assert_eq!( entry["effective_overall"], serde_json::json!(0), "the tier source must NOT leak into the projected overall" ); }