Files
bambuddy/backend/tests/unit/test_location_service.py

220 lines
7.8 KiB
Python

"""Unit tests for storage location service (#1004)."""
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.location import Location
from backend.app.models.spool import Spool
from backend.app.services.location_service import (
assign_location_name,
enrich_spool_dicts_with_location_id,
get_location_by_name,
location_name_key,
prepare_internal_spool_payload,
rename_location,
resolve_location_by_name,
resolve_spool_location_fields,
sync_locations_from_spoolman,
)
@pytest.mark.asyncio
async def test_resolve_location_by_name_creates(db_session: AsyncSession):
loc = await resolve_location_by_name(db_session, "Shelf A")
await db_session.commit()
assert loc is not None
assert loc.name == "Shelf A"
assert loc.name_key == location_name_key("Shelf A")
again = await get_location_by_name(db_session, "shelf a")
assert again is not None
assert again.id == loc.id
@pytest.mark.asyncio
async def test_prepare_internal_spool_payload_from_location_id(db_session: AsyncSession):
loc = Location()
assign_location_name(loc, "Drawer 2")
db_session.add(loc)
await db_session.commit()
await db_session.refresh(loc)
payload = await prepare_internal_spool_payload(
db_session,
{"material": "PLA", "location_id": loc.id},
{"material", "location_id"},
)
assert payload["location_id"] == loc.id
assert payload["storage_location"] == "Drawer 2"
@pytest.mark.asyncio
async def test_resolve_spool_location_fields_prefers_location_id(db_session: AsyncSession):
loc = Location()
assign_location_name(loc, "Catalog A")
db_session.add(loc)
await db_session.commit()
await db_session.refresh(loc)
resolved = await resolve_spool_location_fields(
db_session,
location_id=loc.id,
storage_location="Other",
fields_set={"location_id", "storage_location"},
)
assert resolved is not None
assert resolved.location_id == loc.id
assert resolved.storage_location == "Catalog A"
@pytest.mark.asyncio
async def test_rename_location_updates_spool_storage(db_session: AsyncSession):
loc = Location()
assign_location_name(loc, "Old Shelf")
spool = Spool(material="PLA", location_id=None, storage_location="Old Shelf")
db_session.add(loc)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(loc)
await rename_location(db_session, loc, "New Shelf")
await db_session.commit()
await db_session.refresh(spool)
assert loc.name == "New Shelf"
assert loc.name_key == location_name_key("New Shelf")
assert spool.storage_location == "New Shelf"
assert spool.location_id == loc.id
@pytest.mark.asyncio
async def test_enrich_spool_dicts_with_location_id(db_session: AsyncSession):
loc = Location()
assign_location_name(loc, "Garage")
db_session.add(loc)
await db_session.commit()
spools = [{"id": 1, "storage_location": "Garage"}, {"id": 2, "storage_location": None}]
await enrich_spool_dicts_with_location_id(db_session, spools)
assert spools[0]["location_id"] == loc.id
assert spools[1]["location_id"] is None
@pytest.mark.asyncio
async def test_sync_locations_from_spoolman_stages_without_commit(db_session: AsyncSession):
class FakeClient:
async def get_distinct_locations(self):
return ["Spoolman Shelf"]
changed = await sync_locations_from_spoolman(db_session, FakeClient())
assert changed is True
loc = await get_location_by_name(db_session, "Spoolman Shelf")
assert loc is not None
# Caller owns the transaction — no commit() was called in sync itself.
assert loc.id is not None
@pytest.mark.asyncio
async def test_sync_locations_from_spoolman_dedupes_case_variants(db_session: AsyncSession):
class FakeClient:
async def get_distinct_locations(self):
return ["Drybox 1", "DRYBOX 1", "Locker"]
changed = await sync_locations_from_spoolman(db_session, FakeClient())
assert changed is True
await db_session.commit()
drybox = await get_location_by_name(db_session, "Drybox 1")
locker = await get_location_by_name(db_session, "Locker")
assert drybox is not None
assert locker is not None
from sqlalchemy import func, select
from backend.app.models.location import Location
count = await db_session.scalar(select(func.count()).select_from(Location))
assert count == 2
@pytest.mark.asyncio
async def test_rename_location_duplicate_name_raises(db_session: AsyncSession):
first = Location()
assign_location_name(first, "Shelf A")
second = Location()
assign_location_name(second, "Shelf B")
db_session.add_all([first, second])
await db_session.commit()
await db_session.refresh(first)
await db_session.refresh(second)
with pytest.raises(ValueError, match="already exists"):
await rename_location(db_session, second, "Shelf A")
@pytest.mark.asyncio
async def test_rename_location_picks_up_legacy_row_with_trailing_whitespace(db_session: AsyncSession):
"""A legacy spool whose `storage_location` carries trailing whitespace
must still get relinked by the rename cascade — the SQL `TRIM()` strips
the column, so the Python comparison must also strip `old_name`."""
loc = Location()
assign_location_name(loc, "Old Shelf")
# Simulate a legacy row whose name was stored with the same value but
# the column entry has whitespace padding (this happens in old free-text
# data + manual DB edits).
legacy_spool = Spool(material="PLA", location_id=None, storage_location=" Old Shelf ")
db_session.add(loc)
db_session.add(legacy_spool)
await db_session.commit()
await db_session.refresh(loc)
await db_session.refresh(legacy_spool)
# Force the in-memory name to carry trailing whitespace so the rename
# path lifts a non-stripped `old_name`. This is the asymmetry the fix
# addresses (#1505 review IMPORTANT 10).
loc.name = "Old Shelf "
await rename_location(db_session, loc, "New Shelf")
await db_session.commit()
await db_session.refresh(legacy_spool)
assert legacy_spool.storage_location == "New Shelf"
assert legacy_spool.location_id == loc.id
@pytest.mark.asyncio
async def test_sync_locations_from_spoolman_logs_and_returns_false_on_unavailable(db_session: AsyncSession, caplog):
"""Bare `except Exception: return False` was the prior shape — verify the
narrowed catch surfaces a warning so ops can see Spoolman outages."""
from backend.app.services.spoolman import SpoolmanUnavailableError
class FailingClient:
async def get_distinct_locations(self):
raise SpoolmanUnavailableError("Cannot reach Spoolman")
with caplog.at_level("WARNING", logger="backend.app.services.location_service"):
changed = await sync_locations_from_spoolman(db_session, FailingClient())
assert changed is False
assert any("location sync from Spoolman failed" in rec.message for rec in caplog.records)
@pytest.mark.asyncio
async def test_sync_locations_from_spoolman_handles_dict_payload(db_session: AsyncSession):
"""Newer Spoolman returns `list[dict]` from `/location`; the SpoolmanClient
normalises to `list[str]`, so sync_locations_from_spoolman should accept
both shapes via the client contract."""
class DictShapeClient:
async def get_distinct_locations(self):
# SpoolmanClient.get_distinct_locations is the one that normalises;
# at this layer the contract is `list[str]`. Simulate post-normalisation.
return ["Cabinet 3", "Cabinet 3"] # dedup tested elsewhere — sanity here
changed = await sync_locations_from_spoolman(db_session, DictShapeClient())
assert changed is True
await db_session.commit()
cabinet = await get_location_by_name(db_session, "Cabinet 3")
assert cabinet is not None