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:
@@ -1901,3 +1901,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