diff --git a/fifa17-recon/tools/test_fut_contract.py b/fifa17-recon/tools/test_fut_contract.py index 0c856f3..7e89cb1 100644 --- a/fifa17-recon/tools/test_fut_contract.py +++ b/fifa17-recon/tools/test_fut_contract.py @@ -436,12 +436,58 @@ def test_club_rename_roundtrip(): repr((ui.get("clubName"), ui.get("clubAbbr")))) +def test_move_verdict_shape(): + """PUT /item must return per-item VERDICT records. This guards the fix for the + project's longest-lived bug. + + FutMoveCard's deserializer does not parse an acknowledgement, it builds a vector + of verdict records, and the completion handler raises + EVENT_CARDS_MOVE_CARD_FAILURE when that vector is EMPTY or when a record's + success byte (record+0x0c) is not 1. For seven attempts this endpoint answered + {}, an echo of the moved cards, or a dreamSquads stub, and every one of them told + the client the move had failed, so the client killed the FUT session. It looked + like a client-state bug for weeks because the HTTP status was always 200. + + That failure mode is silent at the transport layer, which is exactly why it needs + a contract test: nothing else in this suite would notice a regression to {}. + + NON-MUTATING. It asks to move ids that cannot exist, so no item changes pile. The + verdicts therefore come back success=false, which is the honest answer and is not + what is being asserted here. What is asserted is the SHAPE and the RECORD COUNT, + because an empty vector fails the client just as hard as a wrong flag. + """ + ghosts = [{"id": 999000001, "pile": "club", "swap": 0, "tradeId": 0}, + {"id": 999000002, "pile": "club", "swap": 0, "tradeId": 0}] + code, body = _req("PUT", G + "/item", {"itemData": ghosts}) + check("PUT /item -> 200", code == 200, repr(code)) + check("PUT /item body is an object", is_obj(body), repr(body)) + recs = body.get("itemData") if is_obj(body) else None + check("PUT /item returns itemData array (NOT {} -- {} reads as move-failed)", + is_arr(recs), repr(body)[:120]) + if not is_arr(recs): + return + check("PUT /item returns one record per requested item", + len(recs) == len(ghosts), "%d records for %d items" % (len(recs), len(ghosts))) + for i, r in enumerate(recs): + check("record[%d] is an object" % i, is_obj(r), repr(r)) + if not is_obj(r): + continue + # id(0x15c) INT via 0x1801c79d0 -- a string here desyncs the reader + check("record[%d].id is a number" % i, is_num(r.get("id")), repr(r.get("id"))) + # pile(0x226) STRING via 0x1801c7aa0 -> enum 0x180142650 + check("record[%d].pile is a string" % i, is_str(r.get("pile")), repr(r.get("pile"))) + # success(0x2fa) BOOL via 0x1801c7620 -> record+0x0c + check("record[%d].success is a bool" % i, isinstance(r.get("success"), bool), + repr(r.get("success"))) + + def main(): tests = [test_credits, test_v2_store_gate, test_store_catalog, test_market_bodies, test_auction_record_shape, test_squad_boot, test_squad_list_shape, test_squad_list_endpoint, test_massinfo_shape, test_club_items, test_identity_consistency, test_club_user_shape, test_club_info_shape, - test_accountinfo_shape, test_club_rename_roundtrip] + test_accountinfo_shape, test_club_rename_roundtrip, + test_move_verdict_shape] try: _get(G + "/user/credits") except Exception as e: