feat(squad): support a role-only partial update distinct from replacement
CI / Build, lint & test (push) Successful in 3m28s
CI / Build, lint & test (push) Successful in 3m28s
`/squad/replace` is a full replacement: it deletes every assignment and
reinserts the supplied slots, and 9bdc163 correctly made it refuse a
replacement carrying no slots so a broken client cannot write its emptiness
back. That guard is load-bearing and is not touched here.
But FIFA 17 sends TWO operations down one wire path. Its captain/kick-taker
screen emits a body with no `players` at all -- `{id, custom, captain,
kicktakers}` -- and the host presented that to `/squad/replace` as a
replacement with zero slots. The guard did exactly its job and refused it, so
every captain/kick-taker change died with a 400 (surfaced to the client as a
502) and the user's edit was silently lost. Confirmed by bisect against the
captures: the same body returned 200 on 08-24 18:06:59 and 502 at 20:05:30,
either side of the Core deploy carrying the guard.
The operation was mis-described, so the fix is to stop mis-describing it, not
to relax the guard. `patch_squad_roles` updates only the captain flag and the
opaque extension, in one transaction, issuing no statement that can insert,
delete or reorder an assignment row -- player slots, the squad manager and club
actives are untouched by construction rather than by care.
Two details that matter:
* the captain is part of `squad_fingerprint`, so a captain move MUST
re-anchor the extension or every later read reports it stale;
* the captain is validated against THIS squad's assignments before any
write, so an invalid target leaves captain AND extension unapplied rather
than half-applying the patch.
Tests cover both halves: the captain moves without disturbing assignments and
re-anchors the fingerprint, and an unfielded captain is refused with the prior
captain and the prior extension payload both intact. The empty-replacement
guard regression test continues to pass unchanged.
This commit is contained in:
@@ -3098,6 +3098,174 @@ async fn test_squad_replace_refuses_to_empty_a_populated_squad() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A role-only patch must move the captain and re-anchor the extension WITHOUT
|
||||
/// disturbing a single assignment.
|
||||
///
|
||||
/// Regression: FIFA 17's captain/kick-taker screen sends a body with no
|
||||
/// `players`, which the host presented to `/squad/replace` as a replacement
|
||||
/// carrying zero slots. The empty-replacement guard correctly refused it, so
|
||||
/// every captain change died with a 400 (surfaced to the client as 502). The
|
||||
/// operation, not the guard, was wrong.
|
||||
#[tokio::test]
|
||||
async fn test_squad_roles_patch_moves_captain_without_touching_assignments() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "RolePatchUser").await;
|
||||
|
||||
let (_, packs) = json_get(&app, "/packs").await;
|
||||
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
|
||||
json_post(
|
||||
&app,
|
||||
&format!("/packs/open/{pack_id}"),
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
let (_, coll) = json_get(&app, "/collection").await;
|
||||
let ids: Vec<String> = coll["collection"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
|
||||
let client_reported = serde_json::json!({
|
||||
"client_reported_chemistry": 52,
|
||||
"client_reported_rating": 90,
|
||||
"client_reported_star_rating": 90
|
||||
});
|
||||
let (s, put) = json_put(
|
||||
&app,
|
||||
"/squad/replace",
|
||||
serde_json::json!({
|
||||
"name": "OpenFUT",
|
||||
"formation": "f442",
|
||||
"slots": [
|
||||
{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false},
|
||||
{"owned_card_id": ids[1], "slot": 1, "is_captain": false, "is_on_bench": false},
|
||||
],
|
||||
"client_reported": client_reported,
|
||||
"extension": {"namespace": "fifa17.squad", "schema_version": 1,
|
||||
"payload": "{\"custom\":\"[1]\",\"kit_numbers\":{\"a\":7}}"},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{put}");
|
||||
let before_fp = put["canonical_fingerprint"].as_str().unwrap().to_string();
|
||||
|
||||
// Move the captain to the second player, carrying a new opaque payload.
|
||||
let (s, patched) = json_put(
|
||||
&app,
|
||||
"/squad/roles",
|
||||
serde_json::json!({
|
||||
"captain_owned_card_id": ids[1],
|
||||
"extension": {"namespace": "fifa17.squad", "schema_version": 1,
|
||||
"payload": "{\"custom\":\"[0,8,16]\",\"kit_numbers\":{\"a\":7}}"},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{patched}");
|
||||
assert_eq!(patched["captain_changed"], true);
|
||||
assert_ne!(
|
||||
patched["canonical_fingerprint"].as_str().unwrap(),
|
||||
before_fp,
|
||||
"the captain is part of the fingerprint, so a captain move MUST re-anchor it"
|
||||
);
|
||||
|
||||
let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let players = ext["players"].as_array().unwrap();
|
||||
assert_eq!(players.len(), 2, "a role patch must not add or drop slots");
|
||||
let captain_of = |owned: &str| -> bool {
|
||||
players
|
||||
.iter()
|
||||
.find(|p| p["owned_card_id"] == owned)
|
||||
.map(|p| p["is_captain"] == true)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
assert!(captain_of(&ids[1]), "the new captain is flagged");
|
||||
assert!(!captain_of(&ids[0]), "the previous captain is cleared");
|
||||
// Fresh, not stale: the patch re-anchored the extension it wrote.
|
||||
assert_eq!(
|
||||
ext["extension"]["payload"], "{\"custom\":\"[0,8,16]\",\"kit_numbers\":{\"a\":7}}",
|
||||
"the patch's payload is the one stored"
|
||||
);
|
||||
}
|
||||
|
||||
/// A role patch naming a captain who is not in the squad must change NOTHING —
|
||||
/// not the captain, not the extension. All-or-nothing, validated before any write.
|
||||
#[tokio::test]
|
||||
async fn test_squad_roles_patch_rejects_unfielded_captain_and_rolls_back() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "RolePatchRollbackUser").await;
|
||||
|
||||
let (_, packs) = json_get(&app, "/packs").await;
|
||||
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
|
||||
json_post(
|
||||
&app,
|
||||
&format!("/packs/open/{pack_id}"),
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
let (_, coll) = json_get(&app, "/collection").await;
|
||||
let ids: Vec<String> = coll["collection"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
|
||||
let original_payload = "{\"custom\":\"[1]\"}";
|
||||
let (s, _) = json_put(
|
||||
&app,
|
||||
"/squad/replace",
|
||||
serde_json::json!({
|
||||
"name": "OpenFUT",
|
||||
"formation": "f442",
|
||||
"slots": [
|
||||
{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false},
|
||||
{"owned_card_id": ids[1], "slot": 1, "is_captain": false, "is_on_bench": false},
|
||||
],
|
||||
"client_reported": serde_json::json!({}),
|
||||
"extension": {"namespace": "fifa17.squad", "schema_version": 1,
|
||||
"payload": original_payload},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
// ids[2] is owned but NOT fielded — a patch must not accept it.
|
||||
let (s, err) = json_put(
|
||||
&app,
|
||||
"/squad/roles",
|
||||
serde_json::json!({
|
||||
"captain_owned_card_id": ids[2],
|
||||
"extension": {"namespace": "fifa17.squad", "schema_version": 1,
|
||||
"payload": "{\"custom\":\"[9,9,9]\"}"},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
s,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"a captain not assigned to the squad must be refused: {err}"
|
||||
);
|
||||
|
||||
let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let players = ext["players"].as_array().unwrap();
|
||||
assert!(
|
||||
players
|
||||
.iter()
|
||||
.any(|p| p["owned_card_id"] == ids[0].as_str() && p["is_captain"] == true),
|
||||
"the original captain survives a refused patch"
|
||||
);
|
||||
assert_eq!(
|
||||
ext["extension"]["payload"], original_payload,
|
||||
"the extension must NOT be written when the captain is refused"
|
||||
);
|
||||
}
|
||||
|
||||
/// `PUT /club/manager` must keep three states apart: absent = say nothing,
|
||||
/// explicit null = remove, id = assign.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user