test_vp_access_code_sync_migration.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. """Regression test for the VP access-code sync migration.
  2. Non-proxy VPs with a target printer must use the target's access code
  3. because the live-mirror bridge forwards the slicer's MQTT/RTSPS auth
  4. bytes through to the real printer. Earlier UIs let the codes diverge,
  5. producing a VP whose listener accepted the bind but whose bridge then
  6. failed at the second hop. The migration in ``run_migrations`` rewrites
  7. mismatched rows on the next boot after upgrade.
  8. """
  9. from __future__ import annotations
  10. import pytest
  11. from sqlalchemy import text
  12. from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
  13. from backend.app.core.database import run_migrations
  14. @pytest.fixture(autouse=True)
  15. def force_sqlite_dialect(monkeypatch):
  16. """Force the SQLite branch regardless of test env settings."""
  17. from backend.app.core import db_dialect
  18. monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
  19. monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
  20. from backend.app.core import database as database_module
  21. monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
  22. def _register_all_models():
  23. """run_migrations touches multiple tables; the full schema must exist."""
  24. from backend.app.models import ( # noqa: F401
  25. ams_history,
  26. ams_label,
  27. api_key,
  28. archive,
  29. color_catalog,
  30. external_link,
  31. filament,
  32. group,
  33. kprofile_note,
  34. maintenance,
  35. notification,
  36. notification_template,
  37. print_log,
  38. print_queue,
  39. printer,
  40. project,
  41. project_bom,
  42. settings,
  43. slot_preset,
  44. smart_plug,
  45. smart_plug_energy_snapshot,
  46. spool,
  47. spool_assignment,
  48. spool_catalog,
  49. spool_k_profile,
  50. spool_usage_history,
  51. spoolbuddy_device,
  52. user,
  53. user_email_pref,
  54. virtual_printer,
  55. )
  56. @pytest.fixture
  57. async def engine():
  58. from backend.app.core.database import Base
  59. _register_all_models()
  60. eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  61. async with eng.begin() as conn:
  62. await conn.run_sync(Base.metadata.create_all)
  63. yield eng
  64. await eng.dispose()
  65. async def _seed_printer(engine, printer_id: int, name: str, access_code: str) -> None:
  66. """Insert a printer row through the ORM so Python-side defaults
  67. (nozzle_count, is_active, auto_archive, print_hours_offset, …) all apply
  68. without us having to mirror every NOT NULL column in raw SQL."""
  69. from backend.app.models.printer import Printer
  70. async with AsyncSession(engine) as session:
  71. session.add(
  72. Printer(
  73. id=printer_id,
  74. name=name,
  75. ip_address=f"192.168.1.{printer_id + 100}",
  76. access_code=access_code,
  77. serial_number=f"01P00A39180000{printer_id}",
  78. model="C12",
  79. )
  80. )
  81. await session.commit()
  82. @pytest.mark.asyncio
  83. async def test_non_proxy_vp_with_target_inherits_access_code(engine):
  84. """A non-proxy VP with a mismatched access_code gets corrected to match
  85. the target printer's code on the next boot."""
  86. await _seed_printer(engine, 1, "Real X1C", "REALCODE")
  87. async with engine.begin() as conn:
  88. await conn.execute(
  89. text(
  90. "INSERT INTO virtual_printers "
  91. "(id, name, enabled, mode, access_code, target_printer_id, serial_suffix, position) "
  92. "VALUES (1, 'Queue VP', 0, 'queue', 'OLDVPCDE', 1, '391800001', 1)"
  93. )
  94. )
  95. async with engine.begin() as conn:
  96. await run_migrations(conn)
  97. async with engine.connect() as conn:
  98. code = (await conn.execute(text("SELECT access_code FROM virtual_printers WHERE id = 1"))).scalar()
  99. assert code == "REALCODE"
  100. @pytest.mark.asyncio
  101. async def test_proxy_vp_access_code_is_left_alone(engine):
  102. """Proxy-mode VPs are NOT touched — the proxy already uses the target's
  103. code transparently at the protocol level, and the model column can
  104. legitimately hold an unused access_code value."""
  105. await _seed_printer(engine, 1, "Real X1C", "REALCODE")
  106. async with engine.begin() as conn:
  107. await conn.execute(
  108. text(
  109. "INSERT INTO virtual_printers "
  110. "(id, name, enabled, mode, access_code, target_printer_id, serial_suffix, position) "
  111. "VALUES (1, 'Proxy VP', 0, 'proxy', 'PROXYCDE', 1, '391800001', 1)"
  112. )
  113. )
  114. async with engine.begin() as conn:
  115. await run_migrations(conn)
  116. async with engine.connect() as conn:
  117. code = (await conn.execute(text("SELECT access_code FROM virtual_printers WHERE id = 1"))).scalar()
  118. assert code == "PROXYCDE"
  119. @pytest.mark.asyncio
  120. async def test_already_matching_vp_is_left_alone(engine):
  121. """A VP whose code already equals the target's needs no change.
  122. Confirms the WHERE clause excludes synced rows so re-running is a no-op."""
  123. await _seed_printer(engine, 1, "Real X1C", "MATCHED1")
  124. async with engine.begin() as conn:
  125. await conn.execute(
  126. text(
  127. "INSERT INTO virtual_printers "
  128. "(id, name, enabled, mode, access_code, target_printer_id, serial_suffix, position) "
  129. "VALUES (1, 'Synced VP', 0, 'archive', 'MATCHED1', 1, '391800001', 1)"
  130. )
  131. )
  132. async with engine.begin() as conn:
  133. await run_migrations(conn)
  134. # Re-run to prove idempotency.
  135. async with engine.begin() as conn:
  136. await run_migrations(conn)
  137. async with engine.connect() as conn:
  138. code = (await conn.execute(text("SELECT access_code FROM virtual_printers WHERE id = 1"))).scalar()
  139. assert code == "MATCHED1"
  140. @pytest.mark.asyncio
  141. async def test_non_proxy_vp_without_target_is_left_alone(engine):
  142. """No target = no bridge = nothing to derive from. The VP keeps its own code."""
  143. async with engine.begin() as conn:
  144. await conn.execute(
  145. text(
  146. "INSERT INTO virtual_printers "
  147. "(id, name, enabled, mode, access_code, target_printer_id, serial_suffix, position) "
  148. "VALUES (1, 'Standalone VP', 0, 'archive', 'STANDALN', NULL, '391800001', 1)"
  149. )
  150. )
  151. async with engine.begin() as conn:
  152. await run_migrations(conn)
  153. async with engine.connect() as conn:
  154. code = (await conn.execute(text("SELECT access_code FROM virtual_printers WHERE id = 1"))).scalar()
  155. assert code == "STANDALN"
  156. @pytest.mark.asyncio
  157. async def test_null_vp_access_code_with_target_gets_populated(engine):
  158. """A VP with no access_code at all (NULL) but a target set is treated
  159. as a divergence — the migration populates it from the target."""
  160. await _seed_printer(engine, 1, "Real X1C", "FRESHCDE")
  161. async with engine.begin() as conn:
  162. await conn.execute(
  163. text(
  164. "INSERT INTO virtual_printers "
  165. "(id, name, enabled, mode, access_code, target_printer_id, serial_suffix, position) "
  166. "VALUES (1, 'Fresh VP', 0, 'queue', NULL, 1, '391800001', 1)"
  167. )
  168. )
  169. async with engine.begin() as conn:
  170. await run_migrations(conn)
  171. async with engine.connect() as conn:
  172. code = (await conn.execute(text("SELECT access_code FROM virtual_printers WHERE id = 1"))).scalar()
  173. assert code == "FRESHCDE"
  174. @pytest.mark.asyncio
  175. async def test_multi_vp_sync_one_run(engine):
  176. """Multiple mismatched VPs against different targets are all corrected
  177. in a single migration pass."""
  178. await _seed_printer(engine, 1, "Printer A", "AAAAAAAA")
  179. await _seed_printer(engine, 2, "Printer B", "BBBBBBBB")
  180. async with engine.begin() as conn:
  181. await conn.execute(
  182. text(
  183. "INSERT INTO virtual_printers "
  184. "(id, name, enabled, mode, access_code, target_printer_id, serial_suffix, position) "
  185. "VALUES "
  186. "(1, 'VP-A', 0, 'archive', 'WRONGAAA', 1, '391800001', 1),"
  187. "(2, 'VP-B', 0, 'queue', 'WRONGBBB', 2, '391800002', 2)"
  188. )
  189. )
  190. async with engine.begin() as conn:
  191. await run_migrations(conn)
  192. async with engine.connect() as conn:
  193. result = await conn.execute(text("SELECT id, access_code FROM virtual_printers ORDER BY id"))
  194. rows = dict(result.fetchall())
  195. assert rows[1] == "AAAAAAAA"
  196. assert rows[2] == "BBBBBBBB"