test_wallet_currency_drop_migration.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. """Migration coverage for removing user_wallets.currency (#3123).
  2. An install has one currency, held in the ``currency`` app setting. The column
  3. recorded whatever was configured when a wallet row happened to be created, and
  4. three of its four writers hardcoded "EUR", so it could only ever disagree with
  5. the setting. Dropping it from the model alone would leave every existing
  6. database carrying a column nothing reads.
  7. """
  8. import pytest
  9. from sqlalchemy import text
  10. from sqlalchemy.ext.asyncio import create_async_engine
  11. from backend.app.core.database import Base, run_migrations
  12. def _register_all_models():
  13. """run_migrations touches many tables; the whole schema has to exist.
  14. Same list as test_vp_mode_rename_migration.py -- importing only the finance
  15. models leaves run_migrations ALTERing tables create_all never built.
  16. """
  17. from backend.app.models import ( # noqa: F401
  18. ams_history,
  19. ams_label,
  20. api_key,
  21. archive,
  22. color_catalog,
  23. external_link,
  24. filament,
  25. finance,
  26. group,
  27. kprofile_note,
  28. maintenance,
  29. notification,
  30. notification_template,
  31. print_log,
  32. print_queue,
  33. printer,
  34. project,
  35. project_bom,
  36. settings,
  37. slot_preset,
  38. smart_plug,
  39. smart_plug_energy_snapshot,
  40. spool,
  41. spool_assignment,
  42. spool_catalog,
  43. spool_k_profile,
  44. spool_usage_history,
  45. spoolbuddy_device,
  46. user,
  47. user_email_pref,
  48. virtual_printer,
  49. )
  50. @pytest.fixture(autouse=True)
  51. def force_sqlite_dialect(monkeypatch):
  52. """run_migrations branches on the global dialect, not on the connection.
  53. settings.database_url may point at Postgres in a dev config, which would
  54. run the Postgres branch against the SQLite engine below. Same fixture as
  55. test_billing_run_id_migration.py.
  56. """
  57. from backend.app.core import db_dialect
  58. monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
  59. monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
  60. # database.py imported is_sqlite at module load time — patch there too.
  61. from backend.app.core import database as database_module
  62. monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
  63. async def _wallet_columns(conn) -> set[str]:
  64. return {row[1] for row in (await conn.execute(text("PRAGMA table_info(user_wallets)"))).all()}
  65. @pytest.mark.asyncio
  66. async def test_an_existing_currency_column_is_dropped(tmp_path):
  67. """The upgrade path: a database that predates the fix."""
  68. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wallet-currency.db'}")
  69. try:
  70. async with engine.begin() as conn:
  71. _register_all_models()
  72. await conn.run_sync(Base.metadata.create_all)
  73. # Recreate the pre-#3123 shape, balance and all, then prove the
  74. # migration takes the column without taking the row with it.
  75. await conn.execute(text("ALTER TABLE user_wallets ADD COLUMN currency VARCHAR(3) NOT NULL DEFAULT 'EUR'"))
  76. await conn.execute(text("INSERT INTO user_wallets (user_id, balance, currency) VALUES (7, 12.34, 'EUR')"))
  77. assert "currency" in await _wallet_columns(conn)
  78. await run_migrations(conn)
  79. assert "currency" not in await _wallet_columns(conn)
  80. row = (await conn.execute(text("SELECT user_id, balance FROM user_wallets"))).all()
  81. assert row == [(7, 12.34)]
  82. finally:
  83. await engine.dispose()
  84. @pytest.mark.asyncio
  85. async def test_the_legacy_create_table_does_not_declare_it(tmp_path):
  86. """_migrate_create_finance_tables carries its own raw CREATE TABLE.
  87. It exists for installs whose finance tables predate the ORM models, and it
  88. declared the column independently of the model. Exercised on its own here,
  89. without the drop migration that would otherwise mask it.
  90. """
  91. from backend.app.core.database import _migrate_create_finance_tables
  92. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wallet-legacy.db'}")
  93. try:
  94. async with engine.begin() as conn:
  95. _register_all_models()
  96. await conn.run_sync(Base.metadata.create_all)
  97. await conn.execute(text("DROP TABLE user_wallets"))
  98. await _migrate_create_finance_tables(conn)
  99. assert await _wallet_columns(conn), "the legacy path must still create the table"
  100. assert "currency" not in await _wallet_columns(conn)
  101. finally:
  102. await engine.dispose()
  103. @pytest.mark.asyncio
  104. async def test_the_migration_is_idempotent(tmp_path):
  105. """Startup runs it every time; the second pass must not error."""
  106. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wallet-twice.db'}")
  107. try:
  108. async with engine.begin() as conn:
  109. _register_all_models()
  110. await conn.run_sync(Base.metadata.create_all)
  111. await conn.execute(text("ALTER TABLE user_wallets ADD COLUMN currency VARCHAR(3) NOT NULL DEFAULT 'EUR'"))
  112. await run_migrations(conn)
  113. await run_migrations(conn)
  114. assert "currency" not in await _wallet_columns(conn)
  115. finally:
  116. await engine.dispose()
  117. class _AsyncCtxStub:
  118. """Async context manager that does nothing — for ``begin_nested()``."""
  119. async def __aenter__(self):
  120. return self
  121. async def __aexit__(self, *_exc):
  122. return False
  123. async def _capture_drop_sql(is_sqlite_value: bool) -> list[str]:
  124. """Every DROP COLUMN statement run_migrations would issue on this dialect.
  125. The project's suite runs on SQLite, so the PostgreSQL branch is otherwise
  126. dead code in CI. Same capture pattern as test_oidc_icon_migration_pg.py.
  127. """
  128. from unittest.mock import AsyncMock, MagicMock, patch
  129. from backend.app.core import database as db_module
  130. executed: list[str] = []
  131. async def fake_safe_execute(_conn, sql: str) -> None:
  132. executed.append(sql)
  133. fake_conn = MagicMock()
  134. fake_conn.begin_nested = lambda: _AsyncCtxStub()
  135. fake_conn.execute = AsyncMock(return_value=MagicMock(fetchone=MagicMock(return_value=None)))
  136. with (
  137. patch("backend.app.core.database.is_sqlite", return_value=is_sqlite_value),
  138. patch("backend.app.core.database._safe_execute", side_effect=fake_safe_execute),
  139. patch("backend.app.core.database._migrate_update_auto_link_constraint", AsyncMock()),
  140. patch("backend.app.core.database._migrate_widen_spoolman_slot_ams_id_range", AsyncMock()),
  141. ):
  142. await db_module.run_migrations(fake_conn)
  143. return [sql for sql in executed if "user_wallets" in sql and "DROP COLUMN" in sql]
  144. @pytest.mark.asyncio
  145. async def test_postgres_drops_it_conditionally():
  146. """PostgreSQL takes IF EXISTS, which SQLite's DROP COLUMN does not accept."""
  147. statements = await _capture_drop_sql(is_sqlite_value=False)
  148. assert statements == ["ALTER TABLE user_wallets DROP COLUMN IF EXISTS currency"]
  149. @pytest.mark.asyncio
  150. async def test_sqlite_drops_it_plainly():
  151. """Companion to the PostgreSQL case, so the dialect switch cannot invert."""
  152. statements = await _capture_drop_sql(is_sqlite_value=True)
  153. assert statements == ["ALTER TABLE user_wallets DROP COLUMN currency"]