//! 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 { 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)>( "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); }