test_location_service.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. """Unit tests for storage location service (#1004)."""
  2. import pytest
  3. from sqlalchemy.ext.asyncio import AsyncSession
  4. from backend.app.models.location import Location
  5. from backend.app.models.spool import Spool
  6. from backend.app.services.location_service import (
  7. assign_location_name,
  8. enrich_spool_dicts_with_location_id,
  9. get_location_by_name,
  10. location_name_key,
  11. prepare_internal_spool_payload,
  12. rename_location,
  13. resolve_location_by_name,
  14. resolve_spool_location_fields,
  15. sync_locations_from_spoolman,
  16. )
  17. @pytest.mark.asyncio
  18. async def test_resolve_location_by_name_creates(db_session: AsyncSession):
  19. loc = await resolve_location_by_name(db_session, "Shelf A")
  20. await db_session.commit()
  21. assert loc is not None
  22. assert loc.name == "Shelf A"
  23. assert loc.name_key == location_name_key("Shelf A")
  24. again = await get_location_by_name(db_session, "shelf a")
  25. assert again is not None
  26. assert again.id == loc.id
  27. @pytest.mark.asyncio
  28. async def test_prepare_internal_spool_payload_from_location_id(db_session: AsyncSession):
  29. loc = Location()
  30. assign_location_name(loc, "Drawer 2")
  31. db_session.add(loc)
  32. await db_session.commit()
  33. await db_session.refresh(loc)
  34. payload = await prepare_internal_spool_payload(
  35. db_session,
  36. {"material": "PLA", "location_id": loc.id},
  37. {"material", "location_id"},
  38. )
  39. assert payload["location_id"] == loc.id
  40. assert payload["storage_location"] == "Drawer 2"
  41. @pytest.mark.asyncio
  42. async def test_resolve_spool_location_fields_prefers_location_id(db_session: AsyncSession):
  43. loc = Location()
  44. assign_location_name(loc, "Catalog A")
  45. db_session.add(loc)
  46. await db_session.commit()
  47. await db_session.refresh(loc)
  48. resolved = await resolve_spool_location_fields(
  49. db_session,
  50. location_id=loc.id,
  51. storage_location="Other",
  52. fields_set={"location_id", "storage_location"},
  53. )
  54. assert resolved is not None
  55. assert resolved.location_id == loc.id
  56. assert resolved.storage_location == "Catalog A"
  57. @pytest.mark.asyncio
  58. async def test_rename_location_updates_spool_storage(db_session: AsyncSession):
  59. loc = Location()
  60. assign_location_name(loc, "Old Shelf")
  61. spool = Spool(material="PLA", location_id=None, storage_location="Old Shelf")
  62. db_session.add(loc)
  63. db_session.add(spool)
  64. await db_session.commit()
  65. await db_session.refresh(loc)
  66. await rename_location(db_session, loc, "New Shelf")
  67. await db_session.commit()
  68. await db_session.refresh(spool)
  69. assert loc.name == "New Shelf"
  70. assert loc.name_key == location_name_key("New Shelf")
  71. assert spool.storage_location == "New Shelf"
  72. assert spool.location_id == loc.id
  73. @pytest.mark.asyncio
  74. async def test_enrich_spool_dicts_with_location_id(db_session: AsyncSession):
  75. loc = Location()
  76. assign_location_name(loc, "Garage")
  77. db_session.add(loc)
  78. await db_session.commit()
  79. spools = [{"id": 1, "storage_location": "Garage"}, {"id": 2, "storage_location": None}]
  80. await enrich_spool_dicts_with_location_id(db_session, spools)
  81. assert spools[0]["location_id"] == loc.id
  82. assert spools[1]["location_id"] is None
  83. @pytest.mark.asyncio
  84. async def test_sync_locations_from_spoolman_stages_without_commit(db_session: AsyncSession):
  85. class FakeClient:
  86. async def get_distinct_locations(self):
  87. return ["Spoolman Shelf"]
  88. changed = await sync_locations_from_spoolman(db_session, FakeClient())
  89. assert changed is True
  90. loc = await get_location_by_name(db_session, "Spoolman Shelf")
  91. assert loc is not None
  92. # Caller owns the transaction — no commit() was called in sync itself.
  93. assert loc.id is not None
  94. @pytest.mark.asyncio
  95. async def test_sync_locations_from_spoolman_dedupes_case_variants(db_session: AsyncSession):
  96. class FakeClient:
  97. async def get_distinct_locations(self):
  98. return ["Drybox 1", "DRYBOX 1", "Locker"]
  99. changed = await sync_locations_from_spoolman(db_session, FakeClient())
  100. assert changed is True
  101. await db_session.commit()
  102. drybox = await get_location_by_name(db_session, "Drybox 1")
  103. locker = await get_location_by_name(db_session, "Locker")
  104. assert drybox is not None
  105. assert locker is not None
  106. from sqlalchemy import func, select
  107. from backend.app.models.location import Location
  108. count = await db_session.scalar(select(func.count()).select_from(Location))
  109. assert count == 2
  110. @pytest.mark.asyncio
  111. async def test_rename_location_duplicate_name_raises(db_session: AsyncSession):
  112. first = Location()
  113. assign_location_name(first, "Shelf A")
  114. second = Location()
  115. assign_location_name(second, "Shelf B")
  116. db_session.add_all([first, second])
  117. await db_session.commit()
  118. await db_session.refresh(first)
  119. await db_session.refresh(second)
  120. with pytest.raises(ValueError, match="already exists"):
  121. await rename_location(db_session, second, "Shelf A")
  122. @pytest.mark.asyncio
  123. async def test_rename_location_picks_up_legacy_row_with_trailing_whitespace(db_session: AsyncSession):
  124. """A legacy spool whose `storage_location` carries trailing whitespace
  125. must still get relinked by the rename cascade — the SQL `TRIM()` strips
  126. the column, so the Python comparison must also strip `old_name`."""
  127. loc = Location()
  128. assign_location_name(loc, "Old Shelf")
  129. # Simulate a legacy row whose name was stored with the same value but
  130. # the column entry has whitespace padding (this happens in old free-text
  131. # data + manual DB edits).
  132. legacy_spool = Spool(material="PLA", location_id=None, storage_location=" Old Shelf ")
  133. db_session.add(loc)
  134. db_session.add(legacy_spool)
  135. await db_session.commit()
  136. await db_session.refresh(loc)
  137. await db_session.refresh(legacy_spool)
  138. # Force the in-memory name to carry trailing whitespace so the rename
  139. # path lifts a non-stripped `old_name`. This is the asymmetry the fix
  140. # addresses (#1505 review IMPORTANT 10).
  141. loc.name = "Old Shelf "
  142. await rename_location(db_session, loc, "New Shelf")
  143. await db_session.commit()
  144. await db_session.refresh(legacy_spool)
  145. assert legacy_spool.storage_location == "New Shelf"
  146. assert legacy_spool.location_id == loc.id
  147. @pytest.mark.asyncio
  148. async def test_sync_locations_from_spoolman_logs_and_returns_false_on_unavailable(db_session: AsyncSession, caplog):
  149. """Bare `except Exception: return False` was the prior shape — verify the
  150. narrowed catch surfaces a warning so ops can see Spoolman outages."""
  151. from backend.app.services.spoolman import SpoolmanUnavailableError
  152. class FailingClient:
  153. async def get_distinct_locations(self):
  154. raise SpoolmanUnavailableError("Cannot reach Spoolman")
  155. with caplog.at_level("WARNING", logger="backend.app.services.location_service"):
  156. changed = await sync_locations_from_spoolman(db_session, FailingClient())
  157. assert changed is False
  158. assert any("location sync from Spoolman failed" in rec.message for rec in caplog.records)
  159. @pytest.mark.asyncio
  160. async def test_sync_locations_from_spoolman_handles_dict_payload(db_session: AsyncSession):
  161. """Newer Spoolman returns `list[dict]` from `/location`; the SpoolmanClient
  162. normalises to `list[str]`, so sync_locations_from_spoolman should accept
  163. both shapes via the client contract."""
  164. class DictShapeClient:
  165. async def get_distinct_locations(self):
  166. # SpoolmanClient.get_distinct_locations is the one that normalises;
  167. # at this layer the contract is `list[str]`. Simulate post-normalisation.
  168. return ["Cabinet 3", "Cabinet 3"] # dedup tested elsewhere — sanity here
  169. changed = await sync_locations_from_spoolman(db_session, DictShapeClient())
  170. assert changed is True
  171. await db_session.commit()
  172. cabinet = await get_location_by_name(db_session, "Cabinet 3")
  173. assert cabinet is not None