| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- """Migration coverage for removing user_wallets.currency (#3123).
- An install has one currency, held in the ``currency`` app setting. The column
- recorded whatever was configured when a wallet row happened to be created, and
- three of its four writers hardcoded "EUR", so it could only ever disagree with
- the setting. Dropping it from the model alone would leave every existing
- database carrying a column nothing reads.
- """
- import pytest
- from sqlalchemy import text
- from sqlalchemy.ext.asyncio import create_async_engine
- from backend.app.core.database import Base, run_migrations
- def _register_all_models():
- """run_migrations touches many tables; the whole schema has to exist.
- Same list as test_vp_mode_rename_migration.py -- importing only the finance
- models leaves run_migrations ALTERing tables create_all never built.
- """
- from backend.app.models import ( # noqa: F401
- ams_history,
- ams_label,
- api_key,
- archive,
- color_catalog,
- external_link,
- filament,
- finance,
- group,
- kprofile_note,
- maintenance,
- notification,
- notification_template,
- print_log,
- print_queue,
- printer,
- project,
- project_bom,
- settings,
- slot_preset,
- smart_plug,
- smart_plug_energy_snapshot,
- spool,
- spool_assignment,
- spool_catalog,
- spool_k_profile,
- spool_usage_history,
- spoolbuddy_device,
- user,
- user_email_pref,
- virtual_printer,
- )
- @pytest.fixture(autouse=True)
- def force_sqlite_dialect(monkeypatch):
- """run_migrations branches on the global dialect, not on the connection.
- settings.database_url may point at Postgres in a dev config, which would
- run the Postgres branch against the SQLite engine below. Same fixture as
- test_billing_run_id_migration.py.
- """
- from backend.app.core import db_dialect
- monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
- monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
- # database.py imported is_sqlite at module load time — patch there too.
- from backend.app.core import database as database_module
- monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
- async def _wallet_columns(conn) -> set[str]:
- return {row[1] for row in (await conn.execute(text("PRAGMA table_info(user_wallets)"))).all()}
- @pytest.mark.asyncio
- async def test_an_existing_currency_column_is_dropped(tmp_path):
- """The upgrade path: a database that predates the fix."""
- engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wallet-currency.db'}")
- try:
- async with engine.begin() as conn:
- _register_all_models()
- await conn.run_sync(Base.metadata.create_all)
- # Recreate the pre-#3123 shape, balance and all, then prove the
- # migration takes the column without taking the row with it.
- await conn.execute(text("ALTER TABLE user_wallets ADD COLUMN currency VARCHAR(3) NOT NULL DEFAULT 'EUR'"))
- await conn.execute(text("INSERT INTO user_wallets (user_id, balance, currency) VALUES (7, 12.34, 'EUR')"))
- assert "currency" in await _wallet_columns(conn)
- await run_migrations(conn)
- assert "currency" not in await _wallet_columns(conn)
- row = (await conn.execute(text("SELECT user_id, balance FROM user_wallets"))).all()
- assert row == [(7, 12.34)]
- finally:
- await engine.dispose()
- @pytest.mark.asyncio
- async def test_the_legacy_create_table_does_not_declare_it(tmp_path):
- """_migrate_create_finance_tables carries its own raw CREATE TABLE.
- It exists for installs whose finance tables predate the ORM models, and it
- declared the column independently of the model. Exercised on its own here,
- without the drop migration that would otherwise mask it.
- """
- from backend.app.core.database import _migrate_create_finance_tables
- engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wallet-legacy.db'}")
- try:
- async with engine.begin() as conn:
- _register_all_models()
- await conn.run_sync(Base.metadata.create_all)
- await conn.execute(text("DROP TABLE user_wallets"))
- await _migrate_create_finance_tables(conn)
- assert await _wallet_columns(conn), "the legacy path must still create the table"
- assert "currency" not in await _wallet_columns(conn)
- finally:
- await engine.dispose()
- @pytest.mark.asyncio
- async def test_the_migration_is_idempotent(tmp_path):
- """Startup runs it every time; the second pass must not error."""
- engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wallet-twice.db'}")
- try:
- async with engine.begin() as conn:
- _register_all_models()
- await conn.run_sync(Base.metadata.create_all)
- await conn.execute(text("ALTER TABLE user_wallets ADD COLUMN currency VARCHAR(3) NOT NULL DEFAULT 'EUR'"))
- await run_migrations(conn)
- await run_migrations(conn)
- assert "currency" not in await _wallet_columns(conn)
- finally:
- await engine.dispose()
- class _AsyncCtxStub:
- """Async context manager that does nothing — for ``begin_nested()``."""
- async def __aenter__(self):
- return self
- async def __aexit__(self, *_exc):
- return False
- async def _capture_drop_sql(is_sqlite_value: bool) -> list[str]:
- """Every DROP COLUMN statement run_migrations would issue on this dialect.
- The project's suite runs on SQLite, so the PostgreSQL branch is otherwise
- dead code in CI. Same capture pattern as test_oidc_icon_migration_pg.py.
- """
- from unittest.mock import AsyncMock, MagicMock, patch
- from backend.app.core import database as db_module
- executed: list[str] = []
- async def fake_safe_execute(_conn, sql: str) -> None:
- executed.append(sql)
- fake_conn = MagicMock()
- fake_conn.begin_nested = lambda: _AsyncCtxStub()
- fake_conn.execute = AsyncMock(return_value=MagicMock(fetchone=MagicMock(return_value=None)))
- with (
- patch("backend.app.core.database.is_sqlite", return_value=is_sqlite_value),
- patch("backend.app.core.database._safe_execute", side_effect=fake_safe_execute),
- patch("backend.app.core.database._migrate_update_auto_link_constraint", AsyncMock()),
- patch("backend.app.core.database._migrate_widen_spoolman_slot_ams_id_range", AsyncMock()),
- ):
- await db_module.run_migrations(fake_conn)
- return [sql for sql in executed if "user_wallets" in sql and "DROP COLUMN" in sql]
- @pytest.mark.asyncio
- async def test_postgres_drops_it_conditionally():
- """PostgreSQL takes IF EXISTS, which SQLite's DROP COLUMN does not accept."""
- statements = await _capture_drop_sql(is_sqlite_value=False)
- assert statements == ["ALTER TABLE user_wallets DROP COLUMN IF EXISTS currency"]
- @pytest.mark.asyncio
- async def test_sqlite_drops_it_plainly():
- """Companion to the PostgreSQL case, so the dialect switch cannot invert."""
- statements = await _capture_drop_sql(is_sqlite_value=True)
- assert statements == ["ALTER TABLE user_wallets DROP COLUMN currency"]
|