1ef2c436ab
CI / Build, lint & test (push) Successful in 2m5s
Adds a data-driven event system (#47 schema, #48 activation, #49 TOTW): - EventDefinition JSON schema (event_type, effects, date range, is_manual) - data/events/totw_week1.json — TOTW event active by date range (2024–2030), injects 5 TOTW cards into the market as [EVENT] listings - data/events/seasonal_events.json — Spring Festival and Icon Weekend, manual-activation-only example events - migrations/0004_events.sql — events override table (NULL/1/0 per event) - services/event — load_event_definitions, compute_is_active (date range + manual override), get_active_events, get_all_with_status, activate/deactivate - routes/events — GET /events, GET /events/:id, POST .../activate, .../deactivate - services/market::refresh_npc_listings now accepts event_defs, injects bonus market cards from active events at 2× premium price - 5 new integration tests (events list, single, activate/deactivate cycle, 404 on unknown, market injection verification); 19/19 passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
485 lines
15 KiB
Rust
485 lines
15 KiB
Rust
use axum::{
|
||
body::Body,
|
||
http::{Request, StatusCode},
|
||
};
|
||
use serde_json::Value;
|
||
use tower::ServiceExt;
|
||
|
||
// We test by spinning up the full app against an in-memory SQLite database.
|
||
|
||
async fn build_test_app() -> axum::Router {
|
||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||
.connect("sqlite::memory:")
|
||
.await
|
||
.expect("in-memory sqlite");
|
||
|
||
sqlx::migrate!("./migrations")
|
||
.run(&pool)
|
||
.await
|
||
.expect("migrations");
|
||
|
||
let data_dir = "data";
|
||
openfut_core::build_app(pool, data_dir)
|
||
.await
|
||
.expect("app build")
|
||
}
|
||
|
||
async fn auth(app: &axum::Router, username: &str) -> Value {
|
||
let payload = serde_json::json!({ "username": username });
|
||
let resp = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/auth/local")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(payload.to_string()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(resp.status(), StatusCode::OK);
|
||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap();
|
||
serde_json::from_slice(&body).unwrap()
|
||
}
|
||
|
||
async fn json_get(app: &axum::Router, uri: &str) -> (StatusCode, Value) {
|
||
let resp = app
|
||
.clone()
|
||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||
.await
|
||
.unwrap();
|
||
let status = resp.status();
|
||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap();
|
||
(status, serde_json::from_slice(&body).unwrap())
|
||
}
|
||
|
||
async fn json_post(app: &axum::Router, uri: &str, payload: Value) -> (StatusCode, Value) {
|
||
let resp = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri(uri)
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(payload.to_string()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
let status = resp.status();
|
||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap();
|
||
(status, serde_json::from_slice(&body).unwrap())
|
||
}
|
||
|
||
// ── Existing tests ───────────────────────────────────────────────────────────
|
||
|
||
#[tokio::test]
|
||
async fn test_health_endpoint() {
|
||
let app = build_test_app().await;
|
||
let (status, json) = json_get(&app, "/health").await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
assert_eq!(json["status"], "ok");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_formations_endpoint() {
|
||
let app = build_test_app().await;
|
||
let (status, json) = json_get(&app, "/formations").await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
assert!(json["formations"].is_array());
|
||
assert!(json["formations"].as_array().unwrap().len() >= 8);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_auth_local_creates_profile_and_club() {
|
||
let app = build_test_app().await;
|
||
let json = auth(&app, "TestPlayer").await;
|
||
assert_eq!(json["profile"]["username"], "TestPlayer");
|
||
assert_eq!(json["club"]["name"], "OpenFUT FC");
|
||
assert_eq!(json["club"]["coins"], 5000);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_profile_not_found_before_auth() {
|
||
let app = build_test_app().await;
|
||
let (status, _) = json_get(&app, "/profile").await;
|
||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_match_result_awards_coins() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "MatchPlayer").await;
|
||
|
||
let payload = serde_json::json!({
|
||
"squad_id": "dummy-squad",
|
||
"opponent_name": "AI Club",
|
||
"goals_for": 3,
|
||
"goals_against": 1,
|
||
"mode": "squad_battles"
|
||
});
|
||
let (status, json) = json_post(&app, "/matches/result", payload).await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
assert_eq!(json["match_record"]["outcome"], "win");
|
||
assert!(json["coins_awarded"].as_i64().unwrap() > 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_sbc_list_returns_definitions() {
|
||
let app = build_test_app().await;
|
||
let (status, json) = json_get(&app, "/sbc").await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
assert!(json["sbcs"].is_array());
|
||
}
|
||
|
||
// ── Phase 3 new tests ────────────────────────────────────────────────────────
|
||
|
||
#[tokio::test]
|
||
async fn test_match_with_goal_positions_tracks_stats() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "GoalTracker").await;
|
||
|
||
let payload = serde_json::json!({
|
||
"squad_id": "dummy-squad",
|
||
"opponent_name": "AI Club",
|
||
"goals_for": 3,
|
||
"goals_against": 0,
|
||
"mode": "squad_battles",
|
||
"goal_positions": ["ST", "ST", "CAM"]
|
||
});
|
||
let (status, _) = json_post(&app, "/matches/result", payload).await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
|
||
let (status, stats) = json_get(&app, "/statistics").await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
assert!(stats["position_goals"].is_object());
|
||
assert_eq!(stats["position_goals"]["ST"], 2);
|
||
assert_eq!(stats["position_goals"]["CAM"], 1);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_buy_and_open_pack() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "PackOpener").await;
|
||
|
||
// Starter gold_pack is granted on registration
|
||
let (status, packs_json) = json_get(&app, "/packs").await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
let packs = packs_json["packs"].as_array().unwrap();
|
||
assert!(!packs.is_empty(), "starter pack should be in inventory");
|
||
|
||
let pack_id = packs[0]["pack_id"].as_str().unwrap();
|
||
let (status, open_json) = json_post(
|
||
&app,
|
||
&format!("/packs/open/{pack_id}"),
|
||
serde_json::json!({}),
|
||
)
|
||
.await;
|
||
assert_eq!(status, StatusCode::OK, "open failed: {open_json}");
|
||
assert!(!open_json["cards"].as_array().unwrap().is_empty());
|
||
|
||
let (status, coll) = json_get(&app, "/collection").await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
assert!(!coll["collection"].as_array().unwrap().is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_buy_bronze_pack_deducts_coins() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "CoinSpender").await;
|
||
|
||
let (status, buy_json) = json_post(
|
||
&app,
|
||
"/packs/buy",
|
||
serde_json::json!({ "pack_definition_id": "bronze_pack" }),
|
||
)
|
||
.await;
|
||
assert_eq!(status, StatusCode::OK, "buy failed: {buy_json}");
|
||
|
||
let (_, club_json) = json_get(&app, "/club").await;
|
||
assert_eq!(club_json["coins"], 5000 - 400);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_sbc_submit_with_bronze_cards() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "SBCPlayer").await;
|
||
|
||
// Buy and open a bronze pack
|
||
let (s, _) = json_post(
|
||
&app,
|
||
"/packs/buy",
|
||
serde_json::json!({ "pack_definition_id": "bronze_pack" }),
|
||
)
|
||
.await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
|
||
let (_, packs_json) = json_get(&app, "/packs").await;
|
||
let bronze_pack = packs_json["packs"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.find(|p| p["definition_id"] == "bronze_pack")
|
||
.expect("bronze pack in inventory");
|
||
let pack_id = bronze_pack["pack_id"].as_str().unwrap();
|
||
|
||
let (s, open_json) = json_post(
|
||
&app,
|
||
&format!("/packs/open/{pack_id}"),
|
||
serde_json::json!({}),
|
||
)
|
||
.await;
|
||
assert_eq!(s, StatusCode::OK, "{open_json}");
|
||
|
||
// Collect bronze card IDs (overall ≤ 64)
|
||
let (_, coll) = json_get(&app, "/collection").await;
|
||
let bronze_ids: Vec<&str> = coll["collection"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.filter(|c| c["card"]["overall"].as_i64().unwrap_or(99) <= 64)
|
||
.take(11)
|
||
.map(|c| c["owned_card_id"].as_str().unwrap())
|
||
.collect();
|
||
|
||
if bronze_ids.len() < 11 {
|
||
// Card pool variance — not enough bronze to fill SBC; skip gracefully
|
||
return;
|
||
}
|
||
|
||
let (s, result) = json_post(
|
||
&app,
|
||
"/sbc/submit",
|
||
serde_json::json!({
|
||
"sbc_id": "sbc_bronze_upgrade",
|
||
"owned_card_ids": bronze_ids
|
||
}),
|
||
)
|
||
.await;
|
||
assert_eq!(s, StatusCode::OK, "SBC submit: {result}");
|
||
assert_eq!(result["passed"], true);
|
||
// bronze_upgrade rewards a silver pack (coins = 0)
|
||
assert!(result["reward"].is_object());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_settings_read_write() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "SettingsUser").await;
|
||
|
||
let (status, defaults) = json_get(&app, "/settings").await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
assert_eq!(defaults["difficulty"], "beginner");
|
||
assert_eq!(defaults["preferred_formation"], "4-4-2");
|
||
|
||
let resp = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("PUT")
|
||
.uri("/settings")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(
|
||
serde_json::json!({
|
||
"difficulty": "world_class",
|
||
"preferred_formation": "4-3-3"
|
||
})
|
||
.to_string(),
|
||
))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(resp.status(), StatusCode::OK);
|
||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap();
|
||
let updated: Value = serde_json::from_slice(&body).unwrap();
|
||
assert_eq!(updated["difficulty"], "world_class");
|
||
assert_eq!(updated["preferred_formation"], "4-3-3");
|
||
|
||
let (s, persisted) = json_get(&app, "/settings").await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
assert_eq!(persisted["difficulty"], "world_class");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_multiple_squads() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "SquadManager").await;
|
||
|
||
let (s1, j1) = json_post(
|
||
&app,
|
||
"/squad",
|
||
serde_json::json!({ "name": "Attack Squad", "formation": "4-3-3", "players": [] }),
|
||
)
|
||
.await;
|
||
assert_eq!(s1, StatusCode::OK, "{j1}");
|
||
let squad1_id = j1["squad"]["id"].as_str().unwrap().to_string();
|
||
|
||
let (s2, j2) = json_post(
|
||
&app,
|
||
"/squad",
|
||
serde_json::json!({ "name": "Defense Squad", "formation": "5-4-1", "players": [] }),
|
||
)
|
||
.await;
|
||
assert_eq!(s2, StatusCode::OK, "{j2}");
|
||
let squad2_id = j2["squad"]["id"].as_str().unwrap().to_string();
|
||
|
||
assert_ne!(squad1_id, squad2_id);
|
||
|
||
let (status, squads_json) = json_get(&app, "/squads").await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
assert_eq!(squads_json["squads"].as_array().unwrap().len(), 2);
|
||
|
||
let (status, sq1) = json_get(&app, &format!("/squads/{squad1_id}")).await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
assert_eq!(sq1["squad"]["name"], "Attack Squad");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_draft_squad_endpoint() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "DraftPlayer").await;
|
||
|
||
let (status, json) = json_get(&app, "/draft/squad?difficulty=beginner").await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
assert_eq!(json["mode"], "draft");
|
||
assert_eq!(json["difficulty"], "beginner");
|
||
assert!(json["cards"].is_array());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_win_streak_tracking() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "StreakPlayer").await;
|
||
|
||
for _ in 0..3 {
|
||
let (s, _) = json_post(
|
||
&app,
|
||
"/matches/result",
|
||
serde_json::json!({
|
||
"squad_id": "dummy",
|
||
"opponent_name": "Bot",
|
||
"goals_for": 2,
|
||
"goals_against": 0,
|
||
"mode": "squad_battles"
|
||
}),
|
||
)
|
||
.await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
}
|
||
|
||
let (s, stats) = json_get(&app, "/statistics").await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
assert_eq!(stats["win_streak"], 3);
|
||
assert_eq!(stats["best_win_streak"], 3);
|
||
}
|
||
|
||
// ── Phase 5: Events ──────────────────────────────────────────────────────────
|
||
|
||
#[tokio::test]
|
||
async fn test_events_list() {
|
||
let app = build_test_app().await;
|
||
let (status, json) = json_get(&app, "/events").await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
assert!(json["events"].is_array());
|
||
// TOTW Week 1 is date-range active (2024–2030)
|
||
let active = json["active_count"].as_u64().unwrap_or(0);
|
||
assert!(active >= 1, "expected at least 1 active event (totw_week1)");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_event_get_single() {
|
||
let app = build_test_app().await;
|
||
let (status, json) = json_get(&app, "/events/totw_week1").await;
|
||
assert_eq!(status, StatusCode::OK);
|
||
assert_eq!(json["event"]["id"], "totw_week1");
|
||
assert_eq!(json["is_active"], true);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_event_activate_deactivate() {
|
||
let app = build_test_app().await;
|
||
|
||
// spring_festival is manual-only — should start inactive
|
||
let (s, json) = json_get(&app, "/events/spring_festival").await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
assert_eq!(json["is_active"], false);
|
||
|
||
// Activate it
|
||
let (s, json) = json_post(
|
||
&app,
|
||
"/events/spring_festival/activate",
|
||
serde_json::json!({}),
|
||
)
|
||
.await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
assert_eq!(json["is_active"], true);
|
||
|
||
// Confirm it's now active
|
||
let (s, json) = json_get(&app, "/events/spring_festival").await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
assert_eq!(json["is_active"], true);
|
||
|
||
// Deactivate it
|
||
let (s, json) = json_post(
|
||
&app,
|
||
"/events/spring_festival/deactivate",
|
||
serde_json::json!({}),
|
||
)
|
||
.await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
assert_eq!(json["is_active"], false);
|
||
|
||
// Confirm inactive again
|
||
let (s, json) = json_get(&app, "/events/spring_festival").await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
assert_eq!(json["is_active"], false);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_event_unknown_returns_404() {
|
||
let app = build_test_app().await;
|
||
let (status, _) = json_get(&app, "/events/no_such_event").await;
|
||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_event_market_refresh_injects_totw_cards() {
|
||
let app = build_test_app().await;
|
||
auth(&app, "EventMarketPlayer").await;
|
||
|
||
// Trigger a market refresh (TOTW event is active by date range)
|
||
let (s, json) = json_post(&app, "/market/refresh", serde_json::json!({})).await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
// totw_week1 has 5 bonus cards + up to 20 NPC listings
|
||
let count = json["listings_generated"].as_u64().unwrap_or(0);
|
||
assert!(
|
||
count >= 5,
|
||
"expected at least 5 listings (event bonus cards)"
|
||
);
|
||
|
||
// Check market contains at least one [EVENT] listing
|
||
let (s, market) = json_get(&app, "/market").await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
let listings = market["listings"].as_array().expect("listings array");
|
||
let event_listing = listings.iter().any(|l| {
|
||
l["seller_name"]
|
||
.as_str()
|
||
.map(|n| n.starts_with("[EVENT]"))
|
||
.unwrap_or(false)
|
||
});
|
||
assert!(
|
||
event_listing,
|
||
"expected at least one [EVENT] market listing"
|
||
);
|
||
}
|