feat(squad): expose extension-aware squad read/write over HTTP

Add two thin transport routes wrapping the existing extension services
(no new domain logic; Core still owns validation, ownership, the atomic
canonical+extension transaction, the server fingerprint, and staleness):

  GET /squad/ext?namespace=<ns>  -> read_squad_with_ext
      returns {squad, players, extension:{state: fresh|stale|missing,
      schema_version, payload, stored_fingerprint, current_fingerprint}}
  PUT /squad/replace             -> replace_squad_with_extension
      body {name, formation, slots[], client_reported, extension};
      resolves the active squad in place (creates if none); returns
      {squad_id, canonical_fingerprint, slots_written}

A game host needs these to read/persist the FIFA squad extension atomically
over HTTP; the service functions existed but were unreachable. Adds an
integration test (replace -> read Fresh, verbatim payload, idempotent PUT
converges to the same fingerprint, missing-namespace -> Missing) and clears
a pre-existing len_zero lint so the crate is clippy-clean.
This commit is contained in:
funman300
2026-08-12 02:47:15 +00:00
parent 615c5fd7a5
commit 9b2c6b82f2
3 changed files with 222 additions and 5 deletions
+82 -1
View File
@@ -675,7 +675,7 @@ async fn test_draft_pick_advances_session() {
assert_eq!(pick1["status"], "active");
assert_eq!(pick1["progress"]["filled"], 1);
assert_eq!(pick1["current_position"], "RB");
assert!(pick1["candidates"].as_array().unwrap().len() >= 1);
assert!(!pick1["candidates"].as_array().unwrap().is_empty());
}
#[tokio::test]
@@ -2279,3 +2279,84 @@ async fn test_owned_query_parameter_order_invariance() {
);
assert_eq!(a["total"], b["total"]);
}
async fn json_put(app: &axum::Router, uri: &str, payload: Value) -> (StatusCode, Value) {
let resp = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
(status, serde_json::from_slice(&body).unwrap())
}
#[tokio::test]
async fn test_squad_ext_replace_read_roundtrip_and_idempotency() {
let app = build_test_app().await;
auth(&app, "SquadExtUser").await;
// Owned cards from the starter pack.
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();
assert!(ids.len() >= 2, "starter pack should yield >=2 owned cards");
let payload = "{\"custom\":\"[1,2,3]\",\"kit_numbers\":{}}";
let body = 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_chemistry": 52,
"client_reported_rating": 90,
"client_reported_star_rating": 90
},
"extension": {"namespace": "fifa17.squad", "schema_version": 1, "payload": payload},
});
let (s, put) = json_put(&app, "/squad/replace", body.clone()).await;
assert_eq!(s, StatusCode::OK, "{put}");
assert_eq!(put["slots_written"], 2);
let fp = put["canonical_fingerprint"].as_str().unwrap().to_string();
// Read the canonical squad + opaque extension back: Fresh, payload verbatim.
let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
assert_eq!(s, StatusCode::OK, "{ext}");
assert_eq!(ext["extension"]["state"], "fresh");
assert_eq!(ext["extension"]["payload"], payload, "opaque payload round-trips verbatim");
assert_eq!(ext["extension"]["schema_version"], 1);
assert_eq!(ext["extension"]["stored_fingerprint"], fp);
assert_eq!(ext["squad"]["formation"], "f442");
assert_eq!(ext["players"].as_array().unwrap().len(), 2);
// Idempotent: an identical replacement converges to the same fingerprint.
let (s2, put2) = json_put(&app, "/squad/replace", body).await;
assert_eq!(s2, StatusCode::OK);
assert_eq!(put2["canonical_fingerprint"], fp, "identical PUT is idempotent");
// A different namespace has no stored extension: Missing, never fabricated.
let (_, other) = json_get(&app, "/squad/ext?namespace=other.ns").await;
assert_eq!(other["extension"]["state"], "missing");
}