|
|
@@ -0,0 +1,182 @@
|
|
|
+"""The one-shot pass that reaches library rows already stored (#2993).
|
|
|
+
|
|
|
+The forward fix classifies on content, which does nothing for the files a user
|
|
|
+has already imported -- the reporter's whole complaint was about files they had
|
|
|
+downloaded and imported before this existed. This backfill re-opens them.
|
|
|
+
|
|
|
+Two properties matter as much as the re-typing itself. It must run once: a
|
|
|
+genuine source 3MF keeps matching ``file_type = '3mf'`` forever, so an ungated
|
|
|
+pass would re-open every model in the library on every boot. And it must leave
|
|
|
+external rows alone: they point at a mount that can be slow, unmounted, or
|
|
|
+enormous, and startup is the worst place to discover that.
|
|
|
+"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import zipfile
|
|
|
+from pathlib import Path
|
|
|
+
|
|
|
+import pytest
|
|
|
+from sqlalchemy import text
|
|
|
+from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
+
|
|
|
+from backend.app.core.database import run_migrations
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture(autouse=True)
|
|
|
+def force_sqlite_dialect(monkeypatch):
|
|
|
+ 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)
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture(autouse=True)
|
|
|
+def base_dir(tmp_path, monkeypatch):
|
|
|
+ """Relative file_path values resolve against settings.base_dir."""
|
|
|
+ from backend.app.core.database import settings
|
|
|
+
|
|
|
+ monkeypatch.setattr(settings, "base_dir", tmp_path)
|
|
|
+ return tmp_path
|
|
|
+
|
|
|
+
|
|
|
+def _register_all_models():
|
|
|
+ from backend.app.models import ( # noqa: F401
|
|
|
+ ams_history,
|
|
|
+ ams_label,
|
|
|
+ api_key,
|
|
|
+ archive,
|
|
|
+ color_catalog,
|
|
|
+ external_link,
|
|
|
+ filament,
|
|
|
+ group,
|
|
|
+ kprofile_note,
|
|
|
+ library,
|
|
|
+ maintenance,
|
|
|
+ notification,
|
|
|
+ notification_template,
|
|
|
+ print_log,
|
|
|
+ print_queue,
|
|
|
+ printer,
|
|
|
+ project,
|
|
|
+ project_bom,
|
|
|
+ settings,
|
|
|
+ slot_preset,
|
|
|
+ smart_plug,
|
|
|
+ smart_plug_energy_snapshot,
|
|
|
+ spool,
|
|
|
+ spool_assignment,
|
|
|
+ spool_catalog,
|
|
|
+ spool_k_profile,
|
|
|
+ spool_usage_history,
|
|
|
+ spoolbuddy_device,
|
|
|
+ user,
|
|
|
+ user_email_pref,
|
|
|
+ virtual_printer,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture
|
|
|
+async def engine():
|
|
|
+ from backend.app.core.database import Base
|
|
|
+
|
|
|
+ _register_all_models()
|
|
|
+
|
|
|
+ eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
|
|
+ async with eng.begin() as conn:
|
|
|
+ await conn.run_sync(Base.metadata.create_all)
|
|
|
+ yield eng
|
|
|
+ await eng.dispose()
|
|
|
+
|
|
|
+
|
|
|
+def _write_3mf(path: Path, *, sliced: bool) -> None:
|
|
|
+ path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
+ names = ["3D/3dmodel.model"] + (["Metadata/plate_3.gcode"] if sliced else ["Metadata/plate_1.png"])
|
|
|
+ with zipfile.ZipFile(path, "w") as zf:
|
|
|
+ for name in names:
|
|
|
+ zf.writestr(name, b"x")
|
|
|
+
|
|
|
+
|
|
|
+async def _insert_file(conn, *, file_id: int, filename: str, path: str, external: bool = False) -> None:
|
|
|
+ await conn.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO library_files "
|
|
|
+ "(id, filename, file_path, file_type, file_size, is_external, print_count) "
|
|
|
+ "VALUES (:id, :filename, :path, '3mf', 0, :ext, 0)"
|
|
|
+ ),
|
|
|
+ {"id": file_id, "filename": filename, "path": path, "ext": 1 if external else 0},
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+async def _types(engine) -> dict[int, str]:
|
|
|
+ async with engine.connect() as conn:
|
|
|
+ return dict((await conn.execute(text("SELECT id, file_type FROM library_files ORDER BY id"))).fetchall())
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_a_stored_sliced_3mf_is_re_typed(engine, base_dir):
|
|
|
+ _write_3mf(base_dir / "files/sliced.3mf", sliced=True)
|
|
|
+ _write_3mf(base_dir / "files/model.3mf", sliced=False)
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _insert_file(conn, file_id=1, filename="Labyrinth.3mf", path="files/sliced.3mf")
|
|
|
+ await _insert_file(conn, file_id=2, filename="Labyrinth.3mf", path="files/model.3mf")
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await run_migrations(conn)
|
|
|
+
|
|
|
+ types = await _types(engine)
|
|
|
+ assert types[1] == "gcode.3mf"
|
|
|
+ assert types[2] == "3mf", "a genuine project must not gain a Print button"
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_a_missing_file_does_not_stop_the_pass(engine, base_dir):
|
|
|
+ """A library with holes in it still finishes -- the row after the gap is
|
|
|
+ the one that proves it."""
|
|
|
+ _write_3mf(base_dir / "files/sliced.3mf", sliced=True)
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _insert_file(conn, file_id=1, filename="gone.3mf", path="files/gone.3mf")
|
|
|
+ await _insert_file(conn, file_id=2, filename="Labyrinth.3mf", path="files/sliced.3mf")
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await run_migrations(conn)
|
|
|
+
|
|
|
+ types = await _types(engine)
|
|
|
+ assert types[1] == "3mf"
|
|
|
+ assert types[2] == "gcode.3mf"
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_external_rows_are_left_to_their_own_scan(engine, base_dir):
|
|
|
+ """Even a sliced one. The folder's scan re-types it without putting a
|
|
|
+ possibly-unreachable mount on the startup path."""
|
|
|
+ _write_3mf(base_dir / "mount/sliced.3mf", sliced=True)
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _insert_file(conn, file_id=1, filename="Labyrinth.3mf", path="mount/sliced.3mf", external=True)
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await run_migrations(conn)
|
|
|
+
|
|
|
+ assert (await _types(engine))[1] == "3mf"
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_it_runs_once(engine, base_dir):
|
|
|
+ """Every boot re-runs the migration set. A source 3MF matches the query
|
|
|
+ forever, so without the flag this re-opens the whole library each time."""
|
|
|
+ _write_3mf(base_dir / "files/model.3mf", sliced=False)
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _insert_file(conn, file_id=1, filename="Labyrinth.3mf", path="files/model.3mf")
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await run_migrations(conn)
|
|
|
+
|
|
|
+ # Second boot: the file becomes readable-as-sliced, and must be ignored,
|
|
|
+ # which can only happen if the pass is genuinely gated rather than merely
|
|
|
+ # idempotent in its effect.
|
|
|
+ _write_3mf(base_dir / "files/model.3mf", sliced=True)
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await run_migrations(conn)
|
|
|
+
|
|
|
+ assert (await _types(engine))[1] == "3mf"
|