Phase 11: FUT Champions mode and Division Rivals weekly rewards
CI / Build, lint & test (push) Failing after 1m33s

FUT Champions (Weekend League):
- POST /fut-champs/start — open a new 30-match week (one active session at a time)
- GET /fut-champs — current session status with matches_remaining
- POST /fut-champs/:id/result — record a match; auto-completes and computes tier at 30
- POST /fut-champs/:id/claim — claim coins + pack reward (idempotent guard)
- GET /fut-champs/history — past sessions newest-first
- 11 reward tiers: Elite (27+ wins, 50k coins + icon pack) down to Bronze 1 (0 wins, 250 coins)

Division Rivals:
- POST /rivals/claim-weekly — claim weekly reward scaled by current division
- Week counter is monotonic (offline-safe, no real-time calendar dependency)
- Division 1-3 get a bonus pack alongside coins

Migration 0008 adds fut_champs_sessions table and two columns to seasons.
12 new integration tests — all 67 pass, clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 17:06:18 -07:00
parent 19c7b9989d
commit 26b8e9efef
9 changed files with 802 additions and 0 deletions
+241
View File
@@ -1168,3 +1168,244 @@ async fn test_upgrades_on_nonexistent_card_return_404() {
).await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
// ── Phase 11: FUT Champions / Division Rivals ─────────────────────────────────
#[tokio::test]
async fn test_fut_champs_no_active_session_initially() {
let app = build_test_app().await;
auth(&app, "ChampsCheck").await;
let (s, json) = json_get(&app, "/fut-champs").await;
assert_eq!(s, StatusCode::OK);
assert!(json["session"].is_null(), "no session should exist on fresh profile");
}
#[tokio::test]
async fn test_fut_champs_start_session() {
let app = build_test_app().await;
auth(&app, "ChampsStarter").await;
let (s, json) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
assert_eq!(s, StatusCode::OK, "{json}");
assert_eq!(json["session"]["week_number"], 1);
assert_eq!(json["session"]["matches_played"], 0);
assert_eq!(json["session"]["status"], "active");
}
#[tokio::test]
async fn test_fut_champs_cannot_start_two_sessions() {
let app = build_test_app().await;
auth(&app, "DoubleStart").await;
json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
let (s, json) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
assert_eq!(s, StatusCode::CONFLICT, "{json}");
}
#[tokio::test]
async fn test_fut_champs_record_match_win() {
let app = build_test_app().await;
auth(&app, "ChampsWinner").await;
let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
let session_id = start["session"]["id"].as_str().unwrap();
let (s, json) = json_post(
&app,
&format!("/fut-champs/{session_id}/result"),
serde_json::json!({ "goals_for": 3, "goals_against": 1 }),
).await;
assert_eq!(s, StatusCode::OK, "{json}");
assert_eq!(json["outcome"], "win");
assert_eq!(json["session"]["wins"], 1);
assert_eq!(json["session"]["matches_played"], 1);
assert_eq!(json["matches_remaining"], 29);
}
#[tokio::test]
async fn test_fut_champs_record_match_draw_and_loss() {
let app = build_test_app().await;
auth(&app, "ChampsDrawLoss").await;
let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
let session_id = start["session"]["id"].as_str().unwrap();
let (_, draw) = json_post(
&app,
&format!("/fut-champs/{session_id}/result"),
serde_json::json!({ "goals_for": 1, "goals_against": 1 }),
).await;
assert_eq!(draw["outcome"], "draw");
assert_eq!(draw["session"]["draws"], 1);
let (_, loss) = json_post(
&app,
&format!("/fut-champs/{session_id}/result"),
serde_json::json!({ "goals_for": 0, "goals_against": 2 }),
).await;
assert_eq!(loss["outcome"], "loss");
assert_eq!(loss["session"]["losses"], 1);
}
/// Play all 30 matches and verify the session auto-completes at the correct tier.
#[tokio::test]
async fn test_fut_champs_session_auto_completes_at_30_matches() {
let app = build_test_app().await;
auth(&app, "Champs30Player").await;
let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
let session_id = start["session"]["id"].as_str().unwrap().to_string();
let mut last_json = serde_json::json!({});
for i in 0..30 {
let (s, json) = json_post(
&app,
&format!("/fut-champs/{session_id}/result"),
// Win 20 out of 30 → Gold 3 tier
serde_json::json!({
"goals_for": if i < 20 { 2 } else { 0 },
"goals_against": if i < 20 { 0 } else { 2 }
}),
).await;
assert_eq!(s, StatusCode::OK, "match {i} failed: {json}");
last_json = json;
}
assert_eq!(last_json["auto_completed"], true);
assert_eq!(last_json["session"]["status"], "completed");
assert_eq!(last_json["session"]["wins"], 20);
assert_eq!(last_json["session"]["reward_tier"], "Gold 3");
assert_eq!(last_json["session"]["reward_coins"], 15_000);
}
#[tokio::test]
async fn test_fut_champs_claim_rewards() {
let app = build_test_app().await;
auth(&app, "ChampsClaimPlayer").await;
let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
let session_id = start["session"]["id"].as_str().unwrap().to_string();
// Play all 30 (win all → Elite tier)
for _ in 0..30 {
json_post(
&app,
&format!("/fut-champs/{session_id}/result"),
serde_json::json!({ "goals_for": 2, "goals_against": 0 }),
).await;
}
let (s, json) = json_post(
&app,
&format!("/fut-champs/{session_id}/claim"),
serde_json::json!({}),
).await;
assert_eq!(s, StatusCode::OK, "{json}");
assert_eq!(json["tier"], "Elite");
assert_eq!(json["coins_awarded"], 50_000);
assert!(json["pack_granted"].is_string(), "Elite should grant an icon pack");
// Double-claim should fail
let (s, _) = json_post(
&app,
&format!("/fut-champs/{session_id}/claim"),
serde_json::json!({}),
).await;
assert_eq!(s, StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_fut_champs_claim_active_session_fails() {
let app = build_test_app().await;
auth(&app, "EarlyClaimPlayer").await;
let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
let session_id = start["session"]["id"].as_str().unwrap();
// Only 1 match played — claim should reject
json_post(
&app,
&format!("/fut-champs/{session_id}/result"),
serde_json::json!({ "goals_for": 2, "goals_against": 0 }),
).await;
let (s, _) = json_post(
&app,
&format!("/fut-champs/{session_id}/claim"),
serde_json::json!({}),
).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_fut_champs_history_grows() {
let app = build_test_app().await;
auth(&app, "HistoryGrowPlayer").await;
let (_, hist) = json_get(&app, "/fut-champs/history").await;
assert_eq!(hist["total"], 0);
// Complete a session
let (_, start) = json_post(&app, "/fut-champs/start", serde_json::json!({})).await;
let session_id = start["session"]["id"].as_str().unwrap().to_string();
for _ in 0..30 {
json_post(
&app,
&format!("/fut-champs/{session_id}/result"),
serde_json::json!({ "goals_for": 1, "goals_against": 0 }),
).await;
}
let (_, hist) = json_get(&app, "/fut-champs/history").await;
assert_eq!(hist["total"], 1);
assert_eq!(hist["history"][0]["status"], "completed");
}
#[tokio::test]
async fn test_fut_champs_tier_boundaries() {
// Verify tier calculation for edge win counts
use openfut_core::models::fut_champs::ChampsTier;
assert_eq!(ChampsTier::from_wins(27).label(), "Elite");
assert_eq!(ChampsTier::from_wins(26).label(), "World Class");
assert_eq!(ChampsTier::from_wins(23).label(), "World Class");
assert_eq!(ChampsTier::from_wins(20).label(), "Gold 3");
assert_eq!(ChampsTier::from_wins(0).label(), "Bronze 1");
assert_eq!(ChampsTier::from_wins(1).label(), "Bronze 2");
assert_eq!(ChampsTier::from_wins(5).label(), "Silver 1");
assert_eq!(ChampsTier::from_wins(14).label(), "Gold 1");
}
#[tokio::test]
async fn test_rivals_weekly_reward_claim() {
let app = build_test_app().await;
auth(&app, "RivalsClaimPlayer").await;
// Play a match to create the season row
json_post(&app, "/matches/result", serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
})).await;
let (s, json) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
assert_eq!(s, StatusCode::OK, "{json}");
assert_eq!(json["week_number"], 1);
assert!(json["coins_awarded"].as_i64().unwrap() > 0);
assert!(json["new_balance"].as_i64().unwrap() > 0);
}
#[tokio::test]
async fn test_rivals_reward_increments_week_counter() {
let app = build_test_app().await;
auth(&app, "RivalsWeekCounter").await;
json_post(&app, "/matches/result", serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
})).await;
json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
let (s, json) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
assert_eq!(s, StatusCode::OK, "{json}");
assert_eq!(json["week_number"], 2, "second claim should be week 2");
}