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