test_smart_plug_power_flag_migration_2629.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. """Migration test for #2629 — smart_plugs.controls_printer_power.
  2. Existing installs have plugs that were assumed to power their linked printer, so
  3. the new column must be added *and backfilled to true*: a NULL or false backfill
  4. would silently stop marking a real printer plug's power-off, which is the
  5. behaviour users have today.
  6. """
  7. from __future__ import annotations
  8. import pytest
  9. from sqlalchemy import text
  10. from sqlalchemy.ext.asyncio import create_async_engine
  11. from backend.app.core.database import run_migrations
  12. LEGACY_SMART_PLUGS = """
  13. CREATE TABLE smart_plugs (
  14. id INTEGER PRIMARY KEY,
  15. name VARCHAR(100) NOT NULL,
  16. ip_address VARCHAR(45),
  17. plug_type VARCHAR(20) DEFAULT 'tasmota',
  18. ha_entity_id VARCHAR(100),
  19. printer_id INTEGER,
  20. enabled BOOLEAN DEFAULT 1,
  21. auto_on BOOLEAN DEFAULT 1,
  22. auto_off BOOLEAN DEFAULT 1,
  23. auto_off_persistent BOOLEAN DEFAULT 0,
  24. off_delay_mode VARCHAR(20) DEFAULT 'time',
  25. off_delay_minutes INTEGER DEFAULT 5,
  26. off_temp_threshold INTEGER DEFAULT 70,
  27. show_in_switchbar BOOLEAN DEFAULT 0,
  28. show_on_printer_card BOOLEAN DEFAULT 1,
  29. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  30. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  31. )
  32. """
  33. @pytest.fixture(autouse=True)
  34. def force_sqlite_dialect(monkeypatch):
  35. """settings.database_url may point at Postgres in dev configs; the test engine
  36. is SQLite, so force the dialect both places run_migrations reads it from."""
  37. from backend.app.core import database as database_module, db_dialect
  38. monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
  39. monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
  40. monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
  41. @pytest.fixture
  42. async def legacy_engine():
  43. """A modern schema with a pre-#2629 smart_plugs table holding one plug."""
  44. from backend.app.core.database import Base
  45. from backend.app.models import ( # noqa: F401
  46. ams_history,
  47. ams_label,
  48. api_key,
  49. archive,
  50. color_catalog,
  51. external_link,
  52. filament,
  53. group,
  54. kprofile_note,
  55. maintenance,
  56. notification,
  57. notification_template,
  58. print_log,
  59. print_queue,
  60. printer,
  61. project,
  62. project_bom,
  63. settings,
  64. slot_preset,
  65. smart_plug,
  66. smart_plug_energy_snapshot,
  67. spool,
  68. spool_assignment,
  69. spool_catalog,
  70. spool_k_profile,
  71. spool_usage_history,
  72. spoolbuddy_device,
  73. user,
  74. user_email_pref,
  75. virtual_printer,
  76. )
  77. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  78. async with engine.begin() as conn:
  79. await conn.run_sync(Base.metadata.create_all)
  80. await conn.execute(text("DROP TABLE smart_plugs"))
  81. await conn.execute(text(LEGACY_SMART_PLUGS))
  82. await conn.execute(
  83. text("INSERT INTO smart_plugs (id, name, plug_type, printer_id) VALUES (1, 'P1S Power', 'tasmota', 1)")
  84. )
  85. yield engine
  86. await engine.dispose()
  87. async def test_column_missing_before_migration(legacy_engine):
  88. """Sanity check so the assertion below can't pass by accident."""
  89. async with legacy_engine.begin() as conn:
  90. columns = {row[1] for row in await conn.execute(text("PRAGMA table_info(smart_plugs)"))}
  91. assert "controls_printer_power" not in columns
  92. async def test_existing_plugs_backfill_to_true(legacy_engine):
  93. """An upgraded install must keep marking its printer offline on power-off."""
  94. async with legacy_engine.begin() as conn:
  95. await run_migrations(conn)
  96. async with legacy_engine.begin() as conn:
  97. result = await conn.execute(text("SELECT controls_printer_power FROM smart_plugs WHERE id = 1"))
  98. assert bool(result.scalar_one()) is True
  99. async def test_migration_is_idempotent(legacy_engine):
  100. """Second boot must not fail on the already-present column."""
  101. async with legacy_engine.begin() as conn:
  102. await run_migrations(conn)
  103. async with legacy_engine.begin() as conn:
  104. await run_migrations(conn)
  105. async with legacy_engine.begin() as conn:
  106. result = await conn.execute(text("SELECT controls_printer_power FROM smart_plugs WHERE id = 1"))
  107. assert bool(result.scalar_one()) is True
  108. class TestPostgresBranch:
  109. """CI runs on SQLite, so the Postgres branch of the dialect switch would be
  110. dead code without this. Captures the SQL ``run_migrations`` would emit,
  111. mirroring ``test_oidc_icon_migration_pg.py``.
  112. """
  113. @staticmethod
  114. async def _capture_sql(is_sqlite_value: bool) -> list[str]:
  115. from unittest.mock import AsyncMock, MagicMock, patch
  116. from backend.app.core import database as db_module
  117. class _AsyncCtxStub:
  118. async def __aenter__(self):
  119. return self
  120. async def __aexit__(self, *_exc):
  121. return False
  122. executed_sql: list[str] = []
  123. async def fake_safe_execute(_conn, sql: str) -> None:
  124. executed_sql.append(sql)
  125. fake_conn = MagicMock()
  126. fake_conn.begin_nested = lambda: _AsyncCtxStub()
  127. fake_conn.execute = AsyncMock(return_value=MagicMock(fetchone=MagicMock(return_value=None)))
  128. with (
  129. patch("backend.app.core.database.is_sqlite", return_value=is_sqlite_value),
  130. patch("backend.app.core.database._safe_execute", side_effect=fake_safe_execute),
  131. patch("backend.app.core.database._migrate_update_auto_link_constraint", AsyncMock()),
  132. patch("backend.app.core.database._migrate_widen_spoolman_slot_ams_id_range", AsyncMock()),
  133. ):
  134. await db_module.run_migrations(fake_conn)
  135. return executed_sql
  136. @pytest.mark.asyncio
  137. async def test_pg_branch_uses_true_and_if_not_exists(self):
  138. executed = await self._capture_sql(is_sqlite_value=False)
  139. stmts = [s for s in executed if "controls_printer_power" in s]
  140. assert len(stmts) == 1, f"expected exactly one statement, got: {stmts!r}"
  141. assert "IF NOT EXISTS" in stmts[0] # idempotent on PG, which has no _safe_execute retry semantics
  142. assert "DEFAULT true" in stmts[0]
  143. @pytest.mark.asyncio
  144. async def test_sqlite_branch_uses_numeric_default(self):
  145. """SQLite has no true/false literal — the switch must not be inverted."""
  146. executed = await self._capture_sql(is_sqlite_value=True)
  147. stmts = [s for s in executed if "controls_printer_power" in s]
  148. assert len(stmts) == 1
  149. assert "DEFAULT 1" in stmts[0]
  150. assert "true" not in stmts[0]