test_archive_plate_2603.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. """Selected plate persists onto the archive and backfills from the queue (#2603).
  2. A whole multi-plate 3MF is uploaded under one filename with no plate suffix, so
  3. the archive parser can't recover the selected plate and Print History fell back
  4. to Plate 1. The queue row keeps the correct ``plate_id``; these tests cover
  5. copying it onto the archive, the startup backfill for pre-existing rows, and that
  6. ``run_migrations`` applies the new column + backfill cleanly.
  7. """
  8. import pytest
  9. from sqlalchemy import text
  10. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  11. import backend.app.models # noqa: F401 - populate Base.metadata
  12. from backend.app.core.database import Base, run_migrations
  13. from backend.app.models.archive import PrintArchive
  14. from backend.app.models.print_queue import PrintQueueItem
  15. from backend.app.models.printer import Printer
  16. @pytest.fixture
  17. def force_sqlite_dialect(monkeypatch):
  18. """Force the SQLite branch of run_migrations regardless of the test env's
  19. DATABASE_URL (this sandbox points it at Postgres)."""
  20. from backend.app.core import database as database_module, db_dialect
  21. monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
  22. monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
  23. monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
  24. # The exact backfill statement run by run_migrations (kept in sync deliberately;
  25. # the run_migrations smoke test below exercises the real one).
  26. _BACKFILL_SQL = (
  27. "UPDATE print_archives "
  28. "SET plate_id = ("
  29. " SELECT pq.plate_id FROM print_queue pq "
  30. " WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL "
  31. " LIMIT 1"
  32. ") "
  33. "WHERE plate_id IS NULL "
  34. "AND EXISTS ("
  35. " SELECT 1 FROM print_queue pq "
  36. " WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL"
  37. ")"
  38. )
  39. @pytest.fixture
  40. async def sm():
  41. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  42. async with engine.begin() as conn:
  43. await conn.run_sync(Base.metadata.create_all)
  44. try:
  45. yield async_sessionmaker(engine, expire_on_commit=False)
  46. finally:
  47. await engine.dispose()
  48. async def _printer(db) -> int:
  49. printer = Printer(name="P", serial_number="S", ip_address="10.0.0.1", access_code="code", model="X1C")
  50. db.add(printer)
  51. await db.flush()
  52. return printer.id
  53. @pytest.mark.asyncio
  54. async def test_archive_row_stores_plate_id(sm):
  55. """The column round-trips a selected plate."""
  56. async with sm() as db:
  57. archive = PrintArchive(filename="heart 3.gcode.3mf", file_path="x", file_size=1, status="printing", plate_id=22)
  58. db.add(archive)
  59. await db.commit()
  60. await db.refresh(archive)
  61. assert archive.plate_id == 22
  62. @pytest.mark.asyncio
  63. async def test_backfill_copies_plate_from_linked_queue_row(sm):
  64. """An archive with no plate inherits it from a queue row that still links to it."""
  65. async with sm() as db:
  66. printer_id = await _printer(db)
  67. archive = PrintArchive(
  68. filename="heart 3.gcode.3mf", file_path="x", file_size=1, status="cancelled", plate_id=None
  69. )
  70. db.add(archive)
  71. await db.flush()
  72. db.add(PrintQueueItem(printer_id=printer_id, archive_id=archive.id, status="cancelled", plate_id=22))
  73. await db.commit()
  74. await db.execute(text(_BACKFILL_SQL))
  75. await db.commit()
  76. await db.refresh(archive)
  77. assert archive.plate_id == 22
  78. @pytest.mark.asyncio
  79. async def test_backfill_does_not_clobber_existing_plate_or_touch_unlinked(sm):
  80. """Idempotent: a set plate is left alone, and an archive with no linked queue plate stays NULL."""
  81. async with sm() as db:
  82. printer_id = await _printer(db)
  83. # Archive already carrying a plate; queue row disagrees — must not be overwritten.
  84. set_archive = PrintArchive(filename="a.3mf", file_path="a", file_size=1, status="cancelled", plate_id=7)
  85. # Archive with no linked queue plate at all — must stay NULL.
  86. null_archive = PrintArchive(filename="b.3mf", file_path="b", file_size=1, status="completed", plate_id=None)
  87. db.add_all([set_archive, null_archive])
  88. await db.flush()
  89. db.add(PrintQueueItem(printer_id=printer_id, archive_id=set_archive.id, status="cancelled", plate_id=3))
  90. await db.commit()
  91. # Run twice — second run must be a no-op.
  92. await db.execute(text(_BACKFILL_SQL))
  93. await db.execute(text(_BACKFILL_SQL))
  94. await db.commit()
  95. await db.refresh(set_archive)
  96. await db.refresh(null_archive)
  97. assert set_archive.plate_id == 7, "an archive that already had a plate must not be relabelled"
  98. assert null_archive.plate_id is None, "an archive with no linked queue plate must stay NULL"
  99. @pytest.mark.asyncio
  100. async def test_run_migrations_adds_column_and_backfills_in_order(force_sqlite_dialect):
  101. """End-to-end: run_migrations adds print_archives.plate_id and backfills it from
  102. print_queue.plate_id without crashing (#2603).
  103. Guards the migration *ordering*: the backfill reads print_queue.plate_id, which is
  104. added earlier in run_migrations. If the backfill ran before that column existed
  105. (as it did in the first draft), a first-ever migration pass would raise
  106. "no such column: print_queue.plate_id" and abort startup. Running the full
  107. migration here — twice — proves the order is correct and idempotent. Mirrors the
  108. harness in test_cancellation_cascade_recovery_migration.py.
  109. """
  110. # run_migrations touches many tables; register the full model set so
  111. # create_all builds the whole schema (imports for side effects only).
  112. from backend.app.models import ( # noqa: F401
  113. ams_history,
  114. ams_label,
  115. api_key,
  116. auth_ephemeral,
  117. color_catalog,
  118. external_link,
  119. filament,
  120. group,
  121. kprofile_note,
  122. maintenance,
  123. notification,
  124. notification_template,
  125. oidc_provider,
  126. print_log,
  127. project,
  128. project_bom,
  129. slot_preset,
  130. smart_plug,
  131. smart_plug_energy_snapshot,
  132. sponsor_toast_state,
  133. spool,
  134. spool_assignment,
  135. spool_catalog,
  136. spool_k_profile,
  137. spool_usage_history,
  138. spoolbuddy_device,
  139. spoolman_k_profile,
  140. spoolman_slot_assignment,
  141. user,
  142. user_email_pref,
  143. user_otp_code,
  144. user_totp,
  145. virtual_printer,
  146. )
  147. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  148. try:
  149. async with engine.begin() as conn:
  150. await conn.run_sync(Base.metadata.create_all)
  151. # Seed the archive + queue row BEFORE any migration pass — an existing
  152. # install upgrading to this version. The archive predates the archive_fts
  153. # FTS table (created inside run_migrations), so it is NOT indexed; the
  154. # backfill's UPDATE would trip the external-content FTS 'delete' ("database
  155. # disk image is malformed") unless the migration rebuilds the FTS index
  156. # first. This is the exact shape that failed the force-color migration
  157. # tests before the rebuild guard was added.
  158. sm = async_sessionmaker(engine, expire_on_commit=False)
  159. async with sm() as db:
  160. printer_id = await _printer(db)
  161. db.add(PrintArchive(id=436, filename="heart 3.gcode.3mf", file_path="x", file_size=1, status="cancelled"))
  162. await db.flush()
  163. db.add(PrintQueueItem(id=177, printer_id=printer_id, archive_id=436, status="cancelled", plate_id=22))
  164. await db.commit()
  165. # Upgrade boot: run_migrations must add print_archives.plate_id, rebuild the
  166. # FTS index, and backfill the plate — without crashing. Also guards the
  167. # ordering bug: the full migration runs top-to-bottom, so if the backfill
  168. # preceded the print_queue.plate_id column it would raise "no such column".
  169. async with engine.begin() as conn:
  170. await run_migrations(conn)
  171. async with sm() as db:
  172. archive = await db.get(PrintArchive, 436)
  173. assert archive.plate_id == 22, "run_migrations must backfill the archive's plate from its queue row"
  174. # A further startup re-runs migrations — idempotent, plate unchanged, and
  175. # (plate now set) the rebuild+backfill is skipped entirely.
  176. async with engine.begin() as conn:
  177. await run_migrations(conn)
  178. async with sm() as db:
  179. assert (await db.get(PrintArchive, 436)).plate_id == 22
  180. finally:
  181. await engine.dispose()