Files
OpenFUT-Core/tests/integration_test.rs
T
funman300 f0dbabc409
CI / Build, lint & test (push) Failing after 58s
Phase 19: persistent notifications system
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>
2026-06-25 17:57:24 -07:00

1596 lines
55 KiB
Rust
Raw Blame History

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