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:
funman300
2026-08-11 21:22:57 +00:00
parent aecbff0de8
commit 6acae54f80
5 changed files with 729 additions and 8 deletions
+378
View File
@@ -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"]);
}