diff --git a/src/app.rs b/src/app.rs index 5ebb561..7bbd617 100644 --- a/src/app.rs +++ b/src/app.rs @@ -172,6 +172,28 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/cards", get(routes::cards::get_cards)) .route("/cards/:card_id", get(routes::cards::get_card)) .route("/collection", get(routes::cards::get_collection)) + .route("/economy/balance", get(routes::economy::get_balance)) + .route( + "/economy/entitlements", + get(routes::economy::get_entitlements), + ) + .route( + "/economy/purchase-entitlement", + post(routes::economy::post_purchase_entitlement), + ) + .route( + "/economy/redeem-entitlement", + post(routes::economy::post_redeem_entitlement), + ) + .route("/economy/sell-item", post(routes::economy::post_sell_item)) + .route( + "/economy/grant-reward", + post(routes::economy::post_grant_reward), + ) + .route( + "/economy/purchase-item", + post(routes::economy::post_purchase_item), + ) .route( "/collection/:owned_card_id", delete(routes::cards::delete_owned_card), diff --git a/src/routes/economy.rs b/src/routes/economy.rs new file mode 100644 index 0000000..e5e8783 --- /dev/null +++ b/src/routes/economy.rs @@ -0,0 +1,146 @@ +//! Generic economy HTTP boundary. +//! +//! Exposes [`crate::services::economy`] over the same game-scoped active-profile +//! resolution every other Core route uses ([`GameId`] header → active profile → +//! club). The caller (a game host) never supplies a club id; Core maps the game +//! to its authoritative club, so there is no cross-club economy access. Every +//! op is a single durable SQLite transaction in the service layer. +//! +//! This surface is deliberately game-neutral: no currency names, pack ids, or +//! wire semantics — those live in the game host/adapter. + +use axum::{extract::State, Json}; +use serde::{Deserialize, Serialize}; + +use crate::{ + app::AppState, + error::AppResult, + extractors::GameId, + services::{club as club_svc, economy, economy::GrantedItem, profile as profile_svc}, +}; + +/// Resolve the game-scoped active profile's club id. +async fn resolve_club(state: &AppState, game: &GameId) -> AppResult { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + Ok(club.id) +} + +#[derive(Serialize)] +pub struct BalanceResponse { + pub balance: i64, +} + +/// `GET /economy/balance` — the club's currency balance. +pub async fn get_balance( + State(state): State, + game: GameId, +) -> AppResult> { + let club = resolve_club(&state, &game).await?; + let balance = economy::balance(&state.pool, &club).await?; + Ok(Json(BalanceResponse { balance })) +} + +/// `GET /economy/entitlements` — the club's unconsumed entitlements. +pub async fn get_entitlements( + State(state): State, + game: GameId, +) -> AppResult>> { + let club = resolve_club(&state, &game).await?; + Ok(Json( + economy::list_unopened_entitlements(&state.pool, &club).await?, + )) +} + +#[derive(Deserialize)] +pub struct PurchaseEntitlementRequest { + pub cost: i64, + pub definition_id: String, +} + +/// `POST /economy/purchase-entitlement` — atomic debit + grant. +pub async fn post_purchase_entitlement( + State(state): State, + game: GameId, + Json(req): Json, +) -> AppResult> { + let club = resolve_club(&state, &game).await?; + Ok(Json( + economy::purchase_entitlement(&state.pool, &club, req.cost, &req.definition_id).await?, + )) +} + +#[derive(Deserialize)] +pub struct RedeemEntitlementRequest { + pub entitlement_id: String, + pub items: Vec, +} + +#[derive(Serialize)] +pub struct RedeemEntitlementResponse { + pub definition_id: String, +} + +/// `POST /economy/redeem-entitlement` — atomic consume-once + add items. +pub async fn post_redeem_entitlement( + State(state): State, + game: GameId, + Json(req): Json, +) -> AppResult> { + let club = resolve_club(&state, &game).await?; + let definition_id = + economy::redeem_entitlement(&state.pool, &club, &req.entitlement_id, &req.items).await?; + Ok(Json(RedeemEntitlementResponse { definition_id })) +} + +#[derive(Deserialize)] +pub struct SellItemRequest { + pub item_id: String, + pub price: i64, +} + +/// `POST /economy/sell-item` — atomic remove + credit. +pub async fn post_sell_item( + State(state): State, + game: GameId, + Json(req): Json, +) -> AppResult> { + let club = resolve_club(&state, &game).await?; + let balance = economy::sell_item(&state.pool, &club, &req.item_id, req.price).await?; + Ok(Json(BalanceResponse { balance })) +} + +#[derive(Deserialize)] +pub struct GrantRewardRequest { + pub amount: i64, +} + +/// `POST /economy/grant-reward` — atomic credit. +pub async fn post_grant_reward( + State(state): State, + game: GameId, + Json(req): Json, +) -> AppResult> { + let club = resolve_club(&state, &game).await?; + let balance = economy::grant_reward(&state.pool, &club, req.amount).await?; + Ok(Json(BalanceResponse { balance })) +} + +#[derive(Deserialize)] +pub struct PurchaseItemRequest { + pub cost: i64, + pub item_id: String, + pub card_id: String, +} + +/// `POST /economy/purchase-item` — atomic debit + mint item. +pub async fn post_purchase_item( + State(state): State, + game: GameId, + Json(req): Json, +) -> AppResult> { + let club = resolve_club(&state, &game).await?; + let balance = + economy::purchase_item(&state.pool, &club, req.cost, &req.item_id, &req.card_id).await?; + Ok(Json(BalanceResponse { balance })) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 10e87c3..3746b04 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -4,6 +4,7 @@ pub mod cards; pub mod club; pub mod division; pub mod draft; +pub mod economy; pub mod fut_champs; pub mod events; pub mod health; diff --git a/src/services/economy.rs b/src/services/economy.rs index 1e78f90..7e644f7 100644 --- a/src/services/economy.rs +++ b/src/services/economy.rs @@ -194,6 +194,27 @@ pub async fn balance(pool: &Pool, club_id: &str) -> AppResult { .ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}"))) } +/// One unopened entitlement a club owns. +#[derive(Debug, Clone, Serialize)] +pub struct Entitlement { + pub id: String, + pub definition_id: String, +} + +/// List a club's unconsumed entitlements (opened = 0), oldest first. +pub async fn list_unopened_entitlements(pool: &Pool, club_id: &str) -> AppResult> { + let rows = sqlx::query_as::<_, (String, String)>( + "SELECT id, definition_id FROM packs WHERE club_id = ? AND opened = 0 ORDER BY created_at ASC, id ASC", + ) + .bind(club_id) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|(id, definition_id)| Entitlement { id, definition_id }) + .collect()) +} + /// Debit `cost` and grant one entitlement, atomically. Fail-closed: if the club /// cannot afford `cost`, nothing is debited and no entitlement is created. pub async fn purchase_entitlement( diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 068f327..e81184c 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -506,12 +506,12 @@ async fn test_cards_filter_by_nation() { 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"); + 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" - ); + assert_eq!(card["nation"].as_str().unwrap().to_lowercase(), "spain"); } } @@ -641,7 +641,12 @@ 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; + 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"); @@ -660,17 +665,26 @@ async fn test_draft_pick_advances_session() { auth(&app, "DraftPicker").await; // Start a draft - let (s, start) = json_post(&app, "/draft/start?difficulty=beginner", serde_json::json!({})).await; + 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 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; + ) + .await; assert_eq!(s, StatusCode::OK, "{pick1}"); assert_eq!(pick1["status"], "active"); assert_eq!(pick1["progress"]["filled"], 1); @@ -683,7 +697,12 @@ 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; + 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(); @@ -692,7 +711,8 @@ async fn test_draft_pick_invalid_card_rejected() { &app, &format!("/draft/sessions/{session_id}/pick"), serde_json::json!({ "card_id": "not_a_real_card_id" }), - ).await; + ) + .await; assert_eq!(s, StatusCode::BAD_REQUEST); } @@ -706,20 +726,34 @@ async fn test_draft_complete_awards_coins() { 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 (_, 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(); + 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; + ) + .await; assert_eq!(s, StatusCode::OK, "pick failed: {next}"); - session_id = next["session_id"].as_str().unwrap_or(&session_id).to_string(); + session_id = next["session_id"] + .as_str() + .unwrap_or(&session_id) + .to_string(); current = next; } @@ -729,7 +763,10 @@ async fn test_draft_complete_awards_coins() { // 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"); + assert!( + coins_after > coins_before, + "coins should increase after completing draft" + ); } #[tokio::test] @@ -737,7 +774,12 @@ 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; + 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(); @@ -745,7 +787,8 @@ async fn test_draft_abandon() { &app, &format!("/draft/sessions/{session_id}/abandon"), serde_json::json!({}), - ).await; + ) + .await; assert_eq!(s, StatusCode::OK); assert_eq!(result["abandoned"], session_id.as_str()); } @@ -758,37 +801,59 @@ async fn test_quick_sell_owned_card() { // 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; + 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(); + 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(); + 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 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"); + 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() + 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"); + assert!( + !still_owned, + "card should be removed from collection after quick sell" + ); // Coins should have increased let (_, club_after) = json_get(&app, "/club").await; @@ -853,10 +918,15 @@ async fn test_division_updates_after_wins() { // 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; + 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; @@ -872,17 +942,25 @@ async fn test_season_ends_after_10_matches_and_promotes() { // 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; + 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["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"); } @@ -900,7 +978,12 @@ async fn test_pack_history_after_opening() { // 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; + 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; @@ -916,19 +999,28 @@ 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(); + 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 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."); @@ -957,16 +1049,24 @@ async fn test_notifications_include_completed_objectives() { 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; + 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"); + assert!( + has_obj_notif, + "completed objectives should appear in notifications" + ); } #[tokio::test] @@ -974,10 +1074,15 @@ 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; + 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()); @@ -991,7 +1096,12 @@ 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; + 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() @@ -1005,7 +1115,10 @@ async fn test_chemistry_styles_list() { 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"); + 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")); @@ -1023,7 +1136,8 @@ async fn test_apply_chemistry_style() { &app, &format!("/collection/{owned_id}/chemistry-style"), serde_json::json!({ "style_id": "hunter" }), - ).await; + ) + .await; assert_eq!(s, StatusCode::OK, "{json}"); assert_eq!(json["chemistry_style"], "hunter"); assert_eq!(json["style_details"]["name"], "Hunter"); @@ -1040,7 +1154,8 @@ async fn test_apply_invalid_chemistry_style_returns_404() { &app, &format!("/collection/{owned_id}/chemistry-style"), serde_json::json!({ "style_id": "nonexistent_style" }), - ).await; + ) + .await; assert_eq!(s, StatusCode::NOT_FOUND); } @@ -1072,16 +1187,14 @@ async fn test_position_change_deducts_coins() { &app, &format!("/collection/{owned_id}/position"), serde_json::json!({ "position": "CM" }), - ).await; + ) + .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 - ); + assert_eq!(club_after["coins"].as_i64().unwrap(), coins_before - 500); } #[tokio::test] @@ -1094,7 +1207,8 @@ async fn test_position_change_invalid_position() { &app, &format!("/collection/{owned_id}/position"), serde_json::json!({ "position": "STRIKER" }), - ).await; + ) + .await; assert_eq!(s, StatusCode::BAD_REQUEST); } @@ -1108,7 +1222,8 @@ async fn test_training_boost_increases_effective_overall() { &app, &format!("/collection/{owned_id}/training"), serde_json::json!({ "boost": 2 }), - ).await; + ) + .await; assert_eq!(s, StatusCode::OK, "{json}"); assert_eq!(json["training_bonus"], 2); assert_eq!( @@ -1129,7 +1244,8 @@ async fn test_training_capped_at_max() { &app, &format!("/collection/{owned_id}/training"), serde_json::json!({ "boost": 3 }), - ).await; + ) + .await; assert_eq!(s, StatusCode::OK); // Trying to add more should fail @@ -1137,7 +1253,8 @@ async fn test_training_capped_at_max() { &app, &format!("/collection/{owned_id}/training"), serde_json::json!({ "boost": 1 }), - ).await; + ) + .await; assert_eq!(s, StatusCode::BAD_REQUEST, "{json}"); } @@ -1151,7 +1268,8 @@ async fn test_training_invalid_boost_value() { &app, &format!("/collection/{owned_id}/training"), serde_json::json!({ "boost": 5 }), - ).await; + ) + .await; assert_eq!(s, StatusCode::BAD_REQUEST); } @@ -1165,7 +1283,8 @@ async fn test_upgrades_on_nonexistent_card_return_404() { &app, "/collection/nonexistent-owned-card-id/chemistry-style", serde_json::json!({ "style_id": "basic" }), - ).await; + ) + .await; assert_eq!(s, StatusCode::NOT_FOUND); } @@ -1178,7 +1297,10 @@ async fn test_fut_champs_no_active_session_initially() { 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"); + assert!( + json["session"].is_null(), + "no session should exist on fresh profile" + ); } #[tokio::test] @@ -1215,7 +1337,8 @@ async fn test_fut_champs_record_match_win() { &app, &format!("/fut-champs/{session_id}/result"), serde_json::json!({ "goals_for": 3, "goals_against": 1 }), - ).await; + ) + .await; assert_eq!(s, StatusCode::OK, "{json}"); assert_eq!(json["outcome"], "win"); assert_eq!(json["session"]["wins"], 1); @@ -1235,7 +1358,8 @@ async fn test_fut_champs_record_match_draw_and_loss() { &app, &format!("/fut-champs/{session_id}/result"), serde_json::json!({ "goals_for": 1, "goals_against": 1 }), - ).await; + ) + .await; assert_eq!(draw["outcome"], "draw"); assert_eq!(draw["session"]["draws"], 1); @@ -1243,7 +1367,8 @@ async fn test_fut_champs_record_match_draw_and_loss() { &app, &format!("/fut-champs/{session_id}/result"), serde_json::json!({ "goals_for": 0, "goals_against": 2 }), - ).await; + ) + .await; assert_eq!(loss["outcome"], "loss"); assert_eq!(loss["session"]["losses"], 1); } @@ -1267,7 +1392,8 @@ async fn test_fut_champs_session_auto_completes_at_30_matches() { "goals_for": if i < 20 { 2 } else { 0 }, "goals_against": if i < 20 { 0 } else { 2 } }), - ).await; + ) + .await; assert_eq!(s, StatusCode::OK, "match {i} failed: {json}"); last_json = json; } @@ -1293,25 +1419,31 @@ async fn test_fut_champs_claim_rewards() { &app, &format!("/fut-champs/{session_id}/result"), serde_json::json!({ "goals_for": 2, "goals_against": 0 }), - ).await; + ) + .await; } let (s, json) = json_post( &app, &format!("/fut-champs/{session_id}/claim"), serde_json::json!({}), - ).await; + ) + .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"); + 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; + ) + .await; assert_eq!(s, StatusCode::CONFLICT); } @@ -1328,13 +1460,15 @@ async fn test_fut_champs_claim_active_session_fails() { &app, &format!("/fut-champs/{session_id}/result"), serde_json::json!({ "goals_for": 2, "goals_against": 0 }), - ).await; + ) + .await; let (s, _) = json_post( &app, &format!("/fut-champs/{session_id}/claim"), serde_json::json!({}), - ).await; + ) + .await; assert_eq!(s, StatusCode::BAD_REQUEST); } @@ -1354,7 +1488,8 @@ async fn test_fut_champs_history_grows() { &app, &format!("/fut-champs/{session_id}/result"), serde_json::json!({ "goals_for": 1, "goals_against": 0 }), - ).await; + ) + .await; } let (_, hist) = json_get(&app, "/fut-champs/history").await; @@ -1382,10 +1517,15 @@ async fn test_rivals_weekly_reward_claim() { 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; + 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}"); @@ -1399,10 +1539,15 @@ 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, + "/matches/result", + serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 1, "goals_against": 0, "mode": "squad_battles" + }), + ) + .await; // First claim succeeds let (s, json) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await; @@ -1411,7 +1556,11 @@ async fn test_rivals_reward_increments_week_counter() { // Immediate re-claim is blocked by the 24-hour cooldown let (s, _) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await; - assert_eq!(s, StatusCode::CONFLICT, "re-claim within 24h should be rejected"); + assert_eq!( + s, + StatusCode::CONFLICT, + "re-claim within 24h should be rejected" + ); } // ── Phase 15: Pack Store ────────────────────────────────────────────────────── @@ -1424,7 +1573,10 @@ async fn test_pack_store_lists_definitions() { 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"); + 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"); @@ -1440,18 +1592,37 @@ async fn test_pack_store_buy_then_opens() { // 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 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; + 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; + 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"); + assert!( + json["cards"] + .as_array() + .map(|a| !a.is_empty()) + .unwrap_or(false), + "opened pack should contain cards" + ); } // ── Phase 17: Level-up system ───────────────────────────────────────────────── @@ -1465,8 +1636,14 @@ async fn test_profile_returns_level_info() { 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"); + 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] @@ -1491,17 +1668,28 @@ async fn test_match_result_includes_level_ups_on_first_win() { // 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; + 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!( + 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" + ); } } } @@ -1528,7 +1716,10 @@ async fn test_notifications_have_unread_count() { 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"); + assert!( + json["notifications"].is_array(), + "notifications not an array" + ); } #[tokio::test] @@ -1538,17 +1729,25 @@ async fn test_level_up_creates_persistent_notification() { // 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; + 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"); + assert!( + has_level_up, + "level_up notification not found after gaining levels" + ); } #[tokio::test] @@ -1558,10 +1757,15 @@ async fn test_mark_all_notifications_read() { // 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; + 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 @@ -1577,10 +1781,15 @@ async fn test_mark_single_notification_read() { // 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; + 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; @@ -1590,10 +1799,17 @@ async fn test_mark_single_notification_read() { 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(); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(&uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } } @@ -1607,8 +1823,14 @@ async fn test_achievements_endpoint_returns_list() { let (status, json) = json_get(&app, "/achievements").await; assert_eq!(status, StatusCode::OK, "{json}"); - assert!(json["achievements"].is_array(), "achievements array missing"); - assert!(json["total"].as_u64().unwrap_or(0) > 0, "no achievements defined"); + assert!( + json["achievements"].is_array(), + "achievements array missing" + ); + assert!( + json["total"].as_u64().unwrap_or(0) > 0, + "no achievements defined" + ); assert!(json["earned"].is_number(), "earned count missing"); } @@ -1617,14 +1839,22 @@ async fn test_first_match_achievement_unlocks() { let app = build_test_app().await; auth(&app, "FirstMatchAchPlayer").await; - let (_, result) = 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 (_, result) = 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 unlocked = result["achievements_unlocked"].as_array().unwrap(); let has_first_match = unlocked.iter().any(|a| a["id"] == "first_match"); - assert!(has_first_match, "first_match achievement not in match result: {result}"); + assert!( + has_first_match, + "first_match achievement not in match result: {result}" + ); } #[tokio::test] @@ -1632,14 +1862,22 @@ async fn test_first_win_achievement_unlocks_on_win() { let app = build_test_app().await; auth(&app, "FirstWinAchPlayer").await; - let (_, result) = 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 (_, result) = 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 unlocked = result["achievements_unlocked"].as_array().unwrap(); let has_first_win = unlocked.iter().any(|a| a["id"] == "first_win"); - assert!(has_first_win, "first_win achievement not in match result: {result}"); + assert!( + has_first_win, + "first_win achievement not in match result: {result}" + ); } #[tokio::test] @@ -1648,16 +1886,26 @@ async fn test_achievements_not_duplicated_on_second_match() { auth(&app, "NoDupAchPlayer").await; // First match — first_match unlocks - 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, + "/matches/result", + serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 1, "goals_against": 0, "mode": "squad_battles" + }), + ) + .await; // Second match — first_match must NOT appear again - let (_, result) = 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 (_, result) = 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 unlocked = result["achievements_unlocked"].as_array().unwrap(); let dup = unlocked.iter().any(|a| a["id"] == "first_match"); @@ -1673,15 +1921,23 @@ async fn test_achievement_grants_coins() { let coins_before = club_before["coins"].as_i64().unwrap_or(0); // first_match achievement grants 500 coins on top of match reward - 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, + "/matches/result", + serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 1, "goals_against": 0, "mode": "squad_battles" + }), + ) + .await; let (_, club_after) = json_get(&app, "/club").await; let coins_after = club_after["coins"].as_i64().unwrap_or(0); // Should have gained match coins + at least the first_match achievement reward (500) - assert!(coins_after > coins_before + 400, "expected achievement coin reward in total"); + assert!( + coins_after > coins_before + 400, + "expected achievement coin reward in total" + ); } // ── Phase 21: Onboarding / Reset ───────────────────────────────────────────── @@ -1709,19 +1965,33 @@ async fn test_auth_reset_clears_profile() { auth(&app, "ResetPlayer").await; // Play a match to generate some state - 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, + "/matches/result", + serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 1, "goals_against": 0, "mode": "squad_battles" + }), + ) + .await; // Reset - let (status, json) = json_post(&app, "/auth/reset", serde_json::json!({ "confirm": "reset" })).await; + let (status, json) = json_post( + &app, + "/auth/reset", + serde_json::json!({ "confirm": "reset" }), + ) + .await; assert_eq!(status, StatusCode::OK, "{json}"); assert_eq!(json["reset"], true); // Profile should be gone let (s, _) = json_get(&app, "/profile").await; - assert_eq!(s, StatusCode::NOT_FOUND, "profile should not exist after reset"); + assert_eq!( + s, + StatusCode::NOT_FOUND, + "profile should not exist after reset" + ); // Status should reflect no profile let (_, status_json) = json_get(&app, "/auth/status").await; @@ -1733,12 +2003,20 @@ async fn test_auth_reset_allows_new_profile() { let app = build_test_app().await; auth(&app, "FirstProfile").await; - json_post(&app, "/auth/reset", serde_json::json!({ "confirm": "reset" })).await; + json_post( + &app, + "/auth/reset", + serde_json::json!({ "confirm": "reset" }), + ) + .await; // Should be able to create a new profile after reset - let (status, json) = json_post(&app, "/auth/local", - serde_json::json!({ "username": "NewProfile" }) - ).await; + let (status, json) = json_post( + &app, + "/auth/local", + serde_json::json!({ "username": "NewProfile" }), + ) + .await; assert_eq!(status, StatusCode::OK, "{json}"); assert_eq!(json["profile"]["username"], "NewProfile"); } @@ -1762,10 +2040,15 @@ async fn test_division_history_records_after_season_end() { // Win all 10 matches to complete and promote for _ in 0..10 { - json_post(&app, "/matches/result", serde_json::json!({ - "squad_id": "dummy", "opponent_name": "Bot", - "goals_for": 3, "goals_against": 0, "mode": "squad_battles" - })).await; + 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, "/division/history").await; @@ -1822,7 +2105,10 @@ async fn test_checkin_claim_awards_coins() { let (_, after) = json_get(&app, "/club").await; let coins_after = after["coins"].as_i64().unwrap_or(0); - assert!(coins_after > coins_before, "coins should increase after checkin"); + assert!( + coins_after > coins_before, + "coins should increase after checkin" + ); } #[tokio::test] @@ -1863,10 +2149,17 @@ async fn test_leaderboard_has_ten_clubs() { let (status, json) = json_get(&app, "/division/leaderboard").await; assert_eq!(status, StatusCode::OK, "{json}"); let table = json["leaderboard"].as_array().unwrap(); - assert_eq!(table.len(), 10, "leaderboard should have 10 clubs (9 NPC + player)"); + assert_eq!( + table.len(), + 10, + "leaderboard should have 10 clubs (9 NPC + player)" + ); // Exactly one entry should be the player's club - let player_entries: Vec<_> = table.iter().filter(|e| e["is_player"].as_bool() == Some(true)).collect(); + let player_entries: Vec<_> = table + .iter() + .filter(|e| e["is_player"].as_bool() == Some(true)) + .collect(); assert_eq!(player_entries.len(), 1, "exactly one player club entry"); assert!(json["division"].is_number()); } @@ -1878,7 +2171,10 @@ async fn test_leaderboard_sorted_by_pts() { let (_, json) = json_get(&app, "/division/leaderboard").await; let table = json["leaderboard"].as_array().unwrap(); - let pts: Vec = table.iter().map(|e| e["pts"].as_i64().unwrap_or(0)).collect(); + let pts: Vec = table + .iter() + .map(|e| e["pts"].as_i64().unwrap_or(0)) + .collect(); let sorted = { let mut s = pts.clone(); s.sort_by(|a, b| b.cmp(a)); @@ -2280,7 +2576,6 @@ async fn test_owned_query_parameter_order_invariance() { assert_eq!(a["total"], b["total"]); } - async fn json_put(app: &axum::Router, uri: &str, payload: Value) -> (StatusCode, Value) { let resp = app .clone() @@ -2309,7 +2604,12 @@ async fn test_squad_ext_replace_read_roundtrip_and_idempotency() { // Owned cards from the starter pack. 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; + json_post( + &app, + &format!("/packs/open/{pack_id}"), + serde_json::json!({}), + ) + .await; let (_, coll) = json_get(&app, "/collection").await; let ids: Vec = coll["collection"] .as_array() @@ -2345,7 +2645,10 @@ async fn test_squad_ext_replace_read_roundtrip_and_idempotency() { let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await; assert_eq!(s, StatusCode::OK, "{ext}"); assert_eq!(ext["extension"]["state"], "fresh"); - assert_eq!(ext["extension"]["payload"], payload, "opaque payload round-trips verbatim"); + assert_eq!( + ext["extension"]["payload"], payload, + "opaque payload round-trips verbatim" + ); assert_eq!(ext["extension"]["schema_version"], 1); assert_eq!(ext["extension"]["stored_fingerprint"], fp); assert_eq!(ext["squad"]["formation"], "f442"); @@ -2354,9 +2657,122 @@ async fn test_squad_ext_replace_read_roundtrip_and_idempotency() { // Idempotent: an identical replacement converges to the same fingerprint. let (s2, put2) = json_put(&app, "/squad/replace", body).await; assert_eq!(s2, StatusCode::OK); - assert_eq!(put2["canonical_fingerprint"], fp, "identical PUT is idempotent"); + assert_eq!( + put2["canonical_fingerprint"], fp, + "identical PUT is idempotent" + ); // A different namespace has no stored extension: Missing, never fabricated. let (_, other) = json_get(&app, "/squad/ext?namespace=other.ns").await; assert_eq!(other["extension"]["state"], "missing"); -} \ No newline at end of file +} + +// ─────────────────────────── economy HTTP boundary ────────────────────────── + +#[tokio::test] +async fn test_economy_balance_and_reward() { + let app = build_test_app().await; + auth(&app, "econ-a").await; + let (st, bal) = json_get(&app, "/economy/balance").await; + assert_eq!(st, StatusCode::OK); + assert_eq!(bal["balance"], 5000); + let (st, r) = json_post( + &app, + "/economy/grant-reward", + serde_json::json!({"amount": 1000}), + ) + .await; + assert_eq!(st, StatusCode::OK); + assert_eq!(r["balance"], 6000); + let (_, club) = json_get(&app, "/club").await; + assert_eq!(club["coins"], 6000); +} + +#[tokio::test] +async fn test_economy_purchase_and_redeem_entitlement() { + let app = build_test_app().await; + auth(&app, "econ-b").await; + let (st, buy) = json_post( + &app, + "/economy/purchase-entitlement", + serde_json::json!({"cost": 400, "definition_id": "pack-x"}), + ) + .await; + assert_eq!(st, StatusCode::OK); + assert_eq!(buy["balance"], 4600); + let ent_id = buy["entitlement_id"].as_str().unwrap().to_string(); + let count_pack_x = |ents: &Value| { + ents.as_array() + .unwrap() + .iter() + .filter(|e| e["definition_id"] == "pack-x") + .count() + }; + let (_, ents) = json_get(&app, "/economy/entitlements").await; + // The club also has a starter pack; exactly one purchased "pack-x" is present. + assert_eq!(count_pack_x(&ents), 1); + let (st, redeem) = json_post( + &app, + "/economy/redeem-entitlement", + serde_json::json!({"entitlement_id": ent_id, "items": [{"item_id": "inst-1", "card_id": "def-1"}]}), + ) + .await; + assert_eq!(st, StatusCode::OK); + assert_eq!(redeem["definition_id"], "pack-x"); + let (_, ents2) = json_get(&app, "/economy/entitlements").await; + assert_eq!(count_pack_x(&ents2), 0); +} + +#[tokio::test] +async fn test_economy_purchase_item_and_sell() { + let app = build_test_app().await; + auth(&app, "econ-c").await; + let (st, buy) = json_post( + &app, + "/economy/purchase-item", + serde_json::json!({"cost": 500, "item_id": "mkt-1", "card_id": "def-9"}), + ) + .await; + assert_eq!(st, StatusCode::OK); + assert_eq!(buy["balance"], 4500); + let (st, sell) = json_post( + &app, + "/economy/sell-item", + serde_json::json!({"item_id": "mkt-1", "price": 200}), + ) + .await; + assert_eq!(st, StatusCode::OK); + assert_eq!(sell["balance"], 4700); + // Selling the same item again fails (not owned) and does not credit. + let (st, _) = json_post( + &app, + "/economy/sell-item", + serde_json::json!({"item_id": "mkt-1", "price": 200}), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); + let (_, bal) = json_get(&app, "/economy/balance").await; + assert_eq!(bal["balance"], 4700); +} + +#[tokio::test] +async fn test_economy_insufficient_funds_fail_closed() { + let app = build_test_app().await; + auth(&app, "econ-d").await; + let (st, _) = json_post( + &app, + "/economy/purchase-entitlement", + serde_json::json!({"cost": 999999, "definition_id": "pack-x"}), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + let (_, bal) = json_get(&app, "/economy/balance").await; + assert_eq!(bal["balance"], 5000); + let (_, ents) = json_get(&app, "/economy/entitlements").await; + // No purchased "pack-x" entitlement was created (starter pack aside). + assert!(!ents + .as_array() + .unwrap() + .iter() + .any(|e| e["definition_id"] == "pack-x")); +}