| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208 |
- """Selected plate persists onto the archive and backfills from the queue (#2603).
- A whole multi-plate 3MF is uploaded under one filename with no plate suffix, so
- the archive parser can't recover the selected plate and Print History fell back
- to Plate 1. The queue row keeps the correct ``plate_id``; these tests cover
- copying it onto the archive, the startup backfill for pre-existing rows, and that
- ``run_migrations`` applies the new column + backfill cleanly.
- """
- import pytest
- from sqlalchemy import text
- from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
- import backend.app.models # noqa: F401 - populate Base.metadata
- from backend.app.core.database import Base, run_migrations
- from backend.app.models.archive import PrintArchive
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.models.printer import Printer
- @pytest.fixture
- def force_sqlite_dialect(monkeypatch):
- """Force the SQLite branch of run_migrations regardless of the test env's
- DATABASE_URL (this sandbox points it at Postgres)."""
- from backend.app.core import database as database_module, db_dialect
- monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
- monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
- monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
- # The exact backfill statement run by run_migrations (kept in sync deliberately;
- # the run_migrations smoke test below exercises the real one).
- _BACKFILL_SQL = (
- "UPDATE print_archives "
- "SET plate_id = ("
- " SELECT pq.plate_id FROM print_queue pq "
- " WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL "
- " LIMIT 1"
- ") "
- "WHERE plate_id IS NULL "
- "AND EXISTS ("
- " SELECT 1 FROM print_queue pq "
- " WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL"
- ")"
- )
- @pytest.fixture
- async def sm():
- engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- try:
- yield async_sessionmaker(engine, expire_on_commit=False)
- finally:
- await engine.dispose()
- async def _printer(db) -> int:
- printer = Printer(name="P", serial_number="S", ip_address="10.0.0.1", access_code="code", model="X1C")
- db.add(printer)
- await db.flush()
- return printer.id
- @pytest.mark.asyncio
- async def test_archive_row_stores_plate_id(sm):
- """The column round-trips a selected plate."""
- async with sm() as db:
- archive = PrintArchive(filename="heart 3.gcode.3mf", file_path="x", file_size=1, status="printing", plate_id=22)
- db.add(archive)
- await db.commit()
- await db.refresh(archive)
- assert archive.plate_id == 22
- @pytest.mark.asyncio
- async def test_backfill_copies_plate_from_linked_queue_row(sm):
- """An archive with no plate inherits it from a queue row that still links to it."""
- async with sm() as db:
- printer_id = await _printer(db)
- archive = PrintArchive(
- filename="heart 3.gcode.3mf", file_path="x", file_size=1, status="cancelled", plate_id=None
- )
- db.add(archive)
- await db.flush()
- db.add(PrintQueueItem(printer_id=printer_id, archive_id=archive.id, status="cancelled", plate_id=22))
- await db.commit()
- await db.execute(text(_BACKFILL_SQL))
- await db.commit()
- await db.refresh(archive)
- assert archive.plate_id == 22
- @pytest.mark.asyncio
- async def test_backfill_does_not_clobber_existing_plate_or_touch_unlinked(sm):
- """Idempotent: a set plate is left alone, and an archive with no linked queue plate stays NULL."""
- async with sm() as db:
- printer_id = await _printer(db)
- # Archive already carrying a plate; queue row disagrees — must not be overwritten.
- set_archive = PrintArchive(filename="a.3mf", file_path="a", file_size=1, status="cancelled", plate_id=7)
- # Archive with no linked queue plate at all — must stay NULL.
- null_archive = PrintArchive(filename="b.3mf", file_path="b", file_size=1, status="completed", plate_id=None)
- db.add_all([set_archive, null_archive])
- await db.flush()
- db.add(PrintQueueItem(printer_id=printer_id, archive_id=set_archive.id, status="cancelled", plate_id=3))
- await db.commit()
- # Run twice — second run must be a no-op.
- await db.execute(text(_BACKFILL_SQL))
- await db.execute(text(_BACKFILL_SQL))
- await db.commit()
- await db.refresh(set_archive)
- await db.refresh(null_archive)
- assert set_archive.plate_id == 7, "an archive that already had a plate must not be relabelled"
- assert null_archive.plate_id is None, "an archive with no linked queue plate must stay NULL"
- @pytest.mark.asyncio
- async def test_run_migrations_adds_column_and_backfills_in_order(force_sqlite_dialect):
- """End-to-end: run_migrations adds print_archives.plate_id and backfills it from
- print_queue.plate_id without crashing (#2603).
- Guards the migration *ordering*: the backfill reads print_queue.plate_id, which is
- added earlier in run_migrations. If the backfill ran before that column existed
- (as it did in the first draft), a first-ever migration pass would raise
- "no such column: print_queue.plate_id" and abort startup. Running the full
- migration here — twice — proves the order is correct and idempotent. Mirrors the
- harness in test_cancellation_cascade_recovery_migration.py.
- """
- # run_migrations touches many tables; register the full model set so
- # create_all builds the whole schema (imports for side effects only).
- from backend.app.models import ( # noqa: F401
- ams_history,
- ams_label,
- api_key,
- auth_ephemeral,
- color_catalog,
- external_link,
- filament,
- group,
- kprofile_note,
- maintenance,
- notification,
- notification_template,
- oidc_provider,
- print_log,
- project,
- project_bom,
- slot_preset,
- smart_plug,
- smart_plug_energy_snapshot,
- sponsor_toast_state,
- spool,
- spool_assignment,
- spool_catalog,
- spool_k_profile,
- spool_usage_history,
- spoolbuddy_device,
- spoolman_k_profile,
- spoolman_slot_assignment,
- user,
- user_email_pref,
- user_otp_code,
- user_totp,
- virtual_printer,
- )
- engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
- try:
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- # Seed the archive + queue row BEFORE any migration pass — an existing
- # install upgrading to this version. The archive predates the archive_fts
- # FTS table (created inside run_migrations), so it is NOT indexed; the
- # backfill's UPDATE would trip the external-content FTS 'delete' ("database
- # disk image is malformed") unless the migration rebuilds the FTS index
- # first. This is the exact shape that failed the force-color migration
- # tests before the rebuild guard was added.
- sm = async_sessionmaker(engine, expire_on_commit=False)
- async with sm() as db:
- printer_id = await _printer(db)
- db.add(PrintArchive(id=436, filename="heart 3.gcode.3mf", file_path="x", file_size=1, status="cancelled"))
- await db.flush()
- db.add(PrintQueueItem(id=177, printer_id=printer_id, archive_id=436, status="cancelled", plate_id=22))
- await db.commit()
- # Upgrade boot: run_migrations must add print_archives.plate_id, rebuild the
- # FTS index, and backfill the plate — without crashing. Also guards the
- # ordering bug: the full migration runs top-to-bottom, so if the backfill
- # preceded the print_queue.plate_id column it would raise "no such column".
- async with engine.begin() as conn:
- await run_migrations(conn)
- async with sm() as db:
- archive = await db.get(PrintArchive, 436)
- assert archive.plate_id == 22, "run_migrations must backfill the archive's plate from its queue row"
- # A further startup re-runs migrations — idempotent, plate unchanged, and
- # (plate now set) the rebuild+backfill is skipped entirely.
- async with engine.begin() as conn:
- await run_migrations(conn)
- async with sm() as db:
- assert (await db.get(PrintArchive, 436)).plate_id == 22
- finally:
- await engine.dispose()
|