diff --git a/src/app.rs b/src/app.rs index 351aebb..eddfa94 100644 --- a/src/app.rs +++ b/src/app.rs @@ -172,6 +172,8 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/packs/open/:pack_id", post(routes::packs::post_open_pack)) .route("/squad", get(routes::squad::get_squad)) .route("/squad", post(routes::squad::post_squad)) + .route("/squad/ext", get(routes::squad::get_squad_ext)) + .route("/squad/replace", put(routes::squad::put_squad_replace)) .route("/squads", get(routes::squad::get_squads)) .route("/squads/:squad_id", get(routes::squad::get_squad_by_id)) .route("/squads/:squad_id", delete(routes::squad::delete_squad)) diff --git a/src/routes/squad.rs b/src/routes/squad.rs index 2db7746..32f5339 100644 --- a/src/routes/squad.rs +++ b/src/routes/squad.rs @@ -1,15 +1,20 @@ use crate::extractors::GameId; use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, Json, }; +use serde::Deserialize; use serde_json::{json, Value}; use crate::{ app::AppState, - error::AppResult, - models::squad::SaveSquadRequest, - services::{club as club_svc, profile as profile_svc, squad as squad_svc}, + error::{AppError, AppResult}, + models::game_ext::OpaqueExtensionWrite, + models::squad::{SaveSquadRequest, SlotAssignment, SquadReplacement}, + services::{ + club as club_svc, profile as profile_svc, squad as squad_svc, + squad::SquadExtState, squad_rules::{ClientReportedEvaluation, DefaultSquadRules}, + }, }; pub async fn get_squad(State(state): State, game: GameId) -> AppResult> { @@ -98,3 +103,132 @@ fn squad_response( "chemistry": chemistry, }) } + + +// ─────────── Game-extension-aware squad transport (host composition) ───────── +// +// These two routes expose the already-existing extension services +// (`read_squad_with_ext` / `replace_squad_with_extension`) over HTTP so a game +// host can read/write the canonical squad AND its opaque game extension in one +// Core round-trip. They add no domain logic — Core still owns validation, +// ownership, the atomic transaction, the server fingerprint, and staleness; it +// never interprets the extension payload. + +#[derive(Deserialize)] +pub struct ExtQuery { + /// Opaque adapter namespace, e.g. `"fifa17.squad"`. + pub namespace: String, +} + +/// `GET /squad/ext?namespace=…` — the active squad, its players, and its opaque +/// extension with an explicit Fresh/Stale/Missing verdict. Never projects a +/// stale blob; the caller decides policy. +pub async fn get_squad_ext( + State(state): State, + game: GameId, + Query(q): Query, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + let (squad, players, state_ext) = + squad_svc::read_squad_with_ext(&state.pool, game.as_str(), &club.id, &q.namespace).await?; + + let extension = match state_ext { + SquadExtState::Fresh(row) => json!({ + "state": "fresh", + "schema_version": row.schema_version, + "payload": row.payload, + "stored_fingerprint": row.canonical_fingerprint, + }), + SquadExtState::Stale { stored, current_fingerprint } => json!({ + "state": "stale", + "schema_version": stored.schema_version, + "payload": stored.payload, + "stored_fingerprint": stored.canonical_fingerprint, + "current_fingerprint": current_fingerprint, + }), + SquadExtState::Missing => json!({ "state": "missing" }), + }; + + Ok(Json(json!({ + "squad": squad, + "players": players, + "extension": extension, + }))) +} + +#[derive(Deserialize)] +pub struct SlotReq { + pub owned_card_id: String, + pub slot: i64, + #[serde(default)] + pub is_captain: bool, + #[serde(default)] + pub is_on_bench: bool, +} + +#[derive(Deserialize)] +pub struct ReplaceReq { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub formation: Option, + pub slots: Vec, + #[serde(default)] + pub client_reported: ClientReportedEvaluation, + pub extension: OpaqueExtensionWrite, +} + +/// `PUT /squad/replace` — full-replacement of the active squad's canonical slots +/// plus its opaque game extension, in ONE Core transaction. Resolves the active +/// squad in place (creates one if none exists). Ownership, duplicate, and size +/// validation happen inside the service before any write. +pub async fn put_squad_replace( + State(state): State, + game: GameId, + Json(req): Json, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + + // Replace the club's active squad in place; if there is none yet, create it. + let squad_id = match squad_svc::get_squad(&state.pool, &club.id).await { + Ok((s, _)) => Some(s.id), + Err(AppError::NotFound(_)) => None, + Err(e) => return Err(e), + }; + + let replacement = SquadReplacement { + name: req.name, + formation: req.formation, + slots: req + .slots + .into_iter() + .map(|s| SlotAssignment { + owned_card_id: s.owned_card_id, + slot: s.slot, + is_captain: s.is_captain, + is_on_bench: s.is_on_bench, + }) + .collect(), + }; + + let out = squad_svc::replace_squad_with_extension( + &state.pool, + &state.card_db, + &DefaultSquadRules, + game.as_str(), + &club.id, + squad_id.as_deref(), + &replacement, + &req.client_reported, + &req.extension, + ) + .await?; + + Ok(Json(json!({ + "squad_id": out.squad.id, + "canonical_fingerprint": out.canonical_fingerprint, + "slots_written": out.slots_written, + }))) +} \ No newline at end of file diff --git a/tests/integration_test.rs b/tests/integration_test.rs index fc6a784..068f327 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -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 = 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"); +} \ No newline at end of file