Files
OpenFUT-Core/tests/integration_test.rs
T
funman300 8fa125cfd6
CI / Build, lint & test (push) Failing after 1m46s
Phase 7 (Core): chemistry v2, expanded card pool & search filters
- Chemistry v2: FUT-style link scoring with per-player breakdown
  (club +3/link cap 6, league +1/link cap 4, nation +1/link cap 3;
   player cap 10, team cap 100); chemistry response now includes
   per-player breakdown with club/league/nation link counts and pts
- Card pool: 34 new cards across Premier League, La Liga, Bundesliga
  with overlapping clubs/nations for meaningful chemistry testing
- Card search: extended GET /cards with nation, league, club,
  min_overall, max_overall, limit query params; results sorted by
  overall descending
- Match opponent: added "ultimate" difficulty band (85+ OVR);
  random formation selection from 6 tactical formations; falls back
  to lower OVR pool when not enough cards at the requested band
- Tests: 9 new integration tests (28 total, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:31:56 -07:00

636 lines
20 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());
}