Files
VoiceStudio/backend/migrations/env.py
T
d57ecea804 fix(test): DB migration-safety tests leak-proof against full-suite order (#909 follow-up) (#917)
The PR #909 data-safe-update tests passed in isolation but failed only in
full-suite CI order. Two independent, order-dependent leaks were at play:

1. Module-identity leak (the #878/#894 class). The `isolated_db`/`fresh_app`/
   `fresh_resolver` fixtures in tests/backend/** purge `core.*`/`services.*`
   from `sys.modules` and never restore them, so `sys.modules["core.db"]`
   afterward is a DIFFERENT object than the one the migration-safety tests
   imported at collection. `monkeypatch.setattr("core.db.DB_PATH", ...)`
   re-resolved the dotted string to the re-imported module, while
   `_run_alembic_upgrade`/`init_db` (bound at collection) kept reading the
   ORIGINAL module's globals — so the patch missed and the upgrade ran against
   the ambient session DB. Result: no backup at the asserted path, and the
   mid-flight-failure injection never hit the expected DB (DID NOT RAISE).
   The same divergence hit the lazy `from core import db_backup` inside
   `_run_alembic_upgrade`, so patching `MAX_BACKUP_DB_BYTES` was silently lost.

2. Logger-disable leak. Alembic's env.py called `fileConfig(...)` with the
   default `disable_existing_loggers=True`, which disabled the already-created
   `omnivoice.db.backup` logger the first time any earlier test ran a real
   `alembic upgrade` — so the oversized-DB "Skipping pre-migration DB backup"
   line was never emitted and the caplog assertion failed. This also silently
   mutes the live app's logging after a real startup migration.

Fixes:
- env.py: `fileConfig(..., disable_existing_loggers=False)` so a migration
  never mutes the app's (or another test's) loggers.
- core/db.py: import `db_backup`/`APP_VERSION` at module level so
  `_run_alembic_upgrade` uses a stable reference immune to a `sys.modules`
  purge, matching what tests patch at collection.
- test_db_migration_safety.py: patch DB_PATH on the imported `core.db` module
  object rather than the re-resolvable dotted string — the correct,
  self-contained seam.

Verified: the four migration-safety tests + the oversized-backup test pass in
full-suite order and in isolation; full `pytest tests/` is green
(2206 passed, 0 failed).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:13:12 +05:30

72 lines
2.8 KiB
Python

"""Alembic environment for OmniVoice Studio.
DB URL is computed from `core.config.DB_PATH` at runtime so Alembic honours
the same `OMNIVOICE_DATA_DIR` override the app does. We use SQLite, so both
offline (SQL-scripted) and online (live-connection) paths are supported.
We do NOT use SQLAlchemy models — the schema lives in `core/db.py`'s
`_BASE_SCHEMA`. Migrations are hand-written using raw `op.execute(...)`
or the typed helpers (`op.add_column`, etc.). No autogeneration.
"""
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from core.config import DB_PATH # noqa: E402 — backend/ is on sys.path via alembic.ini
config = context.config
if config.config_file_name is not None:
# `disable_existing_loggers=False` is deliberate: this env runs *inside* the
# live app (startup `alembic upgrade head`), so the default (True) would
# disable every already-created application logger — e.g. silence
# `omnivoice.db.backup`'s "Skipping pre-migration DB backup" line and the
# rest of the app's logging for the remainder of the process. A migration
# must never mute the app (or leak that mute across a test session).
fileConfig(config.config_file_name, disable_existing_loggers=False)
# SQLite file URL. Honour an externally-set URL (tests pass one via
# `cfg.set_main_option("sqlalchemy.url", ...)` to point at a fixture DB),
# otherwise resolve from `core.config.DB_PATH` so production runs respect
# the `OMNIVOICE_DATA_DIR` override.
if not config.get_main_option("sqlalchemy.url"):
config.set_main_option("sqlalchemy.url", f"sqlite:///{DB_PATH}")
target_metadata = None # no SQLAlchemy models — hand-written migrations only.
def run_migrations_offline() -> None:
"""Emit SQL to stdout without connecting to the DB."""
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True, # SQLite-safe ALTER
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Apply migrations against a live SQLite connection."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True, # SQLite-safe ALTER
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()