test_sqlite_dangling_references.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. """Deleting users, archives and library files leaves no ids pointing at them on SQLite.
  2. SQLite runs with foreign keys off, so the ON DELETE SET NULL that PostgreSQL
  3. applies to print_log_entries.created_by_id, print_log_entries.archive_id and
  4. print_archives.library_file_id never fired: the print log kept a deleted user's
  5. id (so the entries were missing from "no user"), and archives kept the id of a
  6. deleted library file, which SQLite hands to the next file created.
  7. """
  8. from datetime import datetime, timezone
  9. import pytest
  10. from httpx import AsyncClient
  11. from sqlalchemy import select
  12. from backend.app.models.archive import PrintArchive
  13. from backend.app.models.library import LibraryFile
  14. from backend.app.models.print_log import PrintLogEntry
  15. from backend.app.models.user import User
  16. async def _user(db_session, name="leaver") -> User:
  17. user = User(username=name, password_hash="x", role="user")
  18. db_session.add(user)
  19. await db_session.commit()
  20. await db_session.refresh(user)
  21. return user
  22. async def _log_entry(db_session, **fields) -> PrintLogEntry:
  23. entry = PrintLogEntry(status="completed", print_name="Benchy", started_at=datetime.now(timezone.utc), **fields)
  24. db_session.add(entry)
  25. await db_session.commit()
  26. await db_session.refresh(entry)
  27. return entry
  28. async def _reload(db_session, model, row_id):
  29. statement = select(model).where(model.id == row_id).execution_options(populate_existing=True)
  30. return (await db_session.execute(statement)).scalar_one_or_none()
  31. @pytest.mark.asyncio
  32. @pytest.mark.integration
  33. async def test_deleting_a_user_clears_their_print_log_entries(async_client: AsyncClient, db_session):
  34. user = await _user(db_session)
  35. entry = await _log_entry(db_session, created_by_id=user.id)
  36. response = await async_client.delete(f"/api/v1/users/{user.id}")
  37. assert response.status_code == 204, response.text
  38. assert (await _reload(db_session, PrintLogEntry, entry.id)).created_by_id is None
  39. @pytest.mark.asyncio
  40. @pytest.mark.integration
  41. async def test_deleting_a_user_with_their_items_clears_the_log_entries_of_their_archives(
  42. async_client: AsyncClient, db_session, printer_factory, archive_factory
  43. ):
  44. user = await _user(db_session)
  45. printer = await printer_factory()
  46. archive = await archive_factory(printer.id, created_by_id=user.id, with_run=False)
  47. entry = await _log_entry(db_session, archive_id=archive.id, created_by_id=user.id)
  48. response = await async_client.delete(f"/api/v1/users/{user.id}?delete_items=true")
  49. assert response.status_code == 204, response.text
  50. assert await _reload(db_session, PrintArchive, archive.id) is None
  51. reloaded = await _reload(db_session, PrintLogEntry, entry.id)
  52. assert reloaded.archive_id is None
  53. assert reloaded.created_by_id is None
  54. @pytest.mark.asyncio
  55. @pytest.mark.integration
  56. async def test_deleting_a_user_with_their_items_clears_archives_of_their_library_files(
  57. async_client: AsyncClient, db_session, printer_factory, archive_factory
  58. ):
  59. user = await _user(db_session)
  60. library_file = LibraryFile(
  61. filename="part.3mf", file_path="library/files/part.3mf", file_type="3mf", file_size=1, created_by_id=user.id
  62. )
  63. db_session.add(library_file)
  64. await db_session.commit()
  65. printer = await printer_factory()
  66. archive = await archive_factory(printer.id, library_file_id=library_file.id, with_run=False)
  67. response = await async_client.delete(f"/api/v1/users/{user.id}?delete_items=true")
  68. assert response.status_code == 204, response.text
  69. assert (await _reload(db_session, PrintArchive, archive.id)).library_file_id is None
  70. @pytest.mark.asyncio
  71. @pytest.mark.integration
  72. async def test_deleting_a_printer_with_its_archives_clears_their_log_entries(
  73. async_client: AsyncClient, db_session, printer_factory, archive_factory
  74. ):
  75. printer = await printer_factory()
  76. archive = await archive_factory(printer.id, with_run=False)
  77. entry = await _log_entry(db_session, archive_id=archive.id, thumbnail_path="archive/1/thumb.png")
  78. response = await async_client.delete(f"/api/v1/printers/{printer.id}?delete_archives=true")
  79. assert response.status_code == 200, response.text
  80. assert await _reload(db_session, PrintArchive, archive.id) is None
  81. reloaded = await _reload(db_session, PrintLogEntry, entry.id)
  82. assert reloaded.archive_id is None
  83. # The archive files stay on disk here (unlike a single archive delete), so
  84. # the print log keeps its picture.
  85. assert reloaded.thumbnail_path == "archive/1/thumb.png"
  86. @pytest.mark.asyncio
  87. @pytest.mark.integration
  88. async def test_deleting_a_library_file_clears_the_archives_printed_from_it(
  89. async_client: AsyncClient, db_session, printer_factory, archive_factory
  90. ):
  91. library_file = LibraryFile(
  92. filename="ext.3mf", file_path="/mnt/share/ext.3mf", file_type="3mf", file_size=1, is_external=True
  93. )
  94. db_session.add(library_file)
  95. await db_session.commit()
  96. printer = await printer_factory()
  97. archive = await archive_factory(printer.id, library_file_id=library_file.id, with_run=False)
  98. response = await async_client.delete(f"/api/v1/library/files/{library_file.id}")
  99. assert response.status_code == 200, response.text
  100. assert await _reload(db_session, LibraryFile, library_file.id) is None
  101. assert (await _reload(db_session, PrintArchive, archive.id)).library_file_id is None
  102. @pytest.mark.asyncio
  103. @pytest.mark.integration
  104. async def test_any_library_file_delete_clears_the_archives_printed_from_it(
  105. db_session, printer_factory, archive_factory
  106. ):
  107. """Not only the routes: any delete of the row applies the rule (core/sqlite_fk hooks)."""
  108. library_file = LibraryFile(filename="a.3mf", file_path="library/files/a.3mf", file_type="3mf", file_size=1)
  109. db_session.add(library_file)
  110. await db_session.commit()
  111. printer = await printer_factory()
  112. archive = await archive_factory(printer.id, library_file_id=library_file.id, with_run=False)
  113. await db_session.delete(library_file)
  114. await db_session.commit()
  115. assert (await _reload(db_session, PrintArchive, archive.id)).library_file_id is None
  116. @pytest.mark.asyncio
  117. @pytest.mark.integration
  118. async def test_deleting_a_user_with_their_items_releases_other_users_queue_jobs(
  119. async_client: AsyncClient, db_session, printer_factory, archive_factory
  120. ):
  121. """Another user's job on the deleted user's library file or archive must not
  122. keep its id: SQLite gives the highest freed id to the next file or archive,
  123. and the job would print that one."""
  124. from backend.app.models.print_queue import PrintQueueItem
  125. owner = await _user(db_session, "owner")
  126. other = await _user(db_session, "other")
  127. library_file = LibraryFile(
  128. filename="part.3mf", file_path="library/files/part.3mf", file_type="3mf", file_size=1, created_by_id=owner.id
  129. )
  130. db_session.add(library_file)
  131. await db_session.commit()
  132. printer = await printer_factory()
  133. archive = await archive_factory(printer.id, created_by_id=owner.id, with_run=False)
  134. on_file = PrintQueueItem(printer_id=printer.id, library_file_id=library_file.id, position=1, created_by_id=other.id)
  135. on_archive = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, position=2, created_by_id=other.id)
  136. db_session.add_all([on_file, on_archive])
  137. await db_session.commit()
  138. on_file_id, on_archive_id = on_file.id, on_archive.id
  139. response = await async_client.delete(f"/api/v1/users/{owner.id}?delete_items=true")
  140. assert response.status_code == 204, response.text
  141. kept = await _reload(db_session, PrintQueueItem, on_file_id)
  142. assert kept.library_file_id is None
  143. assert kept.status == "cancelled"
  144. # ON DELETE CASCADE on print_queue.archive_id, as on PostgreSQL.
  145. assert await _reload(db_session, PrintQueueItem, on_archive_id) is None
  146. @pytest.mark.asyncio
  147. @pytest.mark.integration
  148. async def test_a_rescan_that_drops_a_vanished_external_file_clears_its_archives(
  149. async_client: AsyncClient, db_session, printer_factory, archive_factory, tmp_path, monkeypatch
  150. ):
  151. from backend.app.core.config import settings as app_settings
  152. data_dir = tmp_path / "data"
  153. data_dir.mkdir()
  154. monkeypatch.setattr(app_settings, "base_dir", data_dir)
  155. monkeypatch.setattr(app_settings, "archive_dir", data_dir / "archive")
  156. share = tmp_path / "share"
  157. share.mkdir()
  158. monkeypatch.setenv("BAMBUDDY_EXTERNAL_ROOTS", str(share))
  159. (share / "bracket.stl").write_bytes(b"fakestl")
  160. folder = await async_client.post(
  161. "/api/v1/library/folders/external",
  162. json={"name": "Share", "external_path": str(share), "readonly": True, "show_hidden": False},
  163. )
  164. assert folder.status_code == 200, folder.text
  165. folder_id = folder.json()["id"]
  166. assert (await async_client.post(f"/api/v1/library/folders/{folder_id}/scan")).json()["added"] == 1
  167. file_id = (await async_client.get(f"/api/v1/library/files?folder_id={folder_id}")).json()[0]["id"]
  168. printer = await printer_factory()
  169. archive = await archive_factory(printer.id, library_file_id=file_id, with_run=False)
  170. (share / "bracket.stl").unlink()
  171. rescan = await async_client.post(f"/api/v1/library/folders/{folder_id}/scan")
  172. assert rescan.json()["removed"] == 1
  173. assert (await _reload(db_session, PrintArchive, archive.id)).library_file_id is None
  174. @pytest.mark.asyncio
  175. @pytest.mark.integration
  176. async def test_deleting_a_user_with_their_items_applies_the_rules_to_their_jobs_and_batches(
  177. async_client: AsyncClient, db_session, printer_factory, archive_factory
  178. ):
  179. """Their queue jobs and batches go with a bulk delete; what points at those
  180. goes too (a job's model variants) or loses the link (another user's job in
  181. their batch), as on PostgreSQL."""
  182. from backend.app.models.print_batch import PrintBatch
  183. from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
  184. owner = await _user(db_session, "owner")
  185. other = await _user(db_session, "other")
  186. library_file = LibraryFile(filename="v.3mf", file_path="library/files/v.3mf", file_type="3mf", file_size=1)
  187. batch = PrintBatch(name="Order", created_by_id=owner.id)
  188. db_session.add_all([library_file, batch])
  189. await db_session.commit()
  190. printer = await printer_factory()
  191. archive = await archive_factory(printer.id, with_run=False)
  192. owned = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, position=1, created_by_id=owner.id)
  193. in_batch = PrintQueueItem(
  194. printer_id=printer.id, archive_id=archive.id, position=2, created_by_id=other.id, batch_id=batch.id
  195. )
  196. db_session.add_all([owned, in_batch])
  197. await db_session.commit()
  198. variant = PrintQueueVariant(queue_item_id=owned.id, library_file_id=library_file.id, target_model="X1C")
  199. db_session.add(variant)
  200. await db_session.commit()
  201. variant_id, in_batch_id = variant.id, in_batch.id
  202. response = await async_client.delete(f"/api/v1/users/{owner.id}?delete_items=true")
  203. assert response.status_code == 204, response.text
  204. assert await _reload(db_session, PrintQueueVariant, variant_id) is None
  205. assert (await _reload(db_session, PrintQueueItem, in_batch_id)).batch_id is None
  206. async def _printing_reprint(db_session, printer_factory, archive_factory):
  207. """An archive made on printer A, being reprinted right now on printer B."""
  208. from backend.app.models.print_queue import PrintQueueItem
  209. printer_a = await printer_factory(name="A", serial_number="SA")
  210. printer_b = await printer_factory(name="B", serial_number="SB")
  211. archive = await archive_factory(printer_a.id, with_run=False)
  212. item = PrintQueueItem(printer_id=printer_b.id, archive_id=archive.id, position=1, status="printing")
  213. db_session.add(item)
  214. await db_session.commit()
  215. return printer_a.id, archive, item.id
  216. @pytest.mark.asyncio
  217. @pytest.mark.integration
  218. async def test_deleting_a_printer_with_its_archives_waits_for_a_running_reprint(
  219. async_client: AsyncClient, db_session, printer_factory, archive_factory
  220. ):
  221. """Like DELETE /archives/{id}: an archive whose queue item is printing is
  222. not deleted from under the dispatcher."""
  223. from backend.app.models.print_queue import PrintQueueItem
  224. printer_a_id, archive, item_id = await _printing_reprint(db_session, printer_factory, archive_factory)
  225. response = await async_client.delete(f"/api/v1/printers/{printer_a_id}?delete_archives=true")
  226. assert response.status_code == 409, response.text
  227. assert "printing" in response.json()["detail"]
  228. assert await _reload(db_session, PrintArchive, archive.id) is not None
  229. assert await _reload(db_session, PrintQueueItem, item_id) is not None
  230. @pytest.mark.asyncio
  231. @pytest.mark.integration
  232. @pytest.mark.parametrize("purge_stats", [False, True])
  233. async def test_the_archive_purge_skips_an_archive_that_is_being_printed(
  234. async_client: AsyncClient, db_session, printer_factory, archive_factory, purge_stats
  235. ):
  236. from datetime import datetime, timedelta, timezone
  237. from backend.app.models.print_queue import PrintQueueItem
  238. _, archive, item_id = await _printing_reprint(db_session, printer_factory, archive_factory)
  239. archive.created_at = datetime.now(timezone.utc) - timedelta(days=400)
  240. await db_session.commit()
  241. response = await async_client.post(
  242. "/api/v1/archives/purge", json={"older_than_days": 365, "purge_stats": purge_stats}
  243. )
  244. assert response.status_code == 200, response.text
  245. assert response.json()["deleted"] == 0
  246. reloaded = await _reload(db_session, PrintArchive, archive.id)
  247. assert reloaded is not None and reloaded.deleted_at is None
  248. assert await _reload(db_session, PrintQueueItem, item_id) is not None