Files
Lumotia/crates/storage/tests/legacy_db_migration.rs
Jake 43d319fd5a agent: lumotia — Phase A.1+A.2 rebrand migration tests + copy_dir_recursive hardening
Phase A of dogfood verification for the Magnotia -> Lumotia rebrand
cascade. The existing in-crate unit tests prove the migration copies
bytes correctly; this commit closes the gaps an atomiser-grade review
would flag.

Phase A.1 — end-to-end integration test (crates/storage/tests/legacy_db_migration.rs):

  Seeds a real on-disk magnotia.db via lumotia_storage::init (which runs
  every schema migration head-to-tail), inserts a transcript via the
  public API, drops the pool, runs migrate_legacy_data_dir_with_pairs,
  then re-opens the migrated lumotia.db and asserts the transcript is
  queryable. Three scenarios covered:
    1. Legacy-only -> migrate -> reopen -> row survives. Also verifies a
       non-DB companion file is carried along by the directory rename.
    2. Idempotency: first boot migrates, user writes new data, second
       boot is a no-op and BOTH rows survive.
    3. Both-paths-present: refuses to merge, target's empty DB is
       preserved, legacy retained on disk as a backup.

  Wires the test surface by renaming the previously-private
  migrate_legacy_data_dir_inner to pub migrate_legacy_data_dir_with_pairs
  (mirroring migrate_tauri_app_data_dir_with_paths in the sibling
  tauri_app_data_migration module).

Phase A.2a — copy_dir_recursive hardening (crates/core/src/paths.rs):

  Pre-existing footgun: the fall-through branch called std::fs::copy()
  on any DirEntry that was not a symlink or a directory. On Unix that
  includes FIFOs, sockets, and char/block device nodes. Opening a FIFO
  for read with no writer attached blocks forever — a stale debug FIFO
  in the user's ~/.magnotia tree would silently hang first launch.

  The branch now explicitly distinguishes is_file() (real regular file
  -> copy) from anything else (-> Err with ErrorKind::Unsupported,
  naming the offending path). Migration becomes re-runnable once the
  user cleans up the offending node. Same-filesystem rename via
  std::fs::rename is atomic and unaffected; only the EXDEV fallback path
  touches the new guard.

Phase A.2b — three adversarial probes (crates/core/src/paths.rs tests):

  - FIFO inside the legacy tree: copy_dir_recursive must return an
    Unsupported error WITHOUT hanging. Test bounded by a 5s wall clock
    + a worker thread so a regression to the old fall-through would
    surface as a panic, not a stalled CI job.
  - Unreadable file (mode 0000): copy_dir_recursive must surface
    PermissionDenied, not silently skip. Skips its core assertion under
    euid 0 (root bypasses DAC permissions, would mask the regression).
  - Dangling symlink (target nonexistent): symlink is recreated at
    destination with link target preserved verbatim; the migration
    does NOT try to dereference and does NOT abort the rest of the copy.

Verification:
- cargo fmt --check: clean
- cargo clippy --workspace --all-targets -- -D warnings: clean
- cargo test --workspace: 409 passed, 0 failed (up from 405 pre-commit;
  3 storage integration tests + 3 paths adversarial + 1 net carry-over)
2026-05-14 07:20:18 +01:00

283 lines
11 KiB
Rust

//! 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 `<legacy>/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"
);
}