test_verdict_per_run.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. """Each print of an archive has its own outcome question (#1898, archive redesign P1b).
  2. Plates of one archive printed on two printers are two runs. Each asks for its
  3. own verdict with its own one-tap token; an answer lands on the run it was
  4. asked about, and the archive shows its latest run's.
  5. """
  6. from __future__ import annotations
  7. from datetime import datetime, timedelta
  8. from types import SimpleNamespace
  9. from unittest.mock import AsyncMock, patch
  10. import pytest
  11. from backend.app.models.archive import PrintArchive
  12. from backend.app.models.print_log import PrintLogEntry
  13. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  14. T1 = datetime(2026, 10, 30, 9)
  15. T2 = T1 + timedelta(hours=1)
  16. @pytest.fixture
  17. async def two_plates(db_session, printer_factory):
  18. """One archive, plate 1 printed on A, then plate 2 on B; both asked for
  19. their outcome and neither is answered."""
  20. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  21. archive = PrintArchive(
  22. printer_id=b.id,
  23. filename="two.gcode.3mf",
  24. file_path="archives/test/two.gcode.3mf",
  25. file_size=1,
  26. print_name="two",
  27. status="completed",
  28. started_at=T2,
  29. confirm_requested=True,
  30. )
  31. db_session.add(archive)
  32. await db_session.flush()
  33. first = PrintLogEntry(
  34. archive_id=archive.id, printer_id=a.id, plate_id=1, status="completed", started_at=T1, confirm_requested=True
  35. )
  36. second = PrintLogEntry(
  37. archive_id=archive.id, printer_id=b.id, plate_id=2, status="completed", started_at=T2, confirm_requested=True
  38. )
  39. db_session.add_all([first, second])
  40. await db_session.commit()
  41. return SimpleNamespace(archive=archive.id, a=a.id, b=b.id, first=first.id, second=second.id)
  42. async def _row(db_session, model, row_id):
  43. db_session.expire_all()
  44. return await db_session.get(model, row_id)
  45. async def _prompt(db_session, plates, run_id):
  46. """The completion's outcome prompt for ``run_id``; returns (ws payload, provider kwargs)."""
  47. from backend.app.main import dispatch_outcome_confirmation
  48. with (
  49. patch("backend.app.main.ws_manager") as ws,
  50. patch("backend.app.main.notification_service") as notify,
  51. ):
  52. ws.send_print_confirm_request = AsyncMock()
  53. notify.on_print_confirm_request = AsyncMock()
  54. sent = await dispatch_outcome_confirmation(db_session, plates.a, "A", {}, plates.archive, run_id=run_id)
  55. assert sent
  56. return ws.send_print_confirm_request.await_args.args[1], notify.on_print_confirm_request.await_args.kwargs
  57. async def test_each_plates_prompt_has_its_own_token(db_session, two_plates):
  58. first_ws, first_links = await _prompt(db_session, two_plates, two_plates.first)
  59. await _prompt(db_session, two_plates, two_plates.second)
  60. first_token = (await _row(db_session, PrintLogEntry, two_plates.first)).confirm_token
  61. second_token = (await _row(db_session, PrintLogEntry, two_plates.second)).confirm_token
  62. assert first_token and second_token and first_token != second_token
  63. assert (await _row(db_session, PrintArchive, two_plates.archive)).confirm_token is None
  64. assert first_ws["run_id"] == two_plates.first
  65. assert first_links["run_id"] == two_plates.first
  66. assert first_links["good_url"].endswith(f"/confirm/{first_token}/good")
  67. assert first_links["confirm_url"].endswith(f"?confirm={two_plates.archive}&run={two_plates.first}")
  68. async def test_an_answered_plate_is_not_asked_again(db_session, two_plates):
  69. from backend.app.main import dispatch_outcome_confirmation
  70. first = await _row(db_session, PrintLogEntry, two_plates.first)
  71. first.user_verdict = "good"
  72. await db_session.commit()
  73. with patch("backend.app.main.ws_manager"), patch("backend.app.main.notification_service"):
  74. assert not await dispatch_outcome_confirmation(
  75. db_session, two_plates.a, "A", {}, two_plates.archive, run_id=two_plates.first
  76. )
  77. async def test_a_plates_link_answers_that_plate(async_client, db_session, two_plates):
  78. await _prompt(db_session, two_plates, two_plates.first)
  79. token = (await _row(db_session, PrintLogEntry, two_plates.first)).confirm_token
  80. response = await async_client.post(f"/api/v1/archives/confirm/{token}/reject")
  81. assert response.status_code == 200, response.text
  82. first = await _row(db_session, PrintLogEntry, two_plates.first)
  83. assert (first.user_verdict, first.user_verdict_source) == ("reject", "link")
  84. assert first.confirm_token_used_at is not None
  85. assert (await _row(db_session, PrintLogEntry, two_plates.second)).user_verdict is None
  86. # The archive shows plate 2, its latest, still unanswered.
  87. assert (await _row(db_session, PrintArchive, two_plates.archive)).user_verdict is None
  88. again = await async_client.get(f"/api/v1/archives/confirm/{token}/good")
  89. assert "Already answered" in again.text and "Rejected" in again.text
  90. assert f"run={two_plates.first}" in again.text
  91. async def test_a_link_sent_before_runs_had_tokens_answers_the_latest(async_client, db_session, two_plates):
  92. archive = await _row(db_session, PrintArchive, two_plates.archive)
  93. archive.confirm_token = "archive-token"
  94. await db_session.commit()
  95. assert (await async_client.post("/api/v1/archives/confirm/archive-token/good")).status_code == 200
  96. archive = await _row(db_session, PrintArchive, two_plates.archive)
  97. assert (archive.user_verdict, archive.confirm_token_used_at is not None) == ("good", True)
  98. assert (await _row(db_session, PrintLogEntry, two_plates.second)).user_verdict == "good"
  99. assert (await _row(db_session, PrintLogEntry, two_plates.first)).user_verdict is None
  100. async def test_the_dialog_answers_the_plate_it_asks_about(async_client, db_session, two_plates):
  101. response = await async_client.patch(
  102. f"/api/v1/archives/{two_plates.archive}",
  103. json={
  104. "user_verdict": "reject",
  105. "failure_reason": "spaghetti",
  106. "user_verdict_source": "dialog",
  107. "run_id": two_plates.first,
  108. },
  109. )
  110. assert response.status_code == 200, response.text
  111. first = await _row(db_session, PrintLogEntry, two_plates.first)
  112. assert (first.user_verdict, first.user_verdict_source, first.failure_reason) == ("reject", "dialog", "spaghetti")
  113. second = await _row(db_session, PrintLogEntry, two_plates.second)
  114. assert (second.user_verdict, second.failure_reason) == (None, None)
  115. archive = await _row(db_session, PrintArchive, two_plates.archive)
  116. assert (archive.user_verdict, archive.failure_reason) == (None, None)
  117. async def test_an_answer_for_the_latest_plate_shows_on_the_archive(async_client, db_session, two_plates):
  118. response = await async_client.patch(
  119. f"/api/v1/archives/{two_plates.archive}",
  120. json={"user_verdict": "good", "user_verdict_source": "printer_card", "run_id": two_plates.second},
  121. )
  122. assert response.status_code == 200, response.text
  123. archive = await _row(db_session, PrintArchive, two_plates.archive)
  124. assert (archive.user_verdict, archive.user_verdict_source) == ("good", "printer_card")
  125. assert (await _row(db_session, PrintLogEntry, two_plates.first)).user_verdict is None
  126. async def test_an_answer_without_a_run_goes_to_the_latest(async_client, db_session, two_plates):
  127. response = await async_client.patch(f"/api/v1/archives/{two_plates.archive}", json={"user_verdict": "good"})
  128. assert response.status_code == 200, response.text
  129. assert (await _row(db_session, PrintLogEntry, two_plates.second)).user_verdict == "good"
  130. assert (await _row(db_session, PrintLogEntry, two_plates.first)).user_verdict is None
  131. assert (await _row(db_session, PrintArchive, two_plates.archive)).user_verdict == "good"
  132. async def test_a_run_of_another_archive_or_other_fields_are_refused(async_client, db_session, two_plates):
  133. other = PrintArchive(filename="o.3mf", file_path="archives/test/o.3mf", file_size=1, status="completed")
  134. db_session.add(other)
  135. await db_session.flush()
  136. stranger = PrintLogEntry(archive_id=other.id, status="completed")
  137. db_session.add(stranger)
  138. await db_session.commit()
  139. url = f"/api/v1/archives/{two_plates.archive}"
  140. assert (await async_client.patch(url, json={"user_verdict": "good", "run_id": stranger.id})).status_code == 404
  141. assert (await async_client.patch(url, json={"notes": "x", "run_id": two_plates.first})).status_code == 400
  142. async def test_the_archive_can_be_read_as_one_of_its_runs(async_client, db_session, two_plates):
  143. first = await _row(db_session, PrintLogEntry, two_plates.first)
  144. first.user_verdict = "reject"
  145. await db_session.commit()
  146. response = await async_client.get(f"/api/v1/archives/{two_plates.archive}?run_id={two_plates.first}")
  147. assert response.status_code == 200, response.text
  148. body = response.json()
  149. assert (body["run_id"], body["printer_id"], body["plate_id"], body["user_verdict"]) == (
  150. two_plates.first,
  151. two_plates.a,
  152. 1,
  153. "reject",
  154. )
  155. plain = (await async_client.get(f"/api/v1/archives/{two_plates.archive}")).json()
  156. assert (plain["run_id"], plain["user_verdict"]) == (None, None)
  157. missing = await async_client.get(f"/api/v1/archives/{two_plates.archive}?run_id=999999")
  158. assert missing.status_code == 404
  159. async def test_each_printer_card_shows_its_own_plate(async_client, db_session, two_plates):
  160. first = await _row(db_session, PrintLogEntry, two_plates.first)
  161. first.user_verdict = "good"
  162. await db_session.commit()
  163. response = await async_client.get("/api/v1/archives/last-per-printer")
  164. assert response.status_code == 200, response.text
  165. cards = {card["printer_id"]: card for card in response.json()}
  166. assert {pid: (c["id"], c["run_id"], c["plate_id"], c["user_verdict"]) for pid, c in cards.items()} == {
  167. two_plates.a: (two_plates.archive, two_plates.first, 1, "good"),
  168. two_plates.b: (two_plates.archive, two_plates.second, 2, None),
  169. }
  170. async def test_clearing_a_printers_plate_answers_its_own_plate(db_session, two_plates):
  171. from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
  172. assert await resolve_pending_confirmation_as_good(db_session, two_plates.a) == two_plates.archive
  173. await db_session.commit()
  174. first = await _row(db_session, PrintLogEntry, two_plates.first)
  175. assert (first.user_verdict, first.user_verdict_source) == ("good", "plate_clear")
  176. assert (await _row(db_session, PrintLogEntry, two_plates.second)).user_verdict is None
  177. assert (await _row(db_session, PrintArchive, two_plates.archive)).user_verdict is None
  178. async def test_a_new_plate_ending_shows_its_open_question_on_the_archive(db_session, two_plates):
  179. """The archive's verdict was the previous print's: the latest run's (none
  180. yet) replaces it once the new run ends."""
  181. from backend.app.services.print_log import mirror_latest_run
  182. archive = await _row(db_session, PrintArchive, two_plates.archive)
  183. first = await db_session.get(PrintLogEntry, two_plates.first)
  184. archive.user_verdict = "good"
  185. archive.user_verdict_source = "dialog"
  186. first.user_verdict = "good"
  187. await db_session.commit()
  188. await mirror_latest_run(db_session, two_plates.archive)
  189. await db_session.commit()
  190. archive = await _row(db_session, PrintArchive, two_plates.archive)
  191. assert (archive.user_verdict, archive.user_verdict_source) == (None, None)
  192. async def test_a_run_without_provenance_keeps_the_archives(db_session, two_plates):
  193. """Runs logged before they kept how the verdict arrived: the same verdict
  194. on the archive keeps its own provenance."""
  195. from backend.app.services.print_log import mirror_latest_run
  196. archive = await _row(db_session, PrintArchive, two_plates.archive)
  197. second = await db_session.get(PrintLogEntry, two_plates.second)
  198. archive.user_verdict = "good"
  199. archive.user_verdict_source = "plate_clear"
  200. second.user_verdict = "good"
  201. await db_session.commit()
  202. await mirror_latest_run(db_session, two_plates.archive)
  203. await db_session.commit()
  204. archive = await _row(db_session, PrintArchive, two_plates.archive)
  205. assert (archive.user_verdict, archive.user_verdict_source) == ("good", "plate_clear")
  206. async def test_a_run_logged_before_runs_owned_their_verdict_leaves_the_archives(db_session, two_plates):
  207. """Its verdict was never copied onto it: the archive's is kept."""
  208. from backend.app.services.print_log import mirror_latest_run
  209. archive = await _row(db_session, PrintArchive, two_plates.archive)
  210. second = await db_session.get(PrintLogEntry, two_plates.second)
  211. archive.user_verdict = "reject"
  212. second.confirm_requested = None
  213. await db_session.commit()
  214. await mirror_latest_run(db_session, two_plates.archive)
  215. await db_session.commit()
  216. assert (await _row(db_session, PrintArchive, two_plates.archive)).user_verdict == "reject"
  217. async def test_a_run_on_a_printer_outside_the_callers_scope_is_not_theirs(async_client, db_session, two_plates):
  218. """Scoped to printer B, the archive's: plate 1 printed on A is neither
  219. read nor judged through the archive, and A's card is not theirs."""
  220. from backend.tests.integration.test_archives_last_per_printer import _admin_token, _auth, _user
  221. admin = await _admin_token(async_client)
  222. jwt, _ = await _user(
  223. async_client,
  224. admin,
  225. "scoped",
  226. ["archives:read_all", "archives:update_all", "printers:read"],
  227. printer_ids=[two_plates.b],
  228. )
  229. url = f"/api/v1/archives/{two_plates.archive}"
  230. assert (await async_client.get(f"{url}?run_id={two_plates.first}", headers=_auth(jwt))).status_code == 404
  231. patched = await async_client.patch(
  232. url, headers=_auth(jwt), json={"user_verdict": "reject", "run_id": two_plates.first}
  233. )
  234. assert patched.status_code == 404
  235. assert (await _row(db_session, PrintLogEntry, two_plates.first)).user_verdict is None
  236. # Their own printer's plate is theirs.
  237. own = await async_client.patch(url, headers=_auth(jwt), json={"user_verdict": "good", "run_id": two_plates.second})
  238. assert own.status_code == 200, own.text
  239. cards = (await async_client.get("/api/v1/archives/last-per-printer", headers=_auth(jwt))).json()
  240. assert [(c["printer_id"], c["run_id"]) for c in cards] == [(two_plates.b, two_plates.second)]
  241. async def test_a_card_is_not_shown_for_an_archive_outside_the_callers_scope(async_client, db_session, two_plates):
  242. """Scoped to printer A, whose plate it printed, while the archive names
  243. B: as before runs, the archive is not theirs to see."""
  244. from backend.tests.integration.test_archives_last_per_printer import _admin_token, _auth, _user
  245. admin = await _admin_token(async_client)
  246. jwt, _ = await _user(
  247. async_client, admin, "scoped-a", ["archives:read_all", "printers:read"], printer_ids=[two_plates.a]
  248. )
  249. cards = (await async_client.get("/api/v1/archives/last-per-printer", headers=_auth(jwt))).json()
  250. assert cards == []