//! End-to-end migration test for the Magnotia -> Lumotia rebrand. //! //! The in-crate unit tests in `crates/core/src/paths.rs` prove that the //! migration copies bytes and renames files correctly, but they don't //! verify that the resulting `lumotia.db` is *openable* by //! `lumotia-storage`. A byte-perfect copy with a torn SQLite header, //! a stale WAL pointer, or a row count off by one would still pass //! those tests. This integration test closes that gap by: //! //! 1. Seeding a real on-disk `magnotia/magnotia.db` via the public //! `lumotia_storage::init` API (which runs every schema migration //! head-to-tail). A real transcript row is inserted via //! `insert_transcript`, then the pool is dropped to flush + close. //! 2. Running the rebrand migration via //! `lumotia_core::paths::migrate_legacy_data_dir_with_pairs` with //! the synthesised (legacy, target) pair. //! 3. Re-opening the migrated `lumotia/lumotia.db` via `init` //! (which re-runs migrations — they must be no-ops against the //! already-migrated schema) and querying for the inserted //! transcript by id. //! //! If any of the three steps fails — rename, reopen, or query — the //! migration is unsafe to ship even though the unit tests pass. use std::path::Path; use lumotia_core::paths::{migrate_legacy_data_dir_with_pairs, MigrationStatus}; use lumotia_storage::{ get_transcript, init, insert_transcript, list_transcripts, InsertTranscriptParams, DEFAULT_PROFILE_ID, }; use tempfile::TempDir; const SEEDED_TRANSCRIPT_ID: &str = "t-rebrand-survivor"; const SEEDED_TRANSCRIPT_TEXT: &str = "Migration sentinel row. If this read fails, the rebrand orphaned user data."; const SEEDED_TRANSCRIPT_TITLE: &str = "rebrand-survivor"; /// Drop the pool and yield long enough for sqlx's background reaper to /// close its connections. The integration test re-opens the migrated DB /// immediately afterwards, so any sqlx-held file descriptor on Windows /// would block the rename. tokio's `yield_now` is a single scheduler /// hop, not a sleep — enough to flush the pool's tokio task queue. async fn drop_and_yield(pool: sqlx::SqlitePool) { pool.close().await; tokio::task::yield_now().await; } /// Build the canonical seed-row params. Centralised so the assertions /// in each test can reference the same expected values without drift. fn seed_params() -> InsertTranscriptParams<'static> { InsertTranscriptParams { id: SEEDED_TRANSCRIPT_ID, text: SEEDED_TRANSCRIPT_TEXT, source: "microphone", profile_id: DEFAULT_PROFILE_ID, title: Some(SEEDED_TRANSCRIPT_TITLE), audio_path: None, duration: 2.5, engine: Some("whisper"), model_id: Some("whisper-tiny-en"), inference_ms: Some(420), sample_rate: Some(16_000), audio_channels: Some(1), format_mode: Some("plain"), remove_fillers: false, british_english: true, anti_hallucination: false, } } /// Seed `/magnotia.db` with the live schema head and one /// transcript row. Returns nothing; the asserts live in the test body. async fn seed_legacy_db(legacy_dir: &Path) { std::fs::create_dir_all(legacy_dir).expect("create legacy dir"); let legacy_db = legacy_dir.join("magnotia.db"); let pool = init(&legacy_db).await.expect("init legacy magnotia.db"); insert_transcript(&pool, &seed_params()) .await .expect("insert seed transcript into legacy db"); drop_and_yield(pool).await; assert!( legacy_db.exists(), "legacy magnotia.db should be on disk after pool drop" ); } #[tokio::test(flavor = "multi_thread")] async fn legacy_db_survives_rebrand_and_remains_queryable() { let tmp = TempDir::new().expect("tempdir"); let legacy = tmp.path().join("magnotia"); let target = tmp.path().join("lumotia"); seed_legacy_db(&legacy).await; // Bonus: drop a non-DB file inside the legacy tree to confirm the // migration sweeps the whole directory, not just the .db file. let companion = legacy .join("recordings") .join("2026-05-13") .join("clip.wav"); std::fs::create_dir_all(companion.parent().unwrap()).expect("nested dir"); std::fs::write(&companion, b"fake-wav-bytes").expect("write companion file"); // Migrate. Blocking I/O is fine inside the multi-thread flavour. let statuses = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())]) .expect("migration must not error"); assert_eq!(statuses.len(), 1, "single pair must yield single status"); match &statuses[0] { MigrationStatus::Migrated { from, to, renamed_db, } => { assert_eq!(from, &legacy); assert_eq!(to, &target); assert!(renamed_db, "magnotia.db must have been renamed"); } other => panic!("expected Migrated, got {other:?}"), } assert!(!legacy.exists(), "legacy dir should be gone after rename"); assert!(target.exists(), "target dir should exist after rename"); let migrated_db = target.join("lumotia.db"); assert!(migrated_db.exists(), "lumotia.db must be at new path"); assert!( !target.join("magnotia.db").exists(), "old db filename must not survive at new path" ); let migrated_companion = target .join("recordings") .join("2026-05-13") .join("clip.wav"); assert!( migrated_companion.exists(), "non-DB files inside legacy must be carried along" ); assert_eq!( std::fs::read(&migrated_companion).expect("read migrated companion"), b"fake-wav-bytes", "non-DB file contents must survive verbatim" ); // Re-open via the same public API a fresh app boot would use. This // also re-runs `run_migrations`, which must be idempotent against // the already-migrated schema. let pool = init(&migrated_db) .await .expect("init migrated lumotia.db must succeed"); let found = get_transcript(&pool, SEEDED_TRANSCRIPT_ID) .await .expect("get_transcript must not error against migrated db") .expect("seeded transcript must survive the migration"); assert_eq!(found.id, SEEDED_TRANSCRIPT_ID); assert_eq!(found.text, SEEDED_TRANSCRIPT_TEXT); assert_eq!(found.title.as_deref(), Some(SEEDED_TRANSCRIPT_TITLE)); // And the list view sees exactly one row, ruling out duplicates or // schema-rebuild-from-empty (which would yield zero). let listed = list_transcripts(&pool, 100) .await .expect("list_transcripts must not error against migrated db"); assert_eq!( listed.len(), 1, "exactly one transcript should be visible post-migration" ); assert_eq!(listed[0].id, SEEDED_TRANSCRIPT_ID); drop_and_yield(pool).await; } #[tokio::test(flavor = "multi_thread")] async fn rerun_after_migration_is_idempotent_and_preserves_user_state() { // First boot: migrate. Second boot: legacy preserved? Per the design // the legacy DOES NOT preserve (rename moves it; see paths.rs // rename_or_copy_tree). So the second run sees no legacy and yields // NoLegacyFound. This test exists to nail down that contract — if a // future change starts copying-rather-than-renaming, the test will // catch the subsequent loss of idempotency. let tmp = TempDir::new().expect("tempdir"); let legacy = tmp.path().join("magnotia"); let target = tmp.path().join("lumotia"); seed_legacy_db(&legacy).await; let first = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())]) .expect("first migration"); assert!(matches!(first[0], MigrationStatus::Migrated { .. })); // User immediately starts using the app: writes new data to the // migrated DB. The follow-up reboot must NOT clobber this. let pool = init(&target.join("lumotia.db")) .await .expect("post-migrate init"); insert_transcript( &pool, &InsertTranscriptParams { id: "t-post-migration", text: "Written after the rebrand boot.", ..seed_params() }, ) .await .expect("insert new row post-migration"); drop_and_yield(pool).await; // Second boot: no legacy on disk, nothing to do. Note empty input // yields a single NoLegacyFound entry by contract. let second = migrate_legacy_data_dir_with_pairs(Vec::new()).expect("second migration"); assert_eq!(second, vec![MigrationStatus::NoLegacyFound]); // Open and confirm both rows present + user's post-migration write // intact. let pool = init(&target.join("lumotia.db")) .await .expect("reopen after second boot"); let listed = list_transcripts(&pool, 100).await.expect("list"); let ids: Vec<_> = listed.iter().map(|r| r.id.as_str()).collect(); assert!( ids.contains(&SEEDED_TRANSCRIPT_ID), "pre-migration row should survive second boot: {ids:?}" ); assert!( ids.contains(&"t-post-migration"), "post-migration row should survive second boot: {ids:?}" ); drop_and_yield(pool).await; } #[tokio::test(flavor = "multi_thread")] async fn both_paths_present_refuses_to_overwrite_user_data() { // Reproduces the "stale legacy + freshly-installed lumotia" scenario: // user reinstalls after deleting their data dir manually, or runs an // older + newer build side-by-side. The migration must NOT clobber // the lumotia-side DB even if the legacy contains a richer one — // user authority on what's authoritative. let tmp = TempDir::new().expect("tempdir"); let legacy = tmp.path().join("magnotia"); let target = tmp.path().join("lumotia"); // Both DBs exist; the legacy has the seeded row, the target is // freshly initialised with no rows. seed_legacy_db(&legacy).await; let pool = init(&target.join("lumotia.db")).await.expect("seed target"); drop_and_yield(pool).await; let statuses = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())]) .expect("migration must not error in both-exist case"); assert_eq!(statuses.len(), 1); assert_eq!( statuses[0], MigrationStatus::TargetAlreadyExists { target: target.clone() } ); // Critical: target's empty DB must be untouched. let pool = init(&target.join("lumotia.db")) .await .expect("reopen target post-decision"); let listed = list_transcripts(&pool, 100).await.expect("list"); assert_eq!( listed.len(), 0, "target's user-installed empty DB must not have been merged with legacy" ); drop_and_yield(pool).await; // And the legacy DB must still be on disk for manual recovery — // we don't quietly delete user data the migration refused to copy. assert!( legacy.join("magnotia.db").exists(), "legacy DB must be preserved as a backup when target exists" ); }