test_printer_locations_api.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. """Printer locations (groups) API (#2962).
  2. Locations live on printers (``printers.location``) and, for appearance and
  3. empty locations, in ``printer_locations``. Every write is one server-side
  4. transaction: the page used to send one PATCH per printer from its cached list,
  5. so a failure halfway split a location, and another user's move was undone.
  6. """
  7. import pytest
  8. from httpx import AsyncClient
  9. from sqlalchemy import inspect, select
  10. from backend.app.models.print_queue import PrintQueueItem
  11. from backend.app.models.printer import Printer
  12. from backend.app.models.printer_location import PrinterLocation
  13. BASE = "/api/v1/printer-locations/"
  14. async def _locations(client: AsyncClient) -> dict[str, dict]:
  15. response = await client.get(BASE)
  16. assert response.status_code == 200
  17. return {loc["name"]: loc for loc in response.json()}
  18. async def _printer_location(db_session, printer) -> str | None:
  19. # A column select reads the row, not the identity map, so nothing needs
  20. # expiring; the id comes from the identity, which never lazy-loads.
  21. printer_id = inspect(printer).identity[0]
  22. return (await db_session.execute(select(Printer.location).where(Printer.id == printer_id))).scalar_one()
  23. @pytest.mark.asyncio
  24. @pytest.mark.integration
  25. class TestList:
  26. async def test_union_of_rows_and_printer_locations(self, async_client, printer_factory, db_session):
  27. await printer_factory(name="A", location="Workshop")
  28. await printer_factory(name="B", location="Workshop")
  29. await printer_factory(name="C", location="")
  30. await printer_factory(name="D", location=None)
  31. db_session.add(PrinterLocation(name="Future rack", icon="home", color="#3b82f6"))
  32. await db_session.commit()
  33. locations = await _locations(async_client)
  34. assert set(locations) == {"Workshop", "Future rack"}
  35. assert locations["Workshop"]["printer_count"] == 2
  36. assert locations["Workshop"]["id"] is None
  37. assert locations["Future rack"] == {
  38. "id": locations["Future rack"]["id"],
  39. "name": "Future rack",
  40. "icon": "home",
  41. "color": "#3b82f6",
  42. "printer_count": 0,
  43. }
  44. async def test_natural_order(self, async_client, db_session):
  45. for name in ("Rack 10", "Rack 2", "Office"):
  46. db_session.add(PrinterLocation(name=name))
  47. await db_session.commit()
  48. response = await async_client.get(BASE)
  49. assert [loc["name"] for loc in response.json()] == ["Office", "Rack 2", "Rack 10"]
  50. @pytest.mark.asyncio
  51. @pytest.mark.integration
  52. class TestCreate:
  53. async def test_an_empty_location_persists(self, async_client):
  54. response = await async_client.post(BASE, json={"name": " Basement ", "icon": "home", "color": "#ef4444"})
  55. assert response.status_code == 201
  56. assert response.json()["name"] == "Basement"
  57. assert "Basement" in await _locations(async_client)
  58. async def test_a_location_printers_already_use_gets_a_row(self, async_client, printer_factory):
  59. await printer_factory(location="Workshop")
  60. response = await async_client.post(BASE, json={"name": "Workshop", "color": "#22c55e"})
  61. assert response.status_code == 201
  62. assert response.json()["printer_count"] == 1
  63. assert response.json()["color"] == "#22c55e"
  64. @pytest.mark.parametrize("name", ["Basement", "basement", "BASEMENT"])
  65. async def test_a_duplicate_is_refused_ignoring_case(self, async_client, name):
  66. await async_client.post(BASE, json={"name": "Basement"})
  67. response = await async_client.post(BASE, json={"name": name})
  68. assert response.status_code == 409
  69. @pytest.mark.parametrize(
  70. "body",
  71. [
  72. {"name": ""},
  73. {"name": " "},
  74. {"name": "x" * 101},
  75. {"name": "Ok", "color": "red"},
  76. {"name": "Ok", "icon": "<script>"},
  77. ],
  78. )
  79. async def test_invalid_input_is_a_422_not_a_500(self, async_client, body):
  80. response = await async_client.post(BASE, json=body)
  81. assert response.status_code == 422
  82. @pytest.mark.asyncio
  83. @pytest.mark.integration
  84. class TestUpdate:
  85. async def test_rename_moves_printers_and_queue_items_together(
  86. self, async_client, printer_factory, archive_factory, db_session
  87. ):
  88. inside = await printer_factory(location="Workshop")
  89. outside = await printer_factory(location="Office")
  90. archive = await archive_factory(inside.id)
  91. pending = PrintQueueItem(
  92. archive_id=archive.id, target_model="X1C", target_location="Workshop", status="pending"
  93. )
  94. done = PrintQueueItem(archive_id=archive.id, target_model="X1C", target_location="Workshop", status="completed")
  95. db_session.add_all([pending, done])
  96. await db_session.commit()
  97. response = await async_client.patch(BASE, json={"name": "Workshop", "new_name": "Garage"})
  98. assert response.status_code == 200
  99. assert response.json()["printer_count"] == 1
  100. assert await _printer_location(db_session, inside) == "Garage"
  101. assert await _printer_location(db_session, outside) == "Office"
  102. await db_session.refresh(pending)
  103. await db_session.refresh(done)
  104. assert pending.target_location == "Garage"
  105. # Finished rows too: a batch clones its next run from its newest row,
  106. # whatever its status.
  107. assert done.target_location == "Garage"
  108. assert set(await _locations(async_client)) == {"Garage", "Office"}
  109. async def test_rename_onto_another_location_is_refused(self, async_client, printer_factory, db_session):
  110. a = await printer_factory(location="Workshop")
  111. await printer_factory(location="Office")
  112. response = await async_client.patch(BASE, json={"name": "Workshop", "new_name": "office"})
  113. assert response.status_code == 409
  114. assert await _printer_location(db_session, a) == "Workshop"
  115. async def test_a_case_only_rename_is_allowed(self, async_client, printer_factory, db_session):
  116. a = await printer_factory(location="workshop")
  117. response = await async_client.patch(BASE, json={"name": "workshop", "new_name": "Workshop"})
  118. assert response.status_code == 200
  119. assert await _printer_location(db_session, a) == "Workshop"
  120. async def test_restyle_only_sent_fields(self, async_client):
  121. await async_client.post(BASE, json={"name": "Shop", "icon": "home", "color": "#ef4444"})
  122. await async_client.patch(BASE, json={"name": "Shop", "color": "#22c55e"})
  123. after_color = (await _locations(async_client))["Shop"]
  124. await async_client.patch(BASE, json={"name": "Shop", "icon": ""})
  125. after_clear = (await _locations(async_client))["Shop"]
  126. assert (after_color["icon"], after_color["color"]) == ("home", "#22c55e")
  127. assert (after_clear["icon"], after_clear["color"]) == (None, "#22c55e")
  128. async def test_unknown_location_is_a_404(self, async_client):
  129. response = await async_client.patch(BASE, json={"name": "Nowhere", "color": "#22c55e"})
  130. assert response.status_code == 404
  131. @pytest.mark.asyncio
  132. @pytest.mark.integration
  133. class TestDelete:
  134. async def test_printers_end_up_ungrouped_and_rows_go(self, async_client, printer_factory, db_session):
  135. a = await printer_factory(location="Workshop")
  136. b = await printer_factory(location="Office")
  137. await async_client.post(BASE, json={"name": "Empty"})
  138. response = await async_client.post(f"{BASE}delete", json={"names": ["Workshop", "Empty", "Nowhere"]})
  139. assert response.status_code == 200
  140. assert response.json() == {"deleted": 2, "printers_ungrouped": 1}
  141. assert await _printer_location(db_session, a) is None
  142. assert await _printer_location(db_session, b) == "Office"
  143. assert set(await _locations(async_client)) == {"Office"}
  144. async def test_pending_jobs_keep_their_target(self, async_client, printer_factory, archive_factory, db_session):
  145. """Turning them into "any location" would start them where they were
  146. meant not to run."""
  147. printer = await printer_factory(location="Workshop")
  148. archive = await archive_factory(printer.id)
  149. item = PrintQueueItem(archive_id=archive.id, target_model="X1C", target_location="Workshop", status="pending")
  150. db_session.add(item)
  151. await db_session.commit()
  152. await async_client.post(f"{BASE}delete", json={"names": ["Workshop"]})
  153. await db_session.refresh(item)
  154. assert item.target_location == "Workshop"
  155. @pytest.mark.asyncio
  156. @pytest.mark.integration
  157. class TestAssign:
  158. async def test_moves_only_the_listed_printers(self, async_client, printer_factory, db_session):
  159. a = await printer_factory(location="Workshop")
  160. b = await printer_factory(location="Workshop")
  161. c = await printer_factory(location="Office")
  162. response = await async_client.post(f"{BASE}assign", json={"printer_ids": [a.id, c.id], "location": "Garage"})
  163. assert response.status_code == 200
  164. assert response.json() == {"moved": 2}
  165. assert await _printer_location(db_session, a) == "Garage"
  166. assert await _printer_location(db_session, b) == "Workshop"
  167. assert await _printer_location(db_session, c) == "Garage"
  168. async def test_null_and_blank_ungroup(self, async_client, printer_factory, db_session):
  169. a = await printer_factory(location="Workshop")
  170. b = await printer_factory(location="Workshop")
  171. await async_client.post(f"{BASE}assign", json={"printer_ids": [a.id], "location": None})
  172. await async_client.post(f"{BASE}assign", json={"printer_ids": [b.id], "location": " "})
  173. assert await _printer_location(db_session, a) is None
  174. assert await _printer_location(db_session, b) is None
  175. async def test_an_unknown_printer_moves_nothing(self, async_client, printer_factory, db_session):
  176. """All or nothing, so a bad id cannot leave half the selection moved."""
  177. a = await printer_factory(location="Workshop")
  178. response = await async_client.post(f"{BASE}assign", json={"printer_ids": [a.id, 99999], "location": "Garage"})
  179. assert response.status_code == 404
  180. assert await _printer_location(db_session, a) == "Workshop"
  181. async def test_a_case_variant_of_a_location_is_refused(self, async_client, printer_factory, db_session):
  182. a = await printer_factory(location="Workshop")
  183. b = await printer_factory(location=None)
  184. response = await async_client.post(f"{BASE}assign", json={"printer_ids": [b.id], "location": "workshop"})
  185. assert response.status_code == 409
  186. assert await _printer_location(db_session, a) == "Workshop"
  187. assert await _printer_location(db_session, b) is None
  188. @pytest.mark.asyncio
  189. @pytest.mark.integration
  190. class TestPrinterLocationInput:
  191. """The printer dialog writes the same column, with the same rules."""
  192. async def test_too_long_is_a_422(self, async_client, printer_factory):
  193. printer = await printer_factory()
  194. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"location": "x" * 101})
  195. assert response.status_code == 422
  196. async def test_blank_is_stored_as_no_location(self, async_client, printer_factory, db_session):
  197. printer = await printer_factory(location="Workshop")
  198. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"location": " "})
  199. assert response.status_code == 200
  200. assert await _printer_location(db_session, printer) is None
  201. @pytest.mark.asyncio
  202. @pytest.mark.integration
  203. class TestExistingData:
  204. """Locations stored before the page existed: untrimmed, blank, case
  205. variants, and on SQLite longer than the column width."""
  206. async def test_migration_trims_and_blanks(self, test_engine, printer_factory, archive_factory, db_session):
  207. from backend.app.core.database import _migrate_normalize_printer_locations
  208. a = await printer_factory(location="Workshop ")
  209. b = await printer_factory(location=" ")
  210. c = await printer_factory(location="Office")
  211. archive = await archive_factory(a.id)
  212. item = PrintQueueItem(archive_id=archive.id, target_model="X1C", target_location=" Workshop", status="pending")
  213. db_session.add(item)
  214. await db_session.commit()
  215. async with test_engine.begin() as conn:
  216. await _migrate_normalize_printer_locations(conn)
  217. # Idempotent: a second run changes nothing.
  218. await _migrate_normalize_printer_locations(conn)
  219. assert await _printer_location(db_session, a) == "Workshop"
  220. assert await _printer_location(db_session, b) is None
  221. assert await _printer_location(db_session, c) == "Office"
  222. await db_session.refresh(item)
  223. assert item.target_location == "Workshop"
  224. async def test_case_variants_from_before_can_still_be_used(self, async_client, printer_factory, db_session):
  225. """Refusing would leave no way to move a printer into a location that is
  226. plainly there, or to style it."""
  227. await printer_factory(location="Workshop")
  228. await printer_factory(location="workshop")
  229. newcomer = await printer_factory(location=None)
  230. moved = await async_client.post(f"{BASE}assign", json={"printer_ids": [newcomer.id], "location": "Workshop"})
  231. styled = await async_client.post(BASE, json={"name": "workshop", "color": "#22c55e"})
  232. third = await async_client.post(BASE, json={"name": "WORKSHOP"})
  233. assert moved.status_code == 200
  234. assert await _printer_location(db_session, newcomer) == "Workshop"
  235. assert styled.status_code == 201
  236. assert third.status_code == 409
  237. async def test_an_overlong_stored_name_can_be_renamed_and_deleted(self, async_client, printer_factory, db_session):
  238. """SQLite never enforced VARCHAR(100)."""
  239. long_name = "L" * 150
  240. a = await printer_factory(location=long_name)
  241. b = await printer_factory(location=long_name + "2")
  242. renamed = await async_client.patch(BASE, json={"name": long_name, "new_name": "Short"})
  243. deleted = await async_client.post(f"{BASE}delete", json={"names": [long_name + "2"]})
  244. assert renamed.status_code == 200
  245. assert await _printer_location(db_session, a) == "Short"
  246. assert deleted.json() == {"deleted": 1, "printers_ungrouped": 1}
  247. assert await _printer_location(db_session, b) is None
  248. class TestPrinterCreateLocation:
  249. def test_create_trims_blanks_and_limits(self):
  250. from pydantic import ValidationError
  251. from backend.app.schemas.printer import PrinterCreate
  252. base = {"name": "P", "serial_number": "00M09A000000001", "ip_address": "10.0.0.1", "access_code": "1234"}
  253. assert PrinterCreate(**base, location=" Shop ").location == "Shop"
  254. assert PrinterCreate(**base, location=" ").location is None
  255. with pytest.raises(ValidationError):
  256. PrinterCreate(**base, location="x" * 101)