feat(host): migrate non-economy UTAS routes to Rust + launcher redesign

Host/adapter (deployed to prod-host):
- POST /ut/auth (+/ut/delete/auth): Rust mints sid, opens Rust session, adopts
  persona from body; POST /openfut/account/sync full Rust envelope.
- GET /userMassInfo: full Rust (was proxy+overlay), shared build_user_mass_info.
- GET/PUT /clientdata/<key>: new clientdata_store.rs (JSON-persisted).
- GET /club/stats/{country,league,team}: context-aware club_stats_body
  (nation/league/team buckets).
- GET /store,/match/keepalive,/captcha,/tfa,/livemessage,/activeMessage: StaticAck.
- GET /watchList, /squad/0, /user: Rust handlers.
- host_test.rs updated for the new routing.

Launcher: bump gitlink to c277213 (shareholder-grade redesign + live account panel).

Docs: PRODUCTION_AUTHORITY_MATRIX, PYTHON_RETIREMENT_PLAN, MATCH_LIFECYCLE, and
route-shapes-2026-08-17 reference fixtures for the still-Python tail.
This commit is contained in:
funman300
2026-08-17 16:04:20 +00:00
parent 42fd3c7e90
commit e06fd57211
25 changed files with 2061 additions and 147 deletions
+316 -95
View File
@@ -35,6 +35,7 @@
//! `{"itemData":[]}` — the honest state until Core inventory is asset-backed.
pub mod async_bridge;
pub mod clientdata_store;
pub mod config;
pub mod economy_store;
pub mod market;
@@ -44,13 +45,13 @@ pub mod pile_store;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy};
use openfut_adapter_fifa17::fut::club_response::{
shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats,
};
use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput};
use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField};
use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind;
use openfut_adapter_fifa17::fut::economy_policy::{
match_reward_total, result_from_end_reason, MatchResult,
@@ -74,9 +75,10 @@ use openfut_adapter_fifa17::fut::store_session::{
validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID,
};
use openfut_identity::ExternalIdentityStore;
use rand::SeedableRng;
use rand::{Rng, SeedableRng};
use serde_json::{json, Value};
use clientdata_store::ClientDataStore;
use config::HostConfig;
// ───────────────────────────── Route classification ─────────────────────────
@@ -92,11 +94,18 @@ pub enum Route {
SquadList,
/// `GET …/squad/active` — the active squad object, projected from Core.
SquadActive,
/// `GET …/userMassInfo` — proxied to Python, with only `.squad` overlaid.
/// `GET …/userMassInfo` — served FULLY from Rust: the Core squad projection
/// plus the Rust/Core economy (coins + unopened packs). No Python.
UserMassInfo,
/// `POST /ut/auth` — proxied to Python (persona adoption + SID mint); the
/// returned `X-UT-SID` is observed to open a Rust session.
/// `POST /ut/auth` — Rust mints the `X-UT-SID`, adopts the persona from the
/// body (or the configured default), and opens a Rust session. Never proxied.
Auth,
/// `POST /openfut/account/sync` — launcher control-plane account summary,
/// served from Rust with authoritative Core coins/entitlements. Never Python.
AccountSync,
/// `GET/PUT/POST …/clientdata/<key>` — opaque per-user client blob store
/// (`userHubData` etc.), round-tripped through the Rust client-data store.
ClientData,
/// `POST /openfut/fifa17/capability` — launcher capability registration,
/// owned entirely in Rust (no economy, no proxy).
Capability,
@@ -125,6 +134,18 @@ pub enum Route {
/// Core-accurately in Rust (player tiers, staff/consumable families, nation
/// buckets). club/stats/staff stays a separate empty-set route.
ClubStats,
/// `GET …/store` (eligibility gate), `…/match/keepalive`, `…/captcha`,
/// `…/tfa`, `…/livemessage`, `…/activeMessage` — Rust-owned UNCONDITIONAL
/// static acks, byte-identical to the Python oracle's constant responses
/// (these are not flag-gated in the oracle, so a constant is exact parity).
StaticAck,
/// `GET …/watchList` — the transfer watch list, served empty from Rust with
/// authoritative Core credits (the oracle persists no watches; add/remove is a
/// no-op ack). Body: `{auctionInfo:[], credits, total:0}`.
WatchList,
/// `GET …/user` — the FUT user profile `{"userInfo": …}`, the same userInfo
/// object `userMassInfo` embeds (shared builder). POST /user (create) stays Python.
User,
/// Anything else — proxied verbatim to the Python oracle.
Passthrough,
}
@@ -143,10 +164,18 @@ pub fn classify(method: &str, path: &str) -> Route {
let get = method.eq_ignore_ascii_case("GET");
let put = method.eq_ignore_ascii_case("PUT");
let post = method.eq_ignore_ascii_case("POST");
// Session/capability vertical (Rust session authority; economy stays Python).
// Session/capability vertical (Rust session authority).
if post && path.starts_with("/ut/auth") {
return Route::Auth;
}
// Rust owns the /ut/delete/auth logout ack (any method).
if path.starts_with("/ut/delete/auth") {
return Route::Auth;
}
// Launcher control-plane account summary (not under /ut/game).
if post && path == "/openfut/account/sync" {
return Route::AccountSync;
}
if post && path == "/openfut/fifa17/capability" {
return Route::Capability;
}
@@ -156,17 +185,27 @@ pub fn classify(method: &str, path: &str) -> Route {
match ut_tail(path) {
Some("squad/list") if get => Route::SquadList,
Some("squad/active") if get => Route::SquadActive,
Some("squad/0") if get => Route::SquadActive,
Some("userMassInfo") if get => Route::UserMassInfo,
Some(tail) if tail.starts_with("clientdata/") => Route::ClientData,
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace,
Some("user/accountinfo") if get => Route::AccountInfo,
Some("user") if get => Route::User,
Some("settings") if get => Route::Settings,
Some("leaderboards/options") if get => Route::LeaderboardOptions,
Some("match/reset") if put => Route::MatchReset,
Some(tail) if tail.starts_with("phishing/") => Route::SecurityQuestion,
Some("club/stats/staff") if get => Route::ClubStatsStaff,
Some("club/stats/year") | Some("club/stats/consumables") if get => Route::ClubStats,
Some(t) if get && t.starts_with("club/stats/") => Route::ClubStats,
Some("hub") if get => Route::Hub,
Some("store") => Route::StaticAck,
Some("match/keepalive") => Route::StaticAck,
Some("captcha") if get => Route::StaticAck,
Some("tfa") => Route::StaticAck,
Some("livemessage") => Route::StaticAck,
Some("activeMessage") => Route::StaticAck,
Some("watchList") => Route::WatchList,
_ => Route::Passthrough,
}
}
@@ -1896,6 +1935,10 @@ pub struct Server {
/// [`Server::with_economy`]; the economy dispatch is inert without it, and
/// `handle_with_ip` does not consult it until the classifier barrier.
economy: Option<Arc<EconomyServices>>,
/// Durable per-user client-data blob store (`clientdata`/`userHubData`).
/// [`Server::new`] gives each instance an ephemeral temp-file store;
/// [`Server::from_config`] wires the configured durable path.
clientdata: Arc<ClientDataStore>,
}
impl Server {
@@ -1916,6 +1959,7 @@ impl Server {
sessions: Arc::new(Mutex::new(SessionStore::new())),
start: Instant::now(),
economy: None,
clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())),
}
}
@@ -1972,6 +2016,7 @@ impl Server {
pool,
});
let clientdata = Arc::new(ClientDataStore::open(cfg.clientdata_path.clone()));
Ok(Server::new(
core,
entities,
@@ -1979,7 +2024,8 @@ impl Server {
Arc::new(PassClient::new(cfg.python_upstream.clone())),
cfg.persona_id,
)
.with_economy(economy))
.with_economy(economy)
.with_clientdata(clientdata))
}
/// Assemble the shared squad dependencies (Core access + the one production
@@ -2001,6 +2047,13 @@ impl Server {
self
}
/// Attach the durable client-data blob store (configured path). Kept separate
/// from construction so tests keep the ephemeral temp-file store.
pub fn with_clientdata(mut self, clientdata: Arc<ClientDataStore>) -> Self {
self.clientdata = clientdata;
self
}
/// Dispatch a FIFA17 economy route to its Rust handler, or `None` if the path
/// is not an economy route (or no economy services are wired). This is the
/// handler-wiring entry point exercised by the integration harness; it is
@@ -2243,37 +2296,10 @@ impl Server {
);
resp
}
Route::UserMassInfo => {
let deps = self.squad_deps();
let (mut resp, log) =
handle_user_mass_info(method, target, headers, body, &deps, self.pass.as_ref());
// Overlay the authoritative Core economy (coins + unopened-pack
// count) so NO stale Python economy value is visible post-barrier.
// userMassInfo remains a hybrid by design: Python supplies the
// non-economy envelope; Rust owns the squad AND the economy fields.
let mut econ_overlaid = false;
if let Some(svc) = &self.economy {
if (200..300).contains(&resp.status) {
if let (Ok(coins), Ok(ents)) = (svc.econ.balance(), svc.econ.entitlements())
{
if let Ok(mut root) = serde_json::from_slice::<Value>(&resp.body) {
if overlay_massinfo_economy(&mut root, coins, ents.len()) {
if let Ok(nb) = serde_json::to_vec(&root) {
set_json_body(&mut resp, nb);
econ_overlaid = true;
}
}
}
}
}
}
eprintln!(
"utas-host owner=RUST_OVERLAY route=userMassInfo status={} squad_outcome={} econ_overlaid={} detail=[{}]",
resp.status, log.outcome, econ_overlaid, log.detail
);
resp
}
Route::UserMassInfo => self.handle_user_mass_info_full(),
Route::Auth => self.handle_auth(method, target, headers, body, client_ip),
Route::AccountSync => self.handle_account_sync(body),
Route::ClientData => self.handle_client_data(method, path, body),
Route::Capability => self.handle_capability(body, client_ip),
Route::StorePurchaseGroup => {
self.handle_store_purchasegroup(method, target, headers, body, client_ip)
@@ -2299,7 +2325,10 @@ impl Server {
json_status(200, &non_economy::club_stats_staff_body())
}
Route::Hub => self.handle_hub(),
Route::ClubStats => self.handle_club_stats(),
Route::ClubStats => self.handle_club_stats(path),
Route::StaticAck => self.handle_static_ack(path),
Route::WatchList => self.handle_watchlist(method),
Route::User => self.handle_user(),
Route::SecurityQuestion => self.handle_security_question(method, target, headers),
Route::Passthrough => {
let resp = match self.pass.forward(method, target, headers, body) {
@@ -2330,43 +2359,157 @@ impl Server {
self.start.elapsed().as_secs_f64()
}
/// `POST /ut/auth` — proxy to Python (which mints the X-UT-SID, adopts the
/// persona and refreshes its save), then OBSERVE the returned SID to open a
/// Rust session bound to the peer IP + configured persona. Account/economy
/// stays Python-authoritative; Rust only tracks the session. Python's response
/// is returned byte-for-byte.
/// `POST /ut/auth` — Rust mints the `X-UT-SID`, adopts the persona from the
/// request body (`nucleusPersonaId`/`nuc`, else the configured persona) and
/// opens a Rust session bound to the peer IP. Never proxied to Python. The
/// SID is not an auth gate — only the Rust `SessionStore` consults it — so
/// minting it in Rust is complete. `/ut/delete/auth` is a `{}` logout ack.
fn handle_auth(
&self,
method: &str,
target: &str,
headers: &[(String, String)],
_headers: &[(String, String)],
body: &[u8],
client_ip: Option<&str>,
) -> WireResponse {
let resp = match self.pass.forward(method, target, headers, body) {
Ok(r) => r,
Err(e) => {
eprintln!("utas-host ERROR auth proxy to Python failed: {e}");
return error_response(502, "upstream_unavailable");
let path = target.split('?').next().unwrap_or(target);
if path.starts_with("/ut/delete/auth") {
eprintln!("utas-host owner=RUST route=auth-delete status=200");
return json_status(200, &json!({}));
}
let persona = non_economy::parse_auth_persona(body).unwrap_or(self.persona_id);
let sid = format!(
"OPENFUT-SID-{:016X}",
rand::rngs::StdRng::from_entropy().gen::<u64>()
);
self.sessions.lock().unwrap().open_session(
&sid,
client_ip.map(|s| s.to_string()),
persona,
self.now(),
);
let epoch_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let server_time = non_economy::format_utc_datetime(epoch_secs);
eprintln!(
"utas-host owner=RUST route=auth status=200 method={} ip={:?} persona={} sid_opened=true",
method, client_ip, persona
);
json_status(200, &non_economy::auth_body(&sid, &server_time))
}
/// `POST /openfut/account/sync` — launcher control-plane account summary. The
/// coins/unopened-pack counts are the AUTHORITATIVE Core economy (balance +
/// entitlements), NEVER Python's stale profile funds. Fail-closed 503 on any
/// Core error — never a Python fallback.
fn handle_account_sync(&self, body: &[u8]) -> WireResponse {
let svc = match &self.economy {
Some(s) => s,
None => {
eprintln!("utas-host owner=RUST route=account-sync status=503 error=no_economy");
return error_response(503, "core_unavailable");
}
};
let mut outcome = "no_sid";
if (200..300).contains(&resp.status) {
if let Some(sid) = observe_sid(&resp.body) {
self.sessions.lock().unwrap().open_session(
&sid,
client_ip.map(|s| s.to_string()),
self.persona_id,
self.now(),
let (coins, ents) = match (svc.econ.balance(), svc.econ.entitlements()) {
(Ok(c), Ok(e)) => (c, e),
_ => {
eprintln!("utas-host owner=RUST route=account-sync status=503 error=core");
return error_response(503, "core_unavailable");
}
};
let req = non_economy::parse_account_sync(body, self.persona_id);
eprintln!(
"utas-host owner=RUST route=account-sync status=200 persona={} coins={} packs={}",
req.persona_id,
coins,
ents.len()
);
json_status(200, &non_economy::account_sync_body(&req, coins, ents.len()))
}
/// `GET/PUT/POST …/clientdata/<key>` — opaque per-user client blob store. GET
/// returns the stored blob for `<persona>:<key>` (or `{}` if never written);
/// PUT/POST parse and store the body under the key and ALWAYS ack `{}`.
fn handle_client_data(&self, method: &str, path: &str, body: &[u8]) -> WireResponse {
let key = ut_tail(path)
.and_then(|t| t.strip_prefix("clientdata/"))
.unwrap_or("");
if method.eq_ignore_ascii_case("GET") {
let blob = self
.clientdata
.get(self.persona_id, key)
.unwrap_or_else(|| json!({}));
eprintln!("utas-host owner=RUST route=clientdata method=GET key={key} status=200");
json_status(200, &blob)
} else {
let val: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
self.clientdata.put(self.persona_id, key, val);
eprintln!(
"utas-host owner=RUST route=clientdata method={method} key={key} status=200 stored=true"
);
json_status(200, &json!({}))
}
}
/// Build the full Rust userMassInfo Value (userInfo + squad + settings +
/// pileSizeClientData) from Core, or a 503 response on Core error. Shared by
/// `GET …/userMassInfo` and `GET …/user` so both agree byte-for-byte.
fn build_user_mass_info(&self) -> Result<(Value, &'static str), WireResponse> {
let svc = match &self.economy {
Some(s) => s,
None => return Err(error_response(503, "core_unavailable")),
};
let (coins, ents) = match (svc.econ.balance(), svc.econ.entitlements()) {
(Ok(c), Ok(e)) => (c, e),
_ => return Err(error_response(503, "core_unavailable")),
};
let deps = self.squad_deps();
let (squad, squad_outcome) = match project_active_squad(&deps) {
HostProjection::Squad(v) => (user_mass_info_squad(v, self.persona_id), "ok"),
HostProjection::Stale => (empty_squad_overlay(self.persona_id), "stale_integrity"),
HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
};
Ok((
non_economy::user_mass_info_body(squad, coins, ents.len(), self.persona_id),
squad_outcome,
))
}
/// `GET …/userMassInfo` — served FULLY from Rust (no Python): the Core squad
/// projection (byte-identical to `GET …/squad/active`) plus the authoritative
/// Core economy (coins + unopened packs). Fail-closed 503 on Core error.
fn handle_user_mass_info_full(&self) -> WireResponse {
match self.build_user_mass_info() {
Ok((body, squad_outcome)) => {
eprintln!(
"utas-host owner=RUST route=userMassInfo status=200 squad_outcome={squad_outcome}"
);
outcome = "session_opened";
json_status(200, &body)
}
Err(e) => {
eprintln!("utas-host owner=RUST route=userMassInfo status={} error=core", e.status);
e
}
}
}
/// `GET …/user` — the FUT user profile: `{"userInfo": …}`, the same userInfo
/// object `userMassInfo` embeds (shared builder). Fail-closed 503 on Core error.
fn handle_user(&self) -> WireResponse {
match self.build_user_mass_info() {
Ok((body, _)) => {
let user_info = body.get("userInfo").cloned().unwrap_or_else(|| json!({}));
eprintln!("utas-host owner=RUST route=user status=200");
json_status(200, &json!({ "userInfo": user_info }))
}
Err(e) => {
eprintln!("utas-host owner=RUST route=user status={} error=core", e.status);
e
}
}
eprintln!(
"utas-host owner=RUST_OBSERVE route=auth status={} ip={:?} outcome={}",
resp.status, client_ip, outcome
);
resp
}
/// `POST /openfut/fifa17/capability` — Rust-owned launcher registration (no
@@ -2530,11 +2673,68 @@ impl Server {
json_status(200, &body)
}
/// `GET …/club/stats/{year,consumables}` — the MY CLUB stat set, computed
/// Core-accurately in Rust (no Python). Player tiers + rare from the Core
/// collection, staff/consumable families from the catalog kind+subtype, nation
/// buckets from the reverse entity resolver. Fail-closed 503 on Core error.
fn handle_club_stats(&self) -> WireResponse {
/// Rust-owned UNCONDITIONAL static acks — `store` eligibility gate, match
/// keepalive, captcha, tfa, livemessage, activeMessage. Byte-identical to the
/// Python oracle's constant responses (no flag gating), so no Python.
fn handle_static_ack(&self, path: &str) -> WireResponse {
let tail = ut_tail(path).unwrap_or("");
let resp = match tail {
"store" => json_status(200, &json!({ "result": "SUCCESS" })),
"match/keepalive" => WireResponse {
status: 204,
headers: Vec::new(),
body: Vec::new(),
},
"captcha" => json_status(
200,
&json!({ "encodedImg": "", "sequence": 0, "sizeBeforeEncode": 0 }),
),
// tfa, livemessage, activeMessage
_ => json_status(200, &json!({})),
};
eprintln!(
"utas-host owner=RUST route=static-ack tail={} status={}",
tail, resp.status
);
resp
}
/// `…/watchList` — transfer watch list. The oracle persists no watches, so
/// add/remove (PUT/POST/DELETE) is a bare `{}` ack; GET returns an empty watch
/// list with the authoritative Core credits. Best-effort credits (0 on Core
/// error) — a cosmetic balance echo, not the authoritative wallet.
fn handle_watchlist(&self, method: &str) -> WireResponse {
if !method.eq_ignore_ascii_case("GET") {
eprintln!("utas-host owner=RUST route=watchlist method={method} status=200");
return json_status(200, &json!({}));
}
let credits = self
.economy
.as_ref()
.and_then(|s| s.econ.balance().ok())
.unwrap_or(0);
eprintln!("utas-host owner=RUST route=watchlist method=GET status=200 credits={credits}");
json_status(200, &json!({ "auctionInfo": [], "credits": credits, "total": 0 }))
}
/// `GET …/club/stats/<mode>` — the MY CLUB stat set, computed Core-accurately
/// in Rust (no Python). Player tiers + rare from the Core collection,
/// staff/consumable families from the catalog kind+subtype, and per-context
/// buckets keyed by the screen's field: nation (year/consumables/club/…),
/// league for `country/<id>`, team for `league/<id>`. Fail-closed 503 on Core.
fn handle_club_stats(&self, path: &str) -> WireResponse {
let mode = ut_tail(path)
.and_then(|t| t.strip_prefix("club/stats/"))
.and_then(|rest| rest.split('/').next())
.unwrap_or("");
// The URL says which SCREEN: `country/<id>` lists LEAGUES (leagueId
// buckets), `league/<id>` lists TEAMS (teamid buckets); all others render
// the default screen (nation buckets). Mirrors fut_club_stats.stats_body.
let ctx = match mode {
"country" => ContextField::League,
"league" => ContextField::Team,
_ => ContextField::Nation,
};
let owned = match self.core.all_owned() {
Ok(o) => o,
Err(e) => {
@@ -2550,6 +2750,8 @@ impl Server {
rating: it.rating as i64,
rare: self.resolver.rareflag_of(it) != 0,
nation_id: self.entities.nation_id(&it.nation).map(|n| n as i64),
league_id: self.entities.league_id(&it.league).map(|n| n as i64),
team_id: self.entities.team_id(&it.club).map(|n| n as i64),
})
.collect();
let players = items
@@ -2557,11 +2759,13 @@ impl Server {
.filter(|i| matches!(i.kind, ContentKind::Player))
.count();
eprintln!(
"utas-host owner=RUST route=club-stats status=200 owned={} players={}",
"utas-host owner=RUST route=club-stats mode={} ctx={:?} status=200 owned={} players={}",
mode,
ctx,
items.len(),
players
);
json_status(200, &club_stats_body(&items))
json_status(200, &club_stats_body(&items, ctx))
}
/// Serve forever on `addr` (thread-per-connection, HTTP/1.1 keep-alive).
@@ -2648,13 +2852,24 @@ fn json_status(status: u16, v: &Value) -> WireResponse {
}
}
/// Observe the minted `sid` from Python's `/ut/auth` JSON response body.
fn observe_sid(body: &[u8]) -> Option<String> {
serde_json::from_slice::<Value>(body)
.ok()?
.get("sid")?
.as_str()
.map(str::to_string)
/// A unique ephemeral temp-file path for the client-data blob store used by
/// [`Server::new`] (tests). Production wires a durable path via
/// [`Server::from_config`]/[`Server::with_clientdata`]. Uniqueness (pid + nanos +
/// a process-local counter) keeps concurrent test servers isolated.
fn ephemeral_clientdata_path() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static CTR: AtomicU64 = AtomicU64::new(0);
let n = CTR.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!(
"openfut-utas-clientdata-{}-{}-{}.json",
std::process::id(),
nanos,
n
))
}
/// A validated capability registration request.
@@ -3098,15 +3313,15 @@ mod tests {
assert_eq!(classify("get", "/ut/game/fifa18/club"), Route::Club);
// method must be GET
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::Passthrough);
// near-misses stay on Python: bare club/stats and the country sub-screen are
// NOT the exact /club route and are not (yet) migrated arms.
// bare `club/stats` (no trailing slash) is not a migrated arm -> Python.
assert_eq!(
classify("GET", "/ut/game/fifa17/club/stats"),
Route::Passthrough
);
// club/stats/<mode> screens are Rust-owned (nation/league/team contexts).
assert_eq!(
classify("GET", "/ut/game/fifa17/club/stats/country/54"),
Route::Passthrough
Route::ClubStats
);
assert_eq!(
classify("GET", "/ut/game/fifa17/clubUser"),
@@ -3286,19 +3501,17 @@ mod tests {
);
assert_eq!(
classify("POST", "/openfut/account/sync"),
Route::Passthrough
Route::AccountSync
);
}
#[test]
fn observe_sid_extracts_minted_sid() {
let body = br#"{"protocol":1,"sid":"OPENFUT-SID-42C15A6F78DC6E74","serverTime":"x"}"#;
assert_eq!(classify("POST", "/ut/delete/auth"), Route::Auth);
assert_eq!(
observe_sid(body).as_deref(),
Some("OPENFUT-SID-42C15A6F78DC6E74")
classify("PUT", "/ut/game/fifa17/clientdata/userHubData"),
Route::ClientData
);
assert_eq!(
classify("GET", "/ut/game/fifa17/clientdata/userHubData"),
Route::ClientData
);
assert_eq!(observe_sid(b"{}"), None);
assert_eq!(observe_sid(b"not json"), None);
}
#[test]
@@ -3611,15 +3824,23 @@ mod tests {
"/ut/game/fifa17/club/stats/consumables",
Route::ClubStats,
),
(
"PUT",
"/ut/game/fifa17/clientdata/userHubData",
Route::ClientData,
),
(
"GET",
"/ut/game/fifa17/clientdata/userHubData",
Route::ClientData,
),
("POST", "/openfut/account/sync", Route::AccountSync),
];
for (m, p, want) in owned {
assert_eq!(classify(m, p), *want, "OWN: {m} {p}");
}
// Still Python (not yet migrated) / lookalikes / wrong method.
let proxied: &[(&str, &str)] = &[
("GET", "/ut/game/fifa17/club/stats/country/54"),
("PUT", "/ut/game/fifa17/clientdata/userHubData"),
("POST", "/openfut/account/sync"),
("GET", "/ut/game/fifa17/settingsfoo"),
("POST", "/ut/game/fifa17/match/reset"), // match/reset is PUT-only
("GET", "/ut/game/fifa17/match/reset"),