Files
OpenFUT-Core/tests/integration_test.rs
T
funman300 bfd6de6896
CI / Build, lint & test (push) Failing after 1m21s
Phase 8 (Core): stateful draft, quick-sell, objectives by ID, market listings
Draft v2 — stateful FUT-style pick sessions:
  - POST /draft/start?difficulty=<> — creates session, returns 5
    candidates for GK slot (position order: GK RB CB CB LB CDM CM CAM RW ST LW)
  - POST /draft/sessions/:id/pick { card_id } — validates candidate,
    advances to next position; on last pick grants coins+pack reward
    (avg OVR ≥84 → gold pack + 2000 coins, ≥78 → silver + 1000, else 400)
  - GET /draft/sessions/:id — session state with per-pick cards
  - POST /draft/sessions/:id/abandon — cancel without reward
  - Migration 0005_draft_sessions.sql

Quick-sell:
  - DELETE /collection/:owned_card_id — removes card, credits coins based
    on overall (85+ → 1500, 80-84 → 900, 75-79 → 600, 65-74 → 300, <65 → 150)

Objectives:
  - GET /objectives/:id — single objective with progress
  - POST /objectives/:id/claim — claim reward by URL param (complement to
    existing POST /objectives/claim body-param endpoint)

Market:
  - GET /market/my-listings — active listings posted by current club
  - DELETE /market/listings/:id — cancel a listing, returns card to collection

Tests: 9 new integration tests (37 total, all passing)

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

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