feat(core): one instance-based ownership model for every kind of owned content
CI / Build, lint & test (push) Successful in 3m21s
CI / Build, lint & test (push) Successful in 3m21s
Core could only own players. Everything else a FUT club holds — managers, staff, consumables, kits, badges, balls, stadiums — had no representation, so the only way to show one to a client was to synthesise it on read. That is the failure mode this commit exists to make impossible: read authority, write authority and persistent ownership authority are now the same rows. MODEL. There is deliberately NO parallel items table. A manager, a consumable, a kit and a player are all rows in `owned_cards`, differing only by a new game-INDEPENDENT `content_kind` (player|manager|staff|consumable|kit|badge|ball| stadium|misc). A game adapter translates its own taxonomy — FIFA 17's `cardsubtypeid` and resource ranges — into one of those tokens before ownership reaches Core; no game's numerics land here. Ownership stays INSTANCE-based: `card_id` is the definition, `id` is the instance, and two copies of one definition remain two rows. `quantity` is a nullable per-instance attribute, not a replacement for the instance. The real profile settles this: its 17 consumables are instance-based and only SOME carry a wire `amount` (observed 1,2,4,5,10,15), while two copies of definition 5003068 exist as two distinct instances. So NULL means "not a stack" and a positive integer is the stack size; collapsing instances into counts is forbidden by the model. ACTIVE DESIGNATIONS. Migration 0024's two-slot kit table becomes `club_active_items` over the five slots that correspond exactly to the client's recovered equipped-state vocabulary (activeBadge 100, activeHomeKit 101, activeAwayKit 102, activeBall 103, activeStadium 104). There is no activeLeagueLogo or activeMisc token, so those kinds correctly get no slot. The invariants are schema-enforced rather than conventional: PK(club_id, slot) allows at most one item per role, `owned_card_id UNIQUE` makes "the same card is both home and away kit" unstorable, and ON DELETE CASCADE means a quick-sold or consumed item cannot be projected back as active. 0024's trigger is preserved in semantics — and dropped EXPLICITLY before its table, because it lives ON `owned_cards`, so DROP TABLE would have orphaned it and broken every later ownership transfer. It still exists because the market moves ownership by UPDATE, which no foreign key can observe. CONSUMABLE ACTIONS. `services/consume.rs` is one transaction primitive — validate source ownership and kind, validate target, mutate, consume the source exactly once, commit — guarded by `UNIQUE(profile_id, action_identity)` in migration 0027, the same discipline as `match_completions`. It supports both deleting the row and decrementing a stack, chosen by the caller, inside the one transaction and the one replay guard. It deliberately contains NO category formulas: an unreversed effect must not be invented, so callers supply the mutation and category validation stays explicit. `/club/kits` is replaced by slot-generic `/club/active-items`. `get_collection` now carries `content_kind` and `quantity`, accepts a `content_kind` filter, and — importantly — stops dropping an owned card with a missing definition silently: the envelope reports `owned_rows`, `unresolved_items` and the offending definition ids. That silent `filter_map` is the documented cause of a club that looks empty while the rows are all present. Verified against a REAL populated club, not a fixture: the production snapshot (migration 19) is copied to a tempdir, migrated to 0024, given two kit designations on real owned instances, then migrated to head. 1986 owned rows survive as content_kind='player', both designations land in `club_active_items`, no row gains a quantity, and the old table is gone. 258 tests pass, clippy clean.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
//! the Core-level half of the migration mutation battery: each hostile input is
|
||||
//! rejected BEFORE any partial write, and re-runs converge instead of duplicating.
|
||||
|
||||
use openfut_core::models::card::ContentKind;
|
||||
use openfut_core::services::card_db::CardDb;
|
||||
use openfut_core::services::import::{
|
||||
apply_profile_import, ImportClub, ImportEntitlement, ImportExtension, ImportOwnedCard,
|
||||
@@ -34,6 +35,8 @@ fn owned(ids: &[String]) -> Vec<ImportOwnedCard> {
|
||||
.map(|(i, id)| ImportOwnedCard {
|
||||
owned_item_id: format!("oc-{i}"),
|
||||
card_id: id.clone(),
|
||||
content_kind: ContentKind::Player,
|
||||
quantity: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -208,6 +211,8 @@ async fn missing_definition_fails_preflight_with_no_writes() {
|
||||
ow.push(ImportOwnedCard {
|
||||
owned_item_id: "oc-bad".into(),
|
||||
card_id: "fifa17_definitely_absent_999999".into(),
|
||||
content_kind: ContentKind::Player,
|
||||
quantity: None,
|
||||
});
|
||||
let err = apply_profile_import(&pool, &db, &request("g_miss", "fp", ow, None))
|
||||
.await
|
||||
|
||||
@@ -3378,3 +3378,188 @@ async fn test_economy_settle_sale_route_rejects_self_dealing() {
|
||||
Some(SELLER_CLUB)
|
||||
);
|
||||
}
|
||||
|
||||
// ── generic active club-item designations + collection taxonomy ───────────────
|
||||
|
||||
/// Insert one owned instance of a given content kind directly, since there is no
|
||||
/// route that grants a kit/badge/ball/stadium yet (the game adapter/import does).
|
||||
async fn seed_owned_kind(
|
||||
pool: &sqlx::SqlitePool,
|
||||
id: &str,
|
||||
club_id: &str,
|
||||
card_id: &str,
|
||||
kind: &str,
|
||||
) {
|
||||
sqlx::query(
|
||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at, content_kind) \
|
||||
VALUES (?, ?, ?, 0, '2026-01-01T00:00:00Z', ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(club_id)
|
||||
.bind(card_id)
|
||||
.bind(kind)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("seed owned item");
|
||||
}
|
||||
|
||||
async fn club_id_of(pool: &sqlx::SqlitePool) -> String {
|
||||
sqlx::query_scalar::<_, String>("SELECT id FROM clubs LIMIT 1")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_active_items_get_returns_every_slot_explicitly() {
|
||||
let (app, _pool) = build_test_app_with_pool().await;
|
||||
auth(&app, "CAGE").await;
|
||||
let (s, j) = json_get(&app, "/club/active-items").await;
|
||||
assert_eq!(s, StatusCode::OK, "{j}");
|
||||
for slot in ["home_kit", "away_kit", "badge", "ball", "stadium"] {
|
||||
assert!(
|
||||
j["active_items"][slot].is_null(),
|
||||
"slot {slot} must be present and null on a fresh club: {j}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_active_items_put_set_and_clear_roundtrip() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
auth(&app, "CAGE").await;
|
||||
let club = club_id_of(&pool).await;
|
||||
// A real definition id keeps the collection projection honest; the kind is
|
||||
// what the designation validates against.
|
||||
seed_owned_kind(&pool, "kit-1", &club, "card_bronze_001", "kit").await;
|
||||
seed_owned_kind(&pool, "badge-1", &club, "card_bronze_002", "badge").await;
|
||||
|
||||
let (s, j) = json_put(
|
||||
&app,
|
||||
"/club/active-items",
|
||||
serde_json::json!({ "slot": "home_kit", "owned_card_id": "kit-1" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{j}");
|
||||
assert_eq!(j["active_items"]["home_kit"]["id"], "kit-1");
|
||||
assert_eq!(j["active_items"]["home_kit"]["content_kind"], "kit");
|
||||
assert!(j["active_items"]["badge"].is_null());
|
||||
|
||||
let (s, j) = json_put(
|
||||
&app,
|
||||
"/club/active-items",
|
||||
serde_json::json!({ "slot": "badge", "owned_card_id": "badge-1" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{j}");
|
||||
assert_eq!(j["active_items"]["badge"]["id"], "badge-1");
|
||||
assert_eq!(j["active_items"]["home_kit"]["id"], "kit-1");
|
||||
|
||||
// A null owned_card_id clears just that slot.
|
||||
let (s, j) = json_put(
|
||||
&app,
|
||||
"/club/active-items",
|
||||
serde_json::json!({ "slot": "home_kit", "owned_card_id": null }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{j}");
|
||||
assert!(j["active_items"]["home_kit"].is_null());
|
||||
assert_eq!(j["active_items"]["badge"]["id"], "badge-1");
|
||||
|
||||
// The designation is durable, not per-response.
|
||||
let (_, j) = json_get(&app, "/club/active-items").await;
|
||||
assert_eq!(j["active_items"]["badge"]["id"], "badge-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_active_items_put_rejects_kind_and_ownership_violations() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
auth(&app, "CAGE").await;
|
||||
let club = club_id_of(&pool).await;
|
||||
seed_owned_kind(&pool, "badge-1", &club, "card_bronze_002", "badge").await;
|
||||
|
||||
// A badge is not a kit.
|
||||
let (s, _) = json_put(
|
||||
&app,
|
||||
"/club/active-items",
|
||||
serde_json::json!({ "slot": "home_kit", "owned_card_id": "badge-1" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
|
||||
// An item the club does not own.
|
||||
let (s, _) = json_put(
|
||||
&app,
|
||||
"/club/active-items",
|
||||
serde_json::json!({ "slot": "badge", "owned_card_id": "nope" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||
|
||||
// A slot outside the recovered equipped-state vocabulary.
|
||||
let (s, _) = json_put(
|
||||
&app,
|
||||
"/club/active-items",
|
||||
serde_json::json!({ "slot": "league_logo", "owned_card_id": "badge-1" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
|
||||
let (_, j) = json_get(&app, "/club/active-items").await;
|
||||
assert!(j["active_items"]["home_kit"].is_null());
|
||||
assert!(j["active_items"]["badge"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collection_carries_content_kind_and_filters_on_it() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
auth(&app, "CAGE").await;
|
||||
let club = club_id_of(&pool).await;
|
||||
seed_owned_kind(&pool, "kit-1", &club, "card_bronze_001", "kit").await;
|
||||
seed_owned_kind(&pool, "player-1", &club, "card_bronze_002", "player").await;
|
||||
|
||||
let (s, j) = json_get(&app, "/collection").await;
|
||||
assert_eq!(s, StatusCode::OK, "{j}");
|
||||
let kinds: Vec<&str> = j["collection"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["content_kind"].as_str().expect("content_kind present"))
|
||||
.collect();
|
||||
assert!(kinds.contains(&"kit"), "kinds: {kinds:?}");
|
||||
assert!(kinds.contains(&"player"), "kinds: {kinds:?}");
|
||||
|
||||
let (_, only_kits) = json_get(&app, "/collection?content_kind=kit").await;
|
||||
assert_eq!(only_kits["total"], 1);
|
||||
assert_eq!(only_kits["collection"][0]["owned_card_id"], "kit-1");
|
||||
|
||||
let (_, none) = json_get(&app, "/collection?content_kind=stadium").await;
|
||||
assert_eq!(none["total"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collection_reports_owned_rows_it_cannot_project() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
auth(&app, "CAGE").await;
|
||||
let club = club_id_of(&pool).await;
|
||||
// An owned row whose definition is NOT in loaded content: it cannot be
|
||||
// projected, but it must be counted and named, never silently dropped.
|
||||
seed_owned_kind(&pool, "ghost", &club, "definitely_absent_999", "consumable").await;
|
||||
|
||||
let (s, j) = json_get(&app, "/collection").await;
|
||||
assert_eq!(s, StatusCode::OK, "a missing definition must not 500: {j}");
|
||||
assert_eq!(j["unresolved_items"], 1);
|
||||
assert_eq!(j["unresolved_definitions"][0], "definitely_absent_999");
|
||||
assert_eq!(
|
||||
j["owned_rows"].as_i64().unwrap(),
|
||||
j["total"].as_i64().unwrap() + 1,
|
||||
"owned_rows is ownership truth, total is what could be projected: {j}"
|
||||
);
|
||||
let ids: Vec<&str> = j["collection"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["owned_card_id"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(!ids.contains(&"ghost"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Owned-content model migrations (0025 content_kind/quantity, 0026
|
||||
//! club_active_items, 0027 consumable_applications).
|
||||
//!
|
||||
//! Two things must hold on a DB that already contains real ownership:
|
||||
//! * every pre-existing owned row survives and reads back as a `player` with no
|
||||
//! stack size (the migration is a pure widening, not a rewrite);
|
||||
//! * every existing kit designation lands in `club_active_items` under its
|
||||
//! generalised slot token, and the old table + trigger are gone.
|
||||
//!
|
||||
//! The first is proved against a COPY of a real populated club snapshot (1986
|
||||
//! owned rows) when `OPENFUT_CORE_SNAPSHOT_DB` points at one; the second is
|
||||
//! proved by staging a DB at migration 0025, writing 0024-era kit rows, and then
|
||||
//! letting the remaining migrations run.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use openfut_core::models::card::{ActiveSlot, ContentKind};
|
||||
use sqlx::migrate::Migrator;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
const OWNED_CONTENT_MIGRATION: i64 = 25;
|
||||
|
||||
async fn pool_for(path: &std::path::Path) -> SqlitePool {
|
||||
let opts = sqlx::sqlite::SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true);
|
||||
SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("open {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The full migrator, truncated after `version`. Used to stage a DB in the state
|
||||
/// it had BEFORE the migrations under test, so their data carry-over is exercised
|
||||
/// on rows that really pre-date them.
|
||||
fn migrator_upto(version: i64) -> Migrator {
|
||||
let full = sqlx::migrate!("./migrations");
|
||||
let subset: Vec<_> = full
|
||||
.iter()
|
||||
.filter(|m| m.version < version)
|
||||
.cloned()
|
||||
.collect();
|
||||
Migrator {
|
||||
migrations: Cow::Owned(subset),
|
||||
ignore_missing: true,
|
||||
locking: true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn table_exists(pool: &SqlitePool, name: &str) -> bool {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?")
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
> 0
|
||||
}
|
||||
|
||||
async fn trigger_names(pool: &SqlitePool) -> Vec<String> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Stage a DB at pre-0025 state with two 0024-era kit designations, then run the
|
||||
/// rest of the migrations: the designations MUST be carried over, not dropped.
|
||||
#[tokio::test]
|
||||
async fn kit_assignments_migrate_into_club_active_items() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let db = dir.path().join("staged.db");
|
||||
let pool = pool_for(&db).await;
|
||||
migrator_upto(OWNED_CONTENT_MIGRATION)
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrate to pre-0025");
|
||||
assert!(
|
||||
table_exists(&pool, "club_kit_assignments").await,
|
||||
"staging must actually be at the 0024 schema"
|
||||
);
|
||||
|
||||
let ts = "2026-01-01T00:00:00Z";
|
||||
sqlx::query("INSERT INTO profiles (id, username, created_at, updated_at) VALUES ('p','p',?,?)")
|
||||
.bind(ts)
|
||||
.bind(ts)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \
|
||||
VALUES ('c','p','c',0,?,?)",
|
||||
)
|
||||
.bind(ts)
|
||||
.bind(ts)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
for id in ["kit-h", "kit-a", "spare"] {
|
||||
sqlx::query(
|
||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \
|
||||
VALUES (?, 'c', ?, 0, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(format!("def-{id}"))
|
||||
.bind(ts)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for (slot, owned) in [("home", "kit-h"), ("away", "kit-a")] {
|
||||
sqlx::query(
|
||||
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) \
|
||||
VALUES ('c', ?, ?, ?)",
|
||||
)
|
||||
.bind(slot)
|
||||
.bind(owned)
|
||||
.bind(ts)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Now the migrations under test.
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrate to head");
|
||||
|
||||
assert!(
|
||||
!table_exists(&pool, "club_kit_assignments").await,
|
||||
"the old kit table must be gone"
|
||||
);
|
||||
assert!(table_exists(&pool, "club_active_items").await);
|
||||
assert!(table_exists(&pool, "consumable_applications").await);
|
||||
|
||||
let rows =
|
||||
sqlx::query("SELECT slot, owned_card_id, updated_at FROM club_active_items ORDER BY slot")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let carried: Vec<(String, String, String)> = rows
|
||||
.iter()
|
||||
.map(|r| (r.get(0), r.get(1), r.get(2)))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
carried,
|
||||
vec![
|
||||
(
|
||||
ActiveSlot::AwayKit.as_str().into(),
|
||||
"kit-a".to_string(),
|
||||
ts.to_string()
|
||||
),
|
||||
(
|
||||
ActiveSlot::HomeKit.as_str().into(),
|
||||
"kit-h".to_string(),
|
||||
ts.to_string()
|
||||
),
|
||||
],
|
||||
"home -> home_kit, away -> away_kit, timestamps preserved"
|
||||
);
|
||||
|
||||
// 0024's trigger is replaced, never merely orphaned: an ownership transfer
|
||||
// must still clear the designation (and must not fail on a missing table).
|
||||
let names = trigger_names(&pool).await;
|
||||
assert!(
|
||||
!names.contains(&"clear_club_kit_assignment_before_transfer".to_string()),
|
||||
"the old trigger must be dropped, got {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"clear_club_active_item_before_transfer".to_string()),
|
||||
"the generalised trigger must exist, got {names:?}"
|
||||
);
|
||||
sqlx::query(
|
||||
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \
|
||||
VALUES ('c2','p','c2',0,?,?)",
|
||||
)
|
||||
.bind(ts)
|
||||
.bind(ts)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE owned_cards SET club_id = 'c2' WHERE id = 'kit-h'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("transfer must succeed after the trigger swap");
|
||||
let remaining =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM club_active_items WHERE club_id='c'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remaining, 1, "the transferred kit's designation is cleared");
|
||||
|
||||
// Backfilled ownership reads back as the default kind with no stack size.
|
||||
let (kind, quantity) = sqlx::query_as::<_, (ContentKind, Option<i64>)>(
|
||||
"SELECT content_kind, quantity FROM owned_cards WHERE id = 'spare'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(kind, ContentKind::Player);
|
||||
assert_eq!(quantity, None);
|
||||
|
||||
// And the new column constraints are real, not documentation.
|
||||
assert!(
|
||||
sqlx::query("UPDATE owned_cards SET content_kind = 'coach' WHERE id = 'spare'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.is_err(),
|
||||
"content_kind CHECK must reject a token outside the vocabulary"
|
||||
);
|
||||
assert!(
|
||||
sqlx::query("UPDATE owned_cards SET quantity = 0 WHERE id = 'spare'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.is_err(),
|
||||
"quantity CHECK must reject a non-positive stack"
|
||||
);
|
||||
assert!(
|
||||
sqlx::query(
|
||||
"INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at) \
|
||||
VALUES ('c', 'league_logo', 'spare', ?)"
|
||||
)
|
||||
.bind(ts)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.is_err(),
|
||||
"slot CHECK must reject a token outside the recovered equipped-state set"
|
||||
);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
/// The migrations must apply cleanly to a COPY of a REAL populated club DB,
|
||||
/// leave every owned row intact, and carry a real kit designation over.
|
||||
///
|
||||
/// The snapshot predates migration 0024, so the copy is first brought up to the
|
||||
/// 0024 schema and given two kit designations pointing at REAL owned instances;
|
||||
/// only then do the migrations under test run. That way the carry-over is proved
|
||||
/// on production ownership, not on synthetic rows.
|
||||
///
|
||||
/// Point `OPENFUT_CORE_SNAPSHOT_DB` at a real `core.db` to run it; without that
|
||||
/// the test reports the skip rather than passing silently on nothing.
|
||||
#[tokio::test]
|
||||
async fn migrations_apply_to_a_real_populated_snapshot() {
|
||||
let Ok(source) = std::env::var("OPENFUT_CORE_SNAPSHOT_DB") else {
|
||||
eprintln!(
|
||||
"SKIPPED migrations_apply_to_a_real_populated_snapshot: set \
|
||||
OPENFUT_CORE_SNAPSHOT_DB=/path/to/core.db to run it"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let copy = dir.path().join("core.db");
|
||||
// Copy, never open the source: the snapshot is read-only evidence.
|
||||
std::fs::copy(&source, ©).unwrap_or_else(|e| panic!("copy {source}: {e}"));
|
||||
let pool = pool_for(©).await;
|
||||
|
||||
let before = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("snapshot must already hold ownership");
|
||||
assert!(
|
||||
before > 0,
|
||||
"the snapshot must be populated to prove anything"
|
||||
);
|
||||
|
||||
// Bring the copy to the 0024 schema and designate two REAL owned instances
|
||||
// as this club's kits, exactly as the pre-generalisation server would have.
|
||||
migrator_upto(OWNED_CONTENT_MIGRATION)
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrate the snapshot to pre-0025");
|
||||
let real: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT id, club_id FROM owned_cards ORDER BY id LIMIT 2")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(real.len(), 2, "need two real owned instances");
|
||||
let ts = "2026-01-01T00:00:00Z";
|
||||
for (slot, (owned_id, club_id)) in ["home", "away"].into_iter().zip(&real) {
|
||||
sqlx::query(
|
||||
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) \
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(club_id)
|
||||
.bind(slot)
|
||||
.bind(owned_id)
|
||||
.bind(ts)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("stage a real kit designation");
|
||||
}
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrations must apply to real populated data");
|
||||
|
||||
let (after, players, stacked) = sqlx::query_as::<_, (i64, i64, i64)>(
|
||||
"SELECT COUNT(*), \
|
||||
SUM(CASE WHEN content_kind = 'player' THEN 1 ELSE 0 END), \
|
||||
SUM(CASE WHEN quantity IS NOT NULL THEN 1 ELSE 0 END) \
|
||||
FROM owned_cards",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(after, before, "no owned row may be lost or duplicated");
|
||||
assert_eq!(players, before, "every backfilled row is a player");
|
||||
assert_eq!(stacked, 0, "no pre-existing row gains a stack size");
|
||||
|
||||
assert!(table_exists(&pool, "club_active_items").await);
|
||||
assert!(!table_exists(&pool, "club_kit_assignments").await);
|
||||
assert!(table_exists(&pool, "consumable_applications").await);
|
||||
|
||||
let carried: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT slot, owned_card_id FROM club_active_items ORDER BY slot")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
carried,
|
||||
vec![
|
||||
(ActiveSlot::AwayKit.as_str().into(), real[1].0.clone()),
|
||||
(ActiveSlot::HomeKit.as_str().into(), real[0].0.clone()),
|
||||
],
|
||||
"real kit designations must land in club_active_items"
|
||||
);
|
||||
eprintln!(
|
||||
"snapshot: {after} owned rows survive as content_kind='player'; \
|
||||
designations carried over: {carried:?}"
|
||||
);
|
||||
drop(dir);
|
||||
}
|
||||
Reference in New Issue
Block a user