From 5276dd206627308248b62398c9c30cf1c9ff1430 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 11 Aug 2026 23:00:08 +0000 Subject: [PATCH] feat(utas-host): send X-OpenFUT-Game to Core + end-to-end /club composition test --- openfut-utas-host/src/lib.rs | 15 ++++- openfut-utas-host/tests/host_test.rs | 96 +++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 258510e..d2b8960 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -118,16 +118,21 @@ pub trait CoreAccess: Send + Sync { } /// Default HTTP implementation: `GET {core_url}/collection?…` (plain HTTP JSON, -/// the same boundary Bridge uses to reach Core). +/// the same boundary Bridge uses to reach Core). Every request carries +/// `X-OpenFUT-Game: ` so Core resolves the game-scoped active profile — for +/// FIFA 17 that is the profile whose inventory is fully asset-mapped, so `/club` +/// filters/paginates a wholly renderable set (no post-pagination drops). pub struct HttpCoreClient { base_url: String, + game: String, client: reqwest::blocking::Client, } impl HttpCoreClient { - pub fn new(base_url: impl Into) -> Self { + pub fn new(base_url: impl Into, game: impl Into) -> Self { HttpCoreClient { base_url: base_url.into().trim_end_matches('/').to_string(), + game: game.into(), client: reqwest::blocking::Client::new(), } } @@ -139,6 +144,7 @@ impl CoreAccess for HttpCoreClient { let resp = self .client .get(&url) + .header("X-OpenFUT-Game", &self.game) .query(params) .send() .map_err(|e| CoreError::Http(e.to_string()))?; @@ -507,7 +513,10 @@ impl Server { let assets: Arc = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))); Ok(Server { - core: Arc::new(HttpCoreClient::new(cfg.core_url.clone())), + core: Arc::new(HttpCoreClient::new( + cfg.core_url.clone(), + Fifa17WireItemIdPolicy::GAME, + )), entities: Arc::new(entities), assets, pass: Arc::new(PassClient::new(cfg.python_upstream.clone())), diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index 1634bfa..50fc623 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -3,7 +3,7 @@ //! upstream, route-classification safety, negatives, and an end-to-end socket run. use std::collections::HashMap; -use std::io::{BufReader, Read, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -13,8 +13,8 @@ use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityReso use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_identity::JsonIdentityStore; use openfut_utas_host::{ - classify, read_request, CoreAccess, CoreError, CorePage, Fifa17IdentityResolver, PassClient, - Route, Server, + classify, read_request, CoreAccess, CoreError, CorePage, Fifa17IdentityResolver, + HttpCoreClient, PassClient, Route, Server, }; use parking_lot::Mutex; use serde_json::Value; @@ -482,3 +482,93 @@ fn host_does_not_refilter_core_results() { "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 = + 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")), + ); + + 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:?}" + ); +}