Phase 10: card upgrade system (chemistry styles, position change, training)
CI / Build, lint & test (push) Failing after 1m37s

- GET /chemistry-styles — lists 18 FUT-style chemistry styles with stat boost breakdowns
- POST /collection/:id/chemistry-style — apply a style (Shadow, Hunter, Anchor, etc.)
- POST /collection/:id/position — override a player's position for 500 coins
- POST /collection/:id/training — add +1/+2/+3 OVR training bonus (max +3 total)
- GET /collection now includes chemistry_style, position_override, training_bonus,
  effective_overall and effective_position fields per owned card
- Migration 0007 adds three columns to owned_cards with safe defaults
- 10 new integration tests — all 55 pass, clippy clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 17:02:44 -07:00
parent a749fba93c
commit 19c7b9989d
15 changed files with 617 additions and 6 deletions
+184
View File
@@ -984,3 +984,187 @@ async fn test_match_result_returns_season_info() {
// season_end is None for non-final match
assert!(result["season_end"].is_null());
}
// ── Phase 10: Card Upgrades ───────────────────────────────────────────────────
async fn get_first_owned_card_id(app: &axum::Router) -> String {
// Open the starter pack to get a card
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;
coll["collection"][0]["owned_card_id"]
.as_str()
.unwrap()
.to_string()
}
#[tokio::test]
async fn test_chemistry_styles_list() {
let app = build_test_app().await;
let (s, json) = json_get(&app, "/chemistry-styles").await;
assert_eq!(s, StatusCode::OK);
assert!(json["chemistry_styles"].is_array());
assert!(json["total"].as_u64().unwrap() >= 18, "expect 18 built-in styles");
// "basic" must always be present
let styles = json["chemistry_styles"].as_array().unwrap();
assert!(styles.iter().any(|s| s["id"] == "basic"));
assert!(styles.iter().any(|s| s["id"] == "shadow"));
assert!(styles.iter().any(|s| s["id"] == "hunter"));
}
#[tokio::test]
async fn test_apply_chemistry_style() {
let app = build_test_app().await;
auth(&app, "ChemPlayer").await;
let owned_id = get_first_owned_card_id(&app).await;
let (s, json) = json_post(
&app,
&format!("/collection/{owned_id}/chemistry-style"),
serde_json::json!({ "style_id": "hunter" }),
).await;
assert_eq!(s, StatusCode::OK, "{json}");
assert_eq!(json["chemistry_style"], "hunter");
assert_eq!(json["style_details"]["name"], "Hunter");
assert!(json["style_details"]["boosts"]["pace"].as_i64().unwrap() > 0);
}
#[tokio::test]
async fn test_apply_invalid_chemistry_style_returns_404() {
let app = build_test_app().await;
auth(&app, "BadChemPlayer").await;
let owned_id = get_first_owned_card_id(&app).await;
let (s, _) = json_post(
&app,
&format!("/collection/{owned_id}/chemistry-style"),
serde_json::json!({ "style_id": "nonexistent_style" }),
).await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_collection_includes_upgrade_fields() {
let app = build_test_app().await;
auth(&app, "UpgradeFieldPlayer").await;
get_first_owned_card_id(&app).await; // opens starter pack
let (s, coll) = json_get(&app, "/collection").await;
assert_eq!(s, StatusCode::OK);
let card = &coll["collection"][0];
assert!(card["chemistry_style"].is_string());
assert!(card["training_bonus"].is_number());
assert!(card["effective_overall"].is_number());
assert!(card["effective_position"].is_string());
}
#[tokio::test]
async fn test_position_change_deducts_coins() {
let app = build_test_app().await;
auth(&app, "PosChangePlayer").await;
let owned_id = get_first_owned_card_id(&app).await;
let (_, club_before) = json_get(&app, "/club").await;
let coins_before = club_before["coins"].as_i64().unwrap();
let (s, json) = json_post(
&app,
&format!("/collection/{owned_id}/position"),
serde_json::json!({ "position": "CM" }),
).await;
assert_eq!(s, StatusCode::OK, "{json}");
assert_eq!(json["position_override"], "CM");
assert_eq!(json["cost_coins"], 500);
let (_, club_after) = json_get(&app, "/club").await;
assert_eq!(
club_after["coins"].as_i64().unwrap(),
coins_before - 500
);
}
#[tokio::test]
async fn test_position_change_invalid_position() {
let app = build_test_app().await;
auth(&app, "BadPosPlayer").await;
let owned_id = get_first_owned_card_id(&app).await;
let (s, _) = json_post(
&app,
&format!("/collection/{owned_id}/position"),
serde_json::json!({ "position": "STRIKER" }),
).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_training_boost_increases_effective_overall() {
let app = build_test_app().await;
auth(&app, "TrainingPlayer").await;
let owned_id = get_first_owned_card_id(&app).await;
let (s, json) = json_post(
&app,
&format!("/collection/{owned_id}/training"),
serde_json::json!({ "boost": 2 }),
).await;
assert_eq!(s, StatusCode::OK, "{json}");
assert_eq!(json["training_bonus"], 2);
assert_eq!(
json["effective_overall"].as_i64().unwrap(),
json["base_overall"].as_i64().unwrap() + 2
);
assert_eq!(json["max_bonus"], 3);
}
#[tokio::test]
async fn test_training_capped_at_max() {
let app = build_test_app().await;
auth(&app, "CapTrainPlayer").await;
let owned_id = get_first_owned_card_id(&app).await;
// Apply +3 (the max)
let (s, _) = json_post(
&app,
&format!("/collection/{owned_id}/training"),
serde_json::json!({ "boost": 3 }),
).await;
assert_eq!(s, StatusCode::OK);
// Trying to add more should fail
let (s, json) = json_post(
&app,
&format!("/collection/{owned_id}/training"),
serde_json::json!({ "boost": 1 }),
).await;
assert_eq!(s, StatusCode::BAD_REQUEST, "{json}");
}
#[tokio::test]
async fn test_training_invalid_boost_value() {
let app = build_test_app().await;
auth(&app, "BadTrainPlayer").await;
let owned_id = get_first_owned_card_id(&app).await;
let (s, _) = json_post(
&app,
&format!("/collection/{owned_id}/training"),
serde_json::json!({ "boost": 5 }),
).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_upgrades_on_nonexistent_card_return_404() {
let app = build_test_app().await;
auth(&app, "OwnerAlpha").await;
// Use a made-up owned_card_id that doesn't belong to this club
let (s, _) = json_post(
&app,
"/collection/nonexistent-owned-card-id/chemistry-style",
serde_json::json!({ "style_id": "basic" }),
).await;
assert_eq!(s, StatusCode::NOT_FOUND);
}