test_ams_labels_api.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. """Integration tests for AMS Labels API endpoints."""
  2. from unittest.mock import MagicMock, patch
  3. import pytest
  4. from httpx import AsyncClient
  5. from backend.app.models.ams_label import AmsLabel
  6. class TestAmsLabelsAPI:
  7. """Integration tests for /api/v1/printers/{printer_id}/ams-labels endpoints."""
  8. def _mock_printer_state(self, ams_units=None):
  9. """Create a mock printer state with AMS data."""
  10. state = MagicMock()
  11. state.connected = True
  12. state.raw_data = {
  13. "ams": ams_units
  14. or [
  15. {"id": "0", "sn": "AMS_SERIAL_0"},
  16. {"id": "1", "sn": "AMS_SERIAL_1"},
  17. ],
  18. }
  19. return state
  20. @pytest.mark.asyncio
  21. @pytest.mark.integration
  22. async def test_get_labels_empty(self, async_client: AsyncClient, printer_factory):
  23. """Returns empty dict when no labels are saved."""
  24. printer = await printer_factory()
  25. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  26. mock_pm.get_status.return_value = self._mock_printer_state()
  27. response = await async_client.get(f"/api/v1/printers/{printer.id}/ams-labels")
  28. assert response.status_code == 200
  29. assert response.json() == {}
  30. @pytest.mark.asyncio
  31. @pytest.mark.integration
  32. async def test_save_label_with_serial(self, async_client: AsyncClient, printer_factory):
  33. """Save a label keyed by AMS serial number."""
  34. printer = await printer_factory()
  35. response = await async_client.put(
  36. f"/api/v1/printers/{printer.id}/ams-labels/0",
  37. json={"label": "Workshop AMS", "ams_serial": "AMS_SERIAL_0"},
  38. )
  39. assert response.status_code == 200
  40. assert response.json() == {"ams_id": 0, "label": "Workshop AMS"}
  41. @pytest.mark.asyncio
  42. @pytest.mark.integration
  43. async def test_save_label_without_serial_uses_synthetic_key(
  44. self, async_client: AsyncClient, printer_factory, db_session
  45. ):
  46. """When no serial is provided, a synthetic key p{printer_id}a{ams_id} is used."""
  47. printer = await printer_factory()
  48. response = await async_client.put(
  49. f"/api/v1/printers/{printer.id}/ams-labels/2",
  50. json={"label": "Old Firmware AMS"},
  51. )
  52. assert response.status_code == 200
  53. # Verify the synthetic key was stored
  54. from sqlalchemy import select
  55. result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == f"p{printer.id}a2"))
  56. label = result.scalar_one_or_none()
  57. assert label is not None
  58. assert label.label == "Old Firmware AMS"
  59. @pytest.mark.asyncio
  60. @pytest.mark.integration
  61. async def test_save_label_whitespace_serial_uses_synthetic_key(
  62. self, async_client: AsyncClient, printer_factory, db_session
  63. ):
  64. """Whitespace-only serial falls back to synthetic key."""
  65. printer = await printer_factory()
  66. response = await async_client.put(
  67. f"/api/v1/printers/{printer.id}/ams-labels/0",
  68. json={"label": "Whitespace Test", "ams_serial": " "},
  69. )
  70. assert response.status_code == 200
  71. from sqlalchemy import select
  72. result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == f"p{printer.id}a0"))
  73. label = result.scalar_one_or_none()
  74. assert label is not None
  75. assert label.label == "Whitespace Test"
  76. @pytest.mark.asyncio
  77. @pytest.mark.integration
  78. async def test_save_label_updates_existing(self, async_client: AsyncClient, printer_factory):
  79. """Saving a label with the same serial updates the existing record."""
  80. printer = await printer_factory()
  81. await async_client.put(
  82. f"/api/v1/printers/{printer.id}/ams-labels/0",
  83. json={"label": "Original Name", "ams_serial": "SN123"},
  84. )
  85. response = await async_client.put(
  86. f"/api/v1/printers/{printer.id}/ams-labels/0",
  87. json={"label": "Updated Name", "ams_serial": "SN123"},
  88. )
  89. assert response.status_code == 200
  90. assert response.json()["label"] == "Updated Name"
  91. @pytest.mark.asyncio
  92. @pytest.mark.integration
  93. async def test_save_label_printer_not_found(self, async_client: AsyncClient):
  94. """Returns 404 when printer does not exist."""
  95. response = await async_client.put(
  96. "/api/v1/printers/99999/ams-labels/0",
  97. json={"label": "Ghost Printer"},
  98. )
  99. assert response.status_code == 404
  100. @pytest.mark.asyncio
  101. @pytest.mark.integration
  102. async def test_save_label_validation_empty_label(self, async_client: AsyncClient, printer_factory):
  103. """Rejects empty label."""
  104. printer = await printer_factory()
  105. response = await async_client.put(
  106. f"/api/v1/printers/{printer.id}/ams-labels/0",
  107. json={"label": ""},
  108. )
  109. assert response.status_code == 422
  110. @pytest.mark.asyncio
  111. @pytest.mark.integration
  112. async def test_get_labels_resolves_serial_to_ams_id(self, async_client: AsyncClient, printer_factory):
  113. """GET returns labels keyed by ams_id, resolved from live printer state."""
  114. printer = await printer_factory()
  115. # Save a label with a known serial
  116. await async_client.put(
  117. f"/api/v1/printers/{printer.id}/ams-labels/0",
  118. json={"label": "Silk Colours", "ams_serial": "AMS_SERIAL_0"},
  119. )
  120. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  121. mock_pm.get_status.return_value = self._mock_printer_state()
  122. response = await async_client.get(f"/api/v1/printers/{printer.id}/ams-labels")
  123. assert response.status_code == 200
  124. data = response.json()
  125. assert data.get("0") == "Silk Colours"
  126. @pytest.mark.asyncio
  127. @pytest.mark.integration
  128. async def test_get_labels_no_printer_state(self, async_client: AsyncClient, printer_factory):
  129. """GET returns empty when printer has no live state."""
  130. printer = await printer_factory()
  131. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  132. mock_pm.get_status.return_value = None
  133. response = await async_client.get(f"/api/v1/printers/{printer.id}/ams-labels")
  134. assert response.status_code == 200
  135. assert response.json() == {}
  136. @pytest.mark.asyncio
  137. @pytest.mark.integration
  138. async def test_delete_label(self, async_client: AsyncClient, printer_factory, db_session):
  139. """Delete removes the label from the database."""
  140. printer = await printer_factory()
  141. await async_client.put(
  142. f"/api/v1/printers/{printer.id}/ams-labels/0",
  143. json={"label": "To Delete", "ams_serial": "DEL_SN"},
  144. )
  145. response = await async_client.delete(f"/api/v1/printers/{printer.id}/ams-labels/0?ams_serial=DEL_SN")
  146. assert response.status_code == 200
  147. assert response.json() == {"success": True}
  148. # Verify it's gone
  149. from sqlalchemy import select
  150. result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == "DEL_SN"))
  151. assert result.scalar_one_or_none() is None
  152. @pytest.mark.asyncio
  153. @pytest.mark.integration
  154. async def test_delete_nonexistent_label_succeeds(self, async_client: AsyncClient, printer_factory):
  155. """Delete returns success even if no label exists (idempotent)."""
  156. printer = await printer_factory()
  157. response = await async_client.delete(f"/api/v1/printers/{printer.id}/ams-labels/0?ams_serial=NONEXISTENT")
  158. assert response.status_code == 200
  159. assert response.json() == {"success": True}
  160. @pytest.mark.asyncio
  161. @pytest.mark.integration
  162. async def test_delete_label_whitespace_serial_uses_synthetic_key(
  163. self, async_client: AsyncClient, printer_factory, db_session
  164. ):
  165. """Delete with whitespace serial falls back to synthetic key."""
  166. printer = await printer_factory()
  167. # Save with synthetic key
  168. await async_client.put(
  169. f"/api/v1/printers/{printer.id}/ams-labels/0",
  170. json={"label": "Synthetic Label"},
  171. )
  172. response = await async_client.delete(f"/api/v1/printers/{printer.id}/ams-labels/0?ams_serial=%20%20")
  173. assert response.status_code == 200
  174. from sqlalchemy import select
  175. result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == f"p{printer.id}a0"))
  176. assert result.scalar_one_or_none() is None