diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 7b1e705..9463308 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -139,11 +139,17 @@ pub fn classify(method: &str, path: &str) -> Route { } } -/// The tail after `/ut/game//` (non-empty title), or `None`. +/// The tail after `/ut/game/<sku>/` or `/ut/v2/game/<sku>/` (non-empty sku), or +/// `None`. Retail FIFA 17 issues the Store family (`store/*`, `purchased`) under +/// the `/ut/v2/game/<sku>/` prefix while other routes use `/ut/game/<sku>/`; both +/// normalize to the same tail so economy classification is prefix-agnostic. The +/// `sku` segment is generic (never hard-coded to `fifa17`). fn ut_tail(path: &str) -> Option<&str> { - let rest = path.strip_prefix("/ut/game/")?; - let (title, tail) = rest.split_once('/')?; - if title.is_empty() { + let rest = path + .strip_prefix("/ut/game/") + .or_else(|| path.strip_prefix("/ut/v2/game/"))?; + let (sku, tail) = rest.split_once('/')?; + if sku.is_empty() { None } else { Some(tail) @@ -209,6 +215,22 @@ fn is_item_id_tail(tail: &str) -> bool { } } +/// `store/transaction` or `store/transaction/<digits>` — the Store BUY create +/// step. Retail sends a trailing numeric transaction id (observed live: +/// `store/transaction/0`). Mirrors the Python oracle's bare `/store/transaction` +/// route, but bounded to a single all-digit id segment so it never absorbs +/// `store/transactions`, `store/transactionfoo`, or `store/transaction/0/extra`. +fn is_store_transaction_tail(tail: &str) -> bool { + match tail.strip_prefix("store/transaction") { + Some("") => true, + Some(rest) => match rest.strip_prefix('/') { + Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()), + None => false, + }, + None => false, + } +} + /// Classify a FIFA17 economy route from method + path, mirroring the Python /// oracle's route table (`utas_server.py` §1418-1553). Returns `None` for any /// non-economy path. Path is already query-stripped by the caller. @@ -219,7 +241,10 @@ pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> { let delete = method.eq_ignore_ascii_case("DELETE"); // The `/ut/delete/game/<sku>/…` family is NOT `/ut/game/…`-prefixed. - if let Some(rest) = path.strip_prefix("/ut/delete/game/") { + if let Some(rest) = path + .strip_prefix("/ut/delete/game/") + .or_else(|| path.strip_prefix("/ut/v2/delete/game/")) + { if let Some((_sku, tail)) = rest.split_once('/') { if tail == "item" && post { return Some(EconomyRoute::QuickSellBody); @@ -237,7 +262,7 @@ pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> { match ut_tail(path) { Some("user/credits") if get => Some(EconomyRoute::Credits), Some(t) if get && t.starts_with("store/purchasegroup") => Some(EconomyRoute::PurchaseGroup), - Some("store/transaction") if put => Some(EconomyRoute::StoreBuy), + Some(t) if put && is_store_transaction_tail(t) => Some(EconomyRoute::StoreBuy), Some("purchased") if post => Some(EconomyRoute::PackOpen), Some("purchased") if get => Some(EconomyRoute::PackReveal), Some(t) if delete && is_item_id_tail(t) => Some(EconomyRoute::QuickSellPath), @@ -3088,4 +3113,138 @@ mod tests { let mut other = serde_json::json!({"other": 1}); assert_eq!(overlay_empty_mypacks(&mut other, StoreMode::CleanV1), 0); } + + #[test] + fn ut_tail_normalizes_v1_and_v2() { + for (path, want) in [ + ( + "/ut/game/fifa17/store/purchasegroup", + Some("store/purchasegroup"), + ), + ( + "/ut/v2/game/fifa17/store/purchasegroup", + Some("store/purchasegroup"), + ), + ( + "/ut/game/fifa17/store/transaction", + Some("store/transaction"), + ), + ( + "/ut/v2/game/fifa17/store/transaction/0", + Some("store/transaction/0"), + ), + ("/ut/game/fifa17/purchased", Some("purchased")), + ("/ut/v2/game/fifa17/purchased", Some("purchased")), + ("/ut/game/fifa17/user/credits", Some("user/credits")), + // generic sku — helper is not fifa17-string-specific. + ( + "/ut/game/fifa23/store/transaction/7", + Some("store/transaction/7"), + ), + ( + "/ut/v2/game/fifa23/store/purchasegroup", + Some("store/purchasegroup"), + ), + // negatives. + ("/ut/auth", None), + ("/openfut/account/sync", None), + ("/ut/game/", None), + ("/ut/game/fifa17", None), + ("/ut/v2/game/fifa17", None), + ("/ut/v2/other/thing", None), + ("/ut/delete/game/fifa17/item", None), + ] { + assert_eq!(ut_tail(path), want, "ut_tail({path})"); + } + } + + #[test] + fn is_store_transaction_tail_is_bounded() { + assert!(is_store_transaction_tail("store/transaction")); + assert!(is_store_transaction_tail("store/transaction/0")); + assert!(is_store_transaction_tail("store/transaction/123")); + assert!(!is_store_transaction_tail("store/transactions")); + assert!(!is_store_transaction_tail("store/transactionfoo")); + assert!(!is_store_transaction_tail("store/transaction/0/extra")); + assert!(!is_store_transaction_tail("store/transaction/")); + assert!(!is_store_transaction_tail("store/transaction/abc")); + assert!(!is_store_transaction_tail("store/purchasegroup")); + } + + #[test] + fn classify_economy_covers_retail_v2_store_family() { + use EconomyRoute::*; + // The exact live-failure shape now classifies as Rust StoreBuy. + assert_eq!( + classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/0"), + Some(StoreBuy) + ); + assert_eq!( + classify_economy("PUT", "/ut/game/fifa17/store/transaction"), + Some(StoreBuy) + ); + assert_eq!( + classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/123"), + Some(StoreBuy) + ); + // purchasegroup + purchased under both prefixes. + assert_eq!( + classify_economy("GET", "/ut/v2/game/fifa17/store/purchasegroup"), + Some(PurchaseGroup) + ); + assert_eq!( + classify_economy("GET", "/ut/game/fifa17/store/purchasegroup/all"), + Some(PurchaseGroup) + ); + assert_eq!( + classify_economy("POST", "/ut/v2/game/fifa17/purchased"), + Some(PackOpen) + ); + assert_eq!( + classify_economy("GET", "/ut/v2/game/fifa17/purchased"), + Some(PackReveal) + ); + // v1 non-store economy routes still classify (regression). + assert_eq!( + classify_economy("GET", "/ut/game/fifa17/user/credits"), + Some(Credits) + ); + assert_eq!( + classify_economy("PUT", "/ut/game/fifa17/item"), + Some(MoveItems) + ); + assert_eq!( + classify_economy("DELETE", "/ut/game/fifa17/item/100000001"), + Some(QuickSellPath) + ); + assert_eq!( + classify_economy("GET", "/ut/game/fifa17/tradePile"), + Some(MarketQuery) + ); + assert_eq!( + classify_economy("POST", "/ut/delete/game/fifa17/item"), + Some(QuickSellBody) + ); + assert_eq!( + classify_economy("POST", "/ut/delete/game/fifa17/match"), + Some(MatchEnd) + ); + // delete family under v2 prefix too (defense-in-depth symmetry). + assert_eq!( + classify_economy("POST", "/ut/v2/delete/game/fifa17/item"), + Some(QuickSellBody) + ); + // negatives: non-economy stays None (proxied to Python). + assert_eq!( + classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/0/extra"), + None + ); + assert_eq!( + classify_economy("PUT", "/ut/game/fifa17/store/transactions"), + None + ); + assert_eq!(classify_economy("GET", "/ut/game/fifa17/hub"), None); + assert_eq!(classify_economy("GET", "/ut/v2/game/fifa17/store"), None); + assert_eq!(classify_economy("POST", "/ut/auth"), None); + } }