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/routes/cards.rs b/src/routes/cards.rs index adc2a27..9a2d8eb 100644 --- a/src/routes/cards.rs +++ b/src/routes/cards.rs @@ -9,7 +9,11 @@ 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. @@ -93,7 +97,10 @@ pub async fn get_cards( 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 +111,14 @@ 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 body = json!({ "owned_card_id": o.id, "is_loan": o.is_loan, "loan_matches_remaining": o.loan_matches_remaining, @@ -122,14 +129,30 @@ pub async fn get_collection(State(state): State) -> AppResult, + /// 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/mod.rs b/src/services/mod.rs index fa534a5..03a2a82 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -6,6 +6,7 @@ pub mod notification; pub mod draft; pub mod event; pub mod fut_champs; +pub mod inventory; pub mod season; pub mod market; pub mod match_service; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 9bdf60a..169bf0c 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1897,3 +1897,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"]); +}