test_pending_upload_archive_project.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. """Approving a pending upload stores it in the File Manager, project checked first.
  2. The project id from the request used to be stored unchecked, after the file
  3. was already stored: an unknown project failed with a server error on
  4. PostgreSQL (foreign key) with the files already written, and was kept as a
  5. broken link on SQLite. 0 means no project.
  6. Approving puts the file in the File Manager folder of the virtual printer it
  7. came in on (review mode); the archive gets it only once it prints.
  8. """
  9. from __future__ import annotations
  10. import pytest
  11. from httpx import AsyncClient
  12. from sqlalchemy import select
  13. from backend.app.models.library import LibraryFile, LibraryFolder
  14. from backend.app.models.pending_upload import PendingUpload
  15. from backend.app.models.virtual_printer import VirtualPrinter
  16. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  17. @pytest.fixture(autouse=True)
  18. def _library_in_tmp(tmp_path, monkeypatch):
  19. """The File Manager writes below archive_dir: keep it out of the real one."""
  20. from backend.app.core.config import settings
  21. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  22. async def _pending(db_session, tmp_path, **extra) -> PendingUpload:
  23. file = tmp_path / "part.3mf"
  24. file.write_bytes(b"3mf")
  25. upload = PendingUpload(
  26. filename="part.3mf", file_path=str(file), file_size=3, source_ip="192.168.1.100", status="pending", **extra
  27. )
  28. db_session.add(upload)
  29. await db_session.commit()
  30. await db_session.refresh(upload)
  31. return upload
  32. async def test_an_unknown_project_is_refused_before_anything_is_stored(async_client: AsyncClient, db_session, tmp_path):
  33. upload_id = (await _pending(db_session, tmp_path)).id
  34. response = await async_client.post(f"/api/v1/pending-uploads/{upload_id}/archive", json={"project_id": 9999})
  35. assert response.status_code == 400, response.text
  36. assert response.json()["detail"] == "Project not found"
  37. db_session.expire_all()
  38. row = (
  39. await db_session.execute(
  40. select(PendingUpload.status, PendingUpload.project_id).where(PendingUpload.id == upload_id)
  41. )
  42. ).one()
  43. assert tuple(row) == ("pending", None)
  44. assert (tmp_path / "part.3mf").exists()
  45. assert (await db_session.execute(select(LibraryFile.id))).first() is None
  46. @pytest.mark.parametrize("given", ["known", 0, None])
  47. async def test_a_known_project_or_none_is_stored(async_client: AsyncClient, db_session, tmp_path, given):
  48. upload_id = (await _pending(db_session, tmp_path)).id
  49. project_id = None
  50. if given == "known":
  51. project_id = (await async_client.post("/api/v1/projects/", json={"name": "P"})).json()["id"]
  52. response = await async_client.post(
  53. f"/api/v1/pending-uploads/{upload_id}/archive",
  54. json={"project_id": project_id if given == "known" else given},
  55. )
  56. assert response.status_code == 200, response.text
  57. db_session.expire_all()
  58. stored = (
  59. await db_session.execute(select(LibraryFile.project_id).where(LibraryFile.id == response.json()["id"]))
  60. ).scalar()
  61. pending = (await db_session.execute(select(PendingUpload.project_id).where(PendingUpload.id == upload_id))).scalar()
  62. assert stored == project_id
  63. assert pending == project_id
  64. async def test_approved_goes_to_the_folder_of_its_virtual_printer_with_notes_and_tags(
  65. async_client: AsyncClient, db_session, tmp_path
  66. ):
  67. folder = LibraryFolder(name="Class 7b", shared=True)
  68. db_session.add(folder)
  69. await db_session.flush()
  70. vp = VirtualPrinter(name="Classroom", mode="review", library_folder_id=folder.id)
  71. db_session.add(vp)
  72. await db_session.flush()
  73. upload_id = (await _pending(db_session, tmp_path, virtual_printer_id=vp.id)).id
  74. response = await async_client.post(
  75. f"/api/v1/pending-uploads/{upload_id}/archive",
  76. json={"notes": "for the science fair", "tags": "Bridge, school ,bridge"},
  77. )
  78. assert response.status_code == 200, response.text
  79. assert response.json()["folder_id"] == folder.id
  80. db_session.expire_all()
  81. library_file = await db_session.get(LibraryFile, response.json()["id"])
  82. await db_session.refresh(library_file, ["tags"])
  83. assert library_file.filename == "part.3mf"
  84. assert library_file.source_type == "virtual_printer"
  85. assert library_file.notes == "for the science fair"
  86. assert sorted(tag.name for tag in library_file.tags) == ["Bridge", "school"]
  87. pending = await db_session.get(PendingUpload, upload_id)
  88. assert pending.status == "approved"
  89. assert not (tmp_path / "part.3mf").exists()
  90. async def test_an_upload_without_its_virtual_printer_goes_to_the_shared_folder(
  91. async_client: AsyncClient, db_session, tmp_path
  92. ):
  93. upload_id = (await _pending(db_session, tmp_path)).id
  94. response = await async_client.post(f"/api/v1/pending-uploads/{upload_id}/archive")
  95. assert response.status_code == 200, response.text
  96. folder = await db_session.get(LibraryFolder, response.json()["folder_id"])
  97. assert folder.name == "Virtual Printer"
  98. assert folder.parent_id is None
  99. assert folder.shared is True
  100. async def test_approve_all_stores_every_upload(async_client: AsyncClient, db_session, tmp_path):
  101. first_id = (await _pending(db_session, tmp_path)).id
  102. other = tmp_path / "other.3mf"
  103. other.write_bytes(b"other")
  104. db_session.add(
  105. PendingUpload(
  106. filename="other.3mf", file_path=str(other), file_size=5, source_ip="192.168.1.100", status="pending"
  107. )
  108. )
  109. gone = PendingUpload(
  110. filename="gone.3mf", file_path=str(tmp_path / "gone.3mf"), file_size=1, source_ip=None, status="pending"
  111. )
  112. db_session.add(gone)
  113. await db_session.commit()
  114. gone_id = gone.id
  115. response = await async_client.post("/api/v1/pending-uploads/archive-all")
  116. assert response.status_code == 200, response.text
  117. assert response.json() == {"stored": 2, "failed": 1}
  118. db_session.expire_all()
  119. names = sorted((await db_session.execute(select(LibraryFile.filename))).scalars())
  120. assert names == ["other.3mf", "part.3mf"]
  121. assert (await db_session.get(PendingUpload, first_id)).status == "approved"
  122. assert (await db_session.get(PendingUpload, gone_id)).status == "discarded"
  123. async def test_approve_all_goes_on_after_one_upload_fails(async_client: AsyncClient, db_session, tmp_path):
  124. # A failed one is rolled back; the rest must still be stored (the
  125. # rollback used to leave the loop on expired rows: a 500).
  126. from unittest.mock import patch
  127. from backend.app.services.virtual_printer import library_store
  128. for name in ("a.3mf", "b.3mf", "c.3mf"):
  129. path = tmp_path / name
  130. path.write_bytes(name.encode())
  131. db_session.add(PendingUpload(filename=name, file_path=str(path), file_size=5, source_ip=None, status="pending"))
  132. await db_session.commit()
  133. real_store = library_store.store_upload
  134. calls = 0
  135. async def flaky_store(db, **kwargs):
  136. nonlocal calls
  137. calls += 1
  138. if calls == 1:
  139. await db.execute(select(LibraryFile.id)) # the failure comes mid-transaction
  140. raise OSError("disk full")
  141. return await real_store(db, **kwargs)
  142. with patch.object(library_store, "store_upload", flaky_store):
  143. response = await async_client.post("/api/v1/pending-uploads/archive-all")
  144. assert response.status_code == 200, response.text
  145. assert response.json() == {"stored": 2, "failed": 1}