test_inventory_bulk.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. """Bulk inventory endpoint coverage for the batch-edit feature (#1795).
  2. Endpoints under test:
  3. - POST /api/v1/inventory/spools/bulk-update
  4. - POST /api/v1/inventory/spools/bulk-delete
  5. - POST /api/v1/inventory/spools/bulk-archive
  6. - POST /api/v1/inventory/spools/bulk-restore
  7. The Spoolman-mode equivalents live in test_spoolman_inventory_api.py.
  8. """
  9. from datetime import datetime, timezone
  10. import pytest
  11. from httpx import AsyncClient
  12. from sqlalchemy import select
  13. from sqlalchemy.ext.asyncio import AsyncSession
  14. from backend.app.models.spool import Spool
  15. @pytest.fixture
  16. async def spool_factory(db_session: AsyncSession):
  17. async def _create(**kwargs):
  18. defaults = {
  19. "material": "PLA",
  20. "subtype": "Basic",
  21. "brand": "Bambu",
  22. "color_name": "Red",
  23. "rgba": "FF0000FF",
  24. "label_weight": 1000,
  25. "core_weight": 250,
  26. "weight_used": 0,
  27. "weight_used_baseline": 0,
  28. "weight_locked": False,
  29. }
  30. defaults.update(kwargs)
  31. spool = Spool(**defaults)
  32. db_session.add(spool)
  33. await db_session.commit()
  34. await db_session.refresh(spool)
  35. return spool
  36. return _create
  37. class TestBulkUpdate:
  38. @pytest.mark.asyncio
  39. @pytest.mark.integration
  40. async def test_applies_patch_to_all_listed_spools(self, async_client: AsyncClient, spool_factory, db_session):
  41. a = await spool_factory(brand="Bambu", note=None)
  42. b = await spool_factory(brand="Bambu", note=None)
  43. c = await spool_factory(brand="Bambu", note=None)
  44. resp = await async_client.post(
  45. "/api/v1/inventory/spools/bulk-update",
  46. json={"ids": [a.id, b.id, c.id], "update": {"brand": "Sunlu", "note": "From bulk edit"}},
  47. )
  48. assert resp.status_code == 200
  49. body = resp.json()
  50. assert body["updated"] == 3
  51. assert body["not_found"] == []
  52. for spool in (a, b, c):
  53. await db_session.refresh(spool)
  54. assert spool.brand == "Sunlu"
  55. assert spool.note == "From bulk edit"
  56. @pytest.mark.asyncio
  57. @pytest.mark.integration
  58. async def test_reports_unknown_ids_in_not_found(self, async_client: AsyncClient, spool_factory, db_session):
  59. real = await spool_factory(brand="Bambu")
  60. resp = await async_client.post(
  61. "/api/v1/inventory/spools/bulk-update",
  62. json={"ids": [real.id, 999_999], "update": {"brand": "Sunlu"}},
  63. )
  64. assert resp.status_code == 200
  65. body = resp.json()
  66. assert body["updated"] == 1
  67. assert body["not_found"] == [999_999]
  68. await db_session.refresh(real)
  69. assert real.brand == "Sunlu"
  70. @pytest.mark.asyncio
  71. @pytest.mark.integration
  72. async def test_empty_update_rejected(self, async_client: AsyncClient, spool_factory):
  73. a = await spool_factory()
  74. resp = await async_client.post(
  75. "/api/v1/inventory/spools/bulk-update",
  76. json={"ids": [a.id], "update": {}},
  77. )
  78. assert resp.status_code == 400
  79. @pytest.mark.asyncio
  80. @pytest.mark.integration
  81. async def test_setting_weight_used_auto_locks(self, async_client: AsyncClient, spool_factory, db_session):
  82. a = await spool_factory(weight_locked=False, weight_used=0.0)
  83. resp = await async_client.post(
  84. "/api/v1/inventory/spools/bulk-update",
  85. json={"ids": [a.id], "update": {"weight_used": 250.5}},
  86. )
  87. assert resp.status_code == 200
  88. await db_session.refresh(a)
  89. assert a.weight_used == 250.5
  90. assert a.weight_locked is True
  91. @pytest.mark.asyncio
  92. @pytest.mark.integration
  93. async def test_empty_ids_list_rejected(self, async_client: AsyncClient):
  94. resp = await async_client.post(
  95. "/api/v1/inventory/spools/bulk-update",
  96. json={"ids": [], "update": {"brand": "X"}},
  97. )
  98. assert resp.status_code == 422
  99. class TestBulkDelete:
  100. @pytest.mark.asyncio
  101. @pytest.mark.integration
  102. async def test_deletes_listed_spools(self, async_client: AsyncClient, spool_factory, db_session):
  103. a = await spool_factory()
  104. b = await spool_factory()
  105. kept = await spool_factory()
  106. resp = await async_client.post(
  107. "/api/v1/inventory/spools/bulk-delete",
  108. json={"ids": [a.id, b.id]},
  109. )
  110. assert resp.status_code == 200
  111. body = resp.json()
  112. assert body["deleted"] == 2
  113. assert body["not_found"] == []
  114. remaining = (await db_session.execute(select(Spool.id))).scalars().all()
  115. assert kept.id in remaining
  116. assert a.id not in remaining
  117. assert b.id not in remaining
  118. @pytest.mark.asyncio
  119. @pytest.mark.integration
  120. async def test_reports_unknown_ids(self, async_client: AsyncClient, spool_factory):
  121. a = await spool_factory()
  122. resp = await async_client.post(
  123. "/api/v1/inventory/spools/bulk-delete",
  124. json={"ids": [a.id, 999_999]},
  125. )
  126. assert resp.status_code == 200
  127. body = resp.json()
  128. assert body["deleted"] == 1
  129. assert body["not_found"] == [999_999]
  130. class TestBulkArchiveRestore:
  131. @pytest.mark.asyncio
  132. @pytest.mark.integration
  133. async def test_bulk_archive_sets_archived_at(self, async_client: AsyncClient, spool_factory, db_session):
  134. a = await spool_factory()
  135. b = await spool_factory()
  136. resp = await async_client.post(
  137. "/api/v1/inventory/spools/bulk-archive",
  138. json={"ids": [a.id, b.id]},
  139. )
  140. assert resp.status_code == 200
  141. body = resp.json()
  142. assert body["archived"] == 2
  143. assert body["already_archived"] == []
  144. assert body["not_found"] == []
  145. for s in (a, b):
  146. await db_session.refresh(s)
  147. assert s.archived_at is not None
  148. @pytest.mark.asyncio
  149. @pytest.mark.integration
  150. async def test_bulk_archive_skips_already_archived(self, async_client: AsyncClient, spool_factory, db_session):
  151. active = await spool_factory()
  152. already = await spool_factory(archived_at=datetime.now(timezone.utc))
  153. resp = await async_client.post(
  154. "/api/v1/inventory/spools/bulk-archive",
  155. json={"ids": [active.id, already.id]},
  156. )
  157. assert resp.status_code == 200
  158. body = resp.json()
  159. assert body["archived"] == 1
  160. assert body["already_archived"] == [already.id]
  161. @pytest.mark.asyncio
  162. @pytest.mark.integration
  163. async def test_bulk_restore_clears_archived_at(self, async_client: AsyncClient, spool_factory, db_session):
  164. archived = await spool_factory(archived_at=datetime.now(timezone.utc))
  165. active = await spool_factory(archived_at=None)
  166. resp = await async_client.post(
  167. "/api/v1/inventory/spools/bulk-restore",
  168. json={"ids": [archived.id, active.id]},
  169. )
  170. assert resp.status_code == 200
  171. body = resp.json()
  172. assert body["restored"] == 1
  173. assert body["already_active"] == [active.id]
  174. await db_session.refresh(archived)
  175. assert archived.archived_at is None