A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160//! Upgrade migration tests: verify the current migration set applies cleanly on//! top of an *old* database (schema + data), not just a fresh one.//!//! Baseline is **v2.0.0**: we stage the migrations that shipped in v2.0.0 (those//! whose timestamp is ≤ the v2.0.0 cutoff), apply them to an empty database to//! reproduce a v2.0.0 install, seed a row, then run the full current migration//! set and assert the upgrade succeeds and the data survived (e.g. across the//! `records` → `happyview_records` prefix rename).//!//! - SQLite runs everywhere (isolated temp-file database).//! - Postgres runs only when `TEST_DATABASE_URL` points at Postgres; it spins up//! a throwaway database so it never touches the shared test schema.
use sqlx::AnyPool;use sqlx::migrate::Migrator;use std::path::Path;
mod common;
/// Timestamp of v2.0.0's final migration (`20260416000003_add_dpop_session_token_hash`)./// Migrations with a timestamp ≤ this constitute the v2.0.0 baseline.const V2_0_0_CUTOFF: &str = "20260416000003";
// Fixed test row, written into the pre-rename `records` table and expected to// survive into `happyview_records`. Literal SQL keeps it backend-agnostic// (`'{"v":1}'` is a valid string literal for both TEXT and Postgres JSONB).// All columns are set explicitly (an ISO-8601 string both TEXT and TIMESTAMPTZ// accept) rather than relying on defaults, so the seed doesn't depend on the// baseline's default state.const SEED_INSERT: &str = "INSERT INTO records (uri, did, collection, rkey, record, cid, indexed_at, created_at) \ VALUES ('at://did:plc:legacy/app.test.post/rk1', 'did:plc:legacy', 'app.test.post', 'rk1', '{\"v\":1}', 'bafylegacycid', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')";const SEED_ASSERT: &str = "SELECT did FROM happyview_records WHERE uri = 'at://did:plc:legacy/app.test.post/rk1'";
/// Copy every `.sql` migration in `src` whose 14-digit timestamp prefix is/// ≤ `cutoff` into `dest`, reproducing the migration set of that release.fn stage_baseline_migrations(src: &str, dest: &Path, cutoff: &str) { std::fs::create_dir_all(dest).expect("create staging dir"); let mut staged = 0usize; for entry in std::fs::read_dir(src).expect("read migrations dir") { let entry = entry.expect("dir entry"); let name = entry.file_name().to_string_lossy().into_owned(); if !name.ends_with(".sql") || name.len() < 14 { continue; } if &name[..14] <= cutoff { std::fs::copy(entry.path(), dest.join(&name)).expect("copy migration"); staged += 1; } } assert!(staged > 0, "no baseline migrations staged from {src}");}
/// Reproduce a v2.0.0 install on `pool`, seed a row, then run the current/// migrations on top and assert the row survived.async fn assert_upgrade_preserves_data(pool: &AnyPool, migrations_dir: &str) { let baseline_dir = std::env::temp_dir().join(format!("hv-baseline-{}", uuid::Uuid::new_v4())); stage_baseline_migrations(migrations_dir, &baseline_dir, V2_0_0_CUTOFF);
// 1. Reproduce a v2.0.0 install. Migrator::new(baseline_dir.as_path()) .await .expect("load baseline migrations") .run(pool) .await .expect("v2.0.0 migrations should apply to an empty database");
// 2. Seed a record using the v2.0.0 `records` schema (pre prefix-rename). sqlx::query(SEED_INSERT) .execute(pool) .await .expect("seed a v2.0.0-era record");
// 3. Upgrade: apply the full current migration set on top of the v2.0.0 schema. Migrator::new(Path::new(migrations_dir)) .await .expect("load current migrations") .run(pool) .await .expect("current migrations should apply on top of the v2.0.0 schema");
// 4. The seeded row survived into the renamed table. let (did,): (String,) = sqlx::query_as(SEED_ASSERT) .fetch_one(pool) .await .expect("seeded record should survive the upgrade"); assert_eq!(did, "did:plc:legacy");
let _ = std::fs::remove_dir_all(&baseline_dir);}
#[tokio::test]async fn sqlite_upgrade_from_v2_0_0_applies_and_preserves_data() { sqlx::any::install_default_drivers();
let tmp_db = std::env::temp_dir().join(format!("hv-upgrade-{}.db", uuid::Uuid::new_v4())); let url = format!("sqlite://{}?mode=rwc", tmp_db.display()); let pool = AnyPool::connect(&url) .await .expect("connect to fresh sqlite database");
assert_upgrade_preserves_data(&pool, "migrations/sqlite").await;
pool.close().await; let _ = std::fs::remove_file(&tmp_db);}
#[tokio::test]#[serial_test::serial]async fn postgres_upgrade_from_v2_0_0_applies_and_preserves_data() { common::require_db!(); sqlx::any::install_default_drivers(); if common::db::test_backend() != happyview::db::DatabaseBackend::Postgres { eprintln!("skipped (TEST_DATABASE_URL is not Postgres)"); return; }
let base_url = std::env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL"); let (server, _db) = base_url .rsplit_once('/') .expect("TEST_DATABASE_URL should have a database path"); let scratch_db = format!("hv_upgrade_{}", uuid::Uuid::new_v4().simple());
// Create a throwaway database on the same server so the upgrade runs in // isolation from the shared (already-migrated) test schema. let admin = AnyPool::connect(&base_url) .await .expect("connect to admin database"); sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {scratch_db}"))) .execute(&admin) .await .expect("create scratch database");
let scratch_url = format!("{server}/{scratch_db}"); let pool = AnyPool::connect(&scratch_url) .await .expect("connect to scratch database");
// Run the upgrade, but catch a panic so the scratch database is always // dropped even when an assertion fails — then re-raise so the test still fails. use futures::FutureExt; let outcome = std::panic::AssertUnwindSafe(assert_upgrade_preserves_data(&pool, "migrations/postgres")) .catch_unwind() .await;
pool.close().await; // FORCE terminates any lingering backend connections so the drop can't hang. let _ = sqlx::query(sqlx::AssertSqlSafe(format!( "DROP DATABASE {scratch_db} WITH (FORCE)" ))) .execute(&admin) .await; admin.close().await;
if let Err(panic) = outcome { std::panic::resume_unwind(panic); }}