Phase 8 (Core): stateful draft, quick-sell, objectives by ID, market listings
CI / Build, lint & test (push) Failing after 1m21s

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>
This commit is contained in:
funman300
2026-06-25 16:41:55 -07:00
parent 8fa125cfd6
commit bfd6de6896
12 changed files with 828 additions and 9 deletions
+197
View File
@@ -633,3 +633,200 @@ async fn test_chemistry_response_has_breakdown_structure() {
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());
}