test_label_templates_api.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. """Integration tests for saved label templates (#3296).
  2. ``/label-templates`` lists, saves, replaces and deletes named sheet layouts.
  3. Listing needs ``inventory:read``; the writes need ``inventory:update``, like
  4. storage locations.
  5. """
  6. from __future__ import annotations
  7. import pytest
  8. from httpx import AsyncClient
  9. # A 3 × 8 sheet of 70 × 36 mm labels on A4, edge to edge (Avery 3490).
  10. LAYOUT_3490 = {
  11. "page": "a4",
  12. "label_w_mm": 70.0,
  13. "label_h_mm": 36.0,
  14. "cols": 3,
  15. "rows": 8,
  16. "top_mm": 4.5,
  17. "left_mm": 0.0,
  18. }
  19. def _template(name: str = "Avery 3490", **layout) -> dict:
  20. return {"name": name, "layout": {**LAYOUT_3490, **layout}}
  21. @pytest.mark.asyncio
  22. @pytest.mark.integration
  23. async def test_crud_round_trip(async_client: AsyncClient):
  24. created = await async_client.post("/api/v1/label-templates", json=_template())
  25. assert created.status_code == 201, created.text
  26. body = created.json()
  27. assert body["name"] == "Avery 3490"
  28. # Gaps omitted from the request are stored as zero and returned as such.
  29. assert body["layout"] == {**LAYOUT_3490, "col_gap_mm": 0.0, "row_gap_mm": 0.0}
  30. listed = await async_client.get("/api/v1/label-templates")
  31. assert [t["id"] for t in listed.json()] == [body["id"]]
  32. replaced = await async_client.put(
  33. f"/api/v1/label-templates/{body['id']}",
  34. json=_template("Avery 3490 (shop)", rows=7, row_gap_mm=2.0),
  35. )
  36. assert replaced.status_code == 200, replaced.text
  37. assert replaced.json()["name"] == "Avery 3490 (shop)"
  38. assert replaced.json()["layout"]["rows"] == 7
  39. assert replaced.json()["layout"]["row_gap_mm"] == 2.0
  40. deleted = await async_client.delete(f"/api/v1/label-templates/{body['id']}")
  41. assert deleted.status_code == 204
  42. assert (await async_client.get("/api/v1/label-templates")).json() == []
  43. @pytest.mark.asyncio
  44. @pytest.mark.integration
  45. async def test_list_sorts_naturally(async_client: AsyncClient):
  46. for name in ("Sheet 10", "sheet 2", "Sheet 1"):
  47. assert (await async_client.post("/api/v1/label-templates", json=_template(name))).status_code == 201
  48. names = [t["name"] for t in (await async_client.get("/api/v1/label-templates")).json()]
  49. assert names == ["Sheet 1", "sheet 2", "Sheet 10"]
  50. @pytest.mark.asyncio
  51. @pytest.mark.integration
  52. async def test_names_are_unique_ignoring_case_and_spaces(async_client: AsyncClient):
  53. assert (await async_client.post("/api/v1/label-templates", json=_template("Avery 3490"))).status_code == 201
  54. dup = await async_client.post("/api/v1/label-templates", json=_template(" avery 3490 "))
  55. assert dup.status_code == 409
  56. @pytest.mark.asyncio
  57. @pytest.mark.integration
  58. async def test_rename_onto_another_template_is_refused(async_client: AsyncClient):
  59. await async_client.post("/api/v1/label-templates", json=_template("First"))
  60. second = (await async_client.post("/api/v1/label-templates", json=_template("Second"))).json()
  61. resp = await async_client.put(f"/api/v1/label-templates/{second['id']}", json=_template("FIRST"))
  62. assert resp.status_code == 409
  63. @pytest.mark.asyncio
  64. @pytest.mark.integration
  65. async def test_changing_only_the_case_of_its_own_name_is_allowed(async_client: AsyncClient):
  66. created = (await async_client.post("/api/v1/label-templates", json=_template("avery 3490"))).json()
  67. resp = await async_client.put(f"/api/v1/label-templates/{created['id']}", json=_template("Avery 3490"))
  68. assert resp.status_code == 200
  69. assert resp.json()["name"] == "Avery 3490"
  70. @pytest.mark.asyncio
  71. @pytest.mark.integration
  72. @pytest.mark.parametrize(
  73. "layout",
  74. [
  75. {"cols": 4}, # 4 × 70 mm is wider than A4
  76. {"rows": 9}, # 4.5 + 9 × 36 mm is taller than A4
  77. {"left_mm": 1.0}, # pushes the third column 1 mm off the page
  78. {"label_w_mm": 39.0}, # below the 40 mm a label needs for its text
  79. {"label_h_mm": 19.0}, # below the 20 mm of the full layout
  80. {"cols": 0},
  81. {"top_mm": -1.0},
  82. {"page": "a3"},
  83. ],
  84. )
  85. async def test_a_layout_that_cannot_print_is_refused(async_client: AsyncClient, layout):
  86. resp = await async_client.post("/api/v1/label-templates", json=_template(**layout))
  87. assert resp.status_code == 422, resp.text
  88. assert (await async_client.get("/api/v1/label-templates")).json() == []
  89. @pytest.mark.asyncio
  90. @pytest.mark.integration
  91. async def test_names_match_whatever_the_unicode_composition(async_client: AsyncClient):
  92. composed = "Caf\u00e9 sheet"
  93. combining = "Cafe\u0301 sheet"
  94. assert (await async_client.post("/api/v1/label-templates", json=_template(composed))).status_code == 201
  95. assert (await async_client.post("/api/v1/label-templates", json=_template(combining))).status_code == 409
  96. assert (await async_client.post("/api/v1/label-templates", json=_template("CAFÉ SHEET"))).status_code == 409
  97. @pytest.mark.asyncio
  98. @pytest.mark.integration
  99. async def test_a_name_that_casefolds_longer_still_fits(async_client: AsyncClient):
  100. # "İ" lowercases to two characters; 100 of them make a 200-character key.
  101. resp = await async_client.post("/api/v1/label-templates", json=_template("\u0130" * 100))
  102. assert resp.status_code == 201, resp.text
  103. @pytest.mark.asyncio
  104. @pytest.mark.integration
  105. async def test_a_blank_name_is_refused(async_client: AsyncClient):
  106. resp = await async_client.post("/api/v1/label-templates", json=_template(" "))
  107. assert resp.status_code == 422
  108. @pytest.mark.asyncio
  109. @pytest.mark.integration
  110. async def test_unknown_id_is_404(async_client: AsyncClient):
  111. assert (await async_client.put("/api/v1/label-templates/99999", json=_template())).status_code == 404
  112. assert (await async_client.delete("/api/v1/label-templates/99999")).status_code == 404
  113. @pytest.mark.asyncio
  114. @pytest.mark.integration
  115. async def test_routes_require_auth_when_enabled(async_client: AsyncClient):
  116. """Every route fails closed without credentials once auth is on."""
  117. await async_client.post(
  118. "/api/v1/auth/setup",
  119. json={"auth_enabled": True, "admin_username": "labeltpl3296admin", "admin_password": "AdminPass1!"},
  120. )
  121. assert (await async_client.get("/api/v1/label-templates")).status_code == 401
  122. assert (await async_client.post("/api/v1/label-templates", json=_template())).status_code == 401
  123. # The auth gate runs before the not-found check, so a synthetic id is enough.
  124. assert (await async_client.put("/api/v1/label-templates/99999", json=_template())).status_code == 401
  125. assert (await async_client.delete("/api/v1/label-templates/99999")).status_code == 401
  126. class TestPermissions:
  127. """Operators can manage saved templates; Viewers can only list them."""
  128. @pytest.fixture
  129. async def tokens(self, db_session):
  130. from sqlalchemy import select
  131. from backend.app.core.auth import create_access_token, get_password_hash
  132. from backend.app.models.group import Group
  133. from backend.app.models.settings import Settings
  134. from backend.app.models.user import User
  135. db_session.add(Settings(key="auth_enabled", value="true"))
  136. await db_session.commit()
  137. tokens = {}
  138. for group_name in ("Operators", "Viewers"):
  139. group = (await db_session.execute(select(Group).where(Group.name == group_name))).scalar_one()
  140. user = User(
  141. username=f"labeltpl_{group_name.lower()}",
  142. password_hash=get_password_hash("password"),
  143. is_active=True,
  144. )
  145. user.groups.append(group)
  146. db_session.add(user)
  147. await db_session.commit()
  148. tokens[group_name] = {"Authorization": f"Bearer {create_access_token(data={'sub': user.username})}"}
  149. return tokens
  150. @pytest.mark.asyncio
  151. @pytest.mark.integration
  152. async def test_operator_can_manage_templates(self, async_client: AsyncClient, tokens):
  153. headers = tokens["Operators"]
  154. created = await async_client.post("/api/v1/label-templates", json=_template(), headers=headers)
  155. assert created.status_code == 201, created.text
  156. template_id = created.json()["id"]
  157. put = await async_client.put(f"/api/v1/label-templates/{template_id}", json=_template("B"), headers=headers)
  158. assert put.status_code == 200
  159. delete = await async_client.delete(f"/api/v1/label-templates/{template_id}", headers=headers)
  160. assert delete.status_code == 204
  161. @pytest.mark.asyncio
  162. @pytest.mark.integration
  163. async def test_viewer_can_list_but_not_change_templates(self, async_client: AsyncClient, tokens):
  164. created = await async_client.post("/api/v1/label-templates", json=_template(), headers=tokens["Operators"])
  165. template_id = created.json()["id"]
  166. viewer = tokens["Viewers"]
  167. listed = await async_client.get("/api/v1/label-templates", headers=viewer)
  168. assert listed.status_code == 200
  169. assert [t["id"] for t in listed.json()] == [template_id]
  170. assert (
  171. await async_client.post("/api/v1/label-templates", json=_template("C"), headers=viewer)
  172. ).status_code == 403
  173. put = await async_client.put(f"/api/v1/label-templates/{template_id}", json=_template("C"), headers=viewer)
  174. assert put.status_code == 403
  175. assert (await async_client.delete(f"/api/v1/label-templates/{template_id}", headers=viewer)).status_code == 403