test_spoolman_inventory_bulk.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. """Bulk Spoolman inventory endpoint coverage for the batch-edit feature (#1795).
  2. Endpoints under test:
  3. - POST /api/v1/spoolman/inventory/spools/bulk-update
  4. - POST /api/v1/spoolman/inventory/spools/bulk-delete
  5. - POST /api/v1/spoolman/inventory/spools/bulk-archive
  6. - POST /api/v1/spoolman/inventory/spools/bulk-restore
  7. """
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from fastapi import HTTPException
  11. from httpx import AsyncClient
  12. SAMPLE_SPOOLMAN_SPOOL = {
  13. "id": 42,
  14. "filament": {
  15. "id": 7,
  16. "name": "PLA Basic",
  17. "material": "PLA",
  18. "color_hex": "FF0000",
  19. "weight": 1000,
  20. "vendor": {"id": 3, "name": "Bambu Lab"},
  21. },
  22. "remaining_weight": 750.0,
  23. "used_weight": 250.0,
  24. "location": "Printer1 - AMS A1",
  25. "comment": "test note",
  26. "first_used": "2024-01-01T00:00:00+00:00",
  27. "last_used": "2024-02-01T00:00:00+00:00",
  28. "registered": "2024-01-01T00:00:00+00:00",
  29. "archived": False,
  30. "price": None,
  31. "extra": {},
  32. }
  33. @pytest.fixture
  34. async def spoolman_settings(db_session):
  35. from backend.app.models.settings import Settings
  36. db_session.add(Settings(key="spoolman_enabled", value="true"))
  37. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  38. await db_session.commit()
  39. @pytest.fixture
  40. def mock_spoolman_client():
  41. mock = MagicMock()
  42. mock.base_url = "http://localhost:7912"
  43. mock.health_check = AsyncMock(return_value=True)
  44. mock.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  45. mock.delete_spool = AsyncMock(return_value=True)
  46. mock.set_spool_archived = AsyncMock(
  47. side_effect=lambda spool_id, archived: {**SAMPLE_SPOOLMAN_SPOOL, "archived": archived}
  48. )
  49. mock.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  50. mock.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  51. mock.is_filament_shared = AsyncMock(return_value=False)
  52. mock.patch_filament = AsyncMock(return_value={"id": 7})
  53. mock.find_or_create_filament = AsyncMock(return_value=7)
  54. mock.find_or_create_vendor = AsyncMock(return_value=3)
  55. mock.ensure_extra_field = AsyncMock(return_value=True)
  56. mock.get_distinct_locations = AsyncMock(return_value=[])
  57. class _Lock:
  58. async def __aenter__(self):
  59. return self
  60. async def __aexit__(self, *args):
  61. return False
  62. mock.extra_lock = lambda spool_id: _Lock()
  63. with (
  64. patch(
  65. "backend.app.api.routes.spoolman_inventory.get_spoolman_client",
  66. AsyncMock(return_value=mock),
  67. ),
  68. patch(
  69. "backend.app.api.routes.spoolman_inventory.init_spoolman_client",
  70. AsyncMock(return_value=mock),
  71. ),
  72. ):
  73. yield mock
  74. class TestSpoolmanBulkUpdate:
  75. @pytest.mark.asyncio
  76. @pytest.mark.integration
  77. async def test_calls_per_spool_update_for_each_id(
  78. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  79. ):
  80. resp = await async_client.post(
  81. "/api/v1/spoolman/inventory/spools/bulk-update",
  82. json={"ids": [42, 43, 44], "update": {"note": "From bulk edit"}},
  83. )
  84. assert resp.status_code == 200
  85. body = resp.json()
  86. assert body["updated"] == 3
  87. assert body["errors"] == []
  88. # update_spool route loops through each, which calls update_spool_full once per ID
  89. assert mock_spoolman_client.update_spool_full.await_count == 3
  90. @pytest.mark.asyncio
  91. @pytest.mark.integration
  92. async def test_collects_per_spool_errors_without_aborting_batch(
  93. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  94. ):
  95. # First two succeed, third raises
  96. mock_spoolman_client.update_spool_full.side_effect = [
  97. SAMPLE_SPOOLMAN_SPOOL,
  98. SAMPLE_SPOOLMAN_SPOOL,
  99. HTTPException(status_code=404, detail="Spool 999 not found"),
  100. ]
  101. resp = await async_client.post(
  102. "/api/v1/spoolman/inventory/spools/bulk-update",
  103. json={"ids": [42, 43, 999], "update": {"note": "Batched"}},
  104. )
  105. assert resp.status_code == 200
  106. body = resp.json()
  107. assert body["updated"] == 2
  108. assert len(body["errors"]) == 1
  109. assert body["errors"][0]["id"] == 999
  110. assert body["errors"][0]["status"] == 404
  111. @pytest.mark.asyncio
  112. @pytest.mark.integration
  113. async def test_empty_update_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
  114. resp = await async_client.post(
  115. "/api/v1/spoolman/inventory/spools/bulk-update",
  116. json={"ids": [42], "update": {}},
  117. )
  118. assert resp.status_code == 400
  119. @pytest.mark.asyncio
  120. @pytest.mark.integration
  121. async def test_empty_ids_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
  122. resp = await async_client.post(
  123. "/api/v1/spoolman/inventory/spools/bulk-update",
  124. json={"ids": [], "update": {"note": "X"}},
  125. )
  126. assert resp.status_code == 422
  127. class TestSpoolmanBulkDelete:
  128. @pytest.mark.asyncio
  129. @pytest.mark.integration
  130. async def test_deletes_listed_spools(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
  131. resp = await async_client.post(
  132. "/api/v1/spoolman/inventory/spools/bulk-delete",
  133. json={"ids": [42, 43, 44]},
  134. )
  135. assert resp.status_code == 200
  136. body = resp.json()
  137. assert body["deleted"] == 3
  138. assert body["errors"] == []
  139. assert mock_spoolman_client.delete_spool.await_count == 3
  140. class TestSpoolmanBulkArchiveRestore:
  141. @pytest.mark.asyncio
  142. @pytest.mark.integration
  143. async def test_bulk_archive_calls_per_spool(
  144. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  145. ):
  146. resp = await async_client.post(
  147. "/api/v1/spoolman/inventory/spools/bulk-archive",
  148. json={"ids": [42, 43]},
  149. )
  150. assert resp.status_code == 200
  151. body = resp.json()
  152. assert body["archived"] == 2
  153. # set_spool_archived(spool_id, archived=True) called for each id
  154. assert mock_spoolman_client.set_spool_archived.await_count == 2
  155. for call in mock_spoolman_client.set_spool_archived.call_args_list:
  156. assert call.kwargs.get("archived") is True
  157. @pytest.mark.asyncio
  158. @pytest.mark.integration
  159. async def test_bulk_restore_calls_per_spool(
  160. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  161. ):
  162. resp = await async_client.post(
  163. "/api/v1/spoolman/inventory/spools/bulk-restore",
  164. json={"ids": [42, 43]},
  165. )
  166. assert resp.status_code == 200
  167. body = resp.json()
  168. assert body["restored"] == 2
  169. assert mock_spoolman_client.set_spool_archived.await_count == 2
  170. for call in mock_spoolman_client.set_spool_archived.call_args_list:
  171. assert call.kwargs.get("archived") is False