"""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()