test_archive_run_photos_backfill.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. """A finish photo only its run kept is put back on its archive, once.
  2. A later print's finish photo was appended to the archive's photo list in
  3. place, which the ORM does not write, so only the run's list had it (archive
  4. #455). The archive serves and shows photos from its own list.
  5. """
  6. from datetime import datetime
  7. import pytest
  8. from sqlalchemy import select, text
  9. from sqlalchemy.ext.asyncio import async_sessionmaker
  10. from backend.app.core.config import settings
  11. from backend.app.core.database import _backfill_archive_run_photos
  12. from backend.app.models.archive import PrintArchive
  13. from backend.app.models.print_log import PrintLogEntry
  14. FLAG = "_backfill_archive_run_photos_done"
  15. @pytest.fixture
  16. async def engine(test_engine, tmp_path, monkeypatch):
  17. # The suite's database, so a --pg run checks it on PostgreSQL too.
  18. monkeypatch.setattr(settings, "base_dir", tmp_path)
  19. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  20. return test_engine
  21. def _photo(tmp_path, folder: str, name: str) -> None:
  22. photos = tmp_path / folder / "photos"
  23. photos.mkdir(parents=True, exist_ok=True)
  24. (photos / name).write_bytes(b"jpg")
  25. async def _seed(engine, archives: dict[str, list | None], runs: list[tuple[str, int, list | None]]) -> dict[str, int]:
  26. maker = async_sessionmaker(engine, expire_on_commit=False)
  27. async with maker() as db:
  28. rows = {
  29. name: PrintArchive(
  30. filename=f"{name}.3mf", file_path=f"{name}/{name}.3mf", file_size=1, status="completed", photos=photos
  31. )
  32. for name, photos in archives.items()
  33. }
  34. db.add_all(rows.values())
  35. await db.flush()
  36. for name, minute, photos in runs:
  37. db.add(
  38. PrintLogEntry(
  39. archive_id=rows[name].id,
  40. status="completed",
  41. started_at=datetime(2026, 11, 5, 9, minute),
  42. photos=photos,
  43. )
  44. )
  45. await db.commit()
  46. return {name: row.id for name, row in rows.items()}
  47. async def _photos(engine) -> dict[int, list | None]:
  48. maker = async_sessionmaker(engine)
  49. async with maker() as db:
  50. return dict((await db.execute(select(PrintArchive.id, PrintArchive.photos))).tuples().all())
  51. async def _run(engine) -> None:
  52. async with engine.begin() as conn:
  53. await _backfill_archive_run_photos(conn)
  54. @pytest.mark.asyncio
  55. async def test_a_photo_only_its_run_kept_is_put_back_in_run_order(engine, tmp_path):
  56. for name in ("p1.jpg", "p2.jpg", "p3.jpg", "upload.jpg"):
  57. _photo(tmp_path, "a", name)
  58. ids = await _seed(
  59. engine,
  60. {"a": ["p1.jpg", "upload.jpg"]},
  61. # Inserted out of order: the archive takes them as the prints ran.
  62. [("a", 30, ["p3.jpg"]), ("a", 0, ["p1.jpg"]), ("a", 15, ["p2.jpg"])],
  63. )
  64. await _run(engine)
  65. assert (await _photos(engine))[ids["a"]] == ["p1.jpg", "upload.jpg", "p2.jpg", "p3.jpg"]
  66. @pytest.mark.asyncio
  67. async def test_an_archive_with_no_photos_left_gets_its_runs(engine, tmp_path):
  68. _photo(tmp_path, "a", "p2.jpg")
  69. ids = await _seed(engine, {"a": None}, [("a", 0, None), ("a", 10, ["p2.jpg"])])
  70. await _run(engine)
  71. assert (await _photos(engine))[ids["a"]] == ["p2.jpg"]
  72. @pytest.mark.asyncio
  73. async def test_a_photo_whose_file_is_gone_stays_off(engine, tmp_path):
  74. _photo(tmp_path, "a", "kept.jpg")
  75. ids = await _seed(
  76. engine, {"a": ["p1.jpg"], "b": ["x.jpg"]}, [("a", 0, ["gone.jpg", "kept.jpg"]), ("b", 0, ["gone.jpg"])]
  77. )
  78. await _run(engine)
  79. photos = await _photos(engine)
  80. assert photos[ids["a"]] == ["p1.jpg", "kept.jpg"]
  81. # Another archive's directory holding the name does not count.
  82. assert photos[ids["b"]] == ["x.jpg"]
  83. @pytest.mark.asyncio
  84. async def test_archives_that_already_list_their_runs_photos_are_left_alone(engine, tmp_path):
  85. _photo(tmp_path, "a", "p1.jpg")
  86. ids = await _seed(engine, {"a": ["upload.jpg", "p1.jpg"], "b": ["only.jpg"]}, [("a", 0, ["p1.jpg"])])
  87. await _run(engine)
  88. photos = await _photos(engine)
  89. assert photos[ids["a"]] == ["upload.jpg", "p1.jpg"]
  90. assert photos[ids["b"]] == ["only.jpg"]
  91. @pytest.mark.asyncio
  92. async def test_it_runs_once(engine, tmp_path):
  93. _photo(tmp_path, "a", "p2.jpg")
  94. ids = await _seed(engine, {"a": ["p1.jpg"]}, [("a", 0, ["p2.jpg"])])
  95. await _run(engine)
  96. async with engine.begin() as conn:
  97. flag = (await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": FLAG})).scalar_one()
  98. assert flag == "true"
  99. # Taken off the archive again after the repair: a later start must not
  100. # be what puts it back.
  101. await conn.execute(text("UPDATE print_archives SET photos = '[\"p1.jpg\"]' WHERE id = :id"), {"id": ids["a"]})
  102. await _run(engine)
  103. assert (await _photos(engine))[ids["a"]] == ["p1.jpg"]
  104. @pytest.mark.asyncio
  105. async def test_nothing_to_repair_still_marks_it_done(engine):
  106. await _run(engine)
  107. await _run(engine)
  108. async with engine.begin() as conn:
  109. count = (await conn.execute(text('SELECT COUNT(*) FROM settings WHERE "key" = :k'), {"k": FLAG})).scalar_one()
  110. assert count == 1
  111. def test_every_start_runs_it():
  112. import inspect
  113. from backend.app.core.database import _run_migrations
  114. assert "await _backfill_archive_run_photos(conn)" in inspect.getsource(_run_migrations)