//! Integration tests for the FIFA 17 UTAS migration host: `/club` served from a //! fake Core through the real adapter, faithful Python passthrough against a mock //! upstream, route-classification safety, negatives, and an end-to-end socket run. use std::collections::HashMap; use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog; use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityResolver}; use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_identity::JsonIdentityStore; use openfut_utas_host::account_store::AccountStore; use openfut_utas_host::{ classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list, handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps, }; use parking_lot::Mutex; use serde_json::Value; // ── Fakes ──────────────────────────────────────────────────────────────────── /// (method, target, body) recorded by the mock upstream. type Recorded = Arc)>>>; /// A recorded squad replacement (the fields a host test asserts on). #[derive(Clone)] struct StoredReplace { formation: Option, slots: Vec<(String, i64, bool, bool)>, // owned_card_id, index, is_captain, is_on_bench ext_payload: String, chemistry: Option, } #[derive(Default)] struct FakeCore { items: Vec, total: i64, calls: AtomicUsize, read_calls: AtomicUsize, replace_calls: AtomicUsize, last_params: Mutex>, /// The stored squad + extension returned by `read_squad_ext`. A successful /// `replace_squad` overwrites it (Fresh), enabling coupled read-after-write. squad: Mutex>, /// The ownership-backed manager assignment, exactly as Core persists it: a /// full squad replacement writes it (or clears it with `None`). manager: Mutex>, replaced: Mutex>, panic_if_called: bool, return_err: bool, } impl FakeCore { fn new(items: Vec, total: i64) -> Self { FakeCore { items, total, ..Default::default() } } fn forbidden() -> Self { FakeCore { panic_if_called: true, ..Default::default() } } fn erroring() -> Self { FakeCore { return_err: true, ..Default::default() } } /// Preset the stored squad read (for read-path tests without a prior PUT). fn with_squad(self, read: CoreSquadRead) -> Self { *self.squad.lock() = Some(read); self } fn calls(&self) -> usize { self.calls.load(Ordering::SeqCst) } fn read_calls(&self) -> usize { self.read_calls.load(Ordering::SeqCst) } fn replace_calls(&self) -> usize { self.replace_calls.load(Ordering::SeqCst) } fn replaced(&self) -> Vec { self.replaced.lock().clone() } fn last(&self) -> Vec<(String, String)> { self.last_params.lock().clone() } fn manager(&self) -> Option { self.manager.lock().clone() } } impl CoreAccess for FakeCore { fn query_owned(&self, params: &[(&str, String)]) -> Result { assert!( !self.panic_if_called, "Core must NOT be called on this path" ); self.calls.fetch_add(1, Ordering::SeqCst); if self.return_err { return Err(CoreError::Status(500)); } *self.last_params.lock() = params .iter() .map(|(k, v)| (k.to_string(), v.clone())) .collect(); Ok(CorePage { items: self.items.clone(), total: self.total, }) } fn read_squad_ext(&self, _namespace: &str) -> Result { assert!( !self.panic_if_called, "Core must NOT be called on this path" ); self.read_calls.fetch_add(1, Ordering::SeqCst); if self.return_err { return Err(CoreError::Status(500)); } self.squad.lock().clone().ok_or(CoreError::Status(404)) } fn replace_squad(&self, req: &CoreReplaceRequest) -> Result { assert!( !self.panic_if_called, "Core must NOT be called on this path" ); self.replace_calls.fetch_add(1, Ordering::SeqCst); if self.return_err { return Err(CoreError::Status(500)); } self.replaced.lock().push(StoredReplace { formation: req.formation.clone(), slots: req .slots .iter() .map(|s| { ( s.owned_card_id.clone(), s.index, s.is_captain, s.is_on_bench, ) }) .collect(), ext_payload: req.ext_payload.clone(), chemistry: req.client_reported.chemistry, }); // Reflect the committed state so a subsequent read is Fresh (deterministic // fingerprint by construction — the fake owns both sides). let slots = req .slots .iter() .map(|s| CoreSquadSlot { owned_card_id: s.owned_card_id.clone(), index: s.index, is_captain: s.is_captain, is_on_bench: s.is_on_bench, }) .collect(); let fingerprint = format!( "fp-{}-{}", req.formation.clone().unwrap_or_default(), req.slots.len() ); *self.squad.lock() = Some(CoreSquadRead { name: req.name.clone().unwrap_or_default(), formation: req.formation.clone().unwrap_or_default(), slots, ext: CoreExtState::Fresh { schema_version: req.ext_schema_version, payload: req.ext_payload.clone(), }, }); Ok(CoreReplaceResult { squad_id: "sq-1".into(), canonical_fingerprint: fingerprint, slots_written: req.slots.len(), }) } fn get_squad_manager(&self) -> Result, CoreError> { if self.return_err { return Err(CoreError::Status(500)); } Ok(self.manager.lock().clone()) } fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> { if self.return_err { return Err(CoreError::Status(500)); } *self.manager.lock() = owned_card_id.map(str::to_string); Ok(()) } } fn entities() -> Fifa17Entities { Fifa17Entities::from_maps( HashMap::from([(13, "Premier League".to_string())]), HashMap::from([(52, "Argentina".to_string())]), HashMap::from([(5, "Chelsea".to_string())]), ) } fn item( owned: &str, card: &str, rating: u8, pos: &str, nation: &str, league: &str, club: &str, ) -> CoreOwnedItem { CoreOwnedItem { owned_card_id: owned.into(), card_id: card.into(), rating, position: pos.into(), nation: nation.into(), league: league.into(), club: club.into(), attributes: [90, 88, 70, 85, 40, 78], contract_matches: None, source_rating: None, core_content_kind: None, } } /// A mock Python upstream: records each request, replies 200 + `X-From-Python`. fn spawn_mock_python() -> (String, Recorded) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let rec = Arc::new(Mutex::new(Vec::new())); let rec2 = rec.clone(); std::thread::spawn(move || { for stream in listener.incoming() { let mut s = match stream { Ok(s) => s, Err(_) => continue, }; let mut r = BufReader::new(s.try_clone().unwrap()); if let Ok(Some(req)) = read_request(&mut r) { rec2.lock() .push((req.method.clone(), req.target.clone(), req.body.clone())); let body = br#"{"python":true}"#; let head = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-From-Python: 1\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); let _ = s.write_all(head.as_bytes()); let _ = s.write_all(body); } } }); (format!("http://{addr}"), rec) } /// A unique temp path for a per-server identity store (integration tests run in /// the same process; each server gets its own store file). fn unique_store_path() -> std::path::PathBuf { static N: AtomicUsize = AtomicUsize::new(0); let n = N.fetch_add(1, Ordering::SeqCst); std::env::temp_dir().join(format!("ofut-host-it-{}-{n}.json", std::process::id())) } fn build_server( core: Arc, upstream: &str, assets_map: Option>, ) -> Server { // The map (card id → asset id) becomes a real identity catalog; an absent // map is an empty catalog (every item dropped — the honest no-mapping case). let cards = assets_map.unwrap_or_default(); let entries: Vec = cards .iter() .map(|(id, asset)| format!("\"{id}\":{{\"asset_id\":{asset}}}")) .collect(); let doc = format!( "{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{}}}}}", entries.join(",") ); let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap(); let store = JsonIdentityStore::open(unique_store_path()).unwrap(); let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))); Server::new( core, Arc::new(entities()), resolver, Arc::new(PassClient::new(upstream)), 33_068_179, ) } /// A real identity resolver over a `card_id -> asset_id` map (same construction /// `build_server` uses), for tests that call `handle_club` directly. fn resolver_for(cards: &[(&str, u32)]) -> Arc { let entries: Vec = cards .iter() .map(|(id, asset)| format!("\"{id}\":{{\"asset_id\":{asset}}}")) .collect(); let doc = format!( "{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{}}}}}", entries.join(",") ); let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap(); let store = JsonIdentityStore::open(unique_store_path()).unwrap(); Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))) } /// A card with an ACTIVE listing has LEFT the club: `/club` must not show it, and /// pagination must run over the CLUB-VISIBLE set (never Core's unfiltered page, /// which would hand back short pages). #[test] fn club_excludes_listed_items_and_paginates_the_visible_set() { let core = Arc::new(FakeCore::new( vec![ item( "oc1", "card_a", 86, "CDM", "Argentina", "Premier League", "Chelsea", ), item( "oc2", "card_b", 85, "ST", "Argentina", "Premier League", "Chelsea", ), item( "oc3", "card_c", 84, "CB", "Argentina", "Premier League", "Chelsea", ), ], 3, )); let resolver = resolver_for(&[("card_a", 20801), ("card_b", 20802), ("card_c", 20803)]); let ents = entities(); // oc2 has an active transfer-market listing. let hidden: std::collections::HashSet = ["oc2".to_string()].into_iter().collect(); let active_kits = CoreKitAssignments::default(); let deps = ClubDeps { core: core.as_ref(), entities: &ents, assets: resolver.as_ref(), hidden: &hidden, active_kits: &active_kits, }; let (resp, log) = handle_club("", &deps); let v: Value = serde_json::from_slice(&resp.body).unwrap(); let assets: Vec = v["itemData"] .as_array() .unwrap() .iter() .map(|i| i["assetId"].as_i64().unwrap()) .collect(); assert_eq!( assets, vec![20801, 20803], "the transfer-pile card is not in the club" ); assert_eq!(log.total, 2, "total is the club-visible count"); // A page of 2 over a 2-item visible set is FULL — not short because a hidden // item consumed a slot. let (resp2, log2) = handle_club("count=2", &deps); let v2: Value = serde_json::from_slice(&resp2.body).unwrap(); assert_eq!( v2["itemData"].as_array().unwrap().len(), 2, "full-width page from the visible set" ); assert_eq!(log2.total, 2); // Nothing hidden → all three player items remain visible. let none: std::collections::HashSet = std::collections::HashSet::new(); let deps_all = ClubDeps { core: core.as_ref(), entities: &ents, assets: resolver.as_ref(), hidden: &none, active_kits: &active_kits, }; let (resp3, log3) = handle_club("", &deps_all); let v3: Value = serde_json::from_slice(&resp3.body).unwrap(); assert_eq!(v3["itemData"].as_array().unwrap().len(), 3); assert_eq!(log3.total, 3); } #[test] fn club_projects_only_owned_kits_with_active_designations() { let core = Arc::new(FakeCore::new( vec![ item( "player", "card_player", 90, "ST", "Argentina", "Premier League", "Chelsea", ), item("home", "kit_home", 0, "", "", "", ""), item("away", "kit_away", 0, "", "", "", ""), ], 3, )); let catalog = Fifa17CardCatalog::from_json_str( r#"{"schema_version":1,"game":"fifa17","cards":{ "card_player":{"asset_id":20801}, "kit_home":{"asset_id":6300006,"kind":"kit","subtype":9, "card_asset_id":35,"team_id":21,"rareflag":0}, "kit_away":{"asset_id":6400003,"kind":"kit","subtype":9, "card_asset_id":35,"team_id":21,"rareflag":0} }}"#, ) .unwrap(); let resolver = Arc::new(Fifa17IdentityResolver::new( catalog, Arc::new(JsonIdentityStore::open(unique_store_path()).unwrap()), )); let ents = entities(); let hidden = std::collections::HashSet::new(); let active_kits = CoreKitAssignments { home_owned_card_id: Some("home".into()), away_owned_card_id: Some("away".into()), }; let deps = ClubDeps { core: core.as_ref(), entities: &ents, assets: resolver.as_ref(), hidden: &hidden, active_kits: &active_kits, }; let (kit_response, kit_log) = handle_club("type=kit", &deps); let kits: Value = serde_json::from_slice(&kit_response.body).unwrap(); assert_eq!(kit_log.total, 2); assert_eq!(kits["itemData"][0]["itemState"], "activeHomeKit"); assert_eq!(kits["itemData"][1]["itemState"], "activeAwayKit"); assert_eq!(kits["itemData"][0]["cardassetid"], 35); assert_eq!(kits["itemData"][0]["teamid"], 21); let (player_response, player_log) = handle_club("type=player", &deps); let players: Value = serde_json::from_slice(&player_response.body).unwrap(); assert_eq!(player_log.total, 1); assert_eq!(players["itemData"].as_array().unwrap().len(), 1); assert_eq!(players["itemData"][0]["itemType"], "player"); } // ── /club served from Core ───────────────────────────────────────────────── #[test] fn club_route_maps_query_and_shapes_core_items() { let core = Arc::new(FakeCore::new( vec![item( "oc1", "card_ch_1", 86, "CDM", "Argentina", "Premier League", "Chelsea", )], 1, )); let server = build_server( core.clone(), "http://127.0.0.1:1", // passthrough must not be used Some(HashMap::from([("card_ch_1".to_string(), 20801u32)])), ); let resp = server.handle( "GET", "/ut/game/fifa17/club?year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=0", &[], b"", ); assert_eq!(resp.status, 200); let body: Value = serde_json::from_slice(&resp.body).unwrap(); let it = &body["itemData"][0]; assert_eq!(it["resourceId"], 20801, "real asset id from the resolver"); assert_eq!(it["rating"], 86); assert_eq!(it["preferredPosition"], "CDM"); assert_eq!(it["leagueId"], 13); assert_eq!(it["teamid"], 5); assert_eq!(it["nation"], 52); // Core was queried once with SEMANTIC params (ids resolved to names), and the // FIFA UI window (start/count) became semantic offset/limit. assert_eq!(core.calls(), 1); let p = core.last(); assert!( p.contains(&("quality".into(), "gold".into())), "level=gold -> quality {p:?}" ); assert!( p.contains(&("league".into(), "Premier League".into())), "league id 13 -> name {p:?}" ); assert!( p.contains(&("club".into(), "Chelsea".into())), "team id 5 -> club name {p:?}" ); // The host must NOT ask Core for a window. Kind and transfer-pile membership // are host-side concepts Core cannot express, so the visible set only exists // after local filtering and shaping; a Core-side window would paginate the // unfiltered set and hand the client short pages. assert!( !p.iter().any(|(k, _)| k == "offset" || k == "limit"), "pagination must not be delegated to Core {p:?}" ); // No raw FIFA id reached Core. for (_, v) in &p { if v == "13" || v == "5" || v == "52" { panic!("raw FIFA id leaked into Core params: {p:?}"); } } } /// The client's `start`/`count` window is applied by the host, over the set that /// survives kind + transfer-pile filtering. A window past the end is an empty /// page, not the first item. #[test] fn club_window_is_applied_locally_after_filtering() { let core = Arc::new(FakeCore::new( vec![item( "oc1", "card_ch_1", 86, "CDM", "Argentina", "Premier League", "Chelsea", )], 1, )); let server = build_server( core.clone(), "http://127.0.0.1:1", Some(HashMap::from([("card_ch_1".to_string(), 20801u32)])), ); let in_window = server.handle( "GET", "/ut/game/fifa17/club?type=player&start=0&count=11", &[], b"", ); let body: Value = serde_json::from_slice(&in_window.body).unwrap(); assert_eq!(body["itemData"].as_array().unwrap().len(), 1); let past_end = server.handle( "GET", "/ut/game/fifa17/club?type=player&start=10&count=11", &[], b"", ); assert_eq!( past_end.status, 200, "a window past the end is still a page" ); let body: Value = serde_json::from_slice(&past_end.body).unwrap(); assert!( body["itemData"].as_array().unwrap().is_empty(), "offset past the visible set yields an empty page, not the first item" ); } #[test] fn club_route_with_empty_asset_map_drops_items_not_fakes_them() { // The current production reality: no card→asset mapping → empty itemData. let core = Arc::new(FakeCore::new( vec![item( "oc1", "card_pl_001", 84, "ST", "England", "Premier League", "Northgate United", )], 1, )); let server = build_server(core.clone(), "http://127.0.0.1:1", None); let resp = server.handle("GET", "/ut/game/fifa17/club?level=any", &[], b""); assert_eq!(resp.status, 200); let body: Value = serde_json::from_slice(&resp.body).unwrap(); assert_eq!( body["itemData"].as_array().unwrap().len(), 0, "no fabricated ids" ); assert_eq!( core.calls(), 1, "Core still queried; drop happens at shaping" ); } #[test] fn club_route_unknown_id_returns_empty_and_never_calls_core() { let core = Arc::new(FakeCore::forbidden()); let server = build_server(core.clone(), "http://127.0.0.1:1", None); // league 9999 is not in the entity map → hard MapError → empty, no Core call. let resp = server.handle("GET", "/ut/game/fifa17/club?league=9999", &[], b""); assert_eq!(resp.status, 200); let body: Value = serde_json::from_slice(&resp.body).unwrap(); assert_eq!(body["itemData"].as_array().unwrap().len(), 0); assert_eq!(core.calls(), 0, "unknown id must short-circuit before Core"); } // ── Passthrough to Python ──────────────────────────────────────────────────── #[test] fn passthrough_forwards_verbatim_and_never_calls_core() { let (upstream, rec) = spawn_mock_python(); let core = Arc::new(FakeCore::forbidden()); // proves /club-only for Core let server = build_server(core, &upstream, None); let resp = server.handle( "POST", "/ut/game/fifa17/purchased/items", &[("X-UT-SID".into(), "sess".into())], br#"{"itemData":[{"id":1}]}"#, ); // upstream response returned faithfully assert_eq!(resp.status, 200); assert!( resp.headers .iter() .any(|(k, v)| k.eq_ignore_ascii_case("x-from-python") && v == "1"), "upstream headers preserved: {:?}", resp.headers ); let body: Value = serde_json::from_slice(&resp.body).unwrap(); assert_eq!(body["python"], true); // upstream received the exact method, target and body std::thread::sleep(std::time::Duration::from_millis(50)); let got = rec.lock().clone(); assert_eq!(got.len(), 1); assert_eq!(got[0].0, "POST"); assert_eq!(got[0].1, "/ut/game/fifa17/purchased/items"); assert_eq!(got[0].2, br#"{"itemData":[{"id":1}]}"#); } #[test] fn club_rename_is_rust_owned_persistent_and_never_calls_python_or_core() { assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::ClubRename); assert_eq!( classify("PUT", "/ut/game/fifa17/user/club"), Route::ClubRename ); assert_eq!( classify("POST", "/ut/game/fifa17/user/club"), Route::ClubRename ); assert_eq!( classify("POST", "/ut/game/fifa17/squad/0"), Route::Passthrough ); let (upstream, rec) = spawn_mock_python(); let core = Arc::new(FakeCore::forbidden()); let account_path = unique_store_path(); let account = Arc::new(AccountStore::open(&account_path)); let server = build_server(core.clone(), &upstream, None).with_account(account.clone()); let resp = server.handle( "PUT", "/ut/game/fifa17/user/club", &[], br#"{"clubName":"Real FUT","clubAbbr":"RF"}"#, ); assert_eq!(resp.status, 200); assert_eq!( serde_json::from_slice::(&resp.body).unwrap(), json!({}) ); assert_eq!(account.club().name, "Real FUT"); assert_eq!(account.club().abbr, "RF"); assert_eq!(AccountStore::open(&account_path).club(), account.club()); assert_eq!(core.calls(), 0); assert_eq!(rec.lock().len(), 0, "rename never reached Python"); let _ = std::fs::remove_file(account_path); } // ── End-to-end over a socket (read_request + write_response + keep-alive) ───── #[test] fn end_to_end_socket_serves_club_and_passthrough() { let (upstream, rec) = spawn_mock_python(); let core = Arc::new(FakeCore::new( vec![item( "oc1", "card_ch_1", 86, "CDM", "Argentina", "Premier League", "Chelsea", )], 1, )); let server = build_server( core, &upstream, Some(HashMap::from([("card_ch_1".to_string(), 20801u32)])), ); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); std::thread::spawn(move || server.serve_listener(listener)); // /club → Rust/Core let club = raw_get( &addr.to_string(), "/ut/game/fifa17/club?level=gold&league=13", ); assert!(club.contains("200 OK"), "club status: {club}"); assert!( club.contains("\"resourceId\":20801") || club.contains("\"resourceId\": 20801"), "club body: {club}" ); // passthrough → Python let pt = raw_get(&addr.to_string(), "/ut/game/fifa17/tradePile?x=1"); assert!(pt.contains("200 OK")); assert!( pt.to_lowercase().contains("x-from-python"), "upstream header relayed (case-insensitive): {pt}" ); std::thread::sleep(std::time::Duration::from_millis(50)); let got = rec.lock().clone(); assert!( got.iter() .any(|(m, t, _)| m == "GET" && t == "/ut/game/fifa17/tradePile?x=1"), "python saw the passthrough with query intact: {got:?}" ); assert!( !got.iter().any(|(_, t, _)| t.contains("/club")), "python must NOT have seen the /club request: {got:?}" ); } /// Minimal raw HTTP/1.1 GET (Connection: close) returning the whole response text. fn raw_get(addr: &str, target: &str) -> String { let mut s = TcpStream::connect(addr).unwrap(); let req = format!("GET {target} HTTP/1.1\r\nHost: fifa\r\nConnection: close\r\n\r\n"); s.write_all(req.as_bytes()).unwrap(); let mut buf = String::new(); s.read_to_string(&mut buf).unwrap(); buf } #[test] fn club_core_error_returns_empty_and_never_falls_back_to_python() { // A Core failure on /club must degrade to an empty page, NOT retry on Python // (which could double-apply a mutation on other routes; the rule is absolute). let (upstream, rec) = spawn_mock_python(); let core = Arc::new(FakeCore::erroring()); let server = build_server( core.clone(), &upstream, Some(HashMap::from([("c".into(), 1u32)])), ); let resp = server.handle("GET", "/ut/game/fifa17/club?level=any", &[], b""); assert_eq!(resp.status, 200); let body: Value = serde_json::from_slice(&resp.body).unwrap(); assert_eq!(body["itemData"].as_array().unwrap().len(), 0); assert_eq!(core.calls(), 1, "Core was attempted once"); std::thread::sleep(std::time::Duration::from_millis(50)); assert!( rec.lock().is_empty(), "Python must NOT be contacted on a /club core error" ); } #[test] fn host_does_not_refilter_core_results() { // Filtering is Core's job. The host must emit whatever Core returns; if it // re-applied the filter it would drop items Core already vetted. let core = Arc::new(FakeCore::new( vec![ item( "oc1", "card_a", 90, "ST", "Argentina", "Premier League", "Chelsea", ), item( "oc2", "card_b", 60, "GK", "England", "Premier League", "Chelsea", ), ], 2, )); let server = build_server( core, "http://127.0.0.1:1", Some(HashMap::from([ ("card_a".into(), 20801u32), ("card_b".into(), 158023u32), ])), ); // Query says gold; Core (faked) returns both regardless. Host must emit BOTH. let resp = server.handle("GET", "/ut/game/fifa17/club?level=gold", &[], b""); let body: Value = serde_json::from_slice(&resp.body).unwrap(); assert_eq!( body["itemData"].as_array().unwrap().len(), 2, "host must not second-guess Core's filtering" ); } // ── End-to-end through the REAL resolver + Core over HTTP ──────────────────── /// A mock OpenFUT Core: records each request's header block and replies with a /// fixed `/collection` body. Lets us prove the host talks to Core over real HTTP /// (game header + query) and composes the real identity resolver. fn spawn_mock_core(collection: Value) -> (String, Recorded) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let rec: Recorded = Arc::new(Mutex::new(Vec::new())); let rec2 = rec.clone(); let body = collection.to_string(); std::thread::spawn(move || { for stream in listener.incoming() { let mut stream = stream.unwrap(); let mut reader = BufReader::new(stream.try_clone().unwrap()); let mut headers = String::new(); loop { let mut line = String::new(); if reader.read_line(&mut line).unwrap() == 0 || line == "\r\n" { break; } headers.push_str(&line); } rec2.lock().push(("GET".into(), headers, vec![])); let resp = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ); stream.write_all(resp.as_bytes()).unwrap(); } }); (format!("http://{addr}"), rec) } #[test] fn club_end_to_end_through_real_resolver_and_sends_game_header() { // Core returns one asset-mapped item (fifa17_20801) and one synthetic item // with no catalog identity. let collection = serde_json::json!({ "collection": [ {"owned_card_id":"oc-mapped","effective_position":"ST","card":{"id":"fifa17_20801","overall":94,"position":"ST","nation":"Argentina","league":"Premier League","club":"Chelsea","pace":90,"shooting":92,"passing":81,"dribbling":91,"defending":33,"physical":80}}, {"owned_card_id":"oc-synth","effective_position":"CB","card":{"id":"card_synth_1","overall":70,"position":"CB","nation":"England","league":"Premier League","club":"Chelsea","pace":60,"shooting":40,"passing":55,"dribbling":50,"defending":72,"physical":75}} ], "total": 2 }); let (core_url, rec) = spawn_mock_core(collection); let catalog = Fifa17CardCatalog::from_json_str( "{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{\"fifa17_20801\":{\"asset_id\":20801}}}", ) .unwrap(); let store = JsonIdentityStore::open(unique_store_path()).unwrap(); let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))); let server = Server::new( Arc::new(HttpCoreClient::new(core_url, "fifa17")), Arc::new(entities()), resolver, Arc::new(PassClient::new("http://127.0.0.1:1")), 33_068_179, ); let resp = server.handle("GET", "/ut/game/fifa17/club?level=gold", &[], b""); assert_eq!(resp.status, 200); let body: Value = serde_json::from_slice(&resp.body).unwrap(); let items = body["itemData"].as_array().unwrap(); assert_eq!( items.len(), 1, "mapped item renders; synthetic (no catalog id) dropped" ); assert_eq!( items[0]["resourceId"], 20801, "real asset id from the catalog" ); assert!( items[0]["id"].as_i64().unwrap() >= 100_000_001, "wire id minted by the store, not a placeholder" ); // The game header reached Core so it can select the fifa17 (all-mapped) profile. std::thread::sleep(std::time::Duration::from_millis(30)); let got = rec.lock().clone(); assert!( got.iter() .any(|(_, h, _)| h.to_lowercase().contains("x-openfut-game: fifa17")), "X-OpenFUT-Game: fifa17 sent to Core: {got:?}" ); } // ── Squad host: routing, PUT pipeline, authorization, coupled read-after-write ─ use serde_json::json; /// Build the production resolver over a card→asset catalog and PRE-ALLOCATE a /// wire id for each owned item (as `/club` would before FIFA ever references /// them). Returns the resolver + the wire id assigned to each owned_card_id. fn resolver_with_wires( items: &[CoreOwnedItem], assets: &[(&str, u32)], ) -> (Fifa17IdentityResolver, HashMap) { let entries: Vec = assets .iter() .map(|(id, a)| format!("\"{id}\":{{\"asset_id\":{a}}}")) .collect(); let doc = format!( "{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{}}}}}", entries.join(",") ); let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap(); let store = JsonIdentityStore::open(unique_store_path()).unwrap(); let resolver = Fifa17IdentityResolver::new(catalog, Arc::new(store)); let mut wires = HashMap::new(); for it in items { if let Some(id) = resolver.resolve(it) { wires.insert(it.owned_card_id.clone(), id.item_id as i64); } } (resolver, wires) } /// A FIFA squad-save body. `players` = (index, wire id, kit number). /// /// `manager` is explicit because a squad save carries an ownership-backed /// manager assignment: a ref the resolver cannot map to an owned instance is an /// unresolved wire id and the whole save is refused (you cannot manage with a /// card you do not own). Tests that are not about the manager pass `None`. fn put_body( formation: &str, captain: i64, players: &[(i64, i64, i64)], custom: &str, manager: Option, ) -> Vec { let ps: Vec = players .iter() .map(|(i, w, k)| json!({"index": i, "itemData": {"id": w, "dream": false}, "kitNumber": k})) .collect(); let mgr: Vec = manager .into_iter() .map(|id| json!({"id": id, "dream": false})) .collect(); json!({ "id": 0, "formation": formation, "squadName": "OpenFUT", "squadType": "REGULAR_SQUAD", "chemistry": 52, "rating": 90, "starRating": 90, "captain": captain, "custom": custom, "manager": mgr, "players": ps, "kicktakers": [{"index": 0, "id": captain, "dream": false}], }) .to_string() .into_bytes() } fn gk() -> CoreOwnedItem { item( "oc-a", "card_a", 87, "GK", "Argentina", "Premier League", "Chelsea", ) } fn st() -> CoreOwnedItem { item( "oc-b", "card_b", 90, "ST", "Argentina", "Premier League", "Chelsea", ) } const ASSETS: &[(&str, u32)] = &[("card_a", 20801), ("card_b", 158023)]; /// A mock Python returning a fixed userMassInfo JSON (userInfo/settings/... + /// a Python-authored squad the overlay must replace). fn spawn_mock_python_json(body: Value) -> (String, Recorded) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let rec = Arc::new(Mutex::new(Vec::new())); let rec2 = rec.clone(); let text = body.to_string(); std::thread::spawn(move || { for stream in listener.incoming() { let mut s = match stream { Ok(s) => s, Err(_) => continue, }; let mut r = BufReader::new(s.try_clone().unwrap()); if let Ok(Some(req)) = read_request(&mut r) { rec2.lock() .push((req.method.clone(), req.target.clone(), req.body.clone())); let head = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", text.len() ); let _ = s.write_all(head.as_bytes()); let _ = s.write_all(text.as_bytes()); } } }); (format!("http://{addr}"), rec) } // ── Classification ────────────────────────────────────────────────────────── #[test] fn classify_squad_and_usermassinfo_routes() { assert_eq!( classify("PUT", "/ut/game/fifa17/squad/0"), Route::SquadReplace ); assert_eq!( classify("PUT", "/ut/game/fifa17/squad/3"), Route::SquadReplace ); assert_eq!( classify("GET", "/ut/game/fifa17/squad/list"), Route::SquadList ); assert_eq!( classify("GET", "/ut/game/fifa17/userMassInfo"), Route::UserMassInfo ); // GET /squad/active and every numeric GET are the one Core-backed current // squad, matching the oracle's single-squad response regardless of URL id. assert_eq!( classify("GET", "/ut/game/fifa17/squad/active"), Route::SquadActive ); assert_eq!( classify("PUT", "/ut/game/fifa17/squad/active"), Route::Passthrough ); assert_eq!( classify("GET", "/ut/game/fifa17/squad/0"), Route::SquadActive ); assert_eq!( classify("GET", "/ut/game/fifa17/squad/5"), Route::SquadActive ); assert_eq!( classify("PUT", "/ut/game/fifa17/squad/list"), Route::Passthrough ); assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club); } #[test] fn numbered_squad_get_returns_current_core_squad_without_python() { let items = vec![gk(), st()]; let (resolver, wires) = resolver_with_wires(&items, ASSETS); let core = Arc::new(FakeCore::new(items, 2)); let (python, recorded) = spawn_mock_python(); let server = Server::new( core, Arc::new(entities()), Arc::new(resolver), Arc::new(PassClient::new(&python)), 33_068_179, ); let put = server.handle( "PUT", "/ut/game/fifa17/squad/0", &[], &put_body( "f442", wires["oc-a"], &[(0, wires["oc-a"], 1), (1, wires["oc-b"], 9)], "[1,2,3]", None, ), ); assert_eq!(put.status, 200); let active = server.handle("GET", "/ut/game/fifa17/squad/active", &[], b""); let numbered = server.handle("GET", "/ut/game/fifa17/squad/5", &[], b""); assert_eq!(numbered.status, 200); assert_eq!(numbered.body, active.body); assert_eq!(recorded.lock().len(), 0, "numeric GET never reached Python"); } // ── PUT pipeline ──────────────────────────────────────────────────────────── #[test] fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() { let items = vec![gk(), st()]; let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; let body = put_body( "f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[1,2,3]", None, ); let (resp, log) = handle_put_squad(&body, &deps); assert_eq!(resp.status, 200, "{log:?}"); assert_eq!(resp.body, br#"{"id":0}"#, "exact save ack"); let recs = core.replaced(); assert_eq!(recs.len(), 1); let r = &recs[0]; assert_eq!(r.formation.as_deref(), Some("f442")); // Canonical slots carry Core owned ids, never FIFA wire integers. let mut owned: Vec<&str> = r.slots.iter().map(|(o, ..)| o.as_str()).collect(); owned.sort(); assert_eq!(owned, vec!["oc-a", "oc-b"]); assert!(r.slots.iter().all(|(o, ..)| o.starts_with("oc-"))); // Captain is the semantic owned item (oc-a), flagged in canonical. assert!(r.slots.iter().any(|(o, _, cap, _)| o == "oc-a" && *cap)); // Extension payload round-trips the opaque custom + kit-by-owned-id. assert!(r.ext_payload.contains("[1,2,3]"), "custom in ext payload"); assert_eq!(r.chemistry, Some(52), "client-reported shadow carried"); } /// The squad's manager is an ownership-backed assignment, not an opaque wire /// echo: a save assigns the owned instance behind the ref, and a later save /// without a manager CLEARS it (a full replacement replaces the manager too). #[test] fn put_assigns_the_owned_manager_and_a_later_save_clears_it() { let items = vec![gk(), st()]; let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; let with_manager = put_body( "f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", Some(w["oc-b"]), ); let (resp, log) = handle_put_squad(&with_manager, &deps); assert_eq!(resp.status, 200, "{log:?}"); assert_eq!( core.manager().as_deref(), Some("oc-b"), "manager persisted as the Core owned id, never the wire id" ); let without_manager = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None); let (resp, log) = handle_put_squad(&without_manager, &deps); assert_eq!(resp.status, 200, "{log:?}"); assert_eq!( core.manager(), None, "a full replacement without a manager clears the assignment" ); } /// The REAL client always sends a manager ref, and on a real profile it does not /// resolve: production's own save points at 100000427, which is absent from /// production's `/club/staff`, and the client accepts that squad back unchanged. /// Refusing the save would therefore break EVERY squad save, so an unresolvable /// manager ref commits the squad with NO manager assignment. #[test] fn put_saves_the_squad_when_the_manager_ref_does_not_resolve() { let items = vec![gk(), st()]; let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; let body = put_body( "f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", Some(100_000_427), ); let (resp, log) = handle_put_squad(&body, &deps); assert_eq!(resp.status, 200, "{log:?}"); assert_eq!(resp.body, br#"{"id":0}"#); assert_eq!(core.replace_calls(), 1, "the squad itself is committed"); assert_eq!( core.manager(), None, "no ownership-backed manager is invented from a ref we cannot map" ); // An occupied PLAYER slot that does not resolve is the opposite case: it // would silently lose an owned card, so it still refuses the whole save. let bad_player = put_body( "f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, 999_999_999, 9)], "[]", None, ); let (resp, log) = handle_put_squad(&bad_player, &deps); assert_eq!(resp.status, 400); assert_eq!(log.outcome, "unresolved_wire_ids"); assert_eq!(core.replace_calls(), 1, "nothing further committed"); } #[test] fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() { // oc-b resolves (identity) but is NOT in the active club's owned set. let items = vec![gk(), st()]; let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(vec![gk()], 1); // only oc-a owned let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; let body = put_body( "f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[]", None, ); let (resp, log) = handle_put_squad(&body, &deps); assert_eq!(resp.status, 403, "resolvable != authorized"); assert_eq!(log.outcome, "unauthorized_item"); assert_eq!(core.replace_calls(), 0, "Core squad left unchanged"); assert!(core.replaced().is_empty()); } #[test] fn put_rejects_unknown_wire_id() { let items = vec![gk(), st()]; let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; // 999_999_999 was never allocated → unresolvable. let body = put_body( "f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, 999_999_999, 9)], "[]", None, ); let (resp, log) = handle_put_squad(&body, &deps); assert_eq!(resp.status, 400); assert_eq!(log.outcome, "unresolved_wire_ids"); assert_eq!(core.replace_calls(), 0); } #[test] fn put_rejects_duplicate_owned_item() { let items = vec![gk(), st()]; let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; let body = put_body( "f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-a"], 9)], "[]", None, ); let (resp, log) = handle_put_squad(&body, &deps); assert_eq!(resp.status, 400); assert_eq!(log.outcome, "duplicate_owned_item"); assert_eq!(core.replace_calls(), 0); } #[test] fn put_core_failure_returns_error_never_python() { // handle_put_squad has no PassClient at all — a Core failure cannot fall back. let items = vec![gk(), st()]; let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::erroring(); let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None); let (resp, log) = handle_put_squad(&body, &deps); assert_eq!(resp.status, 502); assert_eq!(log.outcome, "core_error"); } #[test] fn repeated_identical_put_is_idempotent() { let items = vec![gk(), st()]; let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; let body = put_body( "f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[1,2,3]", None, ); let (r1, _) = handle_put_squad(&body, &deps); let (r2, _) = handle_put_squad(&body, &deps); assert_eq!(r1.body, br#"{"id":0}"#); assert_eq!(r2.body, br#"{"id":0}"#); let recs = core.replaced(); assert_eq!(recs.len(), 2); assert_eq!(recs[0].slots, recs[1].slots, "canonical converges"); assert_eq!(recs[0].formation, recs[1].formation); assert_eq!( recs[0].ext_payload, recs[1].ext_payload, "extension converges" ); } // ── Coupled read-after-write ───────────────────────────────────────────────── /// PUT, then both reads (list + userMassInfo overlay) reflect the same committed /// squad, projected via the shared identity path (real resourceIds + wire ids). #[test] fn coupled_read_after_write_list_and_usermassinfo_agree() { let items = vec![gk(), st()]; let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; let body = put_body( "f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[1,2,3]", None, ); let (put, _) = handle_put_squad(&body, &deps); assert_eq!(put.status, 200); // GET /squad/list let (list_resp, list_log) = handle_squad_list(&deps); assert_eq!(list_log.outcome, "ok"); let list: Value = serde_json::from_slice(&list_resp.body).unwrap(); let entry = &list["squad"][0]; assert_eq!(entry["formation"], "f442"); assert_eq!(entry["id"], 0); assert_eq!(entry["squadType"], "REGULAR_SQUAD"); // userMassInfo overlay against a mock Python let py_body = json!({ "userInfo": {"personaId": 42, "clubName": "OpenFUT"}, "settings": {"a": 1}, "userData": {"b": 2}, "pileSizeClientData": {"c": 3}, "squad": {"id": 0, "personaId": 42, "players": [{"index": 0, "itemData": {"id": 777}, "kitNumber": 99}]}, }); let (py_url, _rec) = spawn_mock_python_json(py_body); let pass = PassClient::new(&py_url); let (umi_resp, umi_log) = handle_user_mass_info( "GET", "/ut/game/fifa17/userMassInfo", &[], b"", &deps, &pass, ); assert_eq!(umi_log.outcome, "ok"); let umi: Value = serde_json::from_slice(&umi_resp.body).unwrap(); // Unrelated fields preserved verbatim. assert_eq!(umi["userInfo"]["personaId"], 42); assert_eq!(umi["settings"]["a"], 1); assert_eq!(umi["userData"]["b"], 2); assert_eq!(umi["pileSizeClientData"]["c"], 3); // .squad replaced by the Rust projection: persona preserved, envelope added. let sq = &umi["squad"]; assert_eq!(sq["personaId"], 42, "persona preserved from Python"); assert_eq!(sq["formation"], "f442"); assert_eq!( sq["captain"], w["oc-a"], "captain is the wire id, not resourceId" ); let occ: Vec<&Value> = sq["players"] .as_array() .unwrap() .iter() .filter(|p| p["itemData"]["id"].as_i64().unwrap() != 0) .collect(); assert_eq!(occ.len(), 2, "the two committed players, not Python's 777"); // DERIVED identity: wire id + real resourceId from the production catalog. let p0 = occ.iter().find(|p| p["index"] == 0).unwrap(); assert_eq!(p0["itemData"]["id"], w["oc-a"]); assert_eq!(p0["itemData"]["resourceId"], 20801); assert_eq!(p0["kitNumber"], 1); let p1 = occ.iter().find(|p| p["index"] == 1).unwrap(); assert_eq!(p1["itemData"]["resourceId"], 158023); assert_eq!(p1["kitNumber"], 9); assert!( umi["squad"]["players"] .as_array() .unwrap() .iter() .all(|p| p["itemData"]["id"] != 777), "no Python squad content survives" ); // Content-Length matches the rewritten body. let cl = umi_resp .headers .iter() .find(|(k, _)| k.eq_ignore_ascii_case("content-length")) .map(|(_, v)| v.parse::().unwrap()); assert_eq!( cl, Some(umi_resp.body.len()), "Content-Length fixed after overlay" ); } /// `GET /squad/active` serves the Core-backed squad object at top level, stamped /// with the host-configured persona (NOT proxied from Python), byte-consistent /// with the committed squad. #[test] fn squad_active_serves_core_backed_object_with_configured_persona() { let items = vec![gk(), st()]; let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; // Commit a squad so Core has a fresh canonical squad + extension. let body = put_body( "f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[1,2,3]", None, ); let (put, _) = handle_put_squad(&body, &deps); assert_eq!(put.status, 200); const PERSONA: i64 = 33_068_179; let (resp, log) = handle_squad_active(&deps, PERSONA); assert_eq!(log.outcome, "ok"); assert_eq!(resp.status, 200); let sq: Value = serde_json::from_slice(&resp.body).unwrap(); // Top-level squad object (not wrapped), persona from host config not Python. assert_eq!(sq["id"], 0); assert_eq!(sq["personaId"], PERSONA, "persona from host config"); assert_eq!(sq["formation"], "f442"); assert_eq!(sq["captain"], w["oc-a"], "captain is the wire id"); assert_eq!(sq["changed"], 0); assert!(sq["actives"].as_array().unwrap().is_empty()); let occ: Vec<&Value> = sq["players"] .as_array() .unwrap() .iter() .filter(|p| p["itemData"]["id"].as_i64().unwrap() != 0) .collect(); assert_eq!(occ.len(), 2, "the two committed players"); let p0 = occ.iter().find(|p| p["index"] == 0).unwrap(); assert_eq!(p0["itemData"]["id"], w["oc-a"]); assert_eq!(p0["itemData"]["resourceId"], 20801); } #[test] fn read_path_is_bounded_no_per_slot_lookup() { let items = vec![gk(), st()]; let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; let body = put_body( "f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[]", None, ); handle_put_squad(&body, &deps); let reads_before = core.read_calls(); let owned_before = core.calls(); let (_r, log) = handle_squad_list(&deps); assert_eq!(log.outcome, "ok"); assert_eq!( core.read_calls() - reads_before, 1, "exactly one squad read" ); assert_eq!( core.calls() - owned_before, 1, "exactly one batch owned fetch (no per-slot)" ); } // ── Stale / Missing integrity ──────────────────────────────────────────────── fn read_with_ext(ext: CoreExtState) -> CoreSquadRead { CoreSquadRead { name: "OpenFUT".into(), formation: "f442".into(), slots: vec![CoreSquadSlot { owned_card_id: "oc-a".into(), index: 0, is_captain: true, is_on_bench: false, }], ext, } } #[test] fn stale_extension_is_not_applied_on_reads() { let items = vec![gk()]; let (resolver, _w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 1).with_squad(read_with_ext(CoreExtState::Stale { schema_version: 1, payload: "{}".into(), })); let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; let (resp, log) = handle_squad_list(&deps); assert_eq!(log.outcome, "stale_integrity"); let v: Value = serde_json::from_slice(&resp.body).unwrap(); assert_eq!( v["squad"].as_array().unwrap().len(), 0, "stale never projected" ); } #[test] fn missing_extension_is_explicit_on_reads() { let items = vec![gk()]; let (resolver, _w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 1).with_squad(read_with_ext(CoreExtState::Missing)); let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; let (resp, log) = handle_squad_list(&deps); assert_eq!(log.outcome, "missing_integrity"); let v: Value = serde_json::from_slice(&resp.body).unwrap(); assert_eq!( v["squad"].as_array().unwrap().len(), 0, "missing never fabricated" ); } #[test] fn usermassinfo_never_serves_python_squad_on_integrity_failure() { let items = vec![gk()]; let (resolver, _w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 1).with_squad(read_with_ext(CoreExtState::Missing)); let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; let py_body = json!({ "userInfo": {"personaId": 7}, "squad": {"id": 0, "players": [{"index": 0, "itemData": {"id": 777}, "kitNumber": 5}]}, }); let (py_url, _rec) = spawn_mock_python_json(py_body); let pass = PassClient::new(&py_url); let (resp, log) = handle_user_mass_info( "GET", "/ut/game/fifa17/userMassInfo", &[], b"", &deps, &pass, ); assert_eq!(log.outcome, "missing_integrity"); let v: Value = serde_json::from_slice(&resp.body).unwrap(); assert_eq!( v["userInfo"]["personaId"], 7, "unrelated fields still Python" ); // Rust owns squad: Python's squad (id 777) must NOT survive. assert!(v["squad"]["players"].as_array().unwrap().is_empty()); assert_eq!( v["squad"]["personaId"], 7, "persona preserved, squad emptied" ); } #[test] fn unrelated_route_still_reaches_python() { let core = Arc::new(FakeCore::forbidden()); let (py_url, rec) = spawn_mock_python(); let server = build_server(core, &py_url, None); let resp = server.handle("POST", "/ut/game/fifa17/packs/purchase", &[], b"{}"); assert_eq!(resp.status, 200); assert!(resp .headers .iter() .any(|(k, _)| k.eq_ignore_ascii_case("x-from-python"))); assert_eq!(rec.lock().len(), 1, "reached Python"); } #[test] fn duplicate_definition_instances_stay_distinct_through_host() { // Two owned copies of ONE definition (card_a): distinct owned ids and wire // ids, one shared resourceId — must not collapse anywhere in the host. let a = item( "oc-a", "card_a", 87, "GK", "Argentina", "Premier League", "Chelsea", ); let c = item( "oc-c", "card_a", 87, "ST", "Argentina", "Premier League", "Chelsea", ); let items = vec![a, c]; let (resolver, w) = resolver_with_wires(&items, &[("card_a", 20801)]); assert_ne!(w["oc-a"], w["oc-c"], "two copies get distinct wire ids"); let core = FakeCore::new(items.clone(), 2); let ent = entities(); let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent, }; let body = put_body( "f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-c"], 7)], "[]", None, ); let (put, _) = handle_put_squad(&body, &deps); assert_eq!(put.status, 200); let py = json!({"userInfo": {"personaId": 1}, "squad": {"players": []}}); let (url, _r) = spawn_mock_python_json(py); let pass = PassClient::new(&url); let (umi, _) = handle_user_mass_info( "GET", "/ut/game/fifa17/userMassInfo", &[], b"", &deps, &pass, ); let v: Value = serde_json::from_slice(&umi.body).unwrap(); let occ: Vec<&Value> = v["squad"]["players"] .as_array() .unwrap() .iter() .filter(|p| p["itemData"]["id"].as_i64().unwrap() != 0) .collect(); assert_eq!(occ.len(), 2); assert_ne!( occ[0]["itemData"]["id"], occ[1]["itemData"]["id"], "two copies of one definition keep distinct wire ids" ); for p in &occ { assert_eq!(p["itemData"]["resourceId"], 20801, "shared asset id"); } } // ── Non-economy routes owned by Rust (no Python fallback, no Core) ────────────── /// The migrated non-economy static routes are served entirely by Rust: exact /// oracle-matching bodies, never proxied to Python, never touching Core. The /// security-question route fails closed (400) for an unknown session in Rust — /// again without any Python fallback. This is the per-domain no-fallback proof. #[test] fn non_economy_routes_rust_owned_no_python_no_core() { let core = Arc::new(FakeCore::forbidden()); // any Core call panics let (py_url, rec) = spawn_mock_python(); let server = build_server(core, &py_url, None); let cases: &[(&str, &str, serde_json::Value)] = &[ ( "GET", "/ut/game/fifa17/user/accountinfo", serde_json::json!({}), ), ( "GET", "/ut/game/fifa17/settings", serde_json::json!({ "configs": [] }), ), ( "GET", "/ut/game/fifa17/leaderboards/options", serde_json::json!({}), ), ("PUT", "/ut/game/fifa17/match/reset", serde_json::json!({})), ( "GET", "/ut/game/fifa17/club/stats/staff", serde_json::json!({}), ), ]; for (m, p, want) in cases { let resp = server.handle(m, p, &[], b""); assert_eq!(resp.status, 200, "{m} {p} status"); let body: Value = serde_json::from_slice(&resp.body).unwrap(); assert_eq!(&body, want, "{m} {p} body"); } // Security-question with an unknown session fails closed in Rust (never proxied). let resp = server.handle( "GET", "/ut/game/fifa17/phishing/trusteddevice?deviceId=6236375476659cd0f6c780e728774b71", &[("X-UT-SID".into(), "unknown-sid".into())], b"", ); assert_eq!(resp.status, 400); let body: Value = serde_json::from_slice(&resp.body).unwrap(); assert_eq!(body, serde_json::json!({ "reason": "invalid_session" })); // None of the migrated routes reached the Python upstream. assert_eq!( rec.lock().len(), 0, "no Python fallback for migrated non-economy routes" ); } /// `GET /hub` is served from Rust: `clubPlayers` counts owned PLAYER cards in /// Core, auction/tradePile counts come from the durable market store (0 with no /// economy wired), and Python is never consulted. #[test] fn hub_counts_players_from_core_no_python() { let items = vec![ item("oc1", "card_a", 84, "ST", "Brazil", "La Liga", "Barcelona"), item("oc2", "card_b", 80, "CM", "Spain", "La Liga", "Real Madrid"), item("oc3", "card_c", 77, "CB", "France", "Ligue 1", "PSG"), ]; let core = Arc::new(FakeCore::new(items, 3)); let (py_url, rec) = spawn_mock_python(); let server = build_server(core, &py_url, None); let resp = server.handle("GET", "/ut/game/fifa17/hub", &[], b""); assert_eq!(resp.status, 200); let body: Value = serde_json::from_slice(&resp.body).unwrap(); assert_eq!(body["clubPlayers"], 3, "counts owned player cards"); assert_eq!(body["auctionCount"], 0, "no economy wired => 0 listings"); assert_eq!( body["tradePile"], serde_json::json!({ "count": 0, "selling": 0, "sold": 0 }) ); assert_eq!(rec.lock().len(), 0, "hub never reaches Python"); } /// `GET /club/stats/year` is served from Rust with Core-accurate player tier /// counts (contextId 1 global bucket), never reaching Python. #[test] fn club_stats_year_counts_tiers_from_core_no_python() { let items = vec![ item("oc1", "card_a", 90, "ST", "Brazil", "La Liga", "Barcelona"), // gold item("oc2", "card_b", 70, "CM", "Spain", "La Liga", "Real Madrid"), // silver item("oc3", "card_c", 60, "CB", "France", "Ligue 1", "PSG"), // bronze ]; let core = Arc::new(FakeCore::new(items, 3)); let (py_url, rec) = spawn_mock_python(); let server = build_server(core, &py_url, None); let resp = server.handle("GET", "/ut/game/fifa17/club/stats/year", &[], b""); assert_eq!(resp.status, 200); let body: Value = serde_json::from_slice(&resp.body).unwrap(); let g: std::collections::HashMap = body["stat"] .as_array() .unwrap() .iter() .filter(|r| r["contextId"] == 1) .map(|r| { ( r["type"].as_str().unwrap().to_string(), r["typeValue"].as_i64().unwrap(), ) }) .collect(); assert_eq!(g["players"], 3); assert_eq!(g["playersGold"], 1); assert_eq!(g["playersSilver"], 1); assert_eq!(g["playersBronze"], 1); assert_eq!(g["consumables"], 0); assert_eq!(g["staff"], 0); assert_eq!(rec.lock().len(), 0, "club/stats never reaches Python"); } /// A resolver over a catalog authored verbatim, so a test can express the /// non-player fields (`kind`/`subtype`/`nation`/`league_id`/`team_id`) that the /// `card_id -> asset_id` helper above cannot. fn resolver_for_catalog(cards_json: &str) -> Arc { let doc = format!("{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{cards_json}}}}}"); let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap(); let store = JsonIdentityStore::open(unique_store_path()).unwrap(); Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))) } /// `?type=staff` and `?type=manager` must both serve the club's staff. /// /// Regression: every `?type=` other than player/kit short-circuited to an empty /// page with `outcome:"unsupported_type"` and never reached Core, so an owned /// manager was unreachable — and FIFA refuses to start a match without one. The /// STAFF tab was observed asking with `type=manager`; `type=staff` is the /// sibling token. #[test] fn staff_and_manager_queries_both_serve_the_clubs_staff() { let core = Arc::new(FakeCore::new( vec![ item("oc-mgr", "fifa17_1000509", 0, "", "", "", ""), item("oc-coach", "fifa17_3000083", 0, "", "", "", ""), item( "oc-p", "card_player", 86, "CDM", "Argentina", "Premier League", "Chelsea", ), ], 3, )); let resolver = resolver_for_catalog( "\"fifa17_1000509\":{\"asset_id\":1000509,\"kind\":\"staff\",\"subtype\":4,\ \"nation\":45,\"league_id\":53,\"team_id\":241},\ \"fifa17_3000083\":{\"asset_id\":3000083,\"kind\":\"staff\",\"subtype\":8},\ \"card_player\":{\"asset_id\":20801}", ); let entities = entities(); let hidden = std::collections::HashSet::new(); let kits = CoreKitAssignments::default(); for token in ["staff", "manager"] { let deps = ClubDeps { core: core.as_ref(), entities: &entities, assets: resolver.as_ref(), hidden: &hidden, active_kits: &kits, }; let (resp, log) = handle_club(&format!("type={token}&start=0&count=50"), &deps); assert_eq!(log.outcome, "ok", "type={token} must not be unsupported"); let body: Value = serde_json::from_slice(&resp.body).unwrap(); let items = body["itemData"].as_array().unwrap(); assert_eq!(items.len(), 2, "type={token}: manager + coach, no player"); let manager = items .iter() .find(|i| i["cardsubtypeid"] == 4) .unwrap_or_else(|| panic!("type={token}: no manager in {items:?}")); // The merge key is read RAW: a version byte here breaks the lookup and // the manager branch has no else-arm to report the miss. assert_eq!(manager["resourceId"], 1_000_509); assert_eq!(manager["itemType"], "staff"); // Slots the client's own merge never writes, so the server is the only // possible source of the flag, the badge and manager chemistry. assert_eq!(manager["nation"], 45); assert_eq!(manager["leagueId"], 53); assert_eq!(manager["teamid"], 241); assert!( manager["contract"].as_i64().unwrap_or(0) > 0, "a manager out of contract is exactly what blocks kickoff" ); let coach = items.iter().find(|i| i["cardsubtypeid"] == 8).unwrap(); assert!( coach.get("nation").is_none() && coach.get("leagueId").is_none(), "coach tables carry no nation/league column; inventing zeroes would be a lie" ); } } /// A player query must never leak staff, and vice versa: a manager carries /// nation/leagueId/teamid, which would pollute the by-league and by-team club /// drill-downs if it appeared among the players. #[test] fn staff_never_leaks_into_the_player_query() { let core = Arc::new(FakeCore::new( vec![ item("oc-mgr", "fifa17_1000509", 0, "", "", "", ""), item( "oc-p", "card_player", 86, "CDM", "Argentina", "Premier League", "Chelsea", ), ], 2, )); let resolver = resolver_for_catalog( "\"fifa17_1000509\":{\"asset_id\":1000509,\"kind\":\"staff\",\"subtype\":4,\ \"nation\":45,\"league_id\":53,\"team_id\":241},\ \"card_player\":{\"asset_id\":20801}", ); let entities = entities(); let hidden = std::collections::HashSet::new(); let kits = CoreKitAssignments::default(); let deps = ClubDeps { core: core.as_ref(), entities: &entities, assets: resolver.as_ref(), hidden: &hidden, active_kits: &kits, }; let (resp, _) = handle_club("type=player&start=0&count=50", &deps); let body: Value = serde_json::from_slice(&resp.body).unwrap(); let items = body["itemData"].as_array().unwrap(); assert_eq!(items.len(), 1); assert_eq!(items[0]["resourceId"], 20801, "only the footballer"); } /// `watchList` had a Rust handler that nothing could reach: no classifier arm /// produced `Route::WatchList`, so every request fell through to Passthrough and, /// against a staging stack with a deliberately dead Python upstream, 502'd. #[test] fn watchlist_is_rust_owned_and_never_passed_through() { for method in ["GET", "PUT", "POST", "DELETE"] { assert_eq!( classify(method, "/ut/game/fifa17/watchList"), Route::WatchList, "{method} watchList must be served by Rust, not proxied" ); } } // ── /club ?type= completeness, and the consumables route ──────────────────── /// A club holding one of everything the taxonomy can ask about: two players (a /// forward and a defender), a manager, a GK coach, a fitness coach, two kits and /// one consumable — the same mix the real profile has, at fixture scale. fn mixed_club_catalog() -> &'static str { "\"fifa17_20801\":{\"asset_id\":20801},\ \"fifa17_158023\":{\"asset_id\":158023},\ \"fifa17_1000509\":{\"asset_id\":1000509,\"kind\":\"staff\",\"subtype\":4,\ \"nation\":45,\"league_id\":53,\"team_id\":241},\ \"fifa17_9000081\":{\"asset_id\":9000081,\"kind\":\"staff\",\"subtype\":6},\ \"fifa17_3000083\":{\"asset_id\":3000083,\"kind\":\"staff\",\"subtype\":8},\ \"fifa17_6300006\":{\"asset_id\":6300006,\"kind\":\"kit\",\"subtype\":9,\ \"card_asset_id\":35,\"team_id\":21},\ \"fifa17_6400003\":{\"asset_id\":6400003,\"kind\":\"kit\",\"subtype\":9,\ \"card_asset_id\":35,\"team_id\":21},\ \"fifa17_5003012\":{\"asset_id\":5003012,\"kind\":\"consumable\",\"subtype\":54,\ \"card_asset_id\":3,\"rareflag\":0,\"rating\":85,\"amount\":15}" } fn mixed_club_items() -> Vec { vec![ item( "oc-st", "fifa17_20801", 94, "ST", "Argentina", "Premier League", "Chelsea", ), item( "oc-cb", "fifa17_158023", 88, "CB", "Argentina", "Premier League", "Chelsea", ), item("oc-mgr", "fifa17_1000509", 0, "", "", "", ""), item("oc-gk-coach", "fifa17_9000081", 0, "", "", "", ""), item("oc-fit-coach", "fifa17_3000083", 0, "", "", "", ""), item("oc-kit-home", "fifa17_6300006", 0, "", "", "", ""), item("oc-kit-away", "fifa17_6400003", 0, "", "", "", ""), item("oc-consumable", "fifa17_5003012", 0, "", "", "", ""), ] } /// Run one `?type=` query against the mixed club. fn club_query(query: &str) -> (Vec, String, &'static str) { let core = Arc::new(FakeCore::new(mixed_club_items(), 8)); let resolver = resolver_for_catalog(mixed_club_catalog()); let ents = entities(); let hidden = std::collections::HashSet::new(); let kits = CoreKitAssignments { home_owned_card_id: Some("oc-kit-home".into()), away_owned_card_id: Some("oc-kit-away".into()), }; let deps = ClubDeps { core: core.as_ref(), entities: &ents, assets: resolver.as_ref(), hidden: &hidden, active_kits: &kits, }; let (resp, log) = handle_club(query, &deps); assert_eq!(resp.status, 200, "UTAS must never fail a club query"); let body: Value = serde_json::from_slice(&resp.body).unwrap(); let items = body["itemData"].as_array().cloned().unwrap_or_default(); (items, log.filter, log.outcome) } /// Every `?type=` arm that claims a family must serve THAT family and nothing /// else. The mirror filter is the point: a manager carries nation/leagueId/teamid, /// so leaking one into a player query would put a coach in the by-league and /// by-team drill-downs — the exact regression the filter exists to prevent. #[test] fn club_type_arms_serve_their_own_family_and_never_leak_another() { // Players: the untyped fetch, `player`, and the taxonomy's `any`/`custom` // arms all mean the club's footballers. for query in ["", "type=player", "type=any", "type=custom"] { let (items, _, outcome) = club_query(query); assert_eq!(outcome, "ok", "{query}"); assert_eq!(items.len(), 2, "{query}: two footballers"); for it in &items { assert_eq!(it["itemType"], "player", "{query}"); assert_eq!(it["cardsubtypeid"], 0, "{query}: no staff/kit subtype"); assert!(it["attributeList"].is_array(), "{query}"); } } // The whole staff family, under either observed token. for query in ["type=staff", "type=manager"] { let (items, _, outcome) = club_query(query); assert_eq!(outcome, "ok", "{query}"); let mut subtypes: Vec = items .iter() .map(|i| i["cardsubtypeid"].as_i64().unwrap()) .collect(); subtypes.sort_unstable(); assert_eq!( subtypes, vec![4, 6, 8], "{query}: manager + both coaches, no footballer and no kit" ); for it in &items { assert_eq!(it["itemType"], "staff", "{query}"); assert!(it.get("attributeList").is_none(), "{query}"); } } // One arm per coach family, by cardsubtypeid. for (query, subtype) in [ ("type=gkcoach", 6), ("type=fitnesscoach", 8), ("type=headcoach", 5), ("type=physio", 7), ] { let (items, _, outcome) = club_query(query); assert_eq!(outcome, "ok", "{query}"); let owned: Vec = items .iter() .map(|i| i["cardsubtypeid"].as_i64().unwrap()) .collect(); // The club owns a GK coach and a fitness coach only, so the other two // arms are legitimately empty — never "everything" to fill the tab. let expected: Vec = if [6, 8].contains(&subtype) { vec![subtype] } else { vec![] }; assert_eq!(owned, expected, "{query}"); assert!( !owned.contains(&4), "{query}: the MANAGER is not a coach family" ); } // Kits, with their ownership-backed active designations. let (kits, _, outcome) = club_query("type=kit"); assert_eq!(outcome, "ok"); assert_eq!(kits.len(), 2); assert_eq!(kits[0]["itemState"], "activeHomeKit"); assert_eq!(kits[1]["itemState"], "activeAwayKit"); for kit in &kits { assert_eq!(kit["cardsubtypeid"], 9); assert!(kit.get("attributeList").is_none()); } // The consumable is in NEITHER: it has its own route and its own envelope. for query in ["", "type=player", "type=staff", "type=kit"] { let (items, _, _) = club_query(query); assert!( !items .iter() .any(|i| i["cardsubtypeid"].as_i64() == Some(54)), "{query}: a consumable must never appear in a /club item list" ); } } /// The three MY CLUB position tabs answer their own group, from the client's own /// position ladder — never the whole club. #[test] fn position_tabs_serve_only_their_own_position_group() { let (forwards, _, outcome) = club_query("type=playerforward"); assert_eq!(outcome, "ok"); assert_eq!(forwards.len(), 1); assert_eq!(forwards[0]["preferredPosition"], "ST"); let (defenders, _, _) = club_query("type=playerdefender"); assert_eq!(defenders.len(), 1); assert_eq!(defenders[0]["preferredPosition"], "CB"); // No midfielder is owned → an empty tab, not the other five cards. let (mids, _, outcome) = club_query("type=playermidfielder"); assert_eq!(outcome, "ok"); assert!(mids.is_empty()); // And no position tab ever contains staff or a kit. for query in [ "type=playerforward", "type=playerdefender", "type=playermidfielder", ] { let (items, _, _) = club_query(query); for it in &items { assert_eq!(it["itemType"], "player", "{query}"); } } } /// The arms that are DELIBERATELY empty answer 200 with an empty list and record /// WHY — distinguishable in the log from a token we do not know. #[test] fn withheld_club_type_arms_are_empty_with_a_recorded_reason() { for (query, reason) in [ ("type=leaguelogos", "subtype_by_elimination_unprobed"), ("type=healing", "served_by_club_consumables_route"), ("type=contract", "served_by_club_consumables_route"), ("type=training", "served_by_club_consumables_route"), ("type=development", "served_by_club_consumables_route"), ("type=unlocks", "not_owned_inventory"), ("type=offlinetrophy", "no_trophy_ownership_in_core"), ("type=onlinetrophy", "no_trophy_ownership_in_core"), ("type=featuredofflinetrophy", "no_trophy_ownership_in_core"), ("type=featuredonlinetrophy", "no_trophy_ownership_in_core"), ("type=allofflinetrophy", "no_trophy_ownership_in_core"), ("type=allonlinetrophy", "no_trophy_ownership_in_core"), ] { let (items, filter, outcome) = club_query(query); assert_eq!(outcome, "withheld", "{query}"); assert!(items.is_empty(), "{query} must serve nothing"); assert!( filter.contains(reason), "{query}: log must carry the reason, got [{filter}]" ); } } /// `?type=equippables` is the family the MY CLUB kit tab actually asks for, and it /// answers with the KIT family only. Withholding it WAS the "0 kits in my club" /// symptom — the tab asked, got the withheld empty body, and drew nothing. Serving /// it is operator-confirmed on the retail client. /// /// Kits only, deliberately: the 2026-08-05 crash was 30 items across five families /// at once, and nothing has retested that shape. /// /// NOT exercised here: `OPENFUT_FIFA17_EQUIPPABLES=0`, which restores the withheld /// body. It is read through a `OnceLock`, so flipping the process-wide env inside /// one test would leak into every other test sharing this binary. #[test] fn equippables_serves_the_kit_family_because_that_is_the_my_club_kit_tab() { let (items, _, outcome) = club_query("type=equippables"); assert_eq!(outcome, "ok"); assert_eq!(items.len(), 2, "the fixture club owns exactly two kits"); for it in &items { assert_eq!(it["itemType"], "kit"); assert_eq!( it["cardsubtypeid"], 9, "kits only: mixing families is the shape that crashed the client" ); } // The two tabs are the same question, so they must never diverge. let (kits, _, _) = club_query("type=kit"); assert_eq!(items, kits, "equippables and kit must serve one body"); } /// The three club-customisation families Core can own are MAPPED (so the arm asks /// Core for the right rows) but their item record is still withheld, so the answer /// is an empty list rather than a guessed record — and never another family's. #[test] fn club_item_arms_are_mapped_but_withhold_the_unverified_record() { for query in ["type=badge", "type=stadium", "type=ball", "type=misc"] { let (items, filter, outcome) = club_query(query); assert_eq!(outcome, "ok", "{query}"); assert!( items.is_empty(), "{query}: the club owns none, and no other family may fill the tab" ); let token = query.trim_start_matches("type="); assert!(filter.contains(&format!("type={token}")), "{filter}"); } } /// A token outside the client's 30-arm taxonomy stays `unsupported_type`: empty, /// and loud in the log, never silently mapped onto the player set. #[test] fn unknown_club_type_is_unsupported_not_silently_mapped() { for query in ["type=nonsense", "type=PLAYER", "type=kits"] { let (items, filter, outcome) = club_query(query); assert_eq!(outcome, "unsupported_type", "{query}"); assert!(items.is_empty(), "{query}"); assert!(filter.starts_with("type="), "{filter}"); } } fn build_server_with_catalog(core: Arc, upstream: &str, cards_json: &str) -> Server { let doc = format!("{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{cards_json}}}}}"); let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap(); let store = JsonIdentityStore::open(unique_store_path()).unwrap(); let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))); Server::new( core, Arc::new(entities()), resolver, Arc::new(PassClient::new(upstream)), 33_068_179, ) } /// `GET club/consumables/` is Rust-owned, served from Core, and answers /// with the STACK wrapper this response class reads — not bare items, which the /// client accepts and silently discards. #[test] fn consumables_route_serves_core_owned_stacks_per_category() { // Two copies of one training card + one contract card + a footballer. let items = vec![ item("oc-c1", "fifa17_5003012", 0, "", "", "", ""), item("oc-c2", "fifa17_5003012", 0, "", "", "", ""), item("oc-c3", "fifa17_5001004", 0, "", "", "", ""), item( "oc-p", "fifa17_20801", 94, "ST", "Argentina", "Premier League", "Chelsea", ), ]; let core = Arc::new(FakeCore::new(items, 4)); let (py_url, rec) = spawn_mock_python(); let server = build_server_with_catalog( core, &py_url, "\"fifa17_20801\":{\"asset_id\":20801},\ \"fifa17_5003012\":{\"asset_id\":5003012,\"kind\":\"consumable\",\"subtype\":54,\ \"card_asset_id\":3,\"rareflag\":0,\"rating\":85,\"amount\":15},\ \"fifa17_5001004\":{\"asset_id\":5001004,\"kind\":\"consumable\",\"subtype\":201,\ \"card_asset_id\":7,\"rareflag\":0,\"rating\":60,\"contract\":7}", ); assert_eq!( classify("GET", "/ut/game/fifa17/club/consumables/training"), Route::ClubConsumables, "a /club PREFIX must not fall through to the generic club route" ); let resp = server.handle("GET", "/ut/game/fifa17/club/consumables/training", &[], b""); assert_eq!(resp.status, 200); let body: Value = serde_json::from_slice(&resp.body).unwrap(); let stacks = body["itemData"].as_array().unwrap(); assert_eq!(stacks.len(), 1, "one stack for the two identical copies"); assert_eq!(stacks[0]["count"], 2); assert_eq!(stacks[0]["resourceId"], 5_003_012); assert_eq!(stacks[0]["item"]["cardsubtypeid"], 54); assert_eq!( stacks[0]["item"]["cardassetid"], 3, "the ART id, not the id" ); assert_eq!(stacks[0]["item"]["amount"], 15, "mandatory for category 0"); assert_eq!(stacks[0]["item"]["rating"], 85, "EA's definition rating"); assert!( stacks[0]["item"].get("attributeList").is_none(), "a consumable has no attributes — that is what makes it not a player" ); // The contracts category serves the OTHER card, and never the training one. let resp = server.handle( "GET", "/ut/game/fifa17/club/consumables/contracts", &[], b"", ); let body: Value = serde_json::from_slice(&resp.body).unwrap(); let stacks = body["itemData"].as_array().unwrap(); assert_eq!(stacks.len(), 1); assert_eq!(stacks[0]["resourceId"], 5_001_004); assert_eq!(stacks[0]["item"]["contract"], 7); assert!( stacks[0]["item"].get("amount").is_none(), "categories 2 and 3 ignore `amount`" ); // A category the club owns nothing in is empty — and the footballer never // appears in any of them. for category in ["healing", "fitness", "position", "playstyle"] { let path = format!("/ut/game/fifa17/club/consumables/{category}"); let resp = server.handle("GET", &path, &[], b""); let body: Value = serde_json::from_slice(&resp.body).unwrap(); assert!( body["itemData"].as_array().unwrap().is_empty(), "{category} must not be filled with another family" ); } assert_eq!(rec.lock().len(), 0, "consumables never reach Python"); } /// An UNKNOWN category segment is empty, not the whole shelf: filling a named tab /// with every family is the same bug class as answering a drill-down with the /// entire club. #[test] fn consumables_route_unknown_category_serves_nothing() { let core = Arc::new(FakeCore::new( vec![item("oc-c1", "fifa17_5003012", 0, "", "", "", "")], 1, )); let (py_url, rec) = spawn_mock_python(); let server = build_server_with_catalog( core, &py_url, "\"fifa17_5003012\":{\"asset_id\":5003012,\"kind\":\"consumable\",\"subtype\":54,\ \"card_asset_id\":3,\"rareflag\":0,\"rating\":85,\"amount\":15}", ); for path in [ "/ut/game/fifa17/club/consumables/somethingelse", "/ut/game/fifa17/club/consumables", ] { let resp = server.handle("GET", path, &[], b""); assert_eq!(resp.status, 200, "{path}"); let body: Value = serde_json::from_slice(&resp.body).unwrap(); assert!(body["itemData"].as_array().unwrap().is_empty(), "{path}"); } assert_eq!(rec.lock().len(), 0, "still never Python"); } /// A consumable whose catalog entry is INCOMPLETE is dropped, not drawn wrong: /// without `amount` the client renders "-1" (its parser initialises the temp to /// -1 and sign-extends), and without a real `cardassetid` it draws the /// `notfound.swf` green box. #[test] fn incomplete_consumable_definitions_are_dropped_not_drawn_wrong() { let core = Arc::new(FakeCore::new( vec![ item("oc-no-amount", "fifa17_5003011", 0, "", "", "", ""), item("oc-no-art", "fifa17_5003013", 0, "", "", "", ""), ], 2, )); let (py_url, _rec) = spawn_mock_python(); let server = build_server_with_catalog( core, &py_url, // (a) art id present, `amount` missing; (b) `amount` present, art missing. "\"fifa17_5003011\":{\"asset_id\":5003011,\"kind\":\"consumable\",\"subtype\":54,\ \"card_asset_id\":3,\"rareflag\":0,\"rating\":65},\ \"fifa17_5003013\":{\"asset_id\":5003013,\"kind\":\"consumable\",\"subtype\":54,\ \"rareflag\":0,\"rating\":85,\"amount\":15}", ); let resp = server.handle("GET", "/ut/game/fifa17/club/consumables/training", &[], b""); assert_eq!(resp.status, 200); let body: Value = serde_json::from_slice(&resp.body).unwrap(); assert!( body["itemData"].as_array().unwrap().is_empty(), "neither definition can be drawn honestly" ); } /// One catalogued card for [`club_resolver`]: the FIFA facts the production /// classification path reads. struct CatalogCard { card_id: &'static str, asset_id: u32, kind: &'static str, subtype: i64, card_asset_id: u32, team_id: Option, } /// A resolver over a catalog that states each card's KIND, so club families flow /// through exactly the production classification path (catalog kind -> shaper), /// not a test-only stub. fn club_resolver(cards: &[CatalogCard]) -> Arc { let entries: Vec = cards .iter() .map(|c| { let team = c .team_id .map(|t| format!(",\"team_id\":{t}")) .unwrap_or_default(); let (id, asset, kind) = (c.card_id, c.asset_id, c.kind); let (subtype, art) = (c.subtype, c.card_asset_id); format!( "\"{id}\":{{\"asset_id\":{asset},\"kind\":\"{kind}\",\"subtype\":{subtype},\ \"card_asset_id\":{art}{team}}}" ) }) .collect(); let doc = format!( "{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{}}}}}", entries.join(",") ); let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap(); let store = JsonIdentityStore::open(unique_store_path()).unwrap(); Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))) } /// A club holding EVERY ownable class, served end to end through the real /// catalog resolver. /// /// This is the whole-taxonomy lock. Each family must reach the client on its own /// `?type=` arm, and the two cardtype-9 families must be WITHHELD rather than /// guessed — and withheld deliberately, which is why their identities resolve /// here: an empty result must not be an accident of a missing asset id. #[test] fn every_ownable_class_projects_on_its_own_arm() { let card = |card_id, asset_id, kind, subtype, card_asset_id, team_id| CatalogCard { card_id, asset_id, kind, subtype, card_asset_id, team_id, }; let cards = vec![ card("c_player", 20801, "player", 0, 0, None), card("c_manager", 1_000_509, "manager", 4, 0, None), card("c_coach", 3_000_083, "staff", 8, 0, None), card("c_kit", 6_300_006, "kit", 9, 35, Some(21)), card("c_badge", 6_000_005, "badge", 11, 39, Some(21)), card("c_stadium", 6_200_000, "stadium", 10, 36, None), card("c_ball", 8_120_194, "ball", 30, 37, None), card("c_logo", 8_010_015, "misc", 31, 40, None), ]; let owned: Vec = cards .iter() .map(|c| { let id = c.card_id; item( &format!("oc_{id}"), id, 84, "ST", "Spain", "La Liga", "Barcelona", ) }) .collect(); let core = Arc::new(FakeCore::new(owned, cards.len() as i64)); let resolver = club_resolver(&cards); let ents = entities(); let hidden = std::collections::HashSet::new(); let active_kits = CoreKitAssignments::default(); let deps = ClubDeps { core: core.as_ref(), entities: &ents, assets: resolver.as_ref(), hidden: &hidden, active_kits: &active_kits, }; let served = |arm: &str| -> Vec { let (resp, _log) = handle_club(&format!("type={arm}&count=50"), &deps); let v: Value = serde_json::from_slice(&resp.body).unwrap(); v["itemData"].as_array().cloned().unwrap_or_default() }; // Projected families, each on its own arm. for (arm, subtype) in [("player", 0i64), ("kit", 9), ("badge", 11), ("stadium", 10)] { let got = served(arm); assert_eq!(got.len(), 1, "type={arm} must serve exactly its own item"); if subtype != 0 { assert_eq!(got[0]["cardsubtypeid"], subtype, "type={arm}"); } } // The staff arm is the whole family: coach AND manager. assert_eq!( served("staff").len(), 2, "manager is inside the staff family" ); assert_eq!( served("manager").len(), 2, "the manager arm asks for the family too" ); // teamid only where the caption resolves TeamName_Abbr15_. assert_eq!(served("badge")[0]["teamid"], 21); assert!( served("stadium")[0].get("teamid").is_none(), "a stadium caption reads assetId, never teamid" ); // Cardtype 9: withheld on purpose. Their catalog entries resolve, so an empty // result here is a decision about the family and not a missing identity. for arm in ["ball", "misc"] { assert!( served(arm).is_empty(), "type={arm} is cardtype 9: no DB name resolver, so it stays withheld" ); } // A consumable never rides in THIS envelope even when owned: it has its own // route and a stack-wrapper element, and the client silently discards a bare // consumable item here. assert!( served("consumables").is_empty() || served("consumables") .iter() .all(|i| i["cardsubtypeid"] != 0) ); } // ── Training apply: verb authority and the Core wire contract ──────────────── /// METHOD IS PART OF ROUTE AUTHORITY. The same `item/resource/` path means /// three different things, and two of them destroy a card. A training apply that /// slid into the GET arm would read a definition and answer 200 having changed /// nothing; one that slid into the PUT arm would SELL the card the player asked /// to spend. Both were live failure modes before the family was read as one unit. #[test] fn the_item_resource_family_stays_verb_split_for_training_cards() { use openfut_utas_host::{classify_economy, EconomyRoute}; // 5003011 is a real GK training card (subtype 54, SPEED +10). let path = "/ut/game/fifa17/item/resource/5003011"; assert_eq!( classify_economy("POST", path), Some(EconomyRoute::ConsumableApply), "POST must be the apply arm" ); assert_eq!( classify_economy("PUT", path), Some(EconomyRoute::QuickSellResource), "PUT must remain quick-sell" ); // GET is NOT an economy route at all: it is the read-only definition lookup, // so it can never reach the apply transaction. assert_eq!( classify_economy("GET", path), None, "GET must not be an economy route" ); assert_eq!(classify("GET", path), Route::Passthrough); // The bare tail is the definition lookup Rust does claim. assert_eq!( classify("GET", "/ut/game/fifa17/item/resource"), Route::ItemDefs ); } /// A non-numeric or empty resource id must not be mistaken for an apply — the /// digit guard is what keeps `item/resource/anything` from reaching Core. #[test] fn only_a_numeric_resource_id_can_be_applied() { use openfut_utas_host::{classify_economy, EconomyRoute}; for tail in ["", "abc", "5003011x", "50030 11"] { let path = format!("/ut/game/fifa17/item/resource/{tail}"); assert_ne!( classify_economy("POST", &path), Some(EconomyRoute::ConsumableApply), "tail {tail:?} must not classify as an apply" ); } } /// The effect must serialise EXACTLY as Core's closed `InstanceEffect` /// vocabulary deserialises it. Core dispatches on `kind`, so a drifted token or /// a renamed field is a 400 at best and a silently skipped mutation at worst. #[test] fn the_training_effect_matches_cores_closed_vocabulary() { use openfut_utas_host::ApplyEffect; let json = ApplyEffect::ApplyTraining { attribute_index: Some(4), amount: 10, max_amount: 15, } .to_json(); assert_eq!( json, serde_json::json!({ "kind": "apply_training", "attribute_index": 4, "amount": 10, "max_amount": 15, }) ); // The rare all-six card MUST serialise a null slot. Sending 0 would train // pace alone, and omitting the key would leave Core guessing. let all_six = ApplyEffect::ApplyTraining { attribute_index: None, amount: 10, max_amount: 10, } .to_json(); assert_eq!( all_six, serde_json::json!({ "kind": "apply_training", "attribute_index": null, "amount": 10, "max_amount": 10, }) ); // The contract arm must keep its own shape while sharing the enum. let contract = ApplyEffect::AddContractMatches { amount: 3, cap: 99, default_when_unset: 7, } .to_json(); assert_eq!( contract, serde_json::json!({ "kind": "add_contract_matches", "amount": 3, "cap": 99, "default_when_unset": 7, }) ); } /// Every training subtype the adapter can resolve must declare a magnitude no /// larger than the ceiling the host sends Core. If these ever disagree, Core /// refuses a legitimate card — a silent, family-wide outage. #[test] fn no_shipped_training_card_exceeds_the_declared_ceiling() { use openfut_adapter_fifa17::fut::training_cards::{ ceiling_for, training_effect, TRAINING_ALL_MAX_AMOUNT, TRAINING_MAX_AMOUNT, }; let mut resolved = 0; for subtype in [51, 52, 53, 54, 55, 56, 61, 62, 63, 64, 65, 66] { for amount in [5, 10, 15] { let e = training_effect(subtype, Some(amount)).expect("shipped card resolves"); assert!( e.amount <= ceiling_for(&e), "subtype {subtype} amount {amount} exceeds the ceiling sent to Core" ); assert_eq!(ceiling_for(&e), TRAINING_MAX_AMOUNT); resolved += 1; } } assert_eq!(resolved, 36, "all 36 single-attribute cards must resolve"); // The six rare all-six cards carry their own, lower ceiling. let mut rare = 0; for subtype in [57, 67] { for amount in [3, 6, 10] { let e = training_effect(subtype, Some(amount)).expect("rare card resolves"); assert_eq!(e.attribute_index, None); assert_eq!(ceiling_for(&e), TRAINING_ALL_MAX_AMOUNT); rare += 1; } } assert_eq!(rare, 6, "all 6 rare all-six cards must resolve"); }