Files
OpenFUT-Core/tests/integration_test.rs
T
funman300 19c7b9989d
CI / Build, lint & test (push) Failing after 1m37s
Phase 10: card upgrade system (chemistry styles, position change, training)
- 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>
2026-06-25 17:02:44 -07:00

1171 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use axum::{
body::Body,
http::{Request, StatusCode},
};
use serde_json::Value;
use tower::ServiceExt;
// We test by spinning up the full app against an in-memory SQLite database.
async fn build_test_app() -> axum::Router {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations");
let data_dir = "data";
openfut_core::build_app(pool, data_dir)
.await
.expect("app build")
}
async fn auth(app: &axum::Router, username: &str) -> Value {
let payload = serde_json::json!({ "username": username });
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/auth/local")
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&body).unwrap()
}
async fn json_get(app: &axum::Router, uri: &str) -> (StatusCode, Value) {
let resp = app
.clone()
.oneshot(Request::builder().uri(uri).body(Body::empty()).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())
}
async fn json_post(app: &axum::Router, uri: &str, payload: Value) -> (StatusCode, Value) {
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.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())
}
// ── Existing tests ───────────────────────────────────────────────────────────
#[tokio::test]
async fn test_health_endpoint() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/health").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["status"], "ok");
}
#[tokio::test]
async fn test_formations_endpoint() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/formations").await;
assert_eq!(status, StatusCode::OK);
assert!(json["formations"].is_array());
assert!(json["formations"].as_array().unwrap().len() >= 8);
}
#[tokio::test]
async fn test_auth_local_creates_profile_and_club() {
let app = build_test_app().await;
let json = auth(&app, "TestPlayer").await;
assert_eq!(json["profile"]["username"], "TestPlayer");
assert_eq!(json["club"]["name"], "OpenFUT FC");
assert_eq!(json["club"]["coins"], 5000);
}
#[tokio::test]
async fn test_profile_not_found_before_auth() {
let app = build_test_app().await;
let (status, _) = json_get(&app, "/profile").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_match_result_awards_coins() {
let app = build_test_app().await;
auth(&app, "MatchPlayer").await;
let payload = serde_json::json!({
"squad_id": "dummy-squad",
"opponent_name": "AI Club",
"goals_for": 3,
"goals_against": 1,
"mode": "squad_battles"
});
let (status, json) = json_post(&app, "/matches/result", payload).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["match_record"]["outcome"], "win");
assert!(json["coins_awarded"].as_i64().unwrap() > 0);
}
#[tokio::test]
async fn test_sbc_list_returns_definitions() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/sbc").await;
assert_eq!(status, StatusCode::OK);
assert!(json["sbcs"].is_array());
}
// ── Phase 3 new tests ────────────────────────────────────────────────────────
#[tokio::test]
async fn test_match_with_goal_positions_tracks_stats() {
let app = build_test_app().await;
auth(&app, "GoalTracker").await;
let payload = serde_json::json!({
"squad_id": "dummy-squad",
"opponent_name": "AI Club",
"goals_for": 3,
"goals_against": 0,
"mode": "squad_battles",
"goal_positions": ["ST", "ST", "CAM"]
});
let (status, _) = json_post(&app, "/matches/result", payload).await;
assert_eq!(status, StatusCode::OK);
let (status, stats) = json_get(&app, "/statistics").await;
assert_eq!(status, StatusCode::OK);
assert!(stats["position_goals"].is_object());
assert_eq!(stats["position_goals"]["ST"], 2);
assert_eq!(stats["position_goals"]["CAM"], 1);
}
#[tokio::test]
async fn test_buy_and_open_pack() {
let app = build_test_app().await;
auth(&app, "PackOpener").await;
// Starter gold_pack is granted on registration
let (status, packs_json) = json_get(&app, "/packs").await;
assert_eq!(status, StatusCode::OK);
let packs = packs_json["packs"].as_array().unwrap();
assert!(!packs.is_empty(), "starter pack should be in inventory");
let pack_id = packs[0]["pack_id"].as_str().unwrap();
let (status, open_json) = json_post(
&app,
&format!("/packs/open/{pack_id}"),
serde_json::json!({}),
)
.await;
assert_eq!(status, StatusCode::OK, "open failed: {open_json}");
assert!(!open_json["cards"].as_array().unwrap().is_empty());
let (status, coll) = json_get(&app, "/collection").await;
assert_eq!(status, StatusCode::OK);
assert!(!coll["collection"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_buy_bronze_pack_deducts_coins() {
let app = build_test_app().await;
auth(&app, "CoinSpender").await;
let (status, buy_json) = json_post(
&app,
"/packs/buy",
serde_json::json!({ "pack_definition_id": "bronze_pack" }),
)
.await;
assert_eq!(status, StatusCode::OK, "buy failed: {buy_json}");
let (_, club_json) = json_get(&app, "/club").await;
assert_eq!(club_json["coins"], 5000 - 400);
}
#[tokio::test]
async fn test_sbc_submit_with_bronze_cards() {
let app = build_test_app().await;
auth(&app, "SBCPlayer").await;
// Buy and open a bronze pack
let (s, _) = json_post(
&app,
"/packs/buy",
serde_json::json!({ "pack_definition_id": "bronze_pack" }),
)
.await;
assert_eq!(s, StatusCode::OK);
let (_, packs_json) = json_get(&app, "/packs").await;
let bronze_pack = packs_json["packs"]
.as_array()
.unwrap()
.iter()
.find(|p| p["definition_id"] == "bronze_pack")
.expect("bronze pack in inventory");
let pack_id = bronze_pack["pack_id"].as_str().unwrap();
let (s, open_json) = json_post(
&app,
&format!("/packs/open/{pack_id}"),
serde_json::json!({}),
)
.await;
assert_eq!(s, StatusCode::OK, "{open_json}");
// Collect bronze card IDs (overall ≤ 64)
let (_, coll) = json_get(&app, "/collection").await;
let bronze_ids: Vec<&str> = coll["collection"]
.as_array()
.unwrap()
.iter()
.filter(|c| c["card"]["overall"].as_i64().unwrap_or(99) <= 64)
.take(11)
.map(|c| c["owned_card_id"].as_str().unwrap())
.collect();
if bronze_ids.len() < 11 {
// Card pool variance — not enough bronze to fill SBC; skip gracefully
return;
}
let (s, result) = json_post(
&app,
"/sbc/submit",
serde_json::json!({
"sbc_id": "sbc_bronze_upgrade",
"owned_card_ids": bronze_ids
}),
)
.await;
assert_eq!(s, StatusCode::OK, "SBC submit: {result}");
assert_eq!(result["passed"], true);
// bronze_upgrade rewards a silver pack (coins = 0)
assert!(result["reward"].is_object());
}
#[tokio::test]
async fn test_settings_read_write() {
let app = build_test_app().await;
auth(&app, "SettingsUser").await;
let (status, defaults) = json_get(&app, "/settings").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(defaults["difficulty"], "beginner");
assert_eq!(defaults["preferred_formation"], "4-4-2");
let resp = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/settings")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"difficulty": "world_class",
"preferred_formation": "4-3-3"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let updated: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(updated["difficulty"], "world_class");
assert_eq!(updated["preferred_formation"], "4-3-3");
let (s, persisted) = json_get(&app, "/settings").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(persisted["difficulty"], "world_class");
}
#[tokio::test]
async fn test_multiple_squads() {
let app = build_test_app().await;
auth(&app, "SquadManager").await;
let (s1, j1) = json_post(
&app,
"/squad",
serde_json::json!({ "name": "Attack Squad", "formation": "4-3-3", "players": [] }),
)
.await;
assert_eq!(s1, StatusCode::OK, "{j1}");
let squad1_id = j1["squad"]["id"].as_str().unwrap().to_string();
let (s2, j2) = json_post(
&app,
"/squad",
serde_json::json!({ "name": "Defense Squad", "formation": "5-4-1", "players": [] }),
)
.await;
assert_eq!(s2, StatusCode::OK, "{j2}");
let squad2_id = j2["squad"]["id"].as_str().unwrap().to_string();
assert_ne!(squad1_id, squad2_id);
let (status, squads_json) = json_get(&app, "/squads").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(squads_json["squads"].as_array().unwrap().len(), 2);
let (status, sq1) = json_get(&app, &format!("/squads/{squad1_id}")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(sq1["squad"]["name"], "Attack Squad");
}
#[tokio::test]
async fn test_draft_squad_endpoint() {
let app = build_test_app().await;
auth(&app, "DraftPlayer").await;
let (status, json) = json_get(&app, "/draft/squad?difficulty=beginner").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["mode"], "draft");
assert_eq!(json["difficulty"], "beginner");
assert!(json["cards"].is_array());
}
#[tokio::test]
async fn test_win_streak_tracking() {
let app = build_test_app().await;
auth(&app, "StreakPlayer").await;
for _ in 0..3 {
let (s, _) = json_post(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy",
"opponent_name": "Bot",
"goals_for": 2,
"goals_against": 0,
"mode": "squad_battles"
}),
)
.await;
assert_eq!(s, StatusCode::OK);
}
let (s, stats) = json_get(&app, "/statistics").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(stats["win_streak"], 3);
assert_eq!(stats["best_win_streak"], 3);
}
// ── Phase 5: Events ──────────────────────────────────────────────────────────
#[tokio::test]
async fn test_events_list() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/events").await;
assert_eq!(status, StatusCode::OK);
assert!(json["events"].is_array());
// TOTW Week 1 is date-range active (20242030)
let active = json["active_count"].as_u64().unwrap_or(0);
assert!(active >= 1, "expected at least 1 active event (totw_week1)");
}
#[tokio::test]
async fn test_event_get_single() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/events/totw_week1").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["event"]["id"], "totw_week1");
assert_eq!(json["is_active"], true);
}
#[tokio::test]
async fn test_event_activate_deactivate() {
let app = build_test_app().await;
// spring_festival is manual-only — should start inactive
let (s, json) = json_get(&app, "/events/spring_festival").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(json["is_active"], false);
// Activate it
let (s, json) = json_post(
&app,
"/events/spring_festival/activate",
serde_json::json!({}),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(json["is_active"], true);
// Confirm it's now active
let (s, json) = json_get(&app, "/events/spring_festival").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(json["is_active"], true);
// Deactivate it
let (s, json) = json_post(
&app,
"/events/spring_festival/deactivate",
serde_json::json!({}),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(json["is_active"], false);
// Confirm inactive again
let (s, json) = json_get(&app, "/events/spring_festival").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(json["is_active"], false);
}
#[tokio::test]
async fn test_event_unknown_returns_404() {
let app = build_test_app().await;
let (status, _) = json_get(&app, "/events/no_such_event").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_event_market_refresh_injects_totw_cards() {
let app = build_test_app().await;
auth(&app, "EventMarketPlayer").await;
// Trigger a market refresh (TOTW event is active by date range)
let (s, json) = json_post(&app, "/market/refresh", serde_json::json!({})).await;
assert_eq!(s, StatusCode::OK);
// totw_week1 has 5 bonus cards + up to 20 NPC listings
let count = json["listings_generated"].as_u64().unwrap_or(0);
assert!(
count >= 5,
"expected at least 5 listings (event bonus cards)"
);
// Check market contains at least one [EVENT] listing
let (s, market) = json_get(&app, "/market").await;
assert_eq!(s, StatusCode::OK);
let listings = market["listings"].as_array().expect("listings array");
let event_listing = listings.iter().any(|l| {
l["seller_name"]
.as_str()
.map(|n| n.starts_with("[EVENT]"))
.unwrap_or(false)
});
assert!(
event_listing,
"expected at least one [EVENT] market listing"
);
}
// ── Phase 7: Extended card search & chemistry ────────────────────────────────
#[tokio::test]
async fn test_cards_filter_by_league() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/cards?league=Premier+League").await;
assert_eq!(status, StatusCode::OK);
let cards = json["cards"].as_array().expect("cards array");
assert!(!cards.is_empty(), "Premier League cards should be loaded");
for card in cards {
assert_eq!(
card["league"].as_str().unwrap().to_lowercase(),
"premier league"
);
}
}
#[tokio::test]
async fn test_cards_filter_by_nation() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/cards?nation=Spain").await;
assert_eq!(status, StatusCode::OK);
let cards = json["cards"].as_array().expect("cards array");
assert!(!cards.is_empty(), "Spanish cards should exist in La Liga data");
for card in cards {
assert_eq!(
card["nation"].as_str().unwrap().to_lowercase(),
"spain"
);
}
}
#[tokio::test]
async fn test_cards_filter_by_min_overall() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/cards?min_overall=84").await;
assert_eq!(status, StatusCode::OK);
let cards = json["cards"].as_array().expect("cards array");
assert!(!cards.is_empty());
for card in cards {
assert!(
card["overall"].as_u64().unwrap() >= 84,
"card below min_overall threshold"
);
}
}
#[tokio::test]
async fn test_cards_filter_by_club() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/cards?club=Northgate+United").await;
assert_eq!(status, StatusCode::OK);
let cards = json["cards"].as_array().expect("cards array");
assert!(!cards.is_empty(), "Northgate United cards expected");
for card in cards {
assert_eq!(
card["club"].as_str().unwrap().to_lowercase(),
"northgate united"
);
}
}
#[tokio::test]
async fn test_cards_filter_combined_params() {
let app = build_test_app().await;
// La Liga cards with overall >= 82 — should include several
let (status, json) = json_get(&app, "/cards?league=La+Liga&min_overall=82").await;
assert_eq!(status, StatusCode::OK);
let cards = json["cards"].as_array().expect("cards array");
assert!(!cards.is_empty(), "combined filter should return results");
for card in cards {
assert_eq!(card["league"].as_str().unwrap().to_lowercase(), "la liga");
assert!(card["overall"].as_u64().unwrap() >= 82);
}
// total reflects untruncated count, returned reflects actual
assert_eq!(json["total"], json["returned"]);
}
#[tokio::test]
async fn test_cards_limit_parameter() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/cards?limit=3").await;
assert_eq!(status, StatusCode::OK);
let returned = json["returned"].as_u64().unwrap();
let total = json["total"].as_u64().unwrap();
assert!(returned <= 3);
assert!(total >= returned, "total should not be less than returned");
// Cards should be sorted by overall descending
let cards = json["cards"].as_array().unwrap();
for w in cards.windows(2) {
assert!(
w[0]["overall"].as_u64().unwrap() >= w[1]["overall"].as_u64().unwrap(),
"cards should be sorted by overall descending"
);
}
}
#[tokio::test]
async fn test_cards_max_overall_boundary() {
let app = build_test_app().await;
let (status, json) = json_get(&app, "/cards?max_overall=70").await;
assert_eq!(status, StatusCode::OK);
let cards = json["cards"].as_array().expect("cards array");
// We don't require results (there may be none), just verify none exceed limit
for card in cards {
assert!(
card["overall"].as_u64().unwrap() <= 70,
"card above max_overall threshold"
);
}
}
#[tokio::test]
async fn test_draft_squad_ultimate_difficulty() {
let app = build_test_app().await;
auth(&app, "UltimateDrafter").await;
let (status, json) = json_get(&app, "/draft/squad?difficulty=ultimate").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["difficulty"], "ultimate");
assert!(json["cards"].is_array());
// Should fall back to lower band if not enough 90+ cards — just verify 11 or fewer cards
let card_count = json["cards"].as_array().unwrap().len();
assert!(card_count <= 11);
}
#[tokio::test]
async fn test_chemistry_response_has_breakdown_structure() {
let app = build_test_app().await;
auth(&app, "ChemPlayer").await;
// Create an empty squad (no players)
let (s, j) = json_post(
&app,
"/squad",
serde_json::json!({ "name": "ChemSquad", "formation": "4-3-3", "players": [] }),
)
.await;
assert_eq!(s, StatusCode::OK, "{j}");
let squad_id = j["squad"]["id"].as_str().unwrap().to_string();
let (status, json) = json_get(&app, &format!("/squads/{squad_id}")).await;
assert_eq!(status, StatusCode::OK);
// Chemistry block should always be present
let chem = &json["chemistry"];
assert!(chem.is_object(), "chemistry should be an object");
assert_eq!(chem["total"], 0, "empty squad has 0 chemistry");
assert_eq!(chem["max"], 100);
assert!(chem["player_chemistries"].is_array());
}
// ── Phase 8: Draft v2, Quick-sell, Objectives, Market my-listings ────────────
#[tokio::test]
async fn test_draft_start_returns_session_with_candidates() {
let app = build_test_app().await;
auth(&app, "DraftStarter").await;
let (status, json) = json_post(&app, "/draft/start?difficulty=professional", serde_json::json!({})).await;
assert_eq!(status, StatusCode::OK, "{json}");
assert!(json["session_id"].is_string());
assert_eq!(json["status"], "active");
assert_eq!(json["current_position"], "GK");
assert!(json["candidates"].is_array());
let candidates = json["candidates"].as_array().unwrap();
// Should have up to 5 candidates (may be less if card pool is small for GK)
assert!(!candidates.is_empty(), "should offer at least 1 candidate");
assert_eq!(json["progress"]["filled"], 0);
assert_eq!(json["progress"]["total"], 11);
}
#[tokio::test]
async fn test_draft_pick_advances_session() {
let app = build_test_app().await;
auth(&app, "DraftPicker").await;
// Start a draft
let (s, start) = json_post(&app, "/draft/start?difficulty=beginner", serde_json::json!({})).await;
assert_eq!(s, StatusCode::OK, "{start}");
let session_id = start["session_id"].as_str().unwrap().to_string();
// Pick the first candidate (GK)
let first_candidate_id = start["candidates"][0]["card_id"].as_str().unwrap().to_string();
let (s, pick1) = json_post(
&app,
&format!("/draft/sessions/{session_id}/pick"),
serde_json::json!({ "card_id": first_candidate_id }),
).await;
assert_eq!(s, StatusCode::OK, "{pick1}");
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);
}
#[tokio::test]
async fn test_draft_pick_invalid_card_rejected() {
let app = build_test_app().await;
auth(&app, "DraftCheat").await;
let (s, start) = json_post(&app, "/draft/start?difficulty=beginner", serde_json::json!({})).await;
assert_eq!(s, StatusCode::OK);
let session_id = start["session_id"].as_str().unwrap().to_string();
// Attempt to pick a card not in candidates
let (s, _) = json_post(
&app,
&format!("/draft/sessions/{session_id}/pick"),
serde_json::json!({ "card_id": "not_a_real_card_id" }),
).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_draft_complete_awards_coins() {
let app = build_test_app().await;
auth(&app, "DraftCompleter").await;
// Get initial coin balance
let (_, club) = json_get(&app, "/club").await;
let coins_before = club["coins"].as_i64().unwrap();
// Start and complete a full draft (pick all 11 positions)
let (_, start) = json_post(&app, "/draft/start?difficulty=beginner", serde_json::json!({})).await;
let mut session_id = start["session_id"].as_str().unwrap().to_string();
let mut current = start;
for _ in 0..11 {
if current["status"] == "completed" { break; }
let card_id = current["candidates"][0]["card_id"].as_str().unwrap().to_string();
let (s, next) = json_post(
&app,
&format!("/draft/sessions/{session_id}/pick"),
serde_json::json!({ "card_id": card_id }),
).await;
assert_eq!(s, StatusCode::OK, "pick failed: {next}");
session_id = next["session_id"].as_str().unwrap_or(&session_id).to_string();
current = next;
}
assert_eq!(current["status"], "completed");
assert!(current["reward"]["coins"].as_i64().unwrap_or(0) > 0);
// Verify coins were granted
let (_, club_after) = json_get(&app, "/club").await;
let coins_after = club_after["coins"].as_i64().unwrap();
assert!(coins_after > coins_before, "coins should increase after completing draft");
}
#[tokio::test]
async fn test_draft_abandon() {
let app = build_test_app().await;
auth(&app, "DraftAbandoner").await;
let (s, start) = json_post(&app, "/draft/start?difficulty=beginner", serde_json::json!({})).await;
assert_eq!(s, StatusCode::OK);
let session_id = start["session_id"].as_str().unwrap().to_string();
let (s, result) = json_post(
&app,
&format!("/draft/sessions/{session_id}/abandon"),
serde_json::json!({}),
).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(result["abandoned"], session_id.as_str());
}
#[tokio::test]
async fn test_quick_sell_owned_card() {
let app = build_test_app().await;
auth(&app, "QuickSeller").await;
// Open starter pack to get a card
let (_, packs) = json_get(&app, "/packs").await;
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap();
let (s, _) = json_post(&app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await;
assert_eq!(s, StatusCode::OK);
// Get the owned card ID
let (_, coll) = json_get(&app, "/collection").await;
let owned_card_id = coll["collection"][0]["owned_card_id"].as_str().unwrap().to_string();
// Get coins before
let (_, club) = json_get(&app, "/club").await;
let coins_before = club["coins"].as_i64().unwrap();
// Quick sell
let resp = app.clone().oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/collection/{owned_card_id}"))
.body(Body::empty())
.unwrap()
).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let sell_json: Value = serde_json::from_slice(&body).unwrap();
let coins_received = sell_json["coins_received"].as_i64().unwrap();
assert!(coins_received >= 150, "quick sell should give at least 150 coins");
// Card should be gone
let (_, coll_after) = json_get(&app, "/collection").await;
let still_owned = coll_after["collection"].as_array().unwrap()
.iter()
.any(|c| c["owned_card_id"].as_str().unwrap() == owned_card_id);
assert!(!still_owned, "card should be removed from collection after quick sell");
// Coins should have increased
let (_, club_after) = json_get(&app, "/club").await;
let coins_after = club_after["coins"].as_i64().unwrap();
assert_eq!(coins_after, coins_before + coins_received);
}
#[tokio::test]
async fn test_objective_get_by_id() {
let app = build_test_app().await;
auth(&app, "ObjectiveViewer").await;
// Get all objectives and pick the first one
// ObjectiveWithProgress uses #[serde(flatten)] so definition fields are inline
let (s, all) = json_get(&app, "/objectives").await;
assert_eq!(s, StatusCode::OK);
let first_id = all["objectives"][0]["id"].as_str().unwrap().to_string();
let (s, single) = json_get(&app, &format!("/objectives/{first_id}")).await;
assert_eq!(s, StatusCode::OK, "{single}");
assert_eq!(single["objective"]["id"], first_id.as_str());
}
#[tokio::test]
async fn test_objective_404_for_unknown() {
let app = build_test_app().await;
auth(&app, "ObjPlayer").await;
let (s, _) = json_get(&app, "/objectives/totally_fake_objective_xyz").await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_market_my_listings_initially_empty() {
let app = build_test_app().await;
auth(&app, "ListingPlayer").await;
let (s, json) = json_get(&app, "/market/my-listings").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(json["total"], 0);
assert!(json["listings"].as_array().unwrap().is_empty());
}
// ── Phase 9: Division, Loans, Pack history, Club customization, Notifications ─
#[tokio::test]
async fn test_division_endpoint_starts_at_5() {
let app = build_test_app().await;
auth(&app, "DivPlayer").await;
let (status, json) = json_get(&app, "/division").await;
assert_eq!(status, StatusCode::OK, "{json}");
assert_eq!(json["division"], 5, "new players start in division 5");
assert_eq!(json["season_points"], 0);
assert_eq!(json["matches_played"], 0);
assert_eq!(json["matches_remaining"], 10);
}
#[tokio::test]
async fn test_division_updates_after_wins() {
let app = build_test_app().await;
auth(&app, "DivWinner").await;
// Play 3 wins
for _ in 0..3 {
json_post(&app, "/matches/result", serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
})).await;
}
let (_, div) = json_get(&app, "/division").await;
assert_eq!(div["matches_played"], 3);
assert_eq!(div["season_points"], 9, "3 wins = 9 pts");
assert_eq!(div["record"]["wins"], 3);
}
#[tokio::test]
async fn test_season_ends_after_10_matches_and_promotes() {
let app = build_test_app().await;
auth(&app, "SeasonPlayer").await;
// Win all 10 matches of the season
for _ in 0..10 {
let (s, result) = json_post(&app, "/matches/result", serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
})).await;
assert_eq!(s, StatusCode::OK);
let _ = result; // just check it doesn't error
}
// 10 wins = 30 pts → should promote from div 5 to div 4
let (_, div) = json_get(&app, "/division").await;
assert_eq!(div["division"], 4, "30 pts should promote from div 5 to div 4");
assert_eq!(div["matches_played"], 0, "season counter reset");
assert_eq!(div["season_number"], 2, "season 2 started");
}
#[tokio::test]
async fn test_pack_history_after_opening() {
let app = build_test_app().await;
auth(&app, "HistoryPlayer").await;
// Initially empty
let (s, hist) = json_get(&app, "/packs/history").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(hist["total"], 0);
// Open the starter pack
let (_, packs) = json_get(&app, "/packs").await;
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap();
json_post(&app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await;
// Should now appear in history
let (s, hist) = json_get(&app, "/packs/history").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(hist["total"], 1, "opened pack should appear in history");
assert!(hist["history"][0]["opened_at"].is_string());
assert!(hist["history"][0]["cards"].is_array());
assert!(!hist["history"][0]["cards"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_club_customization() {
let app = build_test_app().await;
auth(&app, "CustomClub").await;
let resp = app.clone().oneshot(
Request::builder()
.method("PUT")
.uri("/club")
.header("content-type", "application/json")
.body(Body::from(serde_json::json!({
"name": "Galaxy FC",
"manager_name": "Alex Ferguson Jr."
}).to_string()))
.unwrap()
).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["club"]["name"], "Galaxy FC");
assert_eq!(json["club"]["manager_name"], "Alex Ferguson Jr.");
// Verify persisted
let (_, club) = json_get(&app, "/club").await;
assert_eq!(club["name"], "Galaxy FC");
assert_eq!(club["manager_name"], "Alex Ferguson Jr.");
}
#[tokio::test]
async fn test_notifications_empty_on_fresh_profile() {
let app = build_test_app().await;
auth(&app, "NotifPlayer").await;
let (s, json) = json_get(&app, "/notifications").await;
assert_eq!(s, StatusCode::OK);
assert!(json["notifications"].is_array());
// Fresh profile has no completed objectives, so count may be 0
let _ = json["count"].as_u64();
}
#[tokio::test]
async fn test_notifications_include_completed_objectives() {
let app = build_test_app().await;
auth(&app, "ObjNotifPlayer").await;
// Play enough matches to complete the "daily_play_1_match" objective
json_post(&app, "/matches/result", serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
})).await;
let (s, json) = json_get(&app, "/notifications").await;
assert_eq!(s, StatusCode::OK);
let notifs = json["notifications"].as_array().unwrap();
let has_obj_notif = notifs.iter().any(|n| n["type"] == "objective_complete");
assert!(has_obj_notif, "completed objectives should appear in notifications");
}
#[tokio::test]
async fn test_match_result_returns_season_info() {
let app = build_test_app().await;
auth(&app, "SeasonMatchPlayer").await;
let (s, result) = json_post(&app, "/matches/result", serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 2, "goals_against": 1, "mode": "squad_battles"
})).await;
assert_eq!(s, StatusCode::OK);
// expired_loans should be present (empty array for no loan cards)
assert!(result["expired_loans"].is_array());
// 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);
}