fix(fifa17): classify retail v2 economy routes
Live staging (S2) showed the retail FIFA17 client issues the Store family under /ut/v2/game/<sku>/... (PUT /ut/v2/game/fifa17/store/transaction/0), which escaped Rust economy authority to Python. Fix classify_economy: - ut_tail() normalizes both /ut/game/<sku>/ and /ut/v2/game/<sku>/ to the same tail (generic sku, never hard-coded fifa17); delete family likewise accepts /ut/v2/delete/game/. - StoreBuy matches store/transaction and store/transaction/<digits> via a bounded is_store_transaction_tail (never store/transactions, ...foo, or .../<id>/extra), mirroring the Python bare /store/transaction regex. Adds table-driven ut_tail + is_store_transaction_tail + classify_economy v2 unit tests (lib 74).
This commit is contained in:
@@ -139,11 +139,17 @@ pub fn classify(method: &str, path: &str) -> Route {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tail after `/ut/game/<title>/` (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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user