test_sliced_3mf_reclassify_migration_2993.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """The one-shot pass that reaches library rows already stored (#2993).
  2. The forward fix classifies on content, which does nothing for the files a user
  3. has already imported -- the reporter's whole complaint was about files they had
  4. downloaded and imported before this existed. This backfill re-opens them.
  5. Two properties matter as much as the re-typing itself. It must run once: a
  6. genuine source 3MF keeps matching ``file_type = '3mf'`` forever, so an ungated
  7. pass would re-open every model in the library on every boot. And it must leave
  8. external rows alone: they point at a mount that can be slow, unmounted, or
  9. enormous, and startup is the worst place to discover that.
  10. """
  11. from __future__ import annotations
  12. import zipfile
  13. from pathlib import Path
  14. import pytest
  15. from sqlalchemy import text
  16. from sqlalchemy.ext.asyncio import create_async_engine
  17. from backend.app.core.database import run_migrations
  18. @pytest.fixture(autouse=True)
  19. def force_sqlite_dialect(monkeypatch):
  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. @pytest.fixture(autouse=True)
  25. def base_dir(tmp_path, monkeypatch):
  26. """Relative file_path values resolve against settings.base_dir."""
  27. from backend.app.core.database import settings
  28. monkeypatch.setattr(settings, "base_dir", tmp_path)
  29. return tmp_path
  30. def _register_all_models():
  31. from backend.app.models import ( # noqa: F401
  32. ams_history,
  33. ams_label,
  34. api_key,
  35. archive,
  36. color_catalog,
  37. external_link,
  38. filament,
  39. group,
  40. kprofile_note,
  41. library,
  42. maintenance,
  43. notification,
  44. notification_template,
  45. print_log,
  46. print_queue,
  47. printer,
  48. project,
  49. project_bom,
  50. settings,
  51. slot_preset,
  52. smart_plug,
  53. smart_plug_energy_snapshot,
  54. spool,
  55. spool_assignment,
  56. spool_catalog,
  57. spool_k_profile,
  58. spool_usage_history,
  59. spoolbuddy_device,
  60. user,
  61. user_email_pref,
  62. virtual_printer,
  63. )
  64. @pytest.fixture
  65. async def engine():
  66. from backend.app.core.database import Base
  67. _register_all_models()
  68. eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  69. async with eng.begin() as conn:
  70. await conn.run_sync(Base.metadata.create_all)
  71. yield eng
  72. await eng.dispose()
  73. def _write_3mf(path: Path, *, sliced: bool) -> None:
  74. path.parent.mkdir(parents=True, exist_ok=True)
  75. names = ["3D/3dmodel.model"] + (["Metadata/plate_3.gcode"] if sliced else ["Metadata/plate_1.png"])
  76. with zipfile.ZipFile(path, "w") as zf:
  77. for name in names:
  78. zf.writestr(name, b"x")
  79. async def _insert_file(conn, *, file_id: int, filename: str, path: str, external: bool = False) -> None:
  80. await conn.execute(
  81. text(
  82. "INSERT INTO library_files "
  83. "(id, filename, file_path, file_type, file_size, is_external, print_count) "
  84. "VALUES (:id, :filename, :path, '3mf', 0, :ext, 0)"
  85. ),
  86. {"id": file_id, "filename": filename, "path": path, "ext": 1 if external else 0},
  87. )
  88. async def _types(engine) -> dict[int, str]:
  89. async with engine.connect() as conn:
  90. return dict((await conn.execute(text("SELECT id, file_type FROM library_files ORDER BY id"))).fetchall())
  91. @pytest.mark.asyncio
  92. async def test_a_stored_sliced_3mf_is_re_typed(engine, base_dir):
  93. _write_3mf(base_dir / "files/sliced.3mf", sliced=True)
  94. _write_3mf(base_dir / "files/model.3mf", sliced=False)
  95. async with engine.begin() as conn:
  96. await _insert_file(conn, file_id=1, filename="Labyrinth.3mf", path="files/sliced.3mf")
  97. await _insert_file(conn, file_id=2, filename="Labyrinth.3mf", path="files/model.3mf")
  98. async with engine.begin() as conn:
  99. await run_migrations(conn)
  100. types = await _types(engine)
  101. assert types[1] == "gcode.3mf"
  102. assert types[2] == "3mf", "a genuine project must not gain a Print button"
  103. @pytest.mark.asyncio
  104. async def test_a_missing_file_does_not_stop_the_pass(engine, base_dir):
  105. """A library with holes in it still finishes -- the row after the gap is
  106. the one that proves it."""
  107. _write_3mf(base_dir / "files/sliced.3mf", sliced=True)
  108. async with engine.begin() as conn:
  109. await _insert_file(conn, file_id=1, filename="gone.3mf", path="files/gone.3mf")
  110. await _insert_file(conn, file_id=2, filename="Labyrinth.3mf", path="files/sliced.3mf")
  111. async with engine.begin() as conn:
  112. await run_migrations(conn)
  113. types = await _types(engine)
  114. assert types[1] == "3mf"
  115. assert types[2] == "gcode.3mf"
  116. @pytest.mark.asyncio
  117. async def test_external_rows_are_left_to_their_own_scan(engine, base_dir):
  118. """Even a sliced one. The folder's scan re-types it without putting a
  119. possibly-unreachable mount on the startup path."""
  120. _write_3mf(base_dir / "mount/sliced.3mf", sliced=True)
  121. async with engine.begin() as conn:
  122. await _insert_file(conn, file_id=1, filename="Labyrinth.3mf", path="mount/sliced.3mf", external=True)
  123. async with engine.begin() as conn:
  124. await run_migrations(conn)
  125. assert (await _types(engine))[1] == "3mf"
  126. @pytest.mark.asyncio
  127. async def test_it_runs_once(engine, base_dir):
  128. """Every boot re-runs the migration set. A source 3MF matches the query
  129. forever, so without the flag this re-opens the whole library each time."""
  130. _write_3mf(base_dir / "files/model.3mf", sliced=False)
  131. async with engine.begin() as conn:
  132. await _insert_file(conn, file_id=1, filename="Labyrinth.3mf", path="files/model.3mf")
  133. async with engine.begin() as conn:
  134. await run_migrations(conn)
  135. # Second boot: the file becomes readable-as-sliced, and must be ignored,
  136. # which can only happen if the pass is genuinely gated rather than merely
  137. # idempotent in its effect.
  138. _write_3mf(base_dir / "files/model.3mf", sliced=True)
  139. async with engine.begin() as conn:
  140. await run_migrations(conn)
  141. assert (await _types(engine))[1] == "3mf"