From f70cf4415c617e027384cb7b179574bbf70a8548 Mon Sep 17 00:00:00 2001 From: OpenFUT Agent Date: Thu, 13 Aug 2026 18:34:00 +0000 Subject: [PATCH] wip(core): preserve local broad Core refactor + inventory service Divergent development line off origin/main (11a811d): a broad refactor across routes/services/models/app + a large integration_test expansion (+1000), plus an untracked game-independent inventory query service and Docker files. Preserved verbatim before moving the canonical Core checkout to the committed migration trunk (66c88fb). Reconciling this refactor with the migration trunk is a separate user decision; nothing here is lost. --- .dockerignore | 12 + CONTRIBUTING.md | 2 +- Cargo.toml | 2 +- Dockerfile | 56 ++ README.md | 6 +- src/app.rs | 45 +- src/models/card.rs | 27 + src/models/mod.rs | 6 +- src/models/profile.rs | 22 +- src/routes/achievements.rs | 8 +- src/routes/cards.rs | 69 ++- src/routes/club.rs | 55 +- src/routes/division.rs | 36 +- src/routes/draft.rs | 6 +- src/routes/fut_champs.rs | 14 +- src/routes/market.rs | 8 +- src/routes/matches.rs | 12 +- src/routes/mod.rs | 2 +- src/routes/notifications.rs | 26 +- src/routes/packs.rs | 8 +- src/routes/sbc.rs | 8 +- src/routes/upgrades.rs | 9 +- src/services/achievement.rs | 145 ++--- src/services/checkin.rs | 19 +- src/services/draft.rs | 12 +- src/services/fut_champs.rs | 6 +- src/services/inventory.rs | 291 ++++++++++ src/services/market.rs | 11 +- src/services/match_service.rs | 93 ++- src/services/mod.rs | 5 +- src/services/pack.rs | 4 +- src/services/profile.rs | 6 +- src/services/upgrades.rs | 18 +- tests/integration_test.rs | 1000 +++++++++++++++++++++++++++------ 34 files changed, 1633 insertions(+), 416 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 src/services/inventory.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f20d445 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +target/ +**/target/ +*.db +*.db-shm +*.db-wal +.env +.env.local +Dockerfile +.dockerignore +.git +.gitignore +openfut.db diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2672cb1..baec161 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,4 +90,4 @@ migrations/ SQLite migration SQL files - **No copyrighted assets.** All card data in `data/` must be original. - **No real EA services.** Do not hardcode or reverse-engineer EA endpoints. - **Game-independent core.** `openfut-core` must stay game-agnostic; - FIFA-specific logic belongs in `openfut-bridge`. + FIFA-specific logic belongs in the emulation layer (`fifa17-recon/`). diff --git a/Cargo.toml b/Cargo.toml index b41ad37..c70fef2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" authors = ["OpenFUT Contributors"] description = "Offline Ultimate Team backend — game-independent core" license = "MIT" -repository = "https://github.com/openfut/openfut-core" +repository = "https://git.aleshym.co/funman300/OpenFUT-Core.git" [lib] name = "openfut_core" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c423a05 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# syntax=docker/dockerfile:1 +# ---- OpenFUT Core: offline FUT backend (Axum + bundled SQLite) ---- +# Multi-stage: build with the Rust toolchain, ship a slim Debian runtime. +# rustls + bundled SQLite mean no OpenSSL/system-sqlite at runtime. + +FROM rust:1-bookworm AS builder +WORKDIR /build + +# Cache dependency compilation: copy manifests first, build a stub, then the +# real sources. sqlx migrations are compiled in via sqlx::migrate!, so the +# migrations/ dir must be present at build time. +COPY Cargo.toml Cargo.lock* ./ +RUN mkdir -p src \ + && echo 'fn main() {}' > src/main.rs \ + && echo '' > src/lib.rs \ + && cargo build --release --bin openfut-core 2>/dev/null || true +RUN rm -rf src + +COPY src ./src +COPY migrations ./migrations +# Touch so cargo rebuilds against the real sources rather than the stub. +RUN touch src/main.rs src/lib.rs \ + && cargo build --release --bin openfut-core + +# ---- Runtime ---- +FROM debian:bookworm-slim AS runtime +# curl is used by the compose/Docker healthcheck to hit /health. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +# Run unprivileged. +RUN useradd --system --uid 10001 --create-home --home-dir /app openfut +WORKDIR /app + +COPY --from=builder /build/target/release/openfut-core /usr/local/bin/openfut-core +# data/ is read at runtime from DATA_DIR (moddable JSON content) — bundle it. +COPY --chown=openfut:openfut data ./data + +# Persist the SQLite database on a named volume. +RUN mkdir -p /app/db && chown openfut:openfut /app/db + +USER openfut + +ENV LISTEN_ADDR=0.0.0.0:8080 \ + DATABASE_URL=sqlite:///app/db/openfut.db \ + DATA_DIR=/app/data \ + RUST_LOG=openfut_core=info,tower_http=info + +EXPOSE 8080 +VOLUME ["/app/db"] + +HEALTHCHECK --interval=15s --timeout=4s --start-period=10s --retries=5 \ + CMD curl -fsS http://127.0.0.1:8080/health || exit 1 + +ENTRYPOINT ["openfut-core"] diff --git a/README.md b/README.md index 7904754..89fb5fa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ **Offline Ultimate Team backend — game-independent.** -OpenFUT Core is the heart of the OpenFUT project: a fully offline, single-player FUT-style backend written in Rust. It is deliberately decoupled from any specific game, though it is designed to power a FIFA 23 offline experience. +OpenFUT Core is the heart of the OpenFUT project: a fully offline, single-player FUT-style backend +written in Rust. It is deliberately decoupled from any specific game; it is designed to power the +offline FUT economy behind the project's FIFA 17 emulation layer (and, eventually, a FIFA 23 port). +The emulation layer and Core are **not yet wired together** — see the OpenFUT Vault +(`../OpenFUT-Vault/`) for canonical project state. --- diff --git a/src/app.rs b/src/app.rs index e78a4a0..056323f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -172,7 +172,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/squads/:squad_id", get(routes::squad::get_squad_by_id)) .route("/squads/:squad_id", delete(routes::squad::delete_squad)) .route("/objectives", get(routes::objectives::get_objectives)) - .route("/objectives/:objective_id", get(routes::objectives::get_objective)) + .route( + "/objectives/:objective_id", + get(routes::objectives::get_objective), + ) .route( "/objectives/claim", post(routes::objectives::post_claim_objective), @@ -190,7 +193,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/market", get(routes::market::get_market)) .route("/market/buy", post(routes::market::post_market_buy)) .route("/market/sell", post(routes::market::post_market_sell)) - .route("/market/trade-history", get(routes::market::get_trade_history)) + .route( + "/market/trade-history", + get(routes::market::get_trade_history), + ) .route("/market/refresh", post(routes::market::post_market_refresh)) .route("/market/my-listings", get(routes::market::get_my_listings)) .route( @@ -205,15 +211,36 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/settings", get(routes::settings::get_settings)) .route("/settings", put(routes::settings::put_settings)) .route("/division", get(routes::division::get_division)) - .route("/division/history", get(routes::division::get_division_history)) - .route("/division/leaderboard", get(routes::division::get_division_leaderboard)) + .route( + "/division/history", + get(routes::division::get_division_history), + ) + .route( + "/division/leaderboard", + get(routes::division::get_division_leaderboard), + ) .route("/achievements", get(routes::achievements::get_achievements)) - .route("/notifications", get(routes::notifications::get_notifications)) - .route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read)) - .route("/notifications/:id/read", patch(routes::notifications::mark_notification_read)) + .route( + "/notifications", + get(routes::notifications::get_notifications), + ) + .route( + "/notifications/read-all", + post(routes::notifications::mark_all_notifications_read), + ) + .route( + "/notifications/:id/read", + patch(routes::notifications::mark_notification_read), + ) .route("/fut-champs", get(routes::fut_champs::get_fut_champs)) - .route("/fut-champs/start", post(routes::fut_champs::post_start_fut_champs)) - .route("/fut-champs/history", get(routes::fut_champs::get_champs_history)) + .route( + "/fut-champs/start", + post(routes::fut_champs::post_start_fut_champs), + ) + .route( + "/fut-champs/history", + get(routes::fut_champs::get_champs_history), + ) .route( "/fut-champs/:session_id/result", post(routes::fut_champs::post_champs_result), diff --git a/src/models/card.rs b/src/models/card.rs index 4422b3f..06dfdd7 100644 --- a/src/models/card.rs +++ b/src/models/card.rs @@ -13,6 +13,33 @@ pub enum Rarity { Icon, } +/// Visual card quality tier (gold/silver/bronze). +/// +/// Game-independent semantic dimension, kept distinct from `Rarity` (which also +/// carries special-card programs like TOTW/Hero/Icon). Derived from a card's base +/// overall using FIFA 17's proven tier convention: gold >= 75, silver >= 65, +/// otherwise bronze (evidence: `fifa17-recon/tools/fut_cards.py` `tier()`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Quality { + Bronze, + Silver, + Gold, +} + +impl Quality { + /// Classify a base overall rating into its quality tier. + pub fn from_overall(overall: u8) -> Self { + if overall >= 75 { + Quality::Gold + } else if overall >= 65 { + Quality::Silver + } else { + Quality::Bronze + } + } +} + /// A card definition loaded from JSON data files. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CardDefinition { diff --git a/src/models/mod.rs b/src/models/mod.rs index 45069a7..e2217cb 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,18 +1,18 @@ pub mod achievement; pub mod card; pub mod chemistry_style; -pub mod notification; pub mod club; pub mod draft; -pub mod fut_champs; pub mod event; -pub mod season; +pub mod fut_champs; pub mod market; pub mod match_result; +pub mod notification; pub mod objective; pub mod pack; pub mod profile; pub mod reward; pub mod sbc; +pub mod season; pub mod squad; pub mod statistics; diff --git a/src/models/profile.rs b/src/models/profile.rs index 300ea5f..32d29aa 100644 --- a/src/models/profile.rs +++ b/src/models/profile.rs @@ -34,16 +34,16 @@ pub struct CreateProfileRequest { /// XP required to reach each level (cumulative total from level 1). /// Level 1 starts at 0 XP. Level 2 needs 500 total XP, etc. pub const XP_THRESHOLDS: &[i64] = &[ - 0, // level 1 - 500, // level 2 - 1200, // level 3 - 2000, // level 4 - 3000, // level 5 - 4200, // level 6 - 5600, // level 7 - 7200, // level 8 - 9000, // level 9 - 11000, // level 10 + 0, // level 1 + 500, // level 2 + 1200, // level 3 + 2000, // level 4 + 3000, // level 5 + 4200, // level 6 + 5600, // level 7 + 7200, // level 8 + 9000, // level 9 + 11000, // level 10 ]; /// Compute the level for a given cumulative XP total. @@ -71,7 +71,7 @@ pub fn coins_for_level(new_level: i64) -> i64 { /// Pack granted at milestone levels (5, 10, 15, 20, …). pub fn pack_for_level(new_level: i64) -> Option<&'static str> { match new_level { - 5 => Some("bronze_pack"), + 5 => Some("bronze_pack"), 10 => Some("silver_pack"), 15 => Some("gold_pack"), 20 => Some("rare_gold_pack"), diff --git a/src/routes/achievements.rs b/src/routes/achievements.rs index c48093a..9a76b8c 100644 --- a/src/routes/achievements.rs +++ b/src/routes/achievements.rs @@ -10,9 +10,13 @@ use crate::{ pub async fn get_achievements(State(state): State) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; - let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await; + let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id) + .await; let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?; - let earned = achievements.iter().filter(|a| a["unlocked"].as_bool().unwrap_or(false)).count(); + let earned = achievements + .iter() + .filter(|a| a["unlocked"].as_bool().unwrap_or(false)) + .count(); Ok(Json(json!({ "achievements": achievements, "earned": earned, diff --git a/src/routes/cards.rs b/src/routes/cards.rs index adc2a27..fdbcdc4 100644 --- a/src/routes/cards.rs +++ b/src/routes/cards.rs @@ -9,16 +9,26 @@ use crate::{ app::AppState, error::{AppError, AppResult}, models::card::OwnedCard, - services::{club as club_svc, profile as profile_svc}, + services::{ + club as club_svc, + inventory::{self, OwnedItemQuery, OwnedItemView}, + profile as profile_svc, + }, }; /// Quick-sell value for a card based on overall rating. fn quick_sell_coins(overall: u8) -> i64 { - if overall >= 85 { 1500 } - else if overall >= 80 { 900 } - else if overall >= 75 { 600 } - else if overall >= 65 { 300 } - else { 150 } + if overall >= 85 { + 1500 + } else if overall >= 80 { + 900 + } else if overall >= 75 { + 600 + } else if overall >= 65 { + 300 + } else { + 150 + } } #[derive(Debug, Deserialize)] @@ -90,10 +100,15 @@ pub async fn get_cards( cards.truncate(limit); } - Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() }))) + Ok(Json( + json!({ "cards": cards, "total": total, "returned": cards.len() }), + )) } -pub async fn get_collection(State(state): State) -> AppResult> { +pub async fn get_collection( + State(state): State, + Query(query): Query, +) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; @@ -104,14 +119,13 @@ pub async fn get_collection(State(state): State) -> AppResult = owned + let views: Vec = owned .iter() .filter_map(|o| { state.card_db.get(&o.card_id).map(|def| { let effective_overall = def.overall as i64 + o.training_bonus; - let effective_position = - o.position_override.as_deref().unwrap_or(&def.position); - json!({ + let effective_position = o.position_override.as_deref().unwrap_or(&def.position); + let body = json!({ "owned_card_id": o.id, "is_loan": o.is_loan, "loan_matches_remaining": o.loan_matches_remaining, @@ -122,14 +136,30 @@ pub async fn get_collection(State(state): State) -> AppResult) -> AppResult) -> AppResult) -> AppResul let mut rng = rand::rngs::SmallRng::seed_from_u64(seed); const NPC_NAMES: &[&str] = &[ - "Riverside FC", "City Athletic", "County United", "Valley Rangers", - "Harbor Town FC", "Mountside City", "Lakewood Athletic", "Eastbrook United", - "Westfield Rovers", "Northgate FC", "Southport Athletic", "Ironbridge City", - "Milldale United", "Hillcrest Rangers", "Bayside FC", "Thornfield Athletic", - "Greenhill United", "Coldwater City", "Redbury Rangers", "Ashdown FC", + "Riverside FC", + "City Athletic", + "County United", + "Valley Rangers", + "Harbor Town FC", + "Mountside City", + "Lakewood Athletic", + "Eastbrook United", + "Westfield Rovers", + "Northgate FC", + "Southport Athletic", + "Ironbridge City", + "Milldale United", + "Hillcrest Rangers", + "Bayside FC", + "Thornfield Athletic", + "Greenhill United", + "Coldwater City", + "Redbury Rangers", + "Ashdown FC", ]; // Pick 9 NPC names without repetition using the seeded RNG @@ -74,10 +89,13 @@ pub async fn get_division_leaderboard(State(state): State) -> AppResul // Quality bias: index 0-2 = stronger, 6-8 = weaker let quality: f64 = 1.0 - (idx as f64 / 8.0); // 1.0 → 0.0 let expected_win_rate = 0.2 + quality * 0.6; // 0.2–0.8 - let wins = (npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::() * 0.4)) as i64; - let losses = (npc_matches as f64 * (1.0 - expected_win_rate) * (0.8 + rng.gen::() * 0.4)) as i64; - let draws = (npc_matches - wins - losses).max(0); - let pts = wins * 3 + draws; + let wins = + (npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::() * 0.4)) as i64; + let losses = (npc_matches as f64 + * (1.0 - expected_win_rate) + * (0.8 + rng.gen::() * 0.4)) as i64; + let draws = (npc_matches - wins - losses).max(0); + let pts = wins * 3 + draws; json!({ "club_name": name, "wins": wins, diff --git a/src/routes/draft.rs b/src/routes/draft.rs index 1c058c7..2d24810 100644 --- a/src/routes/draft.rs +++ b/src/routes/draft.rs @@ -43,7 +43,8 @@ pub async fn post_draft_start( ) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool).await?; let difficulty = query.difficulty.as_deref().unwrap_or("professional"); - let session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?; + let session = + draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?; Ok(Json(session)) } @@ -53,7 +54,8 @@ pub async fn get_draft_session( Path(session_id): Path, ) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool).await?; - let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?; + let session = + draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?; Ok(Json(session)) } diff --git a/src/routes/fut_champs.rs b/src/routes/fut_champs.rs index fcf39f1..0200518 100644 --- a/src/routes/fut_champs.rs +++ b/src/routes/fut_champs.rs @@ -8,7 +8,9 @@ use serde_json::{json, Value}; use crate::{ app::AppState, error::AppResult, - services::{club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc}, + services::{ + club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc, + }, }; /// GET /fut-champs — current active session, or null if none. @@ -111,13 +113,9 @@ pub async fn post_claim_rivals_reward(State(state): State) -> AppResul // Ensure a season row exists season_svc::get_or_create(&state.pool, &profile.id).await?; - let result = champs_svc::claim_rivals_reward( - &state.pool, - &profile.id, - &club.id, - &state.pack_defs, - ) - .await?; + let result = + champs_svc::claim_rivals_reward(&state.pool, &profile.id, &club.id, &state.pack_defs) + .await?; Ok(Json(result)) } diff --git a/src/routes/market.rs b/src/routes/market.rs index c6bd966..0dbcb00 100644 --- a/src/routes/market.rs +++ b/src/routes/market.rs @@ -19,7 +19,6 @@ pub async fn get_trade_history(State(state): State) -> AppResult, @@ -86,8 +85,11 @@ pub async fn post_market_refresh(State(state): State) -> AppResult) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; - let listings = market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?; - Ok(Json(json!({ "listings": listings, "total": listings.len() }))) + let listings = + market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?; + Ok(Json( + json!({ "listings": listings, "total": listings.len() }), + )) } /// Cancel a player-posted listing and return the card to the collection. diff --git a/src/routes/matches.rs b/src/routes/matches.rs index e266670..e7f30d0 100644 --- a/src/routes/matches.rs +++ b/src/routes/matches.rs @@ -68,9 +68,15 @@ pub async fn post_match_result( let profile = profile_svc::get_active_profile(&state.pool).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; - let result = - match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs, &state.achievement_defs) - .await?; + let result = match_service::process_match( + &state.pool, + &profile.id, + &club.id, + &req, + &state.obj_defs, + &state.achievement_defs, + ) + .await?; Ok(Json(result)) } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 10e87c3..390c3eb 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -4,8 +4,8 @@ pub mod cards; pub mod club; pub mod division; pub mod draft; -pub mod fut_champs; pub mod events; +pub mod fut_champs; pub mod health; pub mod market; pub mod matches; diff --git a/src/routes/notifications.rs b/src/routes/notifications.rs index bfe6752..f847b08 100644 --- a/src/routes/notifications.rs +++ b/src/routes/notifications.rs @@ -7,7 +7,9 @@ use serde_json::{json, Value}; use crate::{ app::AppState, error::{AppError, AppResult}, - services::{club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc}, + services::{ + club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc, + }, }; /// GET /notifications @@ -92,14 +94,16 @@ pub async fn get_notifications(State(state): State) -> AppResult = persistent .iter() - .map(|n| json!({ - "id": n.id, - "type": n.kind, - "title": n.title, - "body": n.body, - "is_read": n.is_read, - "created_at": n.created_at, - })) + .map(|n| { + json!({ + "id": n.id, + "type": n.kind, + "title": n.title, + "body": n.body, + "is_read": n.is_read, + "created_at": n.created_at, + }) + }) .chain(dynamic.iter().cloned()) .collect(); @@ -124,9 +128,7 @@ pub async fn mark_notification_read( } /// POST /notifications/read-all -pub async fn mark_all_notifications_read( - State(state): State, -) -> AppResult> { +pub async fn mark_all_notifications_read(State(state): State) -> AppResult> { let count = notif_svc::mark_all_read(&state.pool).await?; Ok(Json(json!({ "marked_read": count }))) } diff --git a/src/routes/packs.rs b/src/routes/packs.rs index 002168d..2940956 100644 --- a/src/routes/packs.rs +++ b/src/routes/packs.rs @@ -140,8 +140,12 @@ pub async fn post_open_pack( objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1) .await?; let _ = crate::services::achievement::check_and_unlock( - &state.pool, &state.achievement_defs, &profile.id, &club.id, - ).await; + &state.pool, + &state.achievement_defs, + &profile.id, + &club.id, + ) + .await; Ok(Json(result)) } diff --git a/src/routes/sbc.rs b/src/routes/sbc.rs index 2690246..b686116 100644 --- a/src/routes/sbc.rs +++ b/src/routes/sbc.rs @@ -47,8 +47,12 @@ pub async fn post_sbc_submit( if result.passed { let _ = crate::services::achievement::check_and_unlock( - &state.pool, &state.achievement_defs, &profile.id, &club.id, - ).await; + &state.pool, + &state.achievement_defs, + &profile.id, + &club.id, + ) + .await; } Ok(Json(result)) diff --git a/src/routes/upgrades.rs b/src/routes/upgrades.rs index a77f33a..4553ede 100644 --- a/src/routes/upgrades.rs +++ b/src/routes/upgrades.rs @@ -69,13 +69,8 @@ pub async fn post_change_position( 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 = upgrade_svc::change_position( - &state.pool, - &club.id, - &owned_card_id, - &req.position, - ) - .await?; + let updated = + upgrade_svc::change_position(&state.pool, &club.id, &owned_card_id, &req.position).await?; let card_def = state.card_db.get(&updated.card_id); diff --git a/src/services/achievement.rs b/src/services/achievement.rs index 7690e4d..5a80af6 100644 --- a/src/services/achievement.rs +++ b/src/services/achievement.rs @@ -29,79 +29,83 @@ pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result AppResult { - let v: i64 = match trigger { - "matches_played" => sqlx::query_scalar( - "SELECT matches_played FROM statistics WHERE profile_id = ?", - ) - .bind(profile_id) - .fetch_optional(pool) - .await? - .unwrap_or(0), +async fn metric_value( + pool: &Pool, + profile_id: &str, + club_id: &str, + trigger: &str, +) -> AppResult { + let v: i64 = + match trigger { + "matches_played" => { + sqlx::query_scalar("SELECT matches_played FROM statistics WHERE profile_id = ?") + .bind(profile_id) + .fetch_optional(pool) + .await? + .unwrap_or(0) + } - "matches_won" => sqlx::query_scalar( - "SELECT matches_won FROM statistics WHERE profile_id = ?", - ) - .bind(profile_id) - .fetch_optional(pool) - .await? - .unwrap_or(0), + "matches_won" => { + sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?") + .bind(profile_id) + .fetch_optional(pool) + .await? + .unwrap_or(0) + } - "goals_scored" => sqlx::query_scalar( - "SELECT goals_scored FROM statistics WHERE profile_id = ?", - ) - .bind(profile_id) - .fetch_optional(pool) - .await? - .unwrap_or(0), + "goals_scored" => { + sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?") + .bind(profile_id) + .fetch_optional(pool) + .await? + .unwrap_or(0) + } - "packs_opened" => sqlx::query_scalar( - "SELECT packs_opened FROM statistics WHERE profile_id = ?", - ) - .bind(profile_id) - .fetch_optional(pool) - .await? - .unwrap_or(0), + "packs_opened" => { + sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?") + .bind(profile_id) + .fetch_optional(pool) + .await? + .unwrap_or(0) + } - "sbcs_completed" => sqlx::query_scalar( - "SELECT sbcs_completed FROM statistics WHERE profile_id = ?", - ) - .bind(profile_id) - .fetch_optional(pool) - .await? - .unwrap_or(0), + "sbcs_completed" => { + sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?") + .bind(profile_id) + .fetch_optional(pool) + .await? + .unwrap_or(0) + } - "cards_owned" => sqlx::query_scalar( - "SELECT COUNT(*) FROM owned_cards WHERE club_id = ?", - ) - .bind(club_id) - .fetch_one(pool) - .await?, + "cards_owned" => { + sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?") + .bind(club_id) + .fetch_one(pool) + .await? + } - "level" => sqlx::query_scalar( - "SELECT level FROM profiles WHERE id = ?", - ) - .bind(profile_id) - .fetch_optional(pool) - .await? - .unwrap_or(1), + "level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?") + .bind(profile_id) + .fetch_optional(pool) + .await? + .unwrap_or(1), - "objectives_completed" => sqlx::query_scalar( - "SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1", - ) - .bind(profile_id) - .fetch_one(pool) - .await?, + "objectives_completed" => sqlx::query_scalar( + "SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1", + ) + .bind(profile_id) + .fetch_one(pool) + .await?, - "drafts_completed" => sqlx::query_scalar( - "SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'", - ) - .bind(profile_id) - .fetch_one(pool) - .await?, + "drafts_completed" => sqlx::query_scalar( + "SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'", + ) + .bind(profile_id) + .fetch_one(pool) + .await?, - _ => 0, - }; + _ => 0, + }; Ok(v) } @@ -165,11 +169,14 @@ pub async fn check_and_unlock( club_svc::add_coins(pool, club_id, def.reward_coins).await?; } - let body = format!( - "{} Reward: {} coins.", - def.description, def.reward_coins - ); - let _ = notification::create(pool, "achievement", &format!("Achievement: {}", def.title), &body).await; + let body = format!("{} Reward: {} coins.", def.description, def.reward_coins); + let _ = notification::create( + pool, + "achievement", + &format!("Achievement: {}", def.title), + &body, + ) + .await; newly_unlocked.push(def.clone()); } diff --git a/src/services/checkin.rs b/src/services/checkin.rs index dbeacad..61bbdc4 100644 --- a/src/services/checkin.rs +++ b/src/services/checkin.rs @@ -1,4 +1,8 @@ -use crate::{db::Pool, error::AppResult, services::{club, pack}}; +use crate::{ + db::Pool, + error::AppResult, + services::{club, pack}, +}; use uuid::Uuid; const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000]; @@ -8,7 +12,7 @@ const STREAK_7_PACK: &str = "silver_pack"; #[derive(Debug, serde::Serialize)] pub struct CheckinStatus { pub available: bool, - pub streak_day: i64, // current streak (1–7 cycle, 0 if never checked in) + pub streak_day: i64, // current streak (1–7 cycle, 0 if never checked in) pub next_reward_coins: i64, pub next_reward_pack: Option<&'static str>, pub last_checked_in: Option, @@ -56,11 +60,7 @@ pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult AppResult { +pub async fn claim(pool: &Pool, profile_id: &str, club_id: &str) -> AppResult { let row: Option<(i64, String)> = sqlx::query_as( "SELECT streak_day, checked_in_at FROM daily_checkins \ WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1", @@ -82,7 +82,10 @@ pub async fn claim( } } - let last_streak = row.as_ref().map(|(s, last_at)| compute_next_streak(*s, last_at)).unwrap_or(1); + let last_streak = row + .as_ref() + .map(|(s, last_at)| compute_next_streak(*s, last_at)) + .unwrap_or(1); let idx = ((last_streak - 1) % 7) as usize; let coins = STREAK_COINS[idx]; let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None }; diff --git a/src/services/draft.rs b/src/services/draft.rs index e3e6f0a..34a5120 100644 --- a/src/services/draft.rs +++ b/src/services/draft.rs @@ -179,7 +179,14 @@ pub async fn pick_card( let (new_candidates, new_status, reward_coins, reward_pack_id, squad_rating, completed_at) = if all_filled { let (coins, pack, avg) = compute_reward(card_db, &picks); - (None, "completed".to_string(), coins, pack, avg, Some(chrono::Utc::now().to_rfc3339())) + ( + None, + "completed".to_string(), + coins, + pack, + avg, + Some(chrono::Utc::now().to_rfc3339()), + ) } else { let min_overall = difficulty_min_overall(&session.difficulty); let next_pos = &pick_order[next_index]; @@ -284,8 +291,7 @@ async fn fetch_session(pool: &Pool, profile_id: &str, session_id: &str) -> AppRe } fn render_session(session: &DraftSession, card_db: &CardDb) -> serde_json::Value { - let pick_order: Vec = - serde_json::from_str(&session.pick_order).unwrap_or_default(); + let pick_order: Vec = serde_json::from_str(&session.pick_order).unwrap_or_default(); let picks: Vec = serde_json::from_str(&session.picks).unwrap_or_default(); let candidates: Vec = session .current_candidates diff --git a/src/services/fut_champs.rs b/src/services/fut_champs.rs index c834d3f..3b0633f 100644 --- a/src/services/fut_champs.rs +++ b/src/services/fut_champs.rs @@ -26,7 +26,11 @@ pub async fn get_active_session( .map_err(Into::into) } -pub async fn get_session(pool: &Pool, session_id: &str, profile_id: &str) -> AppResult { +pub async fn get_session( + pool: &Pool, + session_id: &str, + profile_id: &str, +) -> AppResult { sqlx::query_as::<_, FutChampsSession>(&format!( "{SESSION_SELECT} WHERE id = ? AND profile_id = ?" )) diff --git a/src/services/inventory.rs b/src/services/inventory.rs new file mode 100644 index 0000000..546bd88 --- /dev/null +++ b/src/services/inventory.rs @@ -0,0 +1,291 @@ +//! Game-independent owned-inventory query: semantic filtering, deterministic +//! ordering, and offset/limit pagination over a club's owned items. +//! +//! This layer is deliberately free of any game-specific concepts. It never sees +//! raw FIFA (or any other game's) numeric entity ids — a game adapter is +//! responsible for translating its wire query into the *semantic* values here +//! (quality tier, entity **names**, semantic offset/limit). The canonical +//! ordering is imposed by Core so pagination is correct and repeatable +//! regardless of what (if any) sort the client requests; see the module tests +//! and `docs`/vault for why the client's `sort` key is treated as UNKNOWN. +//! +//! Order of operations is load-bearing: **filter → order → paginate**. Paginating +//! before filtering is the production bug this replaces (a client that pages an +//! unfiltered/unsorted set re-reads page one forever and amplifies requests). + +use serde::Deserialize; + +use crate::models::card::Quality; + +/// Semantic owned-inventory query. All values are game-independent: a quality +/// tier, entity **names** (not ids), and semantic offset/limit. Every filter is +/// optional; combined filters are ANDed. Absent field = no constraint. +#[derive(Debug, Default, Deserialize)] +pub struct OwnedItemQuery { + /// Quality tier (gold/silver/bronze). Serialized lowercase. + #[serde(default)] + pub quality: Option, + /// Playing position, e.g. "ST" (matched case-insensitively). + #[serde(default)] + pub position: Option, + /// Nation name, e.g. "Argentina" (matched case-insensitively). + #[serde(default)] + pub nation: Option, + /// League name, e.g. "Premier League" (matched case-insensitively). + #[serde(default)] + pub league: Option, + /// Club name, e.g. "Chelsea" (matched case-insensitively). + #[serde(default)] + pub club: Option, + /// Number of leading items to skip after filtering + ordering. + #[serde(default)] + pub offset: Option, + /// Maximum number of items to return in the page. + #[serde(default)] + pub limit: Option, +} + +/// One owned item projected to the attributes needed for querying, plus the +/// response body to hand back verbatim once it survives the filter+page. +pub struct OwnedItemView { + pub owned_card_id: String, + /// Base card overall (drives quality tier). + pub base_overall: u8, + /// Effective overall (base + training bonus); drives ordering. + pub effective_overall: i64, + pub position: String, + pub nation: String, + pub league: String, + pub club: String, + pub body: serde_json::Value, +} + +impl OwnedItemView { + fn quality(&self) -> Quality { + Quality::from_overall(self.base_overall) + } +} + +/// Result of applying a query: the requested page plus the count of items that +/// matched the filter **before** pagination (what a client needs to page). +pub struct QueryPage { + pub items: Vec, + pub total: usize, + pub offset: usize, + pub limit: Option, +} + +/// Does an item satisfy every present filter (AND semantics)? +fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool { + let quality_ok = q.quality.map(|want| item.quality() == want).unwrap_or(true); + let pos_ok = q + .position + .as_ref() + .map(|p| item.position.eq_ignore_ascii_case(p)) + .unwrap_or(true); + let nation_ok = q + .nation + .as_ref() + .map(|n| item.nation.eq_ignore_ascii_case(n)) + .unwrap_or(true); + let league_ok = q + .league + .as_ref() + .map(|l| item.league.eq_ignore_ascii_case(l)) + .unwrap_or(true); + let club_ok = q + .club + .as_ref() + .map(|c| item.club.eq_ignore_ascii_case(c)) + .unwrap_or(true); + quality_ok && pos_ok && nation_ok && league_ok && club_ok +} + +/// Apply the query: filter (AND) → deterministic order → paginate. +/// +/// Ordering is `(effective_overall DESC, owned_card_id ASC)` — a total order, so +/// pages never overlap or repeat. `offset`/`limit` are clamped to sane +/// non-negative values (the wire never sends negatives; clamping keeps a +/// malformed request from panicking). +pub fn apply_query(mut items: Vec, q: &OwnedItemQuery) -> QueryPage { + // 1. filter + items.retain(|it| matches(it, q)); + let total = items.len(); + + // 2. deterministic total order (independent of input/DB order) + items.sort_by(|a, b| { + b.effective_overall + .cmp(&a.effective_overall) + .then_with(|| a.owned_card_id.cmp(&b.owned_card_id)) + }); + + // 3. paginate + let offset = q.offset.unwrap_or(0).max(0) as usize; + let limit = q.limit.map(|l| l.max(0) as usize); + let page: Vec = items + .into_iter() + .skip(offset) + .take(limit.unwrap_or(usize::MAX)) + .map(|it| it.body) + .collect(); + + QueryPage { + items: page, + total, + offset, + limit, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn view( + id: &str, + overall: u8, + position: &str, + nation: &str, + league: &str, + club: &str, + ) -> OwnedItemView { + OwnedItemView { + owned_card_id: id.to_string(), + base_overall: overall, + effective_overall: overall as i64, + position: position.to_string(), + nation: nation.to_string(), + league: league.to_string(), + club: club.to_string(), + body: json!({ "owned_card_id": id, "overall": overall }), + } + } + + fn ids(page: &QueryPage) -> Vec { + page.items + .iter() + .map(|b| b["owned_card_id"].as_str().unwrap().to_string()) + .collect() + } + + fn fixture() -> Vec { + vec![ + view("a", 84, "ST", "England", "Premier League", "Northgate"), + view("b", 86, "CDM", "Ghana", "Premier League", "Chelsea"), + view("c", 72, "ST", "Brazil", "Brasileirao", "Santos"), + view("d", 60, "CM", "Italy", "Serie B", "Modena"), + view("e", 89, "LW", "Argentina", "Primera Division", "Boca"), + ] + } + + #[test] + fn no_filter_returns_all_in_overall_desc_order() { + let p = apply_query(fixture(), &OwnedItemQuery::default()); + assert_eq!(p.total, 5); + assert_eq!(ids(&p), ["e", "b", "a", "c", "d"]); // 89,86,84,72,60 + } + + #[test] + fn quality_gold_selects_overall_75_plus() { + let q = OwnedItemQuery { + quality: Some(Quality::Gold), + ..Default::default() + }; + let p = apply_query(fixture(), &q); + assert_eq!(ids(&p), ["e", "b", "a"]); + } + + #[test] + fn filters_are_anded() { + let q = OwnedItemQuery { + league: Some("Premier League".into()), + position: Some("ST".into()), + ..Default::default() + }; + let p = apply_query(fixture(), &q); + assert_eq!(ids(&p), ["a"]); // only the PL ST, not the PL CDM + } + + #[test] + fn case_insensitive_name_match() { + let q = OwnedItemQuery { + club: Some("chelsea".into()), + ..Default::default() + }; + let p = apply_query(fixture(), &q); + assert_eq!(ids(&p), ["b"]); + } + + #[test] + fn empty_when_nothing_matches() { + let q = OwnedItemQuery { + nation: Some("Argentina".into()), + club: Some("Chelsea".into()), + ..Default::default() + }; + let p = apply_query(fixture(), &q); + assert_eq!(p.total, 0); + assert!(p.items.is_empty()); + } + + #[test] + fn filter_runs_before_pagination() { + // Gold set is [e,b,a]; page (offset 1, limit 1) over the FILTERED set is [b]. + // If pagination ran first, offset/limit would slice the full 5-item set. + let q = OwnedItemQuery { + quality: Some(Quality::Gold), + offset: Some(1), + limit: Some(1), + ..Default::default() + }; + let p = apply_query(fixture(), &q); + assert_eq!(p.total, 3, "total is the filtered count, not the page size"); + assert_eq!(ids(&p), ["b"]); + } + + #[test] + fn pages_do_not_overlap_and_advance() { + let page = |off| { + apply_query( + fixture(), + &OwnedItemQuery { + offset: Some(off), + limit: Some(2), + ..Default::default() + }, + ) + }; + let p0 = page(0); + let p1 = page(2); + assert_eq!(ids(&p0), ["e", "b"]); + assert_eq!(ids(&p1), ["a", "c"]); + // start advancing must NOT re-serve page one + assert_ne!(ids(&p0), ids(&p1)); + assert_eq!(p0.total, 5); + assert_eq!(p1.total, 5); + } + + #[test] + fn offset_past_end_is_empty_not_wrapped() { + let q = OwnedItemQuery { + offset: Some(100), + limit: Some(11), + ..Default::default() + }; + let p = apply_query(fixture(), &q); + assert!(p.items.is_empty()); + assert_eq!(p.total, 5); + } + + #[test] + fn ordering_is_stable_on_overall_ties() { + let items = vec![ + view("z", 80, "ST", "N", "L", "C"), + view("a", 80, "ST", "N", "L", "C"), + view("m", 80, "ST", "N", "L", "C"), + ]; + let p = apply_query(items, &OwnedItemQuery::default()); + assert_eq!(ids(&p), ["a", "m", "z"]); // tie broken by owned id asc + } +} diff --git a/src/services/market.rs b/src/services/market.rs index 4866ce7..769add4 100644 --- a/src/services/market.rs +++ b/src/services/market.rs @@ -285,9 +285,10 @@ pub async fn get_listings_by_seller( let with_cards = listings .into_iter() .filter_map(|l| { - card_db - .get(&l.card_id) - .map(|card| MarketListingWithCard { listing: l, card: card.clone() }) + card_db.get(&l.card_id).map(|card| MarketListingWithCard { + listing: l, + card: card.clone(), + }) }) .collect(); Ok(with_cards) @@ -303,7 +304,9 @@ pub async fn cancel_listing(pool: &Pool, club_id: &str, listing_id: &str) -> App .bind(club_id) .fetch_optional(pool) .await? - .ok_or_else(|| AppError::NotFound(format!("listing '{listing_id}' not found or already sold")))?; + .ok_or_else(|| { + AppError::NotFound(format!("listing '{listing_id}' not found or already sold")) + })?; sqlx::query("DELETE FROM market_listings WHERE id = ?") .bind(listing_id) diff --git a/src/services/match_service.rs b/src/services/match_service.rs index c1b0ce1..2bec401 100644 --- a/src/services/match_service.rs +++ b/src/services/match_service.rs @@ -7,13 +7,14 @@ use crate::{ match_result::{Match, MatchRewardResult, SubmitMatchRequest}, objective::ObjectiveDefinition, }, - services::{achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc, statistics}, + services::{ + achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc, + statistics, + }, }; use rand::{seq::SliceRandom, Rng}; -const FORMATIONS: &[&str] = &[ - "4-3-3", "4-4-2", "4-2-3-1", "4-1-2-1-2", "3-5-2", "5-3-2", -]; +const FORMATIONS: &[&str] = &["4-3-3", "4-4-2", "4-2-3-1", "4-1-2-1-2", "3-5-2", "5-3-2"]; /// Generate a random AI opponent squad for Squad Battles. /// @@ -27,23 +28,53 @@ pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Valu let (min_overall, names): (u8, &[&str]) = match difficulty { "professional" => ( 70, - &["Athletic CF", "City Wanderers", "The Rovers", "United Select", "Blue Stars FC"], + &[ + "Athletic CF", + "City Wanderers", + "The Rovers", + "United Select", + "Blue Stars FC", + ], ), "world_class" => ( 78, - &["Elite Stars FC", "Champions Select", "Premier XI", "Galaxy United", "Titan FC"], + &[ + "Elite Stars FC", + "Champions Select", + "Premier XI", + "Galaxy United", + "Titan FC", + ], ), "legendary" => ( 85, - &["Legends United", "Ultimate XI", "Gold Standard FC", "The Icons", "Heritage FC"], + &[ + "Legends United", + "Ultimate XI", + "Gold Standard FC", + "The Icons", + "Heritage FC", + ], ), "ultimate" => ( 90, - &["Apex XI", "Pantheon FC", "Gods of FUT", "Invincibles Select", "Eternal XI"], + &[ + "Apex XI", + "Pantheon FC", + "Gods of FUT", + "Invincibles Select", + "Eternal XI", + ], ), _ => ( 55, - &["Amateur Town FC", "Sunday League XI", "Park FC", "Village Stars", "Reserve XI"], + &[ + "Amateur Town FC", + "Sunday League XI", + "Park FC", + "Village Stars", + "Reserve XI", + ], ), }; @@ -59,7 +90,11 @@ pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Valu let mut indices: Vec = (0..pool.len()).collect(); indices.shuffle(&mut rng); - let cards: Vec<_> = indices.into_iter().take(11).map(|i| pool[i].clone()).collect(); + let cards: Vec<_> = indices + .into_iter() + .take(11) + .map(|i| pool[i].clone()) + .collect(); let squad_rating = if cards.is_empty() { 0 @@ -141,11 +176,18 @@ pub async fn process_match( for ev in &level_ups { let body = if let Some(ref pack) = ev.pack_granted { - format!("You reached level {}! Reward: {} coins + {pack}.", ev.new_level, ev.coins_granted) + format!( + "You reached level {}! Reward: {} coins + {pack}.", + ev.new_level, ev.coins_granted + ) } else { - format!("You reached level {}! Reward: {} coins.", ev.new_level, ev.coins_granted) + format!( + "You reached level {}! Reward: {} coins.", + ev.new_level, ev.coins_granted + ) }; - let _ = notification::create(pool, "level_up", &format!("Level {}!", ev.new_level), &body).await; + let _ = notification::create(pool, "level_up", &format!("Level {}!", ev.new_level), &body) + .await; } statistics::record_match( @@ -185,7 +227,10 @@ pub async fn process_match( for obj_id in &objectives_updated { let title = "Objective complete!"; - let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", obj_id); + let body = format!( + "\"{}\" is now complete. Claim your reward in Objectives.", + obj_id + ); let _ = notification::create(pool, "objective_complete", title, &body).await; } @@ -193,7 +238,8 @@ pub async fn process_match( let expired_loans = process_loan_expiry(pool, club_id, &req.squad_id).await?; for owned_id in &expired_loans { - let body = format!("Loan card (id: {owned_id}) has expired and been removed from your club."); + let body = + format!("Loan card (id: {owned_id}) has expired and been removed from your club."); let _ = notification::create(pool, "loan_expired", "Loan card expired", &body).await; } @@ -208,14 +254,21 @@ pub async fn process_match( SeasonResult::Relegated => "Relegated", SeasonResult::Maintained => "Maintained", }; - let body = format!("{direction} — now in Division {}. Rewards: {} coins{}.", se.new_division, se.coins_awarded, se.pack_awarded.as_deref().map(|p| format!(" + {p}")).unwrap_or_default()); + let body = format!( + "{direction} — now in Division {}. Rewards: {} coins{}.", + se.new_division, + se.coins_awarded, + se.pack_awarded + .as_deref() + .map(|p| format!(" + {p}")) + .unwrap_or_default() + ); let _ = notification::create(pool, "season_end", "Season complete!", &body).await; } - let achievements_unlocked = - achievement::check_and_unlock(pool, ach_defs, profile_id, club_id) - .await - .unwrap_or_default(); + let achievements_unlocked = achievement::check_and_unlock(pool, ach_defs, profile_id, club_id) + .await + .unwrap_or_default(); Ok(MatchRewardResult { match_record, diff --git a/src/services/mod.rs b/src/services/mod.rs index fa534a5..3ce29f6 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -2,17 +2,18 @@ pub mod achievement; pub mod card_db; pub mod checkin; pub mod club; -pub mod notification; pub mod draft; pub mod event; pub mod fut_champs; -pub mod season; +pub mod inventory; pub mod market; pub mod match_service; +pub mod notification; pub mod objective; pub mod pack; pub mod profile; pub mod sbc; +pub mod season; pub mod settings; pub mod squad; pub mod squad_rules; diff --git a/src/services/pack.rs b/src/services/pack.rs index 1e3199f..bc8bf15 100644 --- a/src/services/pack.rs +++ b/src/services/pack.rs @@ -124,8 +124,8 @@ pub async fn open_pack( } } - let card_ids_json = serde_json::to_string(&cards.iter().map(|c| &c.id).collect::>()) - .unwrap_or_default(); + let card_ids_json = + serde_json::to_string(&cards.iter().map(|c| &c.id).collect::>()).unwrap_or_default(); let now = chrono::Utc::now().to_rfc3339(); sqlx::query("UPDATE packs SET opened = 1, opened_cards = ?, opened_at = ? WHERE id = ?") diff --git a/src/services/profile.rs b/src/services/profile.rs index 1b077a1..452a5a0 100644 --- a/src/services/profile.rs +++ b/src/services/profile.rs @@ -86,7 +86,11 @@ pub async fn add_xp_with_levelup( } tracing::info!(profile_id, new_level = lvl, coins, "level up"); - events.push(LevelUpEvent { new_level: lvl, coins_granted: coins, pack_granted: pack }); + events.push(LevelUpEvent { + new_level: lvl, + coins_granted: coins, + pack_granted: pack, + }); } Ok(events) diff --git a/src/services/upgrades.rs b/src/services/upgrades.rs index 761b04d..cccc15d 100644 --- a/src/services/upgrades.rs +++ b/src/services/upgrades.rs @@ -16,14 +16,12 @@ pub const MAX_TRAINING_BONUS: i64 = 3; pub const POSITION_CHANGE_COST: i64 = 500; async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult { - sqlx::query_as::<_, OwnedCard>(&format!( - "{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?" - )) - .bind(owned_card_id) - .bind(club_id) - .fetch_optional(pool) - .await? - .ok_or_else(|| AppError::NotFound("owned card not found".into())) + sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?")) + .bind(owned_card_id) + .bind(club_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound("owned card not found".into())) } /// Apply a chemistry style to an owned card. @@ -64,8 +62,8 @@ pub async fn change_position( new_position: &str, ) -> AppResult { let valid_positions = [ - "GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW", - "CF", "ST", + "GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW", "CF", + "ST", ]; if !valid_positions.contains(&new_position) { return Err(AppError::BadRequest(format!( diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 9bdf60a..b8e5034 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; json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await; let (s, json) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await; @@ -1420,7 +1565,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"); @@ -1436,18 +1584,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 ───────────────────────────────────────────────── @@ -1461,8 +1628,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] @@ -1487,17 +1660,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" + ); } } } @@ -1524,7 +1708,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] @@ -1534,17 +1721,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] @@ -1554,10 +1749,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 @@ -1573,10 +1773,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; @@ -1586,10 +1791,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); } } @@ -1603,8 +1815,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"); } @@ -1613,14 +1831,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] @@ -1628,14 +1854,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] @@ -1644,16 +1878,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"); @@ -1669,15 +1913,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 ───────────────────────────────────────────── @@ -1705,10 +1957,15 @@ 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!({})).await; @@ -1717,7 +1974,11 @@ async fn test_auth_reset_clears_profile() { // 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; @@ -1732,9 +1993,12 @@ async fn test_auth_reset_allows_new_profile() { json_post(&app, "/auth/reset", serde_json::json!({})).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"); } @@ -1758,10 +2022,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; @@ -1818,7 +2087,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] @@ -1859,10 +2131,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()); } @@ -1874,7 +2153,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)); @@ -1897,3 +2179,381 @@ async fn test_trade_history_empty_initially() { assert!(json["trades"].is_array()); assert_eq!(json["trades"].as_array().unwrap().len(), 0); } + +// ── Owned-item query: filtering, deterministic order, pagination ────────────── +// +// Regression coverage for the FIFA17 "My Squad" owned-player search. Retail +// evidence (sha256-identical response bodies) proved the Python oracle applies +// ONLY league+team and ignores level/rare/position/nation/start/count, which +// re-serves page one forever and amplifies requests. Core intentionally fixes +// this: filter (AND) -> deterministic order -> paginate. Semantics only — no raw +// FIFA ids reach Core (the adapter resolves ids to the names asserted here). + +/// Build an app AND keep the pool, so tests can seed a deterministic inventory. +async fn build_test_app_with_pool() -> (axum::Router, sqlx::SqlitePool) { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrations"); + let app = openfut_core::build_app(pool.clone(), "data") + .await + .expect("app build"); + (app, pool) +} + +/// A deliberately cluttered but KNOWN owned inventory drawn from committed card +/// data. Spans qualities (gold/silver/bronze), several leagues/nations/positions +/// and includes irrelevant "junk" that must disappear under a filter. Ids are +/// `oc_NN` in listed order so the `(overall desc, owned_id asc)` order is fixed. +const CLUTTERED_FIXTURE: &[(&str, &str)] = &[ + ("oc_00", "card_raregold_008"), // 86 CDM Ghana / Premier League / Chelsea + ("oc_01", "card_hero_004"), // 85 CDM Nigeria / Premier League / Chelsea + ("oc_02", "card_raregold_010"), // 87 LB Russia / Premier League / Arsenal + ("oc_03", "card_pl_001"), // 84 ST England / Premier League / Northgate + ("oc_04", "card_ll_008"), // 82 ST Argentina / La Liga / Valencia Azul + ("oc_05", "card_raregold_004"), // 89 LW Argentina / Primera Division / Boca + ("oc_06", "card_totw_004"), // 92 LW Argentina / Primera Division / Boca + ("oc_07", "card_silver_001"), // 72 ST Brazil / Brasileirao / Athletico + ("oc_08", "card_silver_002"), // 70 CM Italy / Serie B / Frosinone + ("oc_09", "card_bronze_001"), // 62 ST Brazil / Serie B / Santos + ("oc_10", "card_bronze_002"), // 60 CM Italy / Serie C / Modena + ("oc_11", "card_raregold_001"), // 88 ST Brazil / Brasileirao / Flamengo + ("oc_12", "card_raregold_002"), // 86 CAM Italy / Serie A / AS Roma + ("oc_13", "card_raregold_003"), // 87 CB Germany / Bundesliga / Bayer +]; + +async fn seed_cluttered(app: &axum::Router, pool: &sqlx::SqlitePool) { + auth(app, "ClutterClub").await; + let club_id: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1") + .fetch_one(pool) + .await + .expect("club exists after auth"); + // Replace the auto-granted starter pack with the deterministic fixture. + sqlx::query("DELETE FROM owned_cards WHERE club_id = ?") + .bind(&club_id) + .execute(pool) + .await + .unwrap(); + for (oc_id, card_id) in CLUTTERED_FIXTURE { + sqlx::query( + "INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus) \ + VALUES (?, ?, ?, 0, NULL, '2026-01-01T00:00:00Z', 'basic', NULL, 0)", + ) + .bind(oc_id) + .bind(&club_id) + .bind(card_id) + .execute(pool) + .await + .unwrap(); + } +} + +fn coll_card_ids(v: &Value) -> Vec { + v["collection"] + .as_array() + .unwrap() + .iter() + .map(|e| e["card"]["id"].as_str().unwrap().to_string()) + .collect() +} + +fn sorted(mut v: Vec) -> Vec { + v.sort(); + v +} + +#[tokio::test] +async fn test_owned_query_no_filter_returns_all() { + let (app, pool) = build_test_app_with_pool().await; + seed_cluttered(&app, &pool).await; + let (s, j) = json_get(&app, "/collection").await; + assert_eq!(s, StatusCode::OK, "{j}"); + assert_eq!(j["total"], 14); + assert_eq!(j["returned"], 14); + assert_eq!(coll_card_ids(&j).len(), 14); +} + +#[tokio::test] +async fn test_owned_query_quality_gold() { + let (app, pool) = build_test_app_with_pool().await; + seed_cluttered(&app, &pool).await; + let (_, j) = json_get(&app, "/collection?quality=gold").await; + assert_eq!(j["total"], 10); + assert_eq!( + sorted(coll_card_ids(&j)), + sorted( + vec![ + "card_raregold_008", + "card_hero_004", + "card_raregold_010", + "card_pl_001", + "card_ll_008", + "card_raregold_004", + "card_totw_004", + "card_raregold_001", + "card_raregold_002", + "card_raregold_003", + ] + .into_iter() + .map(String::from) + .collect() + ) + ); +} + +#[tokio::test] +async fn test_owned_query_position() { + let (app, pool) = build_test_app_with_pool().await; + seed_cluttered(&app, &pool).await; + let (_, j) = json_get(&app, "/collection?position=ST").await; + assert_eq!( + sorted(coll_card_ids(&j)), + sorted( + [ + "card_pl_001", + "card_ll_008", + "card_silver_001", + "card_bronze_001", + "card_raregold_001" + ] + .into_iter() + .map(String::from) + .collect() + ) + ); +} + +#[tokio::test] +async fn test_owned_query_nation() { + let (app, pool) = build_test_app_with_pool().await; + seed_cluttered(&app, &pool).await; + let (_, j) = json_get(&app, "/collection?nation=Argentina").await; + assert_eq!( + sorted(coll_card_ids(&j)), + sorted( + ["card_ll_008", "card_raregold_004", "card_totw_004"] + .into_iter() + .map(String::from) + .collect() + ) + ); +} + +#[tokio::test] +async fn test_owned_query_league() { + let (app, pool) = build_test_app_with_pool().await; + seed_cluttered(&app, &pool).await; + let (_, j) = json_get(&app, "/collection?league=Premier%20League").await; + assert_eq!( + sorted(coll_card_ids(&j)), + sorted( + [ + "card_raregold_008", + "card_hero_004", + "card_raregold_010", + "card_pl_001" + ] + .into_iter() + .map(String::from) + .collect() + ) + ); +} + +#[tokio::test] +async fn test_owned_query_club() { + let (app, pool) = build_test_app_with_pool().await; + seed_cluttered(&app, &pool).await; + let (_, j) = json_get(&app, "/collection?club=Chelsea").await; + assert_eq!( + sorted(coll_card_ids(&j)), + sorted( + ["card_raregold_008", "card_hero_004"] + .into_iter() + .map(String::from) + .collect() + ) + ); +} + +#[tokio::test] +async fn test_owned_query_league_and_club() { + let (app, pool) = build_test_app_with_pool().await; + seed_cluttered(&app, &pool).await; + let (_, j) = json_get(&app, "/collection?league=Premier%20League&club=Chelsea").await; + assert_eq!( + sorted(coll_card_ids(&j)), + sorted( + ["card_raregold_008", "card_hero_004"] + .into_iter() + .map(String::from) + .collect() + ) + ); +} + +#[tokio::test] +async fn test_owned_query_league_and_position() { + let (app, pool) = build_test_app_with_pool().await; + seed_cluttered(&app, &pool).await; + let (_, j) = json_get(&app, "/collection?league=Premier%20League&position=ST").await; + assert_eq!(j["total"], 1); + assert_eq!(coll_card_ids(&j), ["card_pl_001"]); +} + +#[tokio::test] +async fn test_owned_query_quality_and_position() { + let (app, pool) = build_test_app_with_pool().await; + seed_cluttered(&app, &pool).await; + let (_, j) = json_get(&app, "/collection?quality=gold&position=ST").await; + assert_eq!( + sorted(coll_card_ids(&j)), + sorted( + ["card_pl_001", "card_ll_008", "card_raregold_001"] + .into_iter() + .map(String::from) + .collect() + ) + ); +} + +#[tokio::test] +async fn test_owned_query_no_results() { + let (app, pool) = build_test_app_with_pool().await; + seed_cluttered(&app, &pool).await; + let (s, j) = json_get(&app, "/collection?nation=Argentina&club=Chelsea").await; + assert_eq!(s, StatusCode::OK); + assert_eq!(j["total"], 0); + assert!(coll_card_ids(&j).is_empty()); +} + +#[tokio::test] +async fn test_owned_query_deterministic_order_overall_desc() { + let (app, pool) = build_test_app_with_pool().await; + seed_cluttered(&app, &pool).await; + let (_, j) = json_get(&app, "/collection").await; + let overalls: Vec = j["collection"] + .as_array() + .unwrap() + .iter() + .map(|e| e["effective_overall"].as_i64().unwrap()) + .collect(); + let mut sorted_desc = overalls.clone(); + sorted_desc.sort_by(|a, b| b.cmp(a)); + assert_eq!( + overalls, sorted_desc, + "collection must be overall-descending" + ); + assert_eq!( + coll_card_ids(&j)[0], + "card_totw_004", + "highest overall (92) first" + ); +} + +#[tokio::test] +async fn test_owned_query_offset_and_limit() { + let (app, pool) = build_test_app_with_pool().await; + seed_cluttered(&app, &pool).await; + // gold set ordered: totw_004, raregold_004, raregold_001, raregold_010, ... + let (_, j) = json_get(&app, "/collection?quality=gold&limit=3").await; + assert_eq!( + j["total"], 10, + "total is the filtered count, not the page size" + ); + assert_eq!(j["returned"], 3); + assert_eq!( + coll_card_ids(&j), + ["card_totw_004", "card_raregold_004", "card_raregold_001"] + ); + + let (_, j2) = json_get(&app, "/collection?quality=gold&offset=3&limit=3").await; + assert_eq!(j2["total"], 10); + assert_eq!( + coll_card_ids(&j2)[0], + "card_raregold_010", + "offset advances past page one" + ); +} + +#[tokio::test] +async fn test_owned_query_pagination_no_repeated_first_page() { + // THE production regression: paging must advance and never re-serve page one, + // with filters retained on every page. A mutation that ignores `offset` (the + // Python bug) makes every page identical and fails here. + let (app, pool) = build_test_app_with_pool().await; + seed_cluttered(&app, &pool).await; + + let page = |off: u32| { + let app = app.clone(); + async move { + let (_, j) = json_get( + &app, + &format!("/collection?quality=gold&limit=4&start_ignored=0&offset={off}"), + ) + .await; + j + } + }; + let p0 = page(0).await; + let p1 = page(4).await; + let p2 = page(8).await; + + let ids0 = coll_card_ids(&p0); + let ids1 = coll_card_ids(&p1); + let ids2 = coll_card_ids(&p2); + + // sizes: 4, 4, 2 over the 10-item gold set + assert_eq!(ids0.len(), 4); + assert_eq!(ids1.len(), 4); + assert_eq!(ids2.len(), 2); + + // page one is NOT repeated on later pages + assert_ne!(ids0, ids1, "offset advance must not re-serve page one"); + assert_ne!(ids0, ids2); + + // pairwise disjoint (no duplicates across pages) + for a in &ids0 { + assert!( + !ids1.contains(a) && !ids2.contains(a), + "pages overlap on {a}" + ); + } + for a in &ids1 { + assert!(!ids2.contains(a), "pages overlap on {a}"); + } + + // union == the full filtered set, each page still all-gold, no dupes + let mut union: Vec = ids0.iter().chain(&ids1).chain(&ids2).cloned().collect(); + let count = union.len(); + union.sort(); + union.dedup(); + assert_eq!(union.len(), count, "no duplicate items across pages"); + assert_eq!(union.len(), 10, "pages cover the whole filtered set"); + // filter retained across pages: every id on every page is a gold card + let (_, all_gold) = json_get(&app, "/collection?quality=gold").await; + let gold_set = sorted(coll_card_ids(&all_gold)); + assert_eq!(sorted(union), gold_set); + // total constant across pages + assert_eq!(p0["total"], 10); + assert_eq!(p1["total"], 10); + assert_eq!(p2["total"], 10); +} + +#[tokio::test] +async fn test_owned_query_parameter_order_invariance() { + let (app, pool) = build_test_app_with_pool().await; + seed_cluttered(&app, &pool).await; + let (_, a) = json_get(&app, "/collection?league=Premier%20League&position=ST").await; + let (_, b) = json_get(&app, "/collection?position=ST&league=Premier%20League").await; + assert_eq!( + coll_card_ids(&a), + coll_card_ids(&b), + "HTTP param order must not change the result" + ); + assert_eq!(a["total"], b["total"]); +}