test_spool_filament_preset_endpoints.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. """Endpoints for the per-printer-model filament preset overrides.
  2. GET /api/v1/inventory/spools/{id}/filament-presets
  3. PUT /api/v1/inventory/spools/{id}/filament-presets
  4. GET /api/v1/spoolman/inventory/spools/{id}/filament-presets
  5. PUT /api/v1/spoolman/inventory/spools/{id}/filament-presets
  6. Both PUTs replace the whole set, matching the K-profile endpoints beside them:
  7. the spool form always holds the complete list, and an empty body is how the
  8. user clears every override back to the spool's own preset.
  9. The case worth having a test for is the duplicate: (model, diameter) is
  10. UNIQUE, so a payload naming one twice has to be refused -- and refused
  11. *before* the existing rows are deleted, or a rejected save takes the user's
  12. overrides with it.
  13. """
  14. from unittest.mock import AsyncMock, MagicMock, patch
  15. import pytest
  16. from httpx import AsyncClient
  17. SAMPLE_SPOOL = {
  18. "id": 7,
  19. "filament": {
  20. "id": 1,
  21. "name": "PLA Basic",
  22. "material": "PLA",
  23. "weight": 1000,
  24. "color_hex": "303030",
  25. "vendor": {"id": 1, "name": "Bambu"},
  26. },
  27. "remaining_weight": 800.0,
  28. "used_weight": 200.0,
  29. "location": None,
  30. "comment": None,
  31. "first_used": None,
  32. "last_used": None,
  33. "registered": "2024-01-01T00:00:00+00:00",
  34. "archived": False,
  35. "price": None,
  36. "extra": {},
  37. }
  38. INTERNAL = "/api/v1/inventory/spools"
  39. SPOOLMAN = "/api/v1/spoolman/inventory/spools"
  40. @pytest.fixture
  41. async def spool(db_session):
  42. from backend.app.models.spool import Spool
  43. row = Spool(
  44. brand="Bambu",
  45. material="PLA",
  46. color_name="Charcoal",
  47. slicer_filament="GFSA00",
  48. slicer_filament_name="Bambu PLA Basic @BBL X1C",
  49. )
  50. db_session.add(row)
  51. await db_session.commit()
  52. await db_session.refresh(row)
  53. return row
  54. @pytest.fixture
  55. async def spoolman_settings(db_session):
  56. from backend.app.models.settings import Settings
  57. db_session.add(Settings(key="spoolman_enabled", value="true"))
  58. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  59. await db_session.commit()
  60. @pytest.fixture
  61. def mock_spoolman_client():
  62. client = MagicMock()
  63. client.base_url = "http://localhost:7912"
  64. client.health_check = AsyncMock(return_value=True)
  65. client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
  66. with patch(
  67. "backend.app.api.routes.spoolman_inventory._get_client",
  68. AsyncMock(return_value=client),
  69. ):
  70. yield client
  71. def _preset(model, diameter="", code="GFSA09", name="Bambu PLA Basic @BBL H2C"):
  72. return {
  73. "printer_model": model,
  74. "nozzle_diameter": diameter,
  75. "slicer_filament": code,
  76. "slicer_filament_name": name,
  77. }
  78. @pytest.mark.integration
  79. class TestInternalInventory:
  80. @pytest.mark.asyncio
  81. async def test_empty_by_default(self, async_client: AsyncClient, spool):
  82. response = await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")
  83. assert response.status_code == 200
  84. assert response.json() == []
  85. @pytest.mark.asyncio
  86. async def test_put_then_get_round_trips(self, async_client: AsyncClient, spool):
  87. response = await async_client.put(
  88. f"{INTERNAL}/{spool.id}/filament-presets",
  89. json=[_preset("H2C"), _preset("A1 mini", "0.2", "GFSA21", "PLA @A1M 0.2 nozzle")],
  90. )
  91. assert response.status_code == 200, response.text
  92. rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json()
  93. assert len(rows) == 2
  94. by_model = {r["printer_model"]: r for r in rows}
  95. assert by_model["H2C"]["nozzle_diameter"] == ""
  96. assert by_model["H2C"]["slicer_filament"] == "GFSA09"
  97. assert by_model["A1 mini"]["nozzle_diameter"] == "0.2"
  98. assert by_model["A1 mini"]["slicer_filament"] == "GFSA21"
  99. assert all(r["spool_id"] == spool.id for r in rows)
  100. @pytest.mark.asyncio
  101. async def test_put_replaces_rather_than_appends(self, async_client: AsyncClient, spool):
  102. await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")])
  103. await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("X1C")])
  104. rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json()
  105. assert [r["printer_model"] for r in rows] == ["X1C"]
  106. @pytest.mark.asyncio
  107. async def test_empty_body_clears_every_override(self, async_client: AsyncClient, spool):
  108. await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")])
  109. response = await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[])
  110. assert response.status_code == 200
  111. assert (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json() == []
  112. @pytest.mark.asyncio
  113. async def test_replacing_the_same_key_does_not_trip_the_unique_constraint(self, async_client: AsyncClient, spool):
  114. """Deletes and inserts land in one transaction, and SQLAlchemy is free
  115. to order the INSERTs first. Re-saving the same (model, diameter) with a
  116. new preset is the ordinary case -- the user changed their pick."""
  117. await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")])
  118. response = await async_client.put(
  119. f"{INTERNAL}/{spool.id}/filament-presets",
  120. json=[_preset("H2C", "", "GFSA11", "Bambu PLA Matte @BBL H2C")],
  121. )
  122. assert response.status_code == 200, response.text
  123. rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json()
  124. assert len(rows) == 1
  125. assert rows[0]["slicer_filament"] == "GFSA11"
  126. @pytest.mark.asyncio
  127. async def test_duplicate_key_is_rejected_without_losing_the_stored_rows(self, async_client: AsyncClient, spool):
  128. await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")])
  129. response = await async_client.put(
  130. f"{INTERNAL}/{spool.id}/filament-presets",
  131. json=[_preset("X1C", "", "GFSA01"), _preset("X1C", "", "GFSA02")],
  132. )
  133. assert response.status_code == 422
  134. # The rejected save must not have taken the existing override with it.
  135. rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json()
  136. assert [r["printer_model"] for r in rows] == ["H2C"]
  137. @pytest.mark.asyncio
  138. async def test_unknown_spool_is_404(self, async_client: AsyncClient):
  139. response = await async_client.put(f"{INTERNAL}/999999/filament-presets", json=[_preset("H2C")])
  140. assert response.status_code == 404
  141. @pytest.mark.asyncio
  142. async def test_blank_model_is_rejected(self, async_client: AsyncClient, spool):
  143. """An empty printer_model would store a row the cascade can never
  144. match, since it refuses to resolve without a model."""
  145. response = await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("")])
  146. assert response.status_code == 422
  147. @pytest.mark.integration
  148. class TestSpoolmanInventory:
  149. @pytest.mark.asyncio
  150. async def test_empty_by_default(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
  151. response = await async_client.get(f"{SPOOLMAN}/7/filament-presets")
  152. assert response.status_code == 200
  153. assert response.json() == []
  154. @pytest.mark.asyncio
  155. async def test_put_then_get_round_trips(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
  156. response = await async_client.put(
  157. f"{SPOOLMAN}/7/filament-presets",
  158. json=[_preset("H2C"), _preset("H2C", "0.2", "GFSA10", "PLA @H2C 0.2 nozzle")],
  159. )
  160. assert response.status_code == 200, response.text
  161. rows = (await async_client.get(f"{SPOOLMAN}/7/filament-presets")).json()
  162. assert len(rows) == 2
  163. assert all(r["spool_id"] == 7 for r in rows)
  164. assert {r["nozzle_diameter"] for r in rows} == {"", "0.2"}
  165. @pytest.mark.asyncio
  166. async def test_put_replaces_rather_than_appends(
  167. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  168. ):
  169. await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("H2C")])
  170. await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("X1C")])
  171. rows = (await async_client.get(f"{SPOOLMAN}/7/filament-presets")).json()
  172. assert [r["printer_model"] for r in rows] == ["X1C"]
  173. @pytest.mark.asyncio
  174. async def test_duplicate_key_is_rejected_without_losing_the_stored_rows(
  175. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  176. ):
  177. await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("H2C")])
  178. response = await async_client.put(
  179. f"{SPOOLMAN}/7/filament-presets",
  180. json=[_preset("X1C", "", "GFSA01"), _preset("X1C", "", "GFSA02")],
  181. )
  182. assert response.status_code == 422
  183. rows = (await async_client.get(f"{SPOOLMAN}/7/filament-presets")).json()
  184. assert [r["printer_model"] for r in rows] == ["H2C"]
  185. @pytest.mark.asyncio
  186. async def test_one_spools_overrides_do_not_leak_into_another(
  187. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  188. ):
  189. await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("H2C")])
  190. assert (await async_client.get(f"{SPOOLMAN}/8/filament-presets")).json() == []