feat(club): semantic owned-item query + FIFA17 filter/pagination fix
Game-independent owned-inventory query (services::inventory::{OwnedItemQuery,
apply_query} + a Quality tier) that filters (AND) -> orders deterministically
(effective_overall desc, owned_card_id asc) -> paginates, wired into
GET /collection. Fixes the FIFA17 My Squad search: the Python oracle applied
only league+team and ignored level/rare/position/nation/start/count (proven by
response sha256 identity across pages -> the request-amplification bug); Core
now applies all proven filters and paginates. rare=SP left UNKNOWN.
Tests: +9 inventory unit, +14 /collection integration (full matrix incl. the
repeated-first-page regression); 9 mutations killed.
Isolated from an unrelated dirty working tree via a clean worktree at eab522a;
touches only the 5 slice files, no unrelated reformatting.
This commit is contained in:
@@ -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 {
|
||||
|
||||
+31
-8
@@ -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<AppState>) -> AppResult<Json<Value>> {
|
||||
pub async fn get_collection(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<OwnedItemQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
@@ -104,14 +111,14 @@ pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Val
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let with_defs: Vec<Value> = owned
|
||||
let views: Vec<OwnedItemView> = 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<AppState>) -> AppResult<Json<Val
|
||||
"effective_overall": effective_overall,
|
||||
"effective_position": effective_position,
|
||||
"card": def,
|
||||
})
|
||||
});
|
||||
OwnedItemView {
|
||||
owned_card_id: o.id.clone(),
|
||||
base_overall: def.overall,
|
||||
effective_overall,
|
||||
position: effective_position.to_string(),
|
||||
nation: def.nation.clone(),
|
||||
league: def.league.clone(),
|
||||
club: def.club.clone(),
|
||||
body,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(
|
||||
json!({ "collection": with_defs, "total": with_defs.len() }),
|
||||
))
|
||||
let page = inventory::apply_query(views, &query);
|
||||
let returned = page.items.len();
|
||||
Ok(Json(json!({
|
||||
"collection": page.items,
|
||||
"total": page.total,
|
||||
"returned": returned,
|
||||
"offset": page.offset,
|
||||
"limit": page.limit,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
|
||||
|
||||
Executable
+291
@@ -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<Quality>,
|
||||
/// Playing position, e.g. "ST" (matched case-insensitively).
|
||||
#[serde(default)]
|
||||
pub position: Option<String>,
|
||||
/// Nation name, e.g. "Argentina" (matched case-insensitively).
|
||||
#[serde(default)]
|
||||
pub nation: Option<String>,
|
||||
/// League name, e.g. "Premier League" (matched case-insensitively).
|
||||
#[serde(default)]
|
||||
pub league: Option<String>,
|
||||
/// Club name, e.g. "Chelsea" (matched case-insensitively).
|
||||
#[serde(default)]
|
||||
pub club: Option<String>,
|
||||
/// Number of leading items to skip after filtering + ordering.
|
||||
#[serde(default)]
|
||||
pub offset: Option<i64>,
|
||||
/// Maximum number of items to return in the page.
|
||||
#[serde(default)]
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// 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<serde_json::Value>,
|
||||
pub total: usize,
|
||||
pub offset: usize,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// 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<OwnedItemView>, 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<serde_json::Value> = 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<String> {
|
||||
page.items
|
||||
.iter()
|
||||
.map(|b| b["owned_card_id"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fixture() -> Vec<OwnedItemView> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> {
|
||||
v["collection"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e["card"]["id"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sorted(mut v: Vec<String>) -> Vec<String> {
|
||||
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<i64> = 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<String> = 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"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user