Phase 9: division/season tracking, club customization, pack history, notifications
CI / Build, lint & test (push) Failing after 1m42s
CI / Build, lint & test (push) Failing after 1m42s
- GET /division returns live season stats (points, record, promotion threshold) - PUT /club allows updating club name and manager_name - GET /packs/history returns opened packs with full card definitions - GET /notifications dynamically surfaces completed objectives, expiring loans, season end - Club model gains manager_name column (migration 0006 already added it) - Pack model gains opened_cards and opened_at; pack SELECT queries updated - 9 new integration tests — all 45 pass, clippy clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -122,6 +122,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
.route("/auth/local", post(routes::auth::post_auth_local))
|
||||
.route("/profile", get(routes::profile::get_profile))
|
||||
.route("/club", get(routes::club::get_club))
|
||||
.route("/club", put(routes::club::put_club))
|
||||
.route("/cards", get(routes::cards::get_cards))
|
||||
.route("/cards/:card_id", get(routes::cards::get_card))
|
||||
.route("/collection", get(routes::cards::get_collection))
|
||||
@@ -130,6 +131,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
delete(routes::cards::delete_owned_card),
|
||||
)
|
||||
.route("/packs", get(routes::packs::get_packs))
|
||||
.route("/packs/history", get(routes::packs::get_pack_history))
|
||||
.route("/packs/buy", post(routes::packs::post_buy_pack))
|
||||
.route("/packs/open/:pack_id", post(routes::packs::post_open_pack))
|
||||
.route("/squad", get(routes::squad::get_squad))
|
||||
@@ -169,6 +171,8 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
)
|
||||
.route("/settings", get(routes::settings::get_settings))
|
||||
.route("/settings", put(routes::settings::put_settings))
|
||||
.route("/division", get(routes::division::get_division))
|
||||
.route("/notifications", get(routes::notifications::get_notifications))
|
||||
.route("/draft/squad", get(routes::draft::get_draft_squad))
|
||||
.route("/draft/start", post(routes::draft::post_draft_start))
|
||||
.route("/draft/sessions/:id", get(routes::draft::get_draft_session))
|
||||
|
||||
@@ -11,6 +11,7 @@ pub struct Club {
|
||||
pub level: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub manager_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Club {
|
||||
@@ -24,6 +25,7 @@ impl Club {
|
||||
level: 1,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
manager_name: Some("Player Manager".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ pub struct Pack {
|
||||
pub definition_id: String,
|
||||
pub opened: bool,
|
||||
pub created_at: String,
|
||||
pub opened_cards: Option<String>,
|
||||
pub opened_at: Option<String>,
|
||||
}
|
||||
|
||||
/// The result of opening a pack.
|
||||
|
||||
@@ -5,9 +5,33 @@ use crate::{
|
||||
services::{club as club_svc, profile as profile_svc},
|
||||
};
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn get_club(State(state): State<AppState>) -> AppResult<Json<Club>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
Ok(Json(club))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateClubRequest {
|
||||
pub name: Option<String>,
|
||||
pub manager_name: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn put_club(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<UpdateClubRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let updated = club_svc::update_club(
|
||||
&state.pool,
|
||||
&club.id,
|
||||
req.name.as_deref(),
|
||||
req.manager_name.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "club": updated })))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
services::{club as club_svc, profile as profile_svc, season as season_svc},
|
||||
};
|
||||
|
||||
pub async fn get_division(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
// Ensure a season row exists (idempotent)
|
||||
let _ = club; // suppress unused warning; we need club_id later if we extend this
|
||||
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"division": season.division,
|
||||
"season_number": season.season_number,
|
||||
"season_points": season.season_points,
|
||||
"matches_played": season.matches_played,
|
||||
"matches_remaining": season.matches_remaining(),
|
||||
"record": {
|
||||
"wins": season.wins,
|
||||
"draws": season.draws,
|
||||
"losses": season.losses,
|
||||
},
|
||||
"pts_for_promotion": season.pts_for_promotion(),
|
||||
"started_at": season.started_at,
|
||||
})))
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
pub mod auth;
|
||||
pub mod cards;
|
||||
pub mod club;
|
||||
pub mod division;
|
||||
pub mod draft;
|
||||
pub mod events;
|
||||
pub mod health;
|
||||
pub mod market;
|
||||
pub mod matches;
|
||||
pub mod notifications;
|
||||
pub mod objectives;
|
||||
pub mod packs;
|
||||
pub mod profile;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
services::{club as club_svc, objective as obj_svc, profile as profile_svc},
|
||||
};
|
||||
|
||||
/// Returns actionable notifications for the current profile.
|
||||
///
|
||||
/// Built dynamically from DB state — no separate notifications table.
|
||||
/// Covers:
|
||||
/// - Completed objectives whose reward hasn't been claimed yet
|
||||
/// - Loan cards that will expire within 2 matches
|
||||
/// - Season ending soon (≤2 matches remaining)
|
||||
pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let mut notifications: Vec<Value> = Vec::new();
|
||||
|
||||
// 1. Completed but unclaimed objectives
|
||||
let objectives =
|
||||
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
||||
for obj in &objectives {
|
||||
if obj.completed && !obj.claimed {
|
||||
notifications.push(json!({
|
||||
"type": "objective_complete",
|
||||
"objective_id": obj.definition.id,
|
||||
"title": format!("Objective complete: {}", obj.definition.title),
|
||||
"message": format!("Claim your reward for \"{}\"", obj.definition.title),
|
||||
"reward_coins": obj.definition.reward_coins,
|
||||
"reward_pack": obj.definition.reward_pack_id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Loan cards expiring within 2 matches
|
||||
let expiring_loans: Vec<(String, String, Option<i64>)> = sqlx::query_as(
|
||||
"SELECT oc.id, oc.card_id, oc.loan_matches_remaining \
|
||||
FROM owned_cards oc WHERE oc.club_id = ? AND oc.is_loan = 1 \
|
||||
AND oc.loan_matches_remaining <= 2",
|
||||
)
|
||||
.bind(&club.id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
for (owned_id, card_id, remaining) in expiring_loans {
|
||||
let card_name = state
|
||||
.card_db
|
||||
.get(&card_id)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_else(|| card_id.clone());
|
||||
notifications.push(json!({
|
||||
"type": "loan_expiring",
|
||||
"owned_card_id": owned_id,
|
||||
"card_id": card_id,
|
||||
"title": format!("Loan expiring: {card_name}"),
|
||||
"message": format!(
|
||||
"{card_name} has {} match(es) remaining on loan",
|
||||
remaining.unwrap_or(0)
|
||||
),
|
||||
"matches_remaining": remaining,
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. Season ending soon (≤2 matches remaining)
|
||||
let season_row: Option<(i64, i64)> = sqlx::query_as(
|
||||
"SELECT matches_played, division FROM seasons WHERE profile_id = ?",
|
||||
)
|
||||
.bind(&profile.id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if let Some((played, division)) = season_row {
|
||||
let remaining = (10 - played).max(0);
|
||||
if remaining <= 2 && remaining > 0 {
|
||||
notifications.push(json!({
|
||||
"type": "season_ending",
|
||||
"title": format!("Season ending — Division {division}"),
|
||||
"message": format!("{remaining} match(es) left in the season"),
|
||||
"matches_remaining": remaining,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"notifications": notifications,
|
||||
"count": notifications.len(),
|
||||
})))
|
||||
}
|
||||
@@ -57,6 +57,50 @@ pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>>
|
||||
Ok(Json(json!({ "packs": with_defs })))
|
||||
}
|
||||
|
||||
/// Return recently opened packs with the card IDs they contained.
|
||||
pub async fn get_pack_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let opened = sqlx::query_as::<_, crate::models::pack::Pack>(
|
||||
"SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at \
|
||||
FROM packs WHERE club_id = ? AND opened = 1 ORDER BY opened_at DESC LIMIT 50",
|
||||
)
|
||||
.bind(&club.id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let history: Vec<Value> = opened
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let def = state.pack_defs.iter().find(|d| d.id == p.definition_id);
|
||||
// Expand card_ids into full card definitions
|
||||
let cards: Vec<Value> = p
|
||||
.opened_cards
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|id| {
|
||||
json!({
|
||||
"card_id": id,
|
||||
"card": state.card_db.get(id),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"pack_id": p.id,
|
||||
"definition_id": p.definition_id,
|
||||
"name": def.map(|d| &d.name),
|
||||
"opened_at": p.opened_at,
|
||||
"cards": cards,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "history": history, "total": history.len() })))
|
||||
}
|
||||
|
||||
pub async fn post_open_pack(
|
||||
State(state): State<AppState>,
|
||||
Path(pack_id): Path<String>,
|
||||
|
||||
+46
-8
@@ -5,19 +5,30 @@ use crate::{
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
const CLUB_SELECT: &str =
|
||||
"SELECT id, profile_id, name, coins, level, created_at, updated_at, manager_name \
|
||||
FROM clubs";
|
||||
|
||||
pub async fn get_club_by_profile(pool: &Pool, profile_id: &str) -> AppResult<Club> {
|
||||
sqlx::query_as::<_, Club>(
|
||||
"SELECT id, profile_id, name, coins, level, created_at, updated_at FROM clubs WHERE profile_id = ? LIMIT 1"
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("club not found".into()))
|
||||
sqlx::query_as::<_, Club>(&format!("{CLUB_SELECT} WHERE profile_id = ? LIMIT 1"))
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("club not found".into()))
|
||||
}
|
||||
|
||||
pub async fn get_club_by_id(pool: &Pool, club_id: &str) -> AppResult<Club> {
|
||||
sqlx::query_as::<_, Club>(&format!("{CLUB_SELECT} WHERE id = ? LIMIT 1"))
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("club not found".into()))
|
||||
}
|
||||
|
||||
pub async fn create_club(pool: &Pool, club: &Club) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at, manager_name) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&club.id)
|
||||
.bind(&club.profile_id)
|
||||
@@ -26,11 +37,38 @@ pub async fn create_club(pool: &Pool, club: &Club) -> AppResult<()> {
|
||||
.bind(club.level)
|
||||
.bind(club.created_at)
|
||||
.bind(club.updated_at)
|
||||
.bind(&club.manager_name)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_club(
|
||||
pool: &Pool,
|
||||
club_id: &str,
|
||||
name: Option<&str>,
|
||||
manager_name: Option<&str>,
|
||||
) -> AppResult<Club> {
|
||||
let now = Utc::now();
|
||||
if let Some(n) = name {
|
||||
sqlx::query("UPDATE clubs SET name = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(n)
|
||||
.bind(now)
|
||||
.bind(club_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
if let Some(m) = manager_name {
|
||||
sqlx::query("UPDATE clubs SET manager_name = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(m)
|
||||
.bind(now)
|
||||
.bind(club_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
get_club_by_id(pool, club_id).await
|
||||
}
|
||||
|
||||
pub async fn add_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||
let now = Utc::now();
|
||||
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
|
||||
|
||||
+11
-3
@@ -39,6 +39,8 @@ pub async fn grant_pack(pool: &Pool, club_id: &str, definition_id: &str) -> AppR
|
||||
definition_id: definition_id.to_string(),
|
||||
opened: false,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
opened_cards: None,
|
||||
opened_at: None,
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
@@ -61,7 +63,7 @@ pub async fn open_pack(
|
||||
pack_id: &str,
|
||||
) -> AppResult<PackOpenResult> {
|
||||
let pack = sqlx::query_as::<_, Pack>(
|
||||
"SELECT id, club_id, definition_id, opened, created_at FROM packs WHERE id = ? AND club_id = ?"
|
||||
"SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at FROM packs WHERE id = ? AND club_id = ?"
|
||||
)
|
||||
.bind(pack_id)
|
||||
.bind(club_id)
|
||||
@@ -122,7 +124,13 @@ pub async fn open_pack(
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE packs SET opened = 1 WHERE id = ?")
|
||||
let card_ids_json = serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query("UPDATE packs SET opened = 1, opened_cards = ?, opened_at = ? WHERE id = ?")
|
||||
.bind(&card_ids_json)
|
||||
.bind(&now)
|
||||
.bind(pack_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
@@ -152,7 +160,7 @@ pub async fn buy_pack(
|
||||
|
||||
pub async fn get_unopened_packs(pool: &Pool, club_id: &str) -> AppResult<Vec<Pack>> {
|
||||
let packs = sqlx::query_as::<_, Pack>(
|
||||
"SELECT id, club_id, definition_id, opened, created_at FROM packs WHERE club_id = ? AND opened = 0"
|
||||
"SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at FROM packs WHERE club_id = ? AND opened = 0"
|
||||
)
|
||||
.bind(club_id)
|
||||
.fetch_all(pool)
|
||||
|
||||
@@ -830,3 +830,157 @@ async fn test_market_my_listings_initially_empty() {
|
||||
assert_eq!(json["total"], 0);
|
||||
assert!(json["listings"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ── Phase 9: Division, Loans, Pack history, Club customization, Notifications ─
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_division_endpoint_starts_at_5() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "DivPlayer").await;
|
||||
|
||||
let (status, json) = json_get(&app, "/division").await;
|
||||
assert_eq!(status, StatusCode::OK, "{json}");
|
||||
assert_eq!(json["division"], 5, "new players start in division 5");
|
||||
assert_eq!(json["season_points"], 0);
|
||||
assert_eq!(json["matches_played"], 0);
|
||||
assert_eq!(json["matches_remaining"], 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_division_updates_after_wins() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "DivWinner").await;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
let (_, div) = json_get(&app, "/division").await;
|
||||
assert_eq!(div["matches_played"], 3);
|
||||
assert_eq!(div["season_points"], 9, "3 wins = 9 pts");
|
||||
assert_eq!(div["record"]["wins"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_season_ends_after_10_matches_and_promotes() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "SeasonPlayer").await;
|
||||
|
||||
// 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;
|
||||
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["matches_played"], 0, "season counter reset");
|
||||
assert_eq!(div["season_number"], 2, "season 2 started");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pack_history_after_opening() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "HistoryPlayer").await;
|
||||
|
||||
// Initially empty
|
||||
let (s, hist) = json_get(&app, "/packs/history").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(hist["total"], 0);
|
||||
|
||||
// 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;
|
||||
|
||||
// Should now appear in history
|
||||
let (s, hist) = json_get(&app, "/packs/history").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(hist["total"], 1, "opened pack should appear in history");
|
||||
assert!(hist["history"][0]["opened_at"].is_string());
|
||||
assert!(hist["history"][0]["cards"].is_array());
|
||||
assert!(!hist["history"][0]["cards"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
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.");
|
||||
|
||||
// Verify persisted
|
||||
let (_, club) = json_get(&app, "/club").await;
|
||||
assert_eq!(club["name"], "Galaxy FC");
|
||||
assert_eq!(club["manager_name"], "Alex Ferguson Jr.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_notifications_empty_on_fresh_profile() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "NotifPlayer").await;
|
||||
|
||||
let (s, json) = json_get(&app, "/notifications").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert!(json["notifications"].is_array());
|
||||
// Fresh profile has no completed objectives, so count may be 0
|
||||
let _ = json["count"].as_u64();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_notifications_include_completed_objectives() {
|
||||
let app = build_test_app().await;
|
||||
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;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
// expired_loans should be present (empty array for no loan cards)
|
||||
assert!(result["expired_loans"].is_array());
|
||||
// season_end is None for non-final match
|
||||
assert!(result["season_end"].is_null());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user