test_location_service.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  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. is_ams_slot_location,
  11. location_name_key,
  12. prepare_internal_spool_payload,
  13. rename_location,
  14. resolve_location_by_name,
  15. resolve_spool_location_fields,
  16. sync_locations_from_spoolman,
  17. )
  18. @pytest.mark.asyncio
  19. async def test_resolve_location_by_name_creates(db_session: AsyncSession):
  20. loc = await resolve_location_by_name(db_session, "Shelf A")
  21. await db_session.commit()
  22. assert loc is not None
  23. assert loc.name == "Shelf A"
  24. assert loc.name_key == location_name_key("Shelf A")
  25. again = await get_location_by_name(db_session, "shelf a")
  26. assert again is not None
  27. assert again.id == loc.id
  28. @pytest.mark.asyncio
  29. async def test_prepare_internal_spool_payload_from_location_id(db_session: AsyncSession):
  30. loc = Location()
  31. assign_location_name(loc, "Drawer 2")
  32. db_session.add(loc)
  33. await db_session.commit()
  34. await db_session.refresh(loc)
  35. payload = await prepare_internal_spool_payload(
  36. db_session,
  37. {"material": "PLA", "location_id": loc.id},
  38. {"material", "location_id"},
  39. )
  40. assert payload["location_id"] == loc.id
  41. assert payload["storage_location"] == "Drawer 2"
  42. @pytest.mark.asyncio
  43. async def test_resolve_spool_location_fields_prefers_location_id(db_session: AsyncSession):
  44. loc = Location()
  45. assign_location_name(loc, "Catalog A")
  46. db_session.add(loc)
  47. await db_session.commit()
  48. await db_session.refresh(loc)
  49. resolved = await resolve_spool_location_fields(
  50. db_session,
  51. location_id=loc.id,
  52. storage_location="Other",
  53. fields_set={"location_id", "storage_location"},
  54. )
  55. assert resolved is not None
  56. assert resolved.location_id == loc.id
  57. assert resolved.storage_location == "Catalog A"
  58. @pytest.mark.asyncio
  59. async def test_rename_location_updates_spool_storage(db_session: AsyncSession):
  60. loc = Location()
  61. assign_location_name(loc, "Old Shelf")
  62. spool = Spool(material="PLA", location_id=None, storage_location="Old Shelf")
  63. db_session.add(loc)
  64. db_session.add(spool)
  65. await db_session.commit()
  66. await db_session.refresh(loc)
  67. await rename_location(db_session, loc, "New Shelf")
  68. await db_session.commit()
  69. await db_session.refresh(spool)
  70. assert loc.name == "New Shelf"
  71. assert loc.name_key == location_name_key("New Shelf")
  72. assert spool.storage_location == "New Shelf"
  73. assert spool.location_id == loc.id
  74. @pytest.mark.asyncio
  75. async def test_enrich_spool_dicts_with_location_id(db_session: AsyncSession):
  76. loc = Location()
  77. assign_location_name(loc, "Garage")
  78. db_session.add(loc)
  79. await db_session.commit()
  80. spools = [{"id": 1, "storage_location": "Garage"}, {"id": 2, "storage_location": None}]
  81. await enrich_spool_dicts_with_location_id(db_session, spools)
  82. assert spools[0]["location_id"] == loc.id
  83. assert spools[1]["location_id"] is None
  84. @pytest.mark.asyncio
  85. async def test_sync_locations_from_spoolman_stages_without_commit(db_session: AsyncSession):
  86. class FakeClient:
  87. async def get_distinct_locations(self):
  88. return ["Spoolman Shelf"]
  89. changed = await sync_locations_from_spoolman(db_session, FakeClient())
  90. assert changed is True
  91. loc = await get_location_by_name(db_session, "Spoolman Shelf")
  92. assert loc is not None
  93. # Caller owns the transaction — no commit() was called in sync itself.
  94. assert loc.id is not None
  95. @pytest.mark.asyncio
  96. async def test_sync_locations_from_spoolman_dedupes_case_variants(db_session: AsyncSession):
  97. class FakeClient:
  98. async def get_distinct_locations(self):
  99. return ["Drybox 1", "DRYBOX 1", "Locker"]
  100. changed = await sync_locations_from_spoolman(db_session, FakeClient())
  101. assert changed is True
  102. await db_session.commit()
  103. drybox = await get_location_by_name(db_session, "Drybox 1")
  104. locker = await get_location_by_name(db_session, "Locker")
  105. assert drybox is not None
  106. assert locker is not None
  107. from sqlalchemy import func, select
  108. from backend.app.models.location import Location
  109. count = await db_session.scalar(select(func.count()).select_from(Location))
  110. assert count == 2
  111. @pytest.mark.asyncio
  112. async def test_rename_location_duplicate_name_raises(db_session: AsyncSession):
  113. first = Location()
  114. assign_location_name(first, "Shelf A")
  115. second = Location()
  116. assign_location_name(second, "Shelf B")
  117. db_session.add_all([first, second])
  118. await db_session.commit()
  119. await db_session.refresh(first)
  120. await db_session.refresh(second)
  121. with pytest.raises(ValueError, match="already exists"):
  122. await rename_location(db_session, second, "Shelf A")
  123. @pytest.mark.asyncio
  124. async def test_rename_location_picks_up_legacy_row_with_trailing_whitespace(db_session: AsyncSession):
  125. """A legacy spool whose `storage_location` carries trailing whitespace
  126. must still get relinked by the rename cascade — the SQL `TRIM()` strips
  127. the column, so the Python comparison must also strip `old_name`."""
  128. loc = Location()
  129. assign_location_name(loc, "Old Shelf")
  130. # Simulate a legacy row whose name was stored with the same value but
  131. # the column entry has whitespace padding (this happens in old free-text
  132. # data + manual DB edits).
  133. legacy_spool = Spool(material="PLA", location_id=None, storage_location=" Old Shelf ")
  134. db_session.add(loc)
  135. db_session.add(legacy_spool)
  136. await db_session.commit()
  137. await db_session.refresh(loc)
  138. await db_session.refresh(legacy_spool)
  139. # Force the in-memory name to carry trailing whitespace so the rename
  140. # path lifts a non-stripped `old_name`. This is the asymmetry the fix
  141. # addresses (#1505 review IMPORTANT 10).
  142. loc.name = "Old Shelf "
  143. await rename_location(db_session, loc, "New Shelf")
  144. await db_session.commit()
  145. await db_session.refresh(legacy_spool)
  146. assert legacy_spool.storage_location == "New Shelf"
  147. assert legacy_spool.location_id == loc.id
  148. @pytest.mark.asyncio
  149. async def test_sync_locations_from_spoolman_logs_and_returns_false_on_unavailable(db_session: AsyncSession, caplog):
  150. """Bare `except Exception: return False` was the prior shape — verify the
  151. narrowed catch surfaces a warning so ops can see Spoolman outages."""
  152. from backend.app.services.spoolman import SpoolmanUnavailableError
  153. class FailingClient:
  154. async def get_distinct_locations(self):
  155. raise SpoolmanUnavailableError("Cannot reach Spoolman")
  156. with caplog.at_level("WARNING", logger="backend.app.services.location_service"):
  157. changed = await sync_locations_from_spoolman(db_session, FailingClient())
  158. assert changed is False
  159. assert any("location sync from Spoolman failed" in rec.message for rec in caplog.records)
  160. @pytest.mark.asyncio
  161. async def test_sync_locations_from_spoolman_handles_dict_payload(db_session: AsyncSession):
  162. """Newer Spoolman returns `list[dict]` from `/location`; the SpoolmanClient
  163. normalises to `list[str]`, so sync_locations_from_spoolman should accept
  164. both shapes via the client contract."""
  165. class DictShapeClient:
  166. async def get_distinct_locations(self):
  167. # SpoolmanClient.get_distinct_locations is the one that normalises;
  168. # at this layer the contract is `list[str]`. Simulate post-normalisation.
  169. return ["Cabinet 3", "Cabinet 3"] # dedup tested elsewhere — sanity here
  170. changed = await sync_locations_from_spoolman(db_session, DictShapeClient())
  171. assert changed is True
  172. await db_session.commit()
  173. cabinet = await get_location_by_name(db_session, "Cabinet 3")
  174. assert cabinet is not None
  175. class TestIsAmsSlotLocation:
  176. """A printer slot is where a spool is loaded, not where it is stored."""
  177. @pytest.mark.parametrize(
  178. "name",
  179. [
  180. "H2D-1 - AMS A1",
  181. "X1C-2 - AMS C3",
  182. "P1S - AMS-HT A1",
  183. "H2D-1 - AMS HT B1",
  184. "AMS A1",
  185. "AMS-HT A1",
  186. "External Spool",
  187. "H2D-1 - External Spool",
  188. "h2d-1 - ams a1",
  189. " H2D-1 - AMS A1 ",
  190. ],
  191. )
  192. def test_slot_markers_are_recognised(self, name):
  193. assert is_ams_slot_location(name) is True
  194. @pytest.mark.parametrize(
  195. "name",
  196. [
  197. "Drybox 1",
  198. "Shelf A",
  199. "AMS Drybox",
  200. "Spare AMS trays",
  201. "Locker - Top",
  202. "AMS A1 spares",
  203. "dadadad",
  204. ],
  205. )
  206. def test_real_storage_locations_are_kept(self, name):
  207. """The filter has to be narrow: anything it swallows is a place the user
  208. can no longer file a spool under."""
  209. assert is_ams_slot_location(name) is False
  210. @pytest.mark.asyncio
  211. async def test_sync_locations_from_spoolman_skips_ams_slot_markers(db_session: AsyncSession):
  212. """Bambuddy used to write the loaded slot into Spoolman's `location` field.
  213. Importing those back offered a printer slot as a storage location, and in
  214. Spoolman mode they could not even be deleted -- the delete route counts
  215. spools by that same string and answered 409."""
  216. class FakeClient:
  217. async def get_distinct_locations(self):
  218. return ["H2D-1 - AMS A1", "X1C-2 - AMS A1", "H2D-1 - External Spool", "Drybox 1"]
  219. changed = await sync_locations_from_spoolman(db_session, FakeClient())
  220. assert changed is True
  221. await db_session.commit()
  222. assert await get_location_by_name(db_session, "Drybox 1") is not None
  223. for marker in ("H2D-1 - AMS A1", "X1C-2 - AMS A1", "H2D-1 - External Spool"):
  224. assert await get_location_by_name(db_session, marker) is None
  225. @pytest.mark.asyncio
  226. async def test_sync_locations_from_spoolman_reports_no_change_when_only_markers(db_session: AsyncSession):
  227. """`changed` drives the caller's commit — claiming a change for rows that
  228. were all filtered out would open a write transaction on every poll."""
  229. class FakeClient:
  230. async def get_distinct_locations(self):
  231. return ["H2D-1 - AMS A1", "H2D-1 - AMS A2"]
  232. assert await sync_locations_from_spoolman(db_session, FakeClient()) is False