fix(fifa17): route a partial squad PUT to a role patch, not a replacement
FIFA 17 sends two different operations to `PUT …/squad/<id>` and distinguishes
them only by body shape. Across 73 captured squad PUTs in five captures there
are exactly two:
* 68x with `players` -- a full replacement (also carrying squadName,
formation, squadType, manager, chemistry/rating, and redundantly
captain/kicktakers);
* 5x without `players` -- `{id, custom, captain, kicktakers}`, emitted by the
captain/kick-taker screen.
`players` has `#[serde(default)]`, so an absent key and an explicit `[]`
collapsed to the same empty vec and every partial update was handed to Core as
a replacement with zero slots. Core's empty-replacement guard refused it (400)
and the host reported 502, losing the user's captain/kick-taker change.
`classify_squad_put` now tests key PRESENCE on the raw JSON before
deserialising, so absence ("the squad was not part of this edit") stays
distinct from an explicit empty array ("replace with nothing"). An explicit
`"players": []` still classifies as a replacement and still meets the guard --
the patch path is not a way around it.
The patch path carries the contract correction: omitted `players`, `manager`
and actives mean UNCHANGED, never cleared. That is structural --
`CoreRolePatchRequest` has no field able to express them. The extension is
MERGED rather than overwritten, because the partial body carries only `custom`
and `kicktakers`; overwriting would drop every kit number in the squad.
`custom` IS taken from the patch, since the role screen writes per-slot values
into it and the two shapes genuinely differ there.
Also fixes the error mapping on this route: a Core 400 means the REQUEST was
invalid, so it is reported as 400, not as a 502 that blames the server and
hides a client error behind "upstream unavailable".
Tests use the real captured body and assert it takes the patch path
(`replace_squad` call count unchanged), that the manager survives, that kit
numbers survive the merge, that an explicit empty `players` still reaches the
replacement path, and that an unresolvable captain refuses the whole patch
rather than half-applying the kick-takers.
This commit is contained in:
@@ -16,8 +16,9 @@ use openfut_utas_host::account_store::AccountStore;
|
||||
use openfut_utas_host::{
|
||||
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState,
|
||||
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead,
|
||||
CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
||||
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreRolePatchRequest,
|
||||
CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route,
|
||||
Server, SquadDeps,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
@@ -51,6 +52,10 @@ struct FakeCore {
|
||||
/// full squad replacement writes it (or clears it with `None`).
|
||||
manager: Mutex<Option<String>>,
|
||||
replaced: Mutex<Vec<StoredReplace>>,
|
||||
/// Recorded role-only patches: (captain_owned_card_id, ext_payload). Kept
|
||||
/// separate from `replaced` so a test can assert a patch NEVER went through
|
||||
/// the replacement path.
|
||||
role_patches: Mutex<Vec<(Option<String>, String)>>,
|
||||
panic_if_called: bool,
|
||||
return_err: bool,
|
||||
}
|
||||
@@ -95,6 +100,9 @@ impl FakeCore {
|
||||
fn last(&self) -> Vec<(String, String)> {
|
||||
self.last_params.lock().clone()
|
||||
}
|
||||
fn role_patches(&self) -> Vec<(Option<String>, String)> {
|
||||
self.role_patches.lock().clone()
|
||||
}
|
||||
fn manager(&self) -> Option<String> {
|
||||
self.manager.lock().clone()
|
||||
}
|
||||
@@ -132,6 +140,45 @@ impl CoreAccess for FakeCore {
|
||||
self.squad.lock().clone().ok_or(CoreError::Status(404))
|
||||
}
|
||||
|
||||
fn patch_squad_roles(&self, req: &CoreRolePatchRequest) -> Result<(), CoreError> {
|
||||
assert!(
|
||||
!self.panic_if_called,
|
||||
"Core must NOT be called on this path"
|
||||
);
|
||||
if self.return_err {
|
||||
return Err(CoreError::Status(500));
|
||||
}
|
||||
// Mirror Core: the captain must already be fielded, otherwise 400.
|
||||
if let Some(captain) = &req.captain_owned_card_id {
|
||||
let fielded = self
|
||||
.squad
|
||||
.lock()
|
||||
.as_ref()
|
||||
.map(|s| s.slots.iter().any(|sl| &sl.owned_card_id == captain))
|
||||
.unwrap_or(false);
|
||||
if !fielded {
|
||||
return Err(CoreError::Status(400));
|
||||
}
|
||||
}
|
||||
self.role_patches
|
||||
.lock()
|
||||
.push((req.captain_owned_card_id.clone(), req.ext_payload.clone()));
|
||||
// Apply to the stored squad WITHOUT touching slots or the manager, so a
|
||||
// read-after-patch shows exactly what Core would show.
|
||||
if let Some(sq) = self.squad.lock().as_mut() {
|
||||
if let Some(captain) = &req.captain_owned_card_id {
|
||||
for slot in sq.slots.iter_mut() {
|
||||
slot.is_captain = &slot.owned_card_id == captain;
|
||||
}
|
||||
}
|
||||
sq.ext = CoreExtState::Fresh {
|
||||
schema_version: req.ext_schema_version,
|
||||
payload: req.ext_payload.clone(),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError> {
|
||||
assert!(
|
||||
!self.panic_if_called,
|
||||
@@ -1213,6 +1260,141 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
|
||||
assert_eq!(r.chemistry, Some(52), "client-reported shadow carried");
|
||||
}
|
||||
|
||||
/// The REAL captured partial body must succeed and take the PATCH path, not the
|
||||
/// replacement path.
|
||||
///
|
||||
/// Captured 2026-08-25 00:42:42 from the retail client's captain/kick-taker
|
||||
/// screen (`offline-seasons-squadexp-20260825T0018Z.pcap`). It carries no
|
||||
/// `players`, so the old code handed Core a replacement with zero slots; the
|
||||
/// empty-replacement guard refused it (400) and the host reported 502, losing
|
||||
/// the user's change. The body shape is the operation discriminator.
|
||||
#[test]
|
||||
fn put_partial_captain_and_kicktakers_patches_roles_without_replacing() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
// Establish a squad first, so there is something to patch.
|
||||
let full = put_body(
|
||||
"f442",
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[1,2,3]",
|
||||
Some(w["oc-b"]),
|
||||
);
|
||||
let (resp, log) = handle_put_squad(&full, &deps);
|
||||
assert_eq!(resp.status, 200, "{log:?}");
|
||||
assert_eq!(core.replaced().len(), 1);
|
||||
|
||||
// The captured partial shape: no players, no manager, no formation.
|
||||
let partial = format!(
|
||||
r#"{{"id":0,"custom":"[0,8,16,16,8,134220032]","captain":{},"kicktakers":[
|
||||
{{"index":0,"id":{},"dream":false}},{{"index":1,"id":{},"dream":false}},
|
||||
{{"index":2,"id":{},"dream":false}},{{"index":3,"id":{},"dream":false}},
|
||||
{{"index":4,"id":{},"dream":false}}]}}"#,
|
||||
w["oc-b"], w["oc-a"], w["oc-a"], w["oc-b"], w["oc-b"], w["oc-a"]
|
||||
);
|
||||
let (resp, log) = handle_put_squad(partial.as_bytes(), &deps);
|
||||
assert_eq!(resp.status, 200, "the partial shape must succeed: {log:?}");
|
||||
assert_eq!(resp.body, br#"{"id":0}"#, "same ack as a full save");
|
||||
|
||||
// It went through the PATCH path, never the replacement path.
|
||||
assert_eq!(
|
||||
core.replaced().len(),
|
||||
1,
|
||||
"a role patch must NOT reach replace_squad — that is what tripped the guard"
|
||||
);
|
||||
let patches = core.role_patches();
|
||||
assert_eq!(patches.len(), 1);
|
||||
assert_eq!(
|
||||
patches[0].0.as_deref(),
|
||||
Some("oc-b"),
|
||||
"captain resolved to the Core owned id, never the wire id"
|
||||
);
|
||||
// Omitted state is UNCHANGED, not cleared.
|
||||
assert_eq!(
|
||||
core.manager().as_deref(),
|
||||
Some("oc-b"),
|
||||
"a role patch must not touch the manager"
|
||||
);
|
||||
// The patch's opaque custom is carried, and kit numbers from the earlier
|
||||
// full save survive the merge rather than being overwritten away.
|
||||
assert!(patches[0].1.contains("134220032"), "patch custom carried");
|
||||
assert!(
|
||||
patches[0].1.contains("kit_numbers"),
|
||||
"kit numbers preserved from the stored extension: {}",
|
||||
patches[0].1
|
||||
);
|
||||
}
|
||||
|
||||
/// An explicit `"players": []` is still a REPLACEMENT and must still be refused
|
||||
/// by Core's guard — the patch path must not become a way to smuggle a
|
||||
/// destructive write past it.
|
||||
#[test]
|
||||
fn put_explicit_empty_players_is_still_a_replacement_not_a_patch() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, _w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = br#"{"id":0,"formation":"f442","custom":"[]","players":[],"manager":[]}"#;
|
||||
let (resp, log) = handle_put_squad(body, &deps);
|
||||
// Reaches the replacement path (Core decides), and NEVER the patch path.
|
||||
assert_eq!(
|
||||
core.role_patches().len(),
|
||||
0,
|
||||
"an explicit empty players array must not be treated as a role patch: {log:?}"
|
||||
);
|
||||
assert!(
|
||||
resp.status == 200 || resp.status >= 400,
|
||||
"handled by the replacement path"
|
||||
);
|
||||
assert_eq!(
|
||||
core.replaced().len(),
|
||||
1,
|
||||
"it went to replace_squad, where the empty-replacement guard lives"
|
||||
);
|
||||
}
|
||||
|
||||
/// A captain the resolver cannot map must refuse the whole patch, not silently
|
||||
/// apply the kick-takers and report success.
|
||||
#[test]
|
||||
fn put_partial_with_unresolvable_captain_refuses_the_whole_patch() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let full = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[1]", None);
|
||||
assert_eq!(handle_put_squad(&full, &deps).0.status, 200);
|
||||
|
||||
let partial = br#"{"id":0,"custom":"[9]","captain":999999999,"kicktakers":[]}"#;
|
||||
let (resp, log) = handle_put_squad(partial, &deps);
|
||||
assert_eq!(resp.status, 400, "unresolvable captain is a client error");
|
||||
assert_eq!(log.outcome, "unresolved_captain");
|
||||
assert_eq!(
|
||||
core.role_patches().len(),
|
||||
0,
|
||||
"nothing may be written when the captain cannot be resolved"
|
||||
);
|
||||
}
|
||||
|
||||
/// The squad's manager is an ownership-backed assignment, not an opaque wire
|
||||
/// echo: a save assigns the owned instance behind the ref. A later save that
|
||||
/// carries NO manager ref does NOT clear it.
|
||||
|
||||
Reference in New Issue
Block a user