Files
OpenFUT-Core/tests/content_preflight_test.rs
T
funman300 82c3d2c85a
CI / Build, lint & test (push) Successful in 3m23s
feat(core): own the EA-authored non-player definition rating
Adds `CardDefinition.source_rating: Option<u8>` -- the authoritative rating a
NON-PLAYER definition carries in EA's own tables (a staff card's `value` from
managercards/*coachcards/physiocards, a consumable's rating).

WHY NOT `overall`. `overall` feeds quick-sell pricing and squad projection, and
it is deliberately 0 for every non-player. Reusing it would silently revalue
staff, which is out of scope for the manager-contract milestone. `source_rating`
is a separate number read only by tier rules, so both pricing paths stay
byte-identical: Core's `quick_sell_coins(card.overall)` and the host's
`legacy_discard_value(item.rating)` see exactly what they saw before, and
`effective_overall` is unchanged.

MUST stay Option: CardDefinition has no `#[serde(default)]`, so a required field
would reject every already-shipped content pack, whereas a missing Option
deserializes to None. A test pins that backwards compatibility, because it is
the property that lets Core and the emitter be deployed independently.

No migration: Core does not persist definitions at all -- they are JSON content
packs parsed at startup into an immutable in-memory CardDb. `/collection`
embeds the serialized definition wholesale, so `card.source_rating` reaches the
host with no projection change.
2026-08-22 20:08:09 +00:00

208 lines
7.1 KiB
Rust

//! 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"
);
}