6bbc0eaf4f
The host called set_squad_manager unconditionally on every squad save, passing
the resolved manager or None. None was serialised as {"owned_card_id": null},
an EXPLICIT removal, so a save that merely said nothing about the manager
deleted the assignment. That is how a client whose squad model had been
destroyed wiped a real manager row (WAL commit 468, squad_managers 1 -> 0).
FIFA 17 has no wire shape that removes a manager: the client always sends a
ref. So None never means "the user cleared the slot" - it means the ref was
absent, zero, or unmappable, i.e. this save carries no manager decision. The
assignment is now left untouched and the skip is logged.
The capability is removed at the TYPE level: CoreAccess::set_squad_manager
takes &str, not Option<&str>, so the host cannot express a clear at all. Core
still supports deliberate removal via an explicit null for other callers.
The existing test asserted the destructive behaviour as intended ("a later save
without a manager CLEARS it"). That contract was the bug; it now asserts the
manager survives and that both saves still commit their slots. With the fix
reverted the test fails.
Live-proven on staging against the real route (PUT /ut/game/fifa17/squad/<n>;
squad/active is a GET-only tail and falls through to the dead Python upstream,
which is why an earlier replay attempt proved nothing):
exact original shape (no resolvable players, manager: [])
-> 502 core_error, core returned status 400, nothing mutated
valid 23-player save carrying manager: []
-> 200 {"id":0}, manager_write_skipped logged, manager PRESERVED
valid 23-player save carrying the real manager ref
-> 200 {"id":0}, manager assigned
Players 23/23, manager 1, captain, active club items, coins, integrity and FK
identical before and after, and again after a Core+host restart.
2796 lines
98 KiB
Rust
2796 lines
98 KiB
Rust
//! 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<Mutex<Vec<(String, String, Vec<u8>)>>>;
|
|
|
|
/// A recorded squad replacement (the fields a host test asserts on).
|
|
#[derive(Clone)]
|
|
struct StoredReplace {
|
|
formation: Option<String>,
|
|
slots: Vec<(String, i64, bool, bool)>, // owned_card_id, index, is_captain, is_on_bench
|
|
ext_payload: String,
|
|
chemistry: Option<i64>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FakeCore {
|
|
items: Vec<CoreOwnedItem>,
|
|
total: i64,
|
|
calls: AtomicUsize,
|
|
read_calls: AtomicUsize,
|
|
replace_calls: AtomicUsize,
|
|
last_params: Mutex<Vec<(String, String)>>,
|
|
/// The stored squad + extension returned by `read_squad_ext`. A successful
|
|
/// `replace_squad` overwrites it (Fresh), enabling coupled read-after-write.
|
|
squad: Mutex<Option<CoreSquadRead>>,
|
|
/// The ownership-backed manager assignment, exactly as Core persists it: a
|
|
/// full squad replacement writes it (or clears it with `None`).
|
|
manager: Mutex<Option<String>>,
|
|
replaced: Mutex<Vec<StoredReplace>>,
|
|
panic_if_called: bool,
|
|
return_err: bool,
|
|
}
|
|
|
|
impl FakeCore {
|
|
fn new(items: Vec<CoreOwnedItem>, 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<StoredReplace> {
|
|
self.replaced.lock().clone()
|
|
}
|
|
fn last(&self) -> Vec<(String, String)> {
|
|
self.last_params.lock().clone()
|
|
}
|
|
fn manager(&self) -> Option<String> {
|
|
self.manager.lock().clone()
|
|
}
|
|
}
|
|
|
|
impl CoreAccess for FakeCore {
|
|
fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError> {
|
|
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<CoreSquadRead, CoreError> {
|
|
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<CoreReplaceResult, CoreError> {
|
|
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<Option<String>, CoreError> {
|
|
if self.return_err {
|
|
return Err(CoreError::Status(500));
|
|
}
|
|
Ok(self.manager.lock().clone())
|
|
}
|
|
|
|
fn set_squad_manager(&self, owned_card_id: &str) -> Result<(), CoreError> {
|
|
if self.return_err {
|
|
return Err(CoreError::Status(500));
|
|
}
|
|
*self.manager.lock() = Some(owned_card_id.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<FakeCore>,
|
|
upstream: &str,
|
|
assets_map: Option<HashMap<String, u32>>,
|
|
) -> 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<String> = 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<Fifa17IdentityResolver> {
|
|
let entries: Vec<String> = 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<String> = ["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<i64> = 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<String> = 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::<Value>(&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<String, i64>) {
|
|
let entries: Vec<String> = 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<i64>,
|
|
) -> Vec<u8> {
|
|
let ps: Vec<Value> = players
|
|
.iter()
|
|
.map(|(i, w, k)| json!({"index": i, "itemData": {"id": w, "dream": false}, "kitNumber": k}))
|
|
.collect();
|
|
let mgr: Vec<Value> = 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,
|
|
squad_actives: false,
|
|
};
|
|
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. A later save that
|
|
/// carries NO manager ref does NOT clear it.
|
|
///
|
|
/// This test previously asserted the opposite ("a full replacement replaces the
|
|
/// manager too"). That was the bug: FIFA 17 has no wire shape that removes a
|
|
/// manager — the client always sends a ref — so an absent ref means the save
|
|
/// says nothing about the manager, not that the user cleared the slot. A client
|
|
/// whose squad model had been destroyed sent exactly that shape and deleted a
|
|
/// real manager row (WAL commit 468, squad_managers 1 -> 0).
|
|
#[test]
|
|
fn put_assigns_the_owned_manager_and_a_later_save_without_one_leaves_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,
|
|
squad_actives: false,
|
|
};
|
|
|
|
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"
|
|
);
|
|
|
|
// The exact destructive shape: `"manager": []`.
|
|
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().as_deref(),
|
|
Some("oc-b"),
|
|
"a save carrying no manager ref must LEAVE the existing assignment alone"
|
|
);
|
|
|
|
// And the squad itself still committed — the manager decision is separate.
|
|
assert_eq!(core.replace_calls(), 2, "both saves committed their slots");
|
|
}
|
|
|
|
/// 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,
|
|
squad_actives: false,
|
|
};
|
|
|
|
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,
|
|
squad_actives: false,
|
|
};
|
|
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,
|
|
squad_actives: false,
|
|
};
|
|
// 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,
|
|
squad_actives: false,
|
|
};
|
|
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,
|
|
squad_actives: false,
|
|
};
|
|
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,
|
|
squad_actives: false,
|
|
};
|
|
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,
|
|
squad_actives: false,
|
|
};
|
|
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::<usize>().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,
|
|
squad_actives: false,
|
|
};
|
|
// 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,
|
|
squad_actives: false,
|
|
};
|
|
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,
|
|
squad_actives: false,
|
|
};
|
|
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,
|
|
squad_actives: false,
|
|
};
|
|
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,
|
|
squad_actives: false,
|
|
};
|
|
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,
|
|
squad_actives: false,
|
|
};
|
|
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<String, i64> = 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<Fifa17IdentityResolver> {
|
|
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<CoreOwnedItem> {
|
|
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<Value>, 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<i64> = 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<i64> = 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<i64> = 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<FakeCore>, 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/<category>` 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<i64>,
|
|
}
|
|
|
|
/// 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<Fifa17IdentityResolver> {
|
|
let entries: Vec<String> = 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<CoreOwnedItem> = 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<Value> {
|
|
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_<teamid>.
|
|
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/<rid>` 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");
|
|
}
|