test_spoolman_tray_now_migration_1820.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. """Migration for active_print_spoolman.tray_now_at_start (#1820).
  2. The remain%-delta fallback needs to know which slot a print actually drew from,
  3. or a spool swapped into an idle slot mid-print is charged for consumption it
  4. never had. For a print with no 3MF -- the case this fallback exists for -- the
  5. tray in use at the start is often the only evidence, so it is captured at print
  6. start and has to survive on an upgraded database.
  7. Nullable, not backfilled: a row written before the column existed has no answer
  8. to give, and inventing 0 would name a real slot.
  9. """
  10. from __future__ import annotations
  11. import pytest
  12. from sqlalchemy import text
  13. from sqlalchemy.ext.asyncio import create_async_engine
  14. from backend.app.core.database import run_migrations
  15. LEGACY_TABLE = """
  16. CREATE TABLE active_print_spoolman (
  17. id INTEGER PRIMARY KEY AUTOINCREMENT,
  18. printer_id INTEGER NOT NULL,
  19. archive_id INTEGER NOT NULL,
  20. filament_usage TEXT,
  21. ams_trays TEXT NOT NULL,
  22. slot_to_tray TEXT,
  23. layer_usage TEXT,
  24. filament_properties TEXT,
  25. tray_remain_start TEXT,
  26. UNIQUE(printer_id, archive_id)
  27. )
  28. """
  29. @pytest.fixture(autouse=True)
  30. def force_sqlite_dialect(monkeypatch):
  31. """settings.database_url may point at Postgres in dev configs; the test
  32. engine is SQLite, so force the dialect where run_migrations reads it."""
  33. from backend.app.core import database as database_module, db_dialect
  34. monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
  35. monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
  36. monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
  37. def _register_every_model() -> None:
  38. """Put every table on ``Base.metadata``.
  39. ``backend.app.models``'s ``__init__`` re-exports only some of them, and
  40. ``run_migrations`` walks the whole schema -- a table that was never
  41. imported is missing, and its ALTER fails the run before reaching ours.
  42. Walking the package keeps this from rotting as models are added.
  43. """
  44. import importlib
  45. import pkgutil
  46. import backend.app.models as models_pkg
  47. for module in pkgutil.iter_modules(models_pkg.__path__):
  48. importlib.import_module(f"backend.app.models.{module.name}")
  49. @pytest.fixture
  50. async def legacy_engine():
  51. """A modern schema whose tracking table predates the column, mid-print."""
  52. from backend.app.core.database import Base
  53. _register_every_model()
  54. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  55. async with engine.begin() as conn:
  56. await conn.run_sync(Base.metadata.create_all)
  57. await conn.execute(text("DROP TABLE active_print_spoolman"))
  58. await conn.execute(text(LEGACY_TABLE))
  59. await conn.execute(
  60. text(
  61. "INSERT INTO active_print_spoolman (id, printer_id, archive_id, ams_trays, tray_remain_start) "
  62. 'VALUES (1, 1, 42, \'{}\', \'{"0-0": {"remain": 80, "tray_uuid": "AAAA"}}\')'
  63. )
  64. )
  65. yield engine
  66. await engine.dispose()
  67. async def test_column_missing_before_migration(legacy_engine):
  68. """Sanity check, so the assertion below cannot pass by accident."""
  69. async with legacy_engine.begin() as conn:
  70. columns = {row[1] for row in await conn.execute(text("PRAGMA table_info(active_print_spoolman)"))}
  71. assert "tray_now_at_start" not in columns
  72. async def test_the_column_is_added_and_the_row_survives(legacy_engine):
  73. """A print running across the upgrade keeps its remain snapshot; it simply
  74. has no tray evidence, which the fallback reads as "consider every slot"."""
  75. async with legacy_engine.begin() as conn:
  76. await run_migrations(conn)
  77. async with legacy_engine.begin() as conn:
  78. row = (
  79. await conn.execute(
  80. text("SELECT tray_now_at_start, tray_remain_start FROM active_print_spoolman WHERE id = 1")
  81. )
  82. ).one()
  83. assert row[0] is None
  84. assert "AAAA" in row[1]
  85. async def test_migration_is_idempotent(legacy_engine):
  86. """Second boot must not fail on the already-present column."""
  87. async with legacy_engine.begin() as conn:
  88. await run_migrations(conn)
  89. async with legacy_engine.begin() as conn:
  90. await run_migrations(conn)
  91. async with legacy_engine.begin() as conn:
  92. columns = {row[1] for row in await conn.execute(text("PRAGMA table_info(active_print_spoolman)"))}
  93. assert "tray_now_at_start" in columns
  94. async def test_a_fresh_database_has_the_column(legacy_engine):
  95. """The CREATE TABLE carries it too, so a new install never runs the ALTER."""
  96. from backend.app.core.database import Base
  97. _register_every_model()
  98. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  99. try:
  100. async with engine.begin() as conn:
  101. await conn.run_sync(Base.metadata.create_all)
  102. columns = {row[1] for row in await conn.execute(text("PRAGMA table_info(active_print_spoolman)"))}
  103. finally:
  104. await engine.dispose()
  105. assert "tray_now_at_start" in columns
  106. class TestPostgresBranch:
  107. """CI runs on SQLite, so the Postgres side of the dialect switch would be
  108. dead code without this. Captures the SQL run_migrations would emit,
  109. mirroring test_smart_plug_power_flag_migration_2629.
  110. """
  111. @staticmethod
  112. async def _capture_sql(is_sqlite_value: bool) -> list[str]:
  113. from unittest.mock import AsyncMock, MagicMock, patch
  114. from backend.app.core import database as db_module
  115. class _AsyncCtxStub:
  116. async def __aenter__(self):
  117. return self
  118. async def __aexit__(self, *_exc):
  119. return False
  120. executed_sql: list[str] = []
  121. async def fake_safe_execute(_conn, sql: str) -> None:
  122. executed_sql.append(sql)
  123. fake_conn = MagicMock()
  124. fake_conn.begin_nested = lambda: _AsyncCtxStub()
  125. fake_conn.execute = AsyncMock(return_value=MagicMock(fetchone=MagicMock(return_value=None)))
  126. with (
  127. patch("backend.app.core.database.is_sqlite", return_value=is_sqlite_value),
  128. patch("backend.app.core.database._safe_execute", side_effect=fake_safe_execute),
  129. patch("backend.app.core.database._migrate_update_auto_link_constraint", AsyncMock()),
  130. patch("backend.app.core.database._migrate_widen_spoolman_slot_ams_id_range", AsyncMock()),
  131. ):
  132. await db_module.run_migrations(fake_conn)
  133. return executed_sql
  134. @staticmethod
  135. def _alter_statements(executed: list[str]) -> list[str]:
  136. return [s for s in executed if "tray_now_at_start" in s and "ALTER" in s.upper()]
  137. @pytest.mark.asyncio
  138. async def test_pg_branch_is_idempotent_on_its_own(self):
  139. """Postgres has no _safe_execute retry semantics to lean on."""
  140. stmts = self._alter_statements(await self._capture_sql(is_sqlite_value=False))
  141. assert len(stmts) == 1, f"expected exactly one ALTER, got: {stmts!r}"
  142. assert "IF NOT EXISTS" in stmts[0]
  143. assert "INTEGER" in stmts[0]
  144. @pytest.mark.asyncio
  145. async def test_sqlite_branch_omits_if_not_exists(self):
  146. """SQLite's ALTER TABLE has no IF NOT EXISTS; _safe_execute swallows the
  147. duplicate-column error instead."""
  148. stmts = self._alter_statements(await self._capture_sql(is_sqlite_value=True))
  149. assert len(stmts) == 1
  150. assert "IF NOT EXISTS" not in stmts[0]
  151. assert "INTEGER" in stmts[0]