f0dbabc409
CI / Build, lint & test (push) Failing after 58s
New notifications table (migration 0009) stores event-driven alerts alongside the existing dynamic state notifications (unclaimed objectives, expiring loans, season ending soon). Persistent notifications are created automatically during match processing: one per level gained, one per expired loan card, one per objective newly completed, and one when a season ends (with promotion/relegation result and rewards in the body). GET /notifications now returns a merged list — persistent entries (newest-first, limit 50) followed by dynamic entries — plus an unread_count for the badge. Each item carries type, title, body, is_read, and (for persistent) id and created_at. PATCH /notifications/:id/read marks a single persistent notification read. POST /notifications/read-all marks all persistent ones read. Four new tests: unread_count field, level-up notification creation, mark-all-read, single-read PATCH. Core now at 77 tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1596 lines
55 KiB
Rust
1596 lines
55 KiB
Rust
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 (2024–2030)
|
||
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);
|
||
}
|
||
|
||
// ── Phase 11: FUT Champions / Division Rivals ─────────────────────────────────
|
||
|
||
#[tokio::test]
|
||
async fn test_fut_champs_no_active_session_initially() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "ChampsCheck").await;
|
||
|
||
let (s, json) = json_get(&app, "/fut-champs").await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
assert!(json["session"].is_null(), "no session should exist on fresh profile");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_fut_champs_start_session() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "ChampsStarter").await;
|
||
|
||
let (s, json) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
|
||
assert_eq!(s, StatusCode::OK, "{json}");
|
||
assert_eq!(json["session"]["week_number"], 1);
|
||
assert_eq!(json["session"]["matches_played"], 0);
|
||
assert_eq!(json["session"]["status"], "active");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_fut_champs_cannot_start_two_sessions() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "DoubleStart").await;
|
||
|
||
json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
|
||
let (s, json) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
|
||
assert_eq!(s, StatusCode::CONFLICT, "{json}");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_fut_champs_record_match_win() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "ChampsWinner").await;
|
||
|
||
let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
|
||
let session_id = start["session"]["id"].as_str().unwrap();
|
||
|
||
let (s, json) = json_post(
|
||
&app,
|
||
&format!("/fut-champs/{session_id}/result"),
|
||
serde_json::json!({ "goals_for": 3, "goals_against": 1 }),
|
||
).await;
|
||
assert_eq!(s, StatusCode::OK, "{json}");
|
||
assert_eq!(json["outcome"], "win");
|
||
assert_eq!(json["session"]["wins"], 1);
|
||
assert_eq!(json["session"]["matches_played"], 1);
|
||
assert_eq!(json["matches_remaining"], 29);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_fut_champs_record_match_draw_and_loss() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "ChampsDrawLoss").await;
|
||
|
||
let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
|
||
let session_id = start["session"]["id"].as_str().unwrap();
|
||
|
||
let (_, draw) = json_post(
|
||
&app,
|
||
&format!("/fut-champs/{session_id}/result"),
|
||
serde_json::json!({ "goals_for": 1, "goals_against": 1 }),
|
||
).await;
|
||
assert_eq!(draw["outcome"], "draw");
|
||
assert_eq!(draw["session"]["draws"], 1);
|
||
|
||
let (_, loss) = json_post(
|
||
&app,
|
||
&format!("/fut-champs/{session_id}/result"),
|
||
serde_json::json!({ "goals_for": 0, "goals_against": 2 }),
|
||
).await;
|
||
assert_eq!(loss["outcome"], "loss");
|
||
assert_eq!(loss["session"]["losses"], 1);
|
||
}
|
||
|
||
/// Play all 30 matches and verify the session auto-completes at the correct tier.
|
||
#[tokio::test]
|
||
async fn test_fut_champs_session_auto_completes_at_30_matches() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "Champs30Player").await;
|
||
|
||
let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
|
||
let session_id = start["session"]["id"].as_str().unwrap().to_string();
|
||
|
||
let mut last_json = serde_json::json!({});
|
||
for i in 0..30 {
|
||
let (s, json) = json_post(
|
||
&app,
|
||
&format!("/fut-champs/{session_id}/result"),
|
||
// Win 20 out of 30 → Gold 3 tier
|
||
serde_json::json!({
|
||
"goals_for": if i < 20 { 2 } else { 0 },
|
||
"goals_against": if i < 20 { 0 } else { 2 }
|
||
}),
|
||
).await;
|
||
assert_eq!(s, StatusCode::OK, "match {i} failed: {json}");
|
||
last_json = json;
|
||
}
|
||
|
||
assert_eq!(last_json["auto_completed"], true);
|
||
assert_eq!(last_json["session"]["status"], "completed");
|
||
assert_eq!(last_json["session"]["wins"], 20);
|
||
assert_eq!(last_json["session"]["reward_tier"], "Gold 3");
|
||
assert_eq!(last_json["session"]["reward_coins"], 15_000);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_fut_champs_claim_rewards() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "ChampsClaimPlayer").await;
|
||
|
||
let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
|
||
let session_id = start["session"]["id"].as_str().unwrap().to_string();
|
||
|
||
// Play all 30 (win all → Elite tier)
|
||
for _ in 0..30 {
|
||
json_post(
|
||
&app,
|
||
&format!("/fut-champs/{session_id}/result"),
|
||
serde_json::json!({ "goals_for": 2, "goals_against": 0 }),
|
||
).await;
|
||
}
|
||
|
||
let (s, json) = json_post(
|
||
&app,
|
||
&format!("/fut-champs/{session_id}/claim"),
|
||
serde_json::json!({}),
|
||
).await;
|
||
assert_eq!(s, StatusCode::OK, "{json}");
|
||
assert_eq!(json["tier"], "Elite");
|
||
assert_eq!(json["coins_awarded"], 50_000);
|
||
assert!(json["pack_granted"].is_string(), "Elite should grant an icon pack");
|
||
|
||
// Double-claim should fail
|
||
let (s, _) = json_post(
|
||
&app,
|
||
&format!("/fut-champs/{session_id}/claim"),
|
||
serde_json::json!({}),
|
||
).await;
|
||
assert_eq!(s, StatusCode::CONFLICT);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_fut_champs_claim_active_session_fails() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "EarlyClaimPlayer").await;
|
||
|
||
let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
|
||
let session_id = start["session"]["id"].as_str().unwrap();
|
||
|
||
// Only 1 match played — claim should reject
|
||
json_post(
|
||
&app,
|
||
&format!("/fut-champs/{session_id}/result"),
|
||
serde_json::json!({ "goals_for": 2, "goals_against": 0 }),
|
||
).await;
|
||
|
||
let (s, _) = json_post(
|
||
&app,
|
||
&format!("/fut-champs/{session_id}/claim"),
|
||
serde_json::json!({}),
|
||
).await;
|
||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_fut_champs_history_grows() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "HistoryGrowPlayer").await;
|
||
|
||
let (_, hist) = json_get(&app, "/fut-champs/history").await;
|
||
assert_eq!(hist["total"], 0);
|
||
|
||
// Complete a session
|
||
let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
|
||
let session_id = start["session"]["id"].as_str().unwrap().to_string();
|
||
for _ in 0..30 {
|
||
json_post(
|
||
&app,
|
||
&format!("/fut-champs/{session_id}/result"),
|
||
serde_json::json!({ "goals_for": 1, "goals_against": 0 }),
|
||
).await;
|
||
}
|
||
|
||
let (_, hist) = json_get(&app, "/fut-champs/history").await;
|
||
assert_eq!(hist["total"], 1);
|
||
assert_eq!(hist["history"][0]["status"], "completed");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_fut_champs_tier_boundaries() {
|
||
// Verify tier calculation for edge win counts
|
||
use openfut_core::models::fut_champs::ChampsTier;
|
||
assert_eq!(ChampsTier::from_wins(27).label(), "Elite");
|
||
assert_eq!(ChampsTier::from_wins(26).label(), "World Class");
|
||
assert_eq!(ChampsTier::from_wins(23).label(), "World Class");
|
||
assert_eq!(ChampsTier::from_wins(20).label(), "Gold 3");
|
||
assert_eq!(ChampsTier::from_wins(0).label(), "Bronze 1");
|
||
assert_eq!(ChampsTier::from_wins(1).label(), "Bronze 2");
|
||
assert_eq!(ChampsTier::from_wins(5).label(), "Silver 1");
|
||
assert_eq!(ChampsTier::from_wins(14).label(), "Gold 1");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_rivals_weekly_reward_claim() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "RivalsClaimPlayer").await;
|
||
|
||
// Play a match to create the season row
|
||
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_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
|
||
assert_eq!(s, StatusCode::OK, "{json}");
|
||
assert_eq!(json["week_number"], 1);
|
||
assert!(json["coins_awarded"].as_i64().unwrap() > 0);
|
||
assert!(json["new_balance"].as_i64().unwrap() > 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_rivals_reward_increments_week_counter() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "RivalsWeekCounter").await;
|
||
|
||
json_post(&app, "/matches/result", serde_json::json!({
|
||
"squad_id": "dummy", "opponent_name": "Bot",
|
||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||
})).await;
|
||
|
||
json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
|
||
let (s, json) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
|
||
assert_eq!(s, StatusCode::OK, "{json}");
|
||
assert_eq!(json["week_number"], 2, "second claim should be week 2");
|
||
}
|
||
|
||
// ── Phase 15: Pack Store ──────────────────────────────────────────────────────
|
||
|
||
#[tokio::test]
|
||
async fn test_pack_store_lists_definitions() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "StorePlayer").await;
|
||
|
||
let (status, json) = json_get(&app, "/packs/store").await;
|
||
assert_eq!(status, StatusCode::OK, "{json}");
|
||
let packs = json["packs"].as_array().expect("packs array");
|
||
assert!(!packs.is_empty(), "store should have at least one pack definition");
|
||
let first = &packs[0];
|
||
assert!(first["id"].is_string(), "id missing");
|
||
assert!(first["name"].is_string(), "name missing");
|
||
assert!(first["cost_coins"].is_number(), "cost_coins missing");
|
||
assert!(first["total_cards"].is_number(), "total_cards missing");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_pack_store_buy_then_opens() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "StoreBuyer").await;
|
||
|
||
// Get cheapest pack from store
|
||
let (_, store) = json_get(&app, "/packs/store").await;
|
||
let packs = store["packs"].as_array().unwrap();
|
||
let cheapest = packs.iter().min_by_key(|p| p["cost_coins"].as_i64().unwrap_or(i64::MAX)).unwrap();
|
||
let def_id = cheapest["id"].as_str().unwrap();
|
||
|
||
// Buy it
|
||
let (status, json) = json_post(&app, "/packs/buy", serde_json::json!({ "pack_definition_id": def_id })).await;
|
||
assert_eq!(status, StatusCode::OK, "buy failed: {json}");
|
||
let pack_id = json["pack"]["id"].as_str().expect("pack id").to_string();
|
||
|
||
// Open it
|
||
let (status, json) = json_post(&app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await;
|
||
assert_eq!(status, StatusCode::OK, "open failed: {json}");
|
||
assert!(json["cards"].as_array().map(|a| !a.is_empty()).unwrap_or(false), "opened pack should contain cards");
|
||
}
|
||
|
||
// ── Phase 17: Level-up system ─────────────────────────────────────────────────
|
||
|
||
#[tokio::test]
|
||
async fn test_profile_returns_level_info() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "LevelInfoPlayer").await;
|
||
|
||
let (status, json) = json_get(&app, "/profile").await;
|
||
assert_eq!(status, StatusCode::OK, "{json}");
|
||
assert!(json["level"].as_i64().unwrap_or(0) >= 1, "level missing");
|
||
assert!(json["xp"].is_number(), "xp missing");
|
||
assert!(json["xp_to_next_level"].is_number(), "xp_to_next_level missing");
|
||
assert!(json["xp_for_next_level"].is_number(), "xp_for_next_level missing");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_level_for_xp_thresholds() {
|
||
use openfut_core::models::profile::level_for_xp;
|
||
assert_eq!(level_for_xp(0), 1);
|
||
assert_eq!(level_for_xp(499), 1);
|
||
assert_eq!(level_for_xp(500), 2);
|
||
assert_eq!(level_for_xp(1199), 2);
|
||
assert_eq!(level_for_xp(1200), 3);
|
||
assert_eq!(level_for_xp(11000), 10);
|
||
// Beyond table: level 11 at 11000 + 2500 = 13500
|
||
assert_eq!(level_for_xp(13500), 11);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_match_result_includes_level_ups_on_first_win() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "LevelUpWinner").await;
|
||
|
||
// A single win awards ~300 XP (beginner win); fresh profile is at 0 XP.
|
||
// With enough wins we cross the 500 XP threshold (level 2).
|
||
let mut level_ups_seen = false;
|
||
for _ in 0..5 {
|
||
let (status, json) = 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!(status, StatusCode::OK, "{json}");
|
||
if let Some(arr) = json["level_ups"].as_array() {
|
||
if !arr.is_empty() {
|
||
level_ups_seen = true;
|
||
let ev = &arr[0];
|
||
assert!(ev["new_level"].as_i64().unwrap_or(0) >= 2, "should be at least level 2");
|
||
assert!(ev["coins_granted"].as_i64().unwrap_or(0) > 0, "coins granted on level up");
|
||
}
|
||
}
|
||
}
|
||
assert!(level_ups_seen, "expected a level-up event within 5 wins");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_level_up_grants_milestone_pack_at_level_5() {
|
||
use openfut_core::models::profile::{level_for_xp, pack_for_level};
|
||
// Level 5 requires 3000 XP total
|
||
assert_eq!(level_for_xp(3000), 5);
|
||
assert_eq!(pack_for_level(5), Some("bronze_pack"));
|
||
assert_eq!(pack_for_level(10), Some("silver_pack"));
|
||
assert_eq!(pack_for_level(7), None);
|
||
}
|
||
|
||
// ── Phase 19: Persistent notifications ───────────────────────────────────────
|
||
|
||
#[tokio::test]
|
||
async fn test_notifications_have_unread_count() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "NotifCountPlayer").await;
|
||
|
||
let (status, json) = json_get(&app, "/notifications").await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
assert!(json["unread_count"].is_number(), "unread_count missing");
|
||
assert!(json["notifications"].is_array(), "notifications not an array");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_level_up_creates_persistent_notification() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "LevelUpNotifPlayer").await;
|
||
|
||
// Play several wins to guarantee crossing the 500 XP threshold (level 2)
|
||
for _ in 0..5 {
|
||
json_post(&app, "/matches/result", serde_json::json!({
|
||
"squad_id": "dummy", "opponent_name": "Bot",
|
||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||
})).await;
|
||
}
|
||
|
||
let (status, json) = json_get(&app, "/notifications").await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
let notifs = json["notifications"].as_array().unwrap();
|
||
let has_level_up = notifs.iter().any(|n| n["type"] == "level_up");
|
||
assert!(has_level_up, "level_up notification not found after gaining levels");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_mark_all_notifications_read() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "MarkReadPlayer").await;
|
||
|
||
// Generate a notification via level-up
|
||
for _ in 0..5 {
|
||
json_post(&app, "/matches/result", serde_json::json!({
|
||
"squad_id": "dummy", "opponent_name": "Bot",
|
||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||
})).await;
|
||
}
|
||
|
||
// Mark all persistent notifications read
|
||
let (status, json) = json_post(&app, "/notifications/read-all", serde_json::json!({})).await;
|
||
assert_eq!(status, StatusCode::OK, "{json}");
|
||
assert!(json["marked_read"].as_i64().unwrap_or(0) >= 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_mark_single_notification_read() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "SingleReadPlayer").await;
|
||
|
||
// Generate level-up notifications
|
||
for _ in 0..5 {
|
||
json_post(&app, "/matches/result", serde_json::json!({
|
||
"squad_id": "dummy", "opponent_name": "Bot",
|
||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||
})).await;
|
||
}
|
||
|
||
let (_, json) = json_get(&app, "/notifications").await;
|
||
let notifs = json["notifications"].as_array().unwrap();
|
||
// Find one with an id (persistent) and PATCH it
|
||
let persistent = notifs.iter().find(|n| n["id"].is_string());
|
||
if let Some(n) = persistent {
|
||
let id = n["id"].as_str().unwrap();
|
||
let uri = format!("/notifications/{id}/read");
|
||
let resp = app.clone().oneshot(
|
||
Request::builder().method("PATCH").uri(&uri)
|
||
.body(Body::empty()).unwrap()
|
||
).await.unwrap();
|
||
assert_eq!(resp.status(), StatusCode::OK);
|
||
}
|
||
}
|