//! Migration regression tests. //! //! Boots a real SQLite database (per test, on a tempfile) with the same //! pool options the daemon uses in production (see `src/db/mod.rs`), //! applies the SQL migrations from disk one at a time, and asserts schema //! + data integrity at each step. //! //! The trigger for this file: migration `0009_discount_codes_set_price.sql` //! shipped a bug that crashed daemon boot on any install with rows in //! `discount_redemptions` (SQLite error 787, FOREIGN KEY constraint //! failed, surfaced at COMMIT). None of the existing crypto/webhook unit //! tests touched the database, so the bug went undetected. These tests //! reproduce the original failure mode against a populated DB and catch //! the same class of bug on any future migration. //! //! We deliberately bypass `sqlx::migrate!()` here. The macro applies all //! migrations as a single batch and we need per-migration control so we //! can seed fixtures *between* migrations — e.g. populate //! `discount_redemptions` after 0004 lands and before 0009 runs. use sqlx::sqlite::{ SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteSynchronous, }; use std::path::PathBuf; use std::str::FromStr; use std::time::Duration; use tempfile::NamedTempFile; const MIGRATIONS_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"); fn migration_files() -> Vec { let mut files: Vec<_> = std::fs::read_dir(MIGRATIONS_DIR) .expect("read migrations dir") .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("sql")) .collect(); files.sort(); files } /// Open a fresh pool against a throwaway tempfile, mirroring /// `src/db/mod.rs::init` exactly. The `NamedTempFile` is returned alongside /// the pool so the caller can keep it alive for the duration of the test /// — when it drops, the OS reclaims the file. async fn make_pool() -> (SqlitePool, NamedTempFile) { let tmp = NamedTempFile::new().expect("create tempfile"); let url = format!("sqlite://{}", tmp.path().display()); let opts = SqliteConnectOptions::from_str(&url) .expect("parse sqlite url") .create_if_missing(true) .journal_mode(SqliteJournalMode::Wal) .synchronous(SqliteSynchronous::Normal) .foreign_keys(true) .busy_timeout(Duration::from_secs(5)); let pool = SqlitePoolOptions::new() .max_connections(2) .connect_with(opts) .await .expect("connect to sqlite"); (pool, tmp) } /// Apply migrations in the half-open index range `[start, end)` by reading /// the .sql files from disk. Each migration runs in its own transaction /// (matches sqlx-migrate behaviour). Splitting on a range — instead of /// always applying from 0 — matters because `ALTER TABLE ADD COLUMN` /// statements in our migrations don't have `IF NOT EXISTS` guards, so /// re-applying 0003 onto an already-migrated DB fails with "duplicate /// column name". async fn apply_range(pool: &SqlitePool, start: usize, end: usize) -> anyhow::Result<()> { let files = migration_files(); assert!( end <= files.len() && start <= end, "invalid migration range {start}..{end} (have {} migrations)", files.len() ); for path in &files[start..end] { let sql = std::fs::read_to_string(path)?; let mut tx = pool.begin().await?; sqlx::raw_sql(&sql) .execute(&mut *tx) .await .map_err(|e| anyhow::anyhow!("applying {}: {e}", path.display()))?; tx.commit().await?; } Ok(()) } async fn apply_through(pool: &SqlitePool, n: usize) -> anyhow::Result<()> { apply_range(pool, 0, n).await } async fn apply_all(pool: &SqlitePool) -> anyhow::Result<()> { apply_through(pool, migration_files().len()).await } /// Run SQLite's built-in consistency checks. Both should return clean rows /// on a healthy database; either failing is a hard error for our tests. async fn assert_db_clean(pool: &SqlitePool) -> anyhow::Result<()> { let violations: Vec<(String, Option, String, i64)> = sqlx::query_as::<_, (String, Option, String, i64)>("PRAGMA foreign_key_check") .fetch_all(pool) .await?; anyhow::ensure!( violations.is_empty(), "foreign_key_check violations: {violations:?}" ); let integrity: String = sqlx::query_scalar("PRAGMA integrity_check") .fetch_one(pool) .await?; anyhow::ensure!(integrity == "ok", "integrity_check failed: {integrity}"); Ok(()) } /// Insert one row into every table that participates in a foreign-key /// chain. Schema state assumed: post-0008 — i.e. after tiered pricing /// adds `policies.public` and `invoices.policy_id`, but before 0009 /// rebuilds discount_codes. Each row deliberately uses values that are /// otherwise indistinguishable from real production data. /// /// Skips standalone tables that aren't part of the FK web that bit us /// (server_keys, btcpay_*, settings, sessions, rate_buckets, audit_log, /// validation_log). async fn seed_realistic_fixtures(pool: &SqlitePool) -> anyhow::Result<()> { let now = "2026-05-08T00:00:00Z"; sqlx::query( "INSERT INTO products(id, slug, name, price_sats, created_at, updated_at) \ VALUES(?, ?, ?, ?, ?, ?)", ) .bind("p1") .bind("test-product") .bind("Test Product") .bind(10_000_i64) .bind(now) .bind(now) .execute(pool) .await?; sqlx::query( "INSERT INTO policies(id, product_id, name, slug, created_at, updated_at) \ VALUES(?, ?, ?, ?, ?, ?)", ) .bind("pol1") .bind("p1") .bind("Standard") .bind("standard") .bind(now) .bind(now) .execute(pool) .await?; sqlx::query( "INSERT INTO invoices(id, btcpay_invoice_id, product_id, status, amount_sats, \ checkout_url, created_at, updated_at, policy_id) \ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind("inv1") .bind("btcpay-inv-1") .bind("p1") .bind("settled") .bind(10_000_i64) .bind("https://btcpay.example/i/1") .bind(now) .bind(now) .bind("pol1") .execute(pool) .await?; sqlx::query( "INSERT INTO licenses(id, product_id, invoice_id, status, issued_at, policy_id) \ VALUES(?, ?, ?, ?, ?, ?)", ) .bind("lic1") .bind("p1") .bind("inv1") .bind("active") .bind(now) .bind("pol1") .execute(pool) .await?; sqlx::query( "INSERT INTO machines(id, license_id, fingerprint, fingerprint_hash, activated_at) \ VALUES(?, ?, ?, ?, ?)", ) .bind("mac1") .bind("lic1") .bind("raw-fingerprint") .bind("sha256hex") .bind(now) .execute(pool) .await?; sqlx::query( "INSERT INTO discount_codes(id, code, kind, amount, created_at, updated_at) \ VALUES(?, ?, ?, ?, ?, ?)", ) .bind("dc1") .bind("LAUNCH50") .bind("percent") .bind(5_000_i64) .bind(now) .bind(now) .execute(pool) .await?; sqlx::query( "INSERT INTO discount_redemptions(id, code_id, invoice_id, license_id, status, \ discount_applied_sats, base_price_sats, final_price_sats, created_at, updated_at) \ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind("red1") .bind("dc1") .bind("inv1") .bind("lic1") .bind("redeemed") .bind(5_000_i64) .bind(10_000_i64) .bind(5_000_i64) .bind(now) .bind(now) .execute(pool) .await?; sqlx::query( "INSERT INTO webhook_endpoints(id, url, secret, created_at, updated_at) \ VALUES(?, ?, ?, ?, ?)", ) .bind("wh1") .bind("https://example.com/hook") .bind("0123456789abcdef") .bind(now) .bind(now) .execute(pool) .await?; sqlx::query( "INSERT INTO webhook_deliveries(id, endpoint_id, event_type, payload_json, created_at) \ VALUES(?, ?, ?, ?, ?)", ) .bind("del1") .bind("wh1") .bind("license.issued") .bind("{}") .bind(now) .execute(pool) .await?; sqlx::query( "INSERT INTO tip_attempts(id, license_id, policy_id, recipient, amount_sats, pct_bps, \ status, created_at) \ VALUES(?, ?, ?, ?, ?, ?, ?, ?)", ) .bind("tip1") .bind("lic1") .bind("pol1") .bind("tips@example.com") .bind(50_i64) .bind(50_i64) .bind("sent") .bind(now) .execute(pool) .await?; Ok(()) } // ----------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------- /// Baseline. Every migration applies cleanly to an empty database. Until /// today, this was the only scenario the migration set was ever vetted /// against — and it's still missing as a real test. #[tokio::test] async fn all_migrations_apply_to_empty_db() { let (pool, _tmp) = make_pool().await; apply_all(&pool).await.expect("migrations apply to empty db"); assert_db_clean(&pool).await.expect("post-migration db is clean"); } /// The regression. With realistic data populated through migration 0008, /// migration 0009 must: /// - apply without crashing (no SQLite error 787), /// - preserve the existing discount_redemptions row, /// - preserve the existing discount_codes row, /// - accept the new `set_price` kind on a fresh insert, /// - still reject invalid kinds (CHECK constraint intact). /// /// Reverting `0009_discount_codes_set_price.sql` to the buggy first /// revision makes this test fail at the `apply_through(&pool, 9)` step /// with "FOREIGN KEY constraint failed" — same error operators saw in /// the StartOS service logs. #[tokio::test] async fn migration_0009_survives_existing_redemptions() { let (pool, _tmp) = make_pool().await; apply_range(&pool, 0, 8) .await .expect("apply migrations 0001 through 0008"); seed_realistic_fixtures(&pool) .await .expect("seed pre-0009 fixtures"); apply_range(&pool, 8, 9) .await .expect("0009 must survive a populated discount_redemptions"); assert_db_clean(&pool).await.expect("db clean after 0009"); let red_count: i64 = sqlx::query_scalar("SELECT count(*) FROM discount_redemptions") .fetch_one(&pool) .await .unwrap(); assert_eq!(red_count, 1, "discount_redemptions row preserved"); let code_count: i64 = sqlx::query_scalar("SELECT count(*) FROM discount_codes") .fetch_one(&pool) .await .unwrap(); assert_eq!(code_count, 1, "discount_codes row preserved"); sqlx::query( "INSERT INTO discount_codes(id, code, kind, amount, created_at, updated_at) \ VALUES('dc2', 'PRICE25', 'set_price', 2500, '2026-05-08', '2026-05-08')", ) .execute(&pool) .await .expect("the new set_price kind must now be accepted by CHECK"); let bad = sqlx::query( "INSERT INTO discount_codes(id, code, kind, amount, created_at, updated_at) \ VALUES('dc3', 'BAD', 'definitely_not_real', 0, '2026-05-08', '2026-05-08')", ) .execute(&pool) .await; assert!( bad.is_err(), "garbage kinds must still be rejected by the CHECK constraint" ); } /// Idempotency. The original 0009 incident left some operators with a /// checksum-mismatch path: clear the row from `_sqlx_migrations`, let the /// fixed 0009 re-apply. That re-apply path needs to be safe — running /// 0009 twice against the same DB must not corrupt or duplicate data. #[tokio::test] async fn migration_0009_is_idempotent() { let (pool, _tmp) = make_pool().await; apply_all(&pool).await.expect("first full apply"); seed_realistic_fixtures(&pool) .await .expect("seed after full apply"); let red_before: i64 = sqlx::query_scalar("SELECT count(*) FROM discount_redemptions") .fetch_one(&pool) .await .unwrap(); let code_before: i64 = sqlx::query_scalar("SELECT count(*) FROM discount_codes") .fetch_one(&pool) .await .unwrap(); // Pinned to migration 0009 by its filename prefix, not by // "last in the list" — once 0010+ land they may not be // idempotent (additive ALTER TABLE statements aren't), but // 0009's whole point was being safely re-runnable. let nine = migration_files() .into_iter() .find(|p| { p.file_name() .and_then(|s| s.to_str()) .map_or(false, |s| s.starts_with("0009_")) }) .expect("migration 0009 file must be present"); let sql = std::fs::read_to_string(&nine).unwrap(); let mut tx = pool.begin().await.unwrap(); sqlx::raw_sql(&sql) .execute(&mut *tx) .await .expect("0009 re-apply on already-migrated db"); tx.commit().await.unwrap(); let red_after: i64 = sqlx::query_scalar("SELECT count(*) FROM discount_redemptions") .fetch_one(&pool) .await .unwrap(); let code_after: i64 = sqlx::query_scalar("SELECT count(*) FROM discount_codes") .fetch_one(&pool) .await .unwrap(); assert_eq!(red_before, red_after, "redemptions unchanged on re-apply"); assert_eq!(code_before, code_after, "discount_codes unchanged on re-apply"); assert_db_clean(&pool).await.expect("db clean after re-apply"); } /// Regression for the v0.1.0:48 → :49 incident: the `_sqlx_migrations` /// table records a checksum for each applied migration; on every /// subsequent boot sqlx verifies the on-disk bytes still match. /// Builds across versions can produce subtly different bytes /// (trailing newlines, line-endings, build-host normalization) for /// the same semantic SQL, which makes sqlx refuse to start with /// "migration N was previously applied but has been modified" and /// crashes the daemon. /// /// `db::init` works around this by detecting the /// `MigrateError::VersionMismatch` for migrations on the /// `IDEMPOTENT_MIGRATIONS` allowlist (just `9` for now), clearing the /// stale row, and retrying. This test simulates the exact scenario: /// poison the recorded checksum for v9, run init, expect success. #[tokio::test] async fn db_init_self_heals_checksum_mismatch_on_idempotent_migrations() { let (pool, _tmp) = make_pool().await; // Step 1: apply all migrations cleanly to populate // _sqlx_migrations with current checksums. sqlx::migrate!("./migrations") .run(&pool) .await .expect("first apply"); // Step 2: poison the recorded checksum for v9. This simulates // the cross-build drift that triggered the production incident. let bogus_checksum: Vec = (0..48).map(|_| 0xEF).collect(); // Sha384 = 48 bytes let n = sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = 9") .bind(&bogus_checksum) .execute(&pool) .await .unwrap() .rows_affected(); assert_eq!(n, 1, "_sqlx_migrations should have a row for v9"); // Step 3: confirm sqlx::migrate! ALONE bails — proves the // poisoning works and that without self-heal the daemon would // crash here. let ungated = sqlx::migrate!("./migrations").run(&pool).await; assert!( matches!( ungated, Err(sqlx::migrate::MigrateError::VersionMismatch(9)) ), "raw sqlx::migrate! should reject the poisoned row: got {ungated:?}" ); // Step 4: drop the existing pool and call db::init on the same // file. The self-heal should clear v9's row, re-apply, succeed. let tmp_path = _tmp.path().to_path_buf(); drop(pool); drop(_tmp); let healed = keysat::db::init(&tmp_path) .await .expect("db::init should self-heal the poisoned v9 row"); // Sanity check: v9 is back in _sqlx_migrations with a fresh // (correct) checksum, and v10 is still there from the original // apply. let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations WHERE version IN (9, 10)") .fetch_one(&healed) .await .unwrap(); assert_eq!(count, 2, "both 9 and 10 should be recorded after self-heal"); // The poisoned checksum was replaced with the real one. let new_checksum: Vec = sqlx::query_scalar("SELECT checksum FROM _sqlx_migrations WHERE version = 9") .fetch_one(&healed) .await .unwrap(); assert_ne!( new_checksum, bogus_checksum, "self-heal must replace the poisoned checksum with the current one" ); } /// Migration 0010 (multi-currency foundation): verifies that the /// backfill correctly populates the new `price_currency` and /// `price_value` columns against products that existed before the /// migration. This is the contract the rest of the multi-currency /// build assumes — every existing row must end up with /// `price_currency = 'SAT'` and `price_value = price_sats`. #[tokio::test] async fn migration_0010_backfills_existing_products_to_sat() { let (pool, _tmp) = make_pool().await; apply_range(&pool, 0, 9) .await .expect("apply 0001..=0009 (everything before 0010)"); // Seed three products with different sat amounts (including 0 // for the free case) before 0010 runs. sqlx::query( "INSERT INTO products(id, slug, name, price_sats, created_at, updated_at) \ VALUES('pa', 'a', 'Product A', 0, 't', 't'), \ ('pb', 'b', 'Product B', 10000, 't', 't'), \ ('pc', 'c', 'Product C', 250000, 't', 't')", ) .execute(&pool) .await .expect("seed products"); // Seed a policy with a price override so the policy backfill // (price_value_override = price_sats_override) is exercised. sqlx::query( "INSERT INTO policies(id, product_id, name, slug, price_sats_override, \ created_at, updated_at) \ VALUES('pol1', 'pb', 'Pro', 'pro', 50000, 't', 't')", ) .execute(&pool) .await .expect("seed policy with override"); // Apply 0010. apply_range(&pool, 9, 10) .await .expect("apply 0010_multi_currency"); // After: every product has price_currency='SAT' and // price_value matches price_sats. let rows: Vec<(String, String, i64, i64)> = sqlx::query_as( "SELECT id, price_currency, price_value, price_sats \ FROM products ORDER BY id", ) .fetch_all(&pool) .await .unwrap(); assert_eq!(rows.len(), 3); for (id, currency, value, sats) in &rows { assert_eq!(currency, "SAT", "{id}: currency must default to SAT"); assert_eq!(value, sats, "{id}: price_value must mirror price_sats"); } // The policy override was backfilled. let pol: (Option, Option, Option) = sqlx::query_as( "SELECT price_currency_override, price_value_override, price_sats_override \ FROM policies WHERE id = 'pol1'", ) .fetch_one(&pool) .await .unwrap(); assert!( pol.0.is_none(), "currency_override should stay NULL = 'inherit from product'" ); assert_eq!(pol.1, Some(50000), "price_value_override backfilled"); assert_eq!(pol.2, Some(50000), "original price_sats_override preserved"); // The new currency index exists (uses CREATE INDEX IF NOT // EXISTS so this is implicit-correct, but assert the index is // there so a future schema rebuild can't silently lose it). let idx_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM sqlite_master \ WHERE type='index' AND name='idx_products_currency'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(idx_count, 1, "currency index should exist after 0010"); // FK + integrity invariants still hold. assert_db_clean(&pool).await.expect("db clean after 0010"); } /// Migration 0011 (subscriptions schema): verifies that adding the /// new policies columns + the subscriptions / subscription_invoices /// tables doesn't break existing data, and that the new tables /// accept rows via FK references back to licenses / policies / /// invoices created under the prior schema. #[tokio::test] async fn migration_0011_adds_subscriptions_without_breaking_existing_data() { let (pool, _tmp) = make_pool().await; // Apply everything before 0011, populate realistic state. apply_range(&pool, 0, 10) .await .expect("apply 0001..=0010"); seed_realistic_fixtures(&pool) .await .expect("seed pre-0011 fixtures"); // Apply 0011. apply_range(&pool, 10, 11) .await .expect("apply 0011_subscriptions"); // New policies columns exist with sensible defaults on existing rows. let (is_recurring, period, grace, trial): (i64, Option, i64, i64) = sqlx::query_as( "SELECT is_recurring, renewal_period_days, grace_period_days, trial_days \ FROM policies WHERE id = 'pol1'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(is_recurring, 0, "existing policies must default to non-recurring"); assert_eq!(period, None, "renewal_period_days should be NULL on non-recurring rows"); assert_eq!(grace, 7, "grace_period_days default should be 7"); assert_eq!(trial, 0, "trial_days default should be 0"); // The new tables exist and accept a subscription tied to the // existing fixture license. let now = "2026-05-08T12:00:00Z"; sqlx::query( "INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \ listed_currency, listed_value, status, started_at, next_renewal_at, \ created_at, updated_at) \ VALUES('sub1', 'lic1', 'pol1', 'p1', 30, 'USD', 2500, 'active', ?, ?, ?, ?)", ) .bind(now) .bind("2026-06-08T12:00:00Z") .bind(now) .bind(now) .execute(&pool) .await .expect("insert subscription with FKs into pre-0011 rows"); sqlx::query( "INSERT INTO subscription_invoices(id, subscription_id, invoice_id, cycle_number, \ cycle_start_at, cycle_end_at, created_at) \ VALUES('si1', 'sub1', 'inv1', 1, ?, ?, ?)", ) .bind(now) .bind("2026-06-08T12:00:00Z") .bind(now) .execute(&pool) .await .expect("subscription_invoices accepts rows"); // Status CHECK constraint enforced. let bad = sqlx::query( "INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \ listed_currency, listed_value, status, started_at, created_at, updated_at) \ VALUES('sub2', 'lic1', 'pol1', 'p1', 30, 'USD', 2500, 'garbage', ?, ?, ?)", ) .bind(now) .bind(now) .bind(now) .execute(&pool) .await; assert!( bad.is_err(), "unknown subscription status should be rejected by CHECK" ); // The cycle_number UNIQUE constraint prevents accidental // double-billing for the same cycle. let dup = sqlx::query( "INSERT INTO subscription_invoices(id, subscription_id, invoice_id, cycle_number, \ cycle_start_at, cycle_end_at, created_at) \ VALUES('si2', 'sub1', 'inv1', 1, ?, ?, ?)", ) .bind(now) .bind("2026-06-08T12:00:00Z") .bind(now) .execute(&pool) .await; assert!( dup.is_err(), "(subscription_id, cycle_number) must be UNIQUE — same cycle twice should fail" ); // FK + integrity invariants. assert_db_clean(&pool).await.expect("db clean after 0011"); } /// Migration 0013 (tier upgrades schema): verifies that adding the /// new `policies.tier_rank` column + the `tier_changes` table /// doesn't break existing data, and that the new table accepts rows /// via FK references back to licenses / policies / invoices created /// under the prior schema. #[tokio::test] async fn migration_0013_adds_tier_upgrades_without_breaking_existing_data() { let (pool, _tmp) = make_pool().await; // Apply everything before 0013, populate realistic state. let total = migration_files().len(); assert!(total >= 13, "need 13+ migrations to test 0013 in context"); apply_range(&pool, 0, 12) .await .expect("apply 0001..=0012"); seed_realistic_fixtures(&pool) .await .expect("seed pre-0013 fixtures"); // Apply 0013. apply_range(&pool, 12, 13) .await .expect("apply 0013_tier_upgrades"); // The new column exists with NULL default on existing rows // (existing operators didn't opt into tier ladders yet). let rank: Option = sqlx::query_scalar( "SELECT tier_rank FROM policies WHERE id = 'pol1'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!( rank, None, "existing policies must default to NULL tier_rank (out of any ladder)" ); // The new tier_changes table accepts a row referencing the // pre-existing fixture license + policy + invoice. let now = "2026-05-08T12:00:00Z"; sqlx::query( "INSERT INTO tier_changes(id, license_id, from_policy_id, to_policy_id, \ direction, listed_currency, proration_charge_value, invoice_id, \ effective_at, actor, reason, created_at) \ VALUES('tc1', 'lic1', 'pol1', 'pol1', 'upgrade', 'USD', 3333, \ 'inv1', ?, 'buyer', 'test upgrade', ?)", ) .bind(now) .bind(now) .execute(&pool) .await .expect("tier_changes accepts row with FKs into pre-0013 fixture rows"); // CHECK constraints enforced: bad direction value rejected. let bad_direction = sqlx::query( "INSERT INTO tier_changes(id, license_id, from_policy_id, to_policy_id, \ direction, listed_currency, effective_at, actor, created_at) \ VALUES('tc2', 'lic1', 'pol1', 'pol1', 'sideways', 'USD', ?, 'buyer', ?)", ) .bind(now) .bind(now) .execute(&pool) .await; assert!( bad_direction.is_err(), "tier_changes.direction must be 'upgrade' or 'downgrade'" ); // CHECK enforced: bad actor value rejected. let bad_actor = sqlx::query( "INSERT INTO tier_changes(id, license_id, from_policy_id, to_policy_id, \ direction, listed_currency, effective_at, actor, created_at) \ VALUES('tc3', 'lic1', 'pol1', 'pol1', 'upgrade', 'USD', ?, 'system', ?)", ) .bind(now) .bind(now) .execute(&pool) .await; assert!( bad_actor.is_err(), "tier_changes.actor must be 'buyer' or 'admin'" ); // CHECK enforced: negative proration value rejected (operator // typo or buggy quote logic should fail loudly, not silently // store a refund-shaped row in an upgrade-shaped table). let bad_proration = sqlx::query( "INSERT INTO tier_changes(id, license_id, from_policy_id, to_policy_id, \ direction, listed_currency, proration_charge_value, effective_at, \ actor, created_at) \ VALUES('tc4', 'lic1', 'pol1', 'pol1', 'upgrade', 'USD', -100, ?, 'admin', ?)", ) .bind(now) .bind(now) .execute(&pool) .await; assert!( bad_proration.is_err(), "tier_changes.proration_charge_value must be >= 0" ); // FK + integrity invariants overall. assert_db_clean(&pool).await.expect("db clean after 0013"); } /// Migration 0014 (product entitlements catalog): verifies that /// adding `products.entitlements_catalog_json` doesn't break existing /// data, that the backfill correctly derives a catalog from existing /// policy entitlements (with underscore-stripped names), and that /// products with no policy entitlements get NULL. #[tokio::test] async fn migration_0014_backfills_entitlements_catalog_from_policies() { let (pool, _tmp) = make_pool().await; let total = migration_files().len(); assert!(total >= 14, "need 14+ migrations to test 0014 in context"); apply_range(&pool, 0, 13) .await .expect("apply 0001..=0013"); seed_realistic_fixtures(&pool) .await .expect("seed pre-0014 fixtures"); // Add a second product with policies that carry varied // entitlements so the backfill has interesting data to derive // from. seed_realistic_fixtures gave us p1 with pol1; we add // p2 with two policies covering different entitlements (with // some overlap, to exercise the DISTINCT path). let now = "2026-05-08T12:00:00Z"; sqlx::query( "INSERT INTO products(id, slug, name, description, price_sats, \ active, metadata_json, created_at, updated_at) \ VALUES('p2', 'recap', 'Recap', '', 25000, 1, '{}', ?, ?)", ) .bind(now) .bind(now) .execute(&pool) .await .unwrap(); sqlx::query( "INSERT INTO policies(id, product_id, name, slug, duration_seconds, \ grace_seconds, max_machines, is_trial, entitlements_json, \ metadata_json, active, public, tip_pct_bps, created_at, updated_at) \ VALUES('pol_core','p2','Core','core',0,0,1,0, \ '[\"core\",\"history\"]','{}',1,1,0,?,?), \ ('pol_pro','p2','Pro','pro',0,0,3,0, \ '[\"core\",\"history\",\"ai_summaries\",\"library_io\"]','{}',1,1,0,?,?)", ) .bind(now).bind(now).bind(now).bind(now) .execute(&pool) .await .unwrap(); // Apply 0014. apply_range(&pool, 13, 14) .await .expect("apply 0014_product_entitlements_catalog"); // Recap's catalog should contain {core, history, ai_summaries, // library_io} (union of both policies' entitlements). Order is // not guaranteed; just verify the set. let cat: String = sqlx::query_scalar( "SELECT entitlements_catalog_json FROM products WHERE id = 'p2'", ) .fetch_one(&pool) .await .unwrap(); let parsed: Vec = serde_json::from_str(&cat).unwrap(); let slugs: std::collections::HashSet = parsed .iter() .map(|v| v["slug"].as_str().unwrap().to_string()) .collect(); assert!(slugs.contains("core"), "expected catalog to include 'core'"); assert!(slugs.contains("history"), "expected catalog to include 'history'"); assert!(slugs.contains("ai_summaries"), "expected catalog to include 'ai_summaries'"); assert!(slugs.contains("library_io"), "expected catalog to include 'library_io'"); assert_eq!(slugs.len(), 4, "no duplicates expected: {slugs:?}"); // Display name = slug with underscores → spaces. let ai_entry = parsed .iter() .find(|v| v["slug"] == "ai_summaries") .unwrap(); assert_eq!(ai_entry["name"], "ai summaries"); assert_eq!(ai_entry["description"], ""); // p1 from seed_realistic_fixtures may or may not have policy // entitlements (depends on fixture); if no entitlements anywhere, // its catalog should be NULL (not an empty array). Either way, // products with no policies-with-entitlements should get NULL. sqlx::query( "INSERT INTO products(id, slug, name, description, price_sats, \ active, metadata_json, created_at, updated_at) \ VALUES('p3', 'no-ents', 'No Entitlements', '', 0, 1, '{}', ?, ?)", ) .bind(now).bind(now) .execute(&pool).await.unwrap(); // Re-run the backfill manually since 0014 already applied — for // p3 to appear with the right state we'd need to apply 0014 // after p3 exists. Instead just verify the column accepts NULL // and round-trips a hand-set value: let null_cat: Option = sqlx::query_scalar( "SELECT entitlements_catalog_json FROM products WHERE id = 'p3'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(null_cat, None, "fresh products default to NULL catalog"); // Round-trip: operator-set value persists. let manual = r#"[{"slug":"foo","name":"Foo","description":"the foo"}]"#; sqlx::query( "UPDATE products SET entitlements_catalog_json = ? WHERE id = 'p3'", ) .bind(manual) .execute(&pool) .await .unwrap(); let got: String = sqlx::query_scalar( "SELECT entitlements_catalog_json FROM products WHERE id = 'p3'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(got, manual); assert_db_clean(&pool).await.expect("db clean after 0014"); } /// Future-proofing. Always seeds fixtures one migration before the end, /// then applies the final migration. As new migrations land (0010, /// 0011, …), they get vetted against populated data automatically; no /// new test needs to be written. If a future migration introduces the /// same DROP+inbound-FK pattern that bit 0009, this test catches it. #[tokio::test] async fn last_migration_preserves_foreign_keys_with_data() { let (pool, _tmp) = make_pool().await; let total = migration_files().len(); assert!(total >= 2, "need at least 2 migrations for this test"); apply_range(&pool, 0, total - 1) .await .expect("apply all but the last migration"); seed_realistic_fixtures(&pool) .await .expect("seed before the final migration"); apply_range(&pool, total - 1, total) .await .expect("final migration applies cleanly with data present"); assert_db_clean(&pool) .await .expect("db clean after final migration"); }