feat(utas-host): send X-OpenFUT-Game to Core + end-to-end /club composition test

This commit is contained in:
funman300
2026-08-11 23:00:08 +00:00
parent 88da16a11e
commit 5276dd2066
2 changed files with 105 additions and 6 deletions
+12 -3
View File
@@ -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: <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<String>) -> Self {
pub fn new(base_url: impl Into<String>, game: impl Into<String>) -> 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<dyn ItemIdentityResolver + Send + Sync> =
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())),
+93 -3
View File
@@ -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<dyn ItemIdentityResolver + Send + Sync> =
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:?}"
);
}