test_bulk_spool_create.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. """Unit tests for bulk spool creation.
  2. Tests:
  3. - SpoolBulkCreate schema validation (quantity bounds)
  4. - Bulk create endpoint creates the requested number of spools
  5. - Bulk create with quantity=1 (single spool)
  6. - Bulk create returns spools with k_profiles loaded
  7. - Bulk create copies the inherited supplier links onto every copy
  8. """
  9. from unittest.mock import AsyncMock, MagicMock
  10. import pytest
  11. from pydantic import ValidationError
  12. from backend.app.models.spool import Spool
  13. from backend.app.models.supplier import SpoolSupplier
  14. from backend.app.schemas.spool import SpoolBulkCreate, SpoolCreate
  15. # ── Schema Validation ──────────────────────────────────────────────────────
  16. class TestSpoolBulkCreateSchema:
  17. """Tests for the SpoolBulkCreate Pydantic model."""
  18. def test_default_quantity_is_1(self):
  19. data = SpoolBulkCreate(spool=SpoolCreate(material="PLA"))
  20. assert data.quantity == 1
  21. def test_quantity_within_range(self):
  22. data = SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=50)
  23. assert data.quantity == 50
  24. def test_quantity_max_100(self):
  25. data = SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=100)
  26. assert data.quantity == 100
  27. def test_quantity_zero_rejected(self):
  28. with pytest.raises(ValidationError, match="greater than or equal to 1"):
  29. SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=0)
  30. def test_quantity_negative_rejected(self):
  31. with pytest.raises(ValidationError, match="greater than or equal to 1"):
  32. SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=-1)
  33. def test_quantity_over_100_rejected(self):
  34. with pytest.raises(ValidationError, match="less than or equal to 100"):
  35. SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=101)
  36. def test_spool_fields_preserved(self):
  37. data = SpoolBulkCreate(
  38. spool=SpoolCreate(
  39. material="PETG",
  40. brand="Polymaker",
  41. subtype="Basic",
  42. color_name="Red",
  43. rgba="FF0000FF",
  44. label_weight=750,
  45. note="Test batch",
  46. ),
  47. quantity=5,
  48. )
  49. assert data.spool.material == "PETG"
  50. assert data.spool.brand == "Polymaker"
  51. assert data.spool.label_weight == 750
  52. assert data.spool.note == "Test batch"
  53. assert data.quantity == 5
  54. def test_spool_without_slicer_filament_is_stock(self):
  55. """A spool without slicer_filament is a 'stock' spool (computed, not stored)."""
  56. data = SpoolBulkCreate(
  57. spool=SpoolCreate(material="PLA", label_weight=1000),
  58. quantity=3,
  59. )
  60. assert data.spool.slicer_filament is None
  61. def test_spool_with_slicer_filament_is_configured(self):
  62. data = SpoolBulkCreate(
  63. spool=SpoolCreate(material="PLA", slicer_filament="GFL99"),
  64. quantity=2,
  65. )
  66. assert data.spool.slicer_filament == "GFL99"
  67. def test_material_required(self):
  68. with pytest.raises(ValidationError):
  69. SpoolBulkCreate(spool=SpoolCreate(material=""), quantity=1)
  70. # ── Endpoint Logic ─────────────────────────────────────────────────────────
  71. def _make_mock_spool(spool_id):
  72. """Create a mock Spool ORM object."""
  73. spool = MagicMock()
  74. spool.id = spool_id
  75. spool.material = "PLA"
  76. spool.label_weight = 1000
  77. spool.k_profiles = []
  78. return spool
  79. def _result(rows):
  80. """A stand-in for the Result of a query that selected ``rows``."""
  81. result = MagicMock()
  82. result.scalars.return_value.all.return_value = list(rows)
  83. result.scalars.return_value.first.return_value = rows[0] if rows else None
  84. result.first.return_value = (rows[0],) if rows else None
  85. return result
  86. def _make_db(refetched, *, donor_id=None, donor_links=()):
  87. """Session double that answers each of the bulk path's queries separately.
  88. The endpoint runs three different selects — the supplier donor lookup,
  89. that donor's link rows (#2988), and the re-fetch of the created spools —
  90. so one canned result for all of them hands Spool rows to the supplier
  91. code and invents a donor that was never seeded. Route on what each select
  92. asks for instead. Returns the session and the list ``db.add`` lands in.
  93. """
  94. added = []
  95. db = AsyncMock()
  96. db.add = added.append
  97. async def execute(statement, *_args, **_kwargs):
  98. described = statement.column_descriptions[0]
  99. if described["entity"] is SpoolSupplier:
  100. return _result(donor_links)
  101. if described["name"] == "id":
  102. return _result([] if donor_id is None else [donor_id])
  103. return _result(refetched)
  104. db.execute = AsyncMock(side_effect=execute)
  105. return db, added
  106. class TestBulkCreateEndpoint:
  107. """Tests for the bulk_create_spools endpoint logic."""
  108. @pytest.mark.asyncio
  109. async def test_creates_requested_number_of_spools(self):
  110. """Verify N spools are created and added to the session."""
  111. from backend.app.api.routes.inventory import bulk_create_spools
  112. data = SpoolBulkCreate(
  113. spool=SpoolCreate(material="PLA", brand="Test", label_weight=1000),
  114. quantity=3,
  115. )
  116. db, added_objects = _make_db([_make_mock_spool(i + 1) for i in range(3)])
  117. result = await bulk_create_spools(data=data, db=db, _=None)
  118. assert len(result) == 3
  119. assert len(added_objects) == 3
  120. db.commit.assert_awaited_once()
  121. @pytest.mark.asyncio
  122. async def test_single_quantity_creates_one_spool(self):
  123. """Bulk create with quantity=1 should create exactly one spool."""
  124. from backend.app.api.routes.inventory import bulk_create_spools
  125. data = SpoolBulkCreate(
  126. spool=SpoolCreate(material="PETG"),
  127. quantity=1,
  128. )
  129. db, added_objects = _make_db([_make_mock_spool(1)])
  130. result = await bulk_create_spools(data=data, db=db, _=None)
  131. assert len(result) == 1
  132. assert len(added_objects) == 1
  133. @pytest.mark.asyncio
  134. async def test_all_spools_have_same_fields(self):
  135. """All created spools should have identical field values."""
  136. from backend.app.api.routes.inventory import bulk_create_spools
  137. data = SpoolBulkCreate(
  138. spool=SpoolCreate(
  139. material="ABS",
  140. brand="Bambu Lab",
  141. color_name="Black",
  142. rgba="000000FF",
  143. label_weight=750,
  144. ),
  145. quantity=3,
  146. )
  147. db, added_objects = _make_db([_make_mock_spool(i + 1) for i in range(3)])
  148. await bulk_create_spools(data=data, db=db, _=None)
  149. # The spools, not everything the session was handed: the bulk path also
  150. # adds the inherited supplier links, and a SpoolSupplier has no material.
  151. spools = [obj for obj in added_objects if isinstance(obj, Spool)]
  152. assert len(spools) == 3
  153. for spool_obj in spools:
  154. assert spool_obj.material == "ABS"
  155. assert spool_obj.brand == "Bambu Lab"
  156. assert spool_obj.color_name == "Black"
  157. assert spool_obj.label_weight == 750