| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926 |
- """API coverage for the supplier master list and spool assignments (#2988).
- The master list lives under /api/v1/inventory/suppliers (Locations pattern),
- gated by the plain inventory permissions. Assignments exist for both
- inventories: `spool_suppliers` for built-in spools and the
- `spoolman_spool_suppliers` twin keyed by the remote spool id.
- """
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.api.routes.inventory import DUPLICATE_SUPPLIER_NAME
- from backend.app.models.spool import Spool
- from backend.app.models.spool_usage_history import SpoolUsageHistory
- from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Supplier
- SAMPLE_SPOOLMAN_SPOOL = {
- "id": 7,
- "filament": {
- "id": 1,
- "name": "PETG CF",
- "material": "PETG",
- "weight": 1000,
- "color_hex": "000000",
- "vendor": {"id": 1, "name": "BrandX"},
- },
- "remaining_weight": 600.0,
- "used_weight": 400.0,
- "location": None,
- "comment": None,
- "first_used": None,
- "last_used": None,
- "registered": "2024-01-01T00:00:00+00:00",
- "archived": False,
- "price": None,
- "extra": {},
- }
- @pytest.fixture
- async def spool_factory(db_session: AsyncSession):
- async def _create(**kwargs):
- defaults = {
- "material": "PLA",
- "subtype": "Matte",
- "brand": "Bambu Lab",
- "color_name": "Charcoal",
- "rgba": "333333FF",
- "label_weight": 1000,
- "core_weight": 250,
- "weight_used": 0,
- "weight_used_baseline": 0,
- "weight_locked": False,
- }
- defaults.update(kwargs)
- spool = Spool(**defaults)
- db_session.add(spool)
- await db_session.commit()
- await db_session.refresh(spool)
- return spool
- return _create
- @pytest.fixture
- async def supplier_factory(db_session: AsyncSession):
- _counter = [0]
- async def _create(**kwargs):
- _counter[0] += 1
- defaults = {"name": f"Supplier {_counter[0]}"}
- defaults.update(kwargs)
- supplier = Supplier(**defaults)
- db_session.add(supplier)
- await db_session.commit()
- await db_session.refresh(supplier)
- return supplier
- return _create
- @pytest.fixture
- async def spoolman_settings(db_session: AsyncSession):
- from backend.app.models.settings import Settings
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
- await db_session.commit()
- @pytest.fixture
- def mock_spoolman_client():
- client = MagicMock()
- client.base_url = "http://localhost:7912"
- client.health_check = AsyncMock(return_value=True)
- client.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
- client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOLMAN_SPOOL])
- client.get_distinct_locations = AsyncMock(return_value=[])
- client.delete_spool = AsyncMock(return_value=None)
- with (
- patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=client),
- ),
- # The supplier delete reconciles twin rows against Spoolman before it
- # refuses (#2988); inventory.py resolves its own client.
- patch("backend.app.api.routes.inventory.get_spoolman_client", AsyncMock(return_value=client)),
- ):
- yield client
- @pytest.mark.unit
- def test_supplier_relationships_use_the_default_loader():
- """No relationship-level eager loader (#2988).
- ``Spool.supplier_links`` used to be ``lazy="selectin"``, which made every
- ``select(Spool)`` in the app — usage tracker, AMS sync, labels, backup —
- pay two extra round trips for assignments it never reads. The routes that
- embed them ask for ``selectinload()`` at the query site instead.
- """
- from sqlalchemy import inspect as sa_inspect
- assert sa_inspect(Spool).relationships["supplier_links"].lazy == "select"
- assert sa_inspect(SpoolSupplier).relationships["supplier"].lazy == "select"
- assert sa_inspect(SpoolmanSpoolSupplier).relationships["supplier"].lazy == "select"
- class TestSupplierCrud:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_create_and_list(self, async_client: AsyncClient):
- resp = await async_client.post(
- "/api/v1/inventory/suppliers",
- json={"name": "Filament24", "website": "https://filament24.example", "customer_number": "C-1042"},
- )
- assert resp.status_code == 201
- body = resp.json()
- assert body["name"] == "Filament24"
- assert body["spool_count"] == 0
- listing = await async_client.get("/api/v1/inventory/suppliers")
- assert listing.status_code == 200
- assert [s["name"] for s in listing.json()] == ["Filament24"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update(self, async_client: AsyncClient, supplier_factory):
- supplier = await supplier_factory(name="Old Name")
- resp = await async_client.patch(f"/api/v1/inventory/suppliers/{supplier.id}", json={"name": "New Name"})
- assert resp.status_code == 200
- assert resp.json()["name"] == "New Name"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_delete_unreferenced(self, async_client: AsyncClient, supplier_factory):
- supplier = await supplier_factory()
- resp = await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")
- assert resp.status_code == 200
- assert (await async_client.get("/api/v1/inventory/suppliers")).json() == []
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_delete_referenced_is_refused(
- self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
- ):
- supplier = await supplier_factory()
- spool = await spool_factory()
- db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id))
- await db_session.commit()
- resp = await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")
- assert resp.status_code == 409
- assert "cannot be deleted" in resp.json()["detail"]
- # The listing surfaces the usage count behind the refusal.
- listing = await async_client.get("/api/v1/inventory/suppliers")
- assert listing.json()[0]["spool_count"] == 1
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_delete_refused_for_spoolman_only_reference(
- self, async_client: AsyncClient, supplier_factory, db_session: AsyncSession
- ):
- """A supplier used only by Spoolman-mode assignments is still protected."""
- supplier = await supplier_factory()
- db_session.add(SpoolmanSpoolSupplier(spoolman_spool_id=7, supplier_id=supplier.id))
- await db_session.commit()
- resp = await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")
- assert resp.status_code == 409
- assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 1
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_reads_case_insensitively(self, async_client: AsyncClient, supplier_factory):
- """Ordered on the folded name, so "extrudr" sits between "Bambu Store"
- and "Zultrat" rather than after every capitalised name (#2988)."""
- for name in ["Zultrat", "extrudr", "Bambu Store"]:
- await supplier_factory(name=name)
- listing = (await async_client.get("/api/v1/inventory/suppliers")).json()
- assert [s["name"] for s in listing] == ["Bambu Store", "extrudr", "Zultrat"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_padded_name_at_the_cap_is_measured_after_trimming(
- self, async_client: AsyncClient, supplier_factory
- ):
- """The length limit applies to the stored name, not to surrounding
- whitespace that is stripped anyway (#2988)."""
- name = "S" * 200
- resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": f" {name} "})
- assert resp.status_code == 201
- assert resp.json()["name"] == name
- other = await supplier_factory(name="Other")
- resp = await async_client.patch(f"/api/v1/inventory/suppliers/{other.id}", json={"name": f" {'T' * 200} "})
- assert resp.status_code == 200
- assert resp.json()["name"] == "T" * 200
- # A name that really is too long is still refused.
- resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "U" * 201})
- assert resp.status_code == 422
- class TestSupplierNameUniqueness:
- """Supplier names are the feature's key (#2988): CSV import resolves
- against them and a rename re-points every assignment, so two rows with
- the same name silently send an import to the wrong supplier."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_duplicate_name_is_refused(self, async_client: AsyncClient):
- assert (await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr"})).status_code == 201
- resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr"})
- assert resp.status_code == 409
- assert resp.json()["detail"] == DUPLICATE_SUPPLIER_NAME
- assert len((await async_client.get("/api/v1/inventory/suppliers")).json()) == 1
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_duplicate_is_case_and_whitespace_insensitive(self, async_client: AsyncClient):
- """The CSV map is keyed on the trimmed lower-cased name, so a case
- variant would be just as ambiguous as an exact duplicate."""
- await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr"})
- resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": " eXtRuDr "})
- assert resp.status_code == 409
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_duplicate_is_refused_for_non_ascii_names(self, async_client: AsyncClient):
- """The fold has to be the Python one to be worth anything here.
- SQLite's lower() folds ASCII only, so a unique index on lower(name)
- saw these as two different names and let both in — while the import
- map, which folds in Python, collapsed them onto a single entry and
- resolved to whichever row it built last. That is exactly the silent
- wrong-supplier assignment the rule exists to prevent.
- """
- assert (await async_client.post("/api/v1/inventory/suppliers", json={"name": "Ökofilament"})).status_code == 201
- resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "ökofilament"})
- assert resp.status_code == 409
- assert resp.json()["detail"] == DUPLICATE_SUPPLIER_NAME
- assert len((await async_client.get("/api/v1/inventory/suppliers")).json()) == 1
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_rename_onto_an_existing_name_is_refused(self, async_client: AsyncClient, supplier_factory):
- a = await supplier_factory(name="Extrudr")
- b = await supplier_factory(name="Filament24")
- resp = await async_client.patch(f"/api/v1/inventory/suppliers/{b.id}", json={"name": "extrudr"})
- assert resp.status_code == 409
- # Renaming a supplier to the name it already has is not a conflict.
- assert (
- await async_client.patch(f"/api/v1/inventory/suppliers/{a.id}", json={"name": "Extrudr"})
- ).status_code == 200
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_name_is_trimmed_on_write(self, async_client: AsyncClient):
- resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": " Extrudr "})
- assert resp.status_code == 201
- assert resp.json()["name"] == "Extrudr"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_explicit_null_name_is_a_validation_error(self, async_client: AsyncClient, supplier_factory):
- """422, not the 500 a NOT NULL violation used to produce."""
- supplier = await supplier_factory()
- resp = await async_client.patch(f"/api/v1/inventory/suppliers/{supplier.id}", json={"name": None})
- assert resp.status_code == 422
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_csv_separator_in_name_is_refused(self, async_client: AsyncClient):
- """A ';' in the name would split into unknown names on CSV import and
- silently drop every assignment that used it."""
- resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr; GmbH"})
- assert resp.status_code == 422
- class TestSpoolSupplierAssignments:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_replace_all_and_embed_in_spool_response(
- self, async_client: AsyncClient, supplier_factory, spool_factory
- ):
- a = await supplier_factory(name="Supplier A")
- b = await supplier_factory(name="Supplier B")
- spool = await spool_factory()
- resp = await async_client.put(
- f"/api/v1/inventory/spools/{spool.id}/suppliers",
- json=[
- {
- "supplier_id": a.id,
- "supplier_article_number": "A-100",
- "quoted_price_per_kg": 19.99,
- "is_purchase_source": True,
- },
- {"supplier_id": b.id, "quoted_price_per_kg": 22.5},
- ],
- )
- assert resp.status_code == 200
- body = resp.json()
- assert {row["supplier_name"] for row in body} == {"Supplier A", "Supplier B"}
- assert [row["is_purchase_source"] for row in sorted(body, key=lambda r: r["supplier_id"])] == [True, False]
- # Embedded in the inventory listing.
- listing = await async_client.get("/api/v1/inventory/spools")
- spool_row = next(s for s in listing.json() if s["id"] == spool.id)
- assert {row["supplier_name"] for row in spool_row["suppliers"]} == {"Supplier A", "Supplier B"}
- # Replace-all: shrinking the list removes the other assignment.
- resp = await async_client.put(
- f"/api/v1/inventory/spools/{spool.id}/suppliers",
- json=[{"supplier_id": b.id, "is_purchase_source": True}],
- )
- assert resp.status_code == 200
- assert [row["supplier_name"] for row in resp.json()] == ["Supplier B"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_two_purchase_sources_are_refused(self, async_client: AsyncClient, supplier_factory, spool_factory):
- a = await supplier_factory()
- b = await supplier_factory()
- spool = await spool_factory()
- resp = await async_client.put(
- f"/api/v1/inventory/spools/{spool.id}/suppliers",
- json=[
- {"supplier_id": a.id, "is_purchase_source": True},
- {"supplier_id": b.id, "is_purchase_source": True},
- ],
- )
- assert resp.status_code == 400
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_unknown_supplier_is_refused(self, async_client: AsyncClient, spool_factory):
- spool = await spool_factory()
- resp = await async_client.put(
- f"/api/v1/inventory/spools/{spool.id}/suppliers",
- json=[{"supplier_id": 999999}],
- )
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_duplicate_supplier_is_refused(self, async_client: AsyncClient, supplier_factory, spool_factory):
- a = await supplier_factory()
- spool = await spool_factory()
- resp = await async_client.put(
- f"/api/v1/inventory/spools/{spool.id}/suppliers",
- json=[{"supplier_id": a.id}, {"supplier_id": a.id}],
- )
- assert resp.status_code == 400
- class TestSpoolmanSupplierAssignments:
- """Spoolman parity (#2988): same endpoints, same shape, twin table."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_replace_all_and_embed(
- self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client
- ):
- a = await supplier_factory(name="Supplier A")
- resp = await async_client.put(
- "/api/v1/spoolman/inventory/spools/7/suppliers",
- json=[
- {
- "supplier_id": a.id,
- "supplier_article_number": "A-100",
- "quoted_price_per_kg": 19.99,
- "is_purchase_source": True,
- }
- ],
- )
- assert resp.status_code == 200
- body = resp.json()
- assert body[0]["supplier_name"] == "Supplier A"
- assert body[0]["quoted_price_per_kg"] == 19.99
- # Same rows via GET, embedded in the single-spool and list responses.
- assert (await async_client.get("/api/v1/spoolman/inventory/spools/7/suppliers")).json() == body
- single = await async_client.get("/api/v1/spoolman/inventory/spools/7")
- assert [row["supplier_name"] for row in single.json()["suppliers"]] == ["Supplier A"]
- listing = await async_client.get("/api/v1/spoolman/inventory/spools")
- spool_row = next(s for s in listing.json() if s["id"] == 7)
- assert [row["supplier_name"] for row in spool_row["suppliers"]] == ["Supplier A"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_validation_matches_builtin_inventory(
- self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client
- ):
- a = await supplier_factory()
- b = await supplier_factory()
- resp = await async_client.put(
- "/api/v1/spoolman/inventory/spools/7/suppliers",
- json=[{"supplier_id": a.id, "is_purchase_source": True}, {"supplier_id": b.id, "is_purchase_source": True}],
- )
- assert resp.status_code == 400
- resp = await async_client.put(
- "/api/v1/spoolman/inventory/spools/7/suppliers",
- json=[{"supplier_id": 999999}],
- )
- assert resp.status_code == 404
- class TestSupplierInheritance:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_new_spool_of_same_product_inherits_sources(
- self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
- ):
- supplier = await supplier_factory(name="Supplier A")
- donor = await spool_factory()
- db_session.add(
- SpoolSupplier(
- spool_id=donor.id,
- supplier_id=supplier.id,
- supplier_article_number="A-100",
- quoted_price_per_kg=19.99,
- is_purchase_source=True,
- )
- )
- await db_session.commit()
- resp = await async_client.post(
- "/api/v1/inventory/spools",
- json={"material": "PLA", "subtype": "Matte", "brand": "Bambu Lab", "color_name": "Charcoal"},
- )
- assert resp.status_code == 200
- suppliers = resp.json()["suppliers"]
- assert [row["supplier_name"] for row in suppliers] == ["Supplier A"]
- assert suppliers[0]["supplier_article_number"] == "A-100"
- assert suppliers[0]["quoted_price_per_kg"] == 19.99
- # Where THIS spool was bought is unknown — never inherited.
- assert suppliers[0]["is_purchase_source"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_different_product_inherits_nothing(
- self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
- ):
- supplier = await supplier_factory()
- donor = await spool_factory()
- db_session.add(SpoolSupplier(spool_id=donor.id, supplier_id=supplier.id))
- await db_session.commit()
- resp = await async_client.post(
- "/api/v1/inventory/spools",
- json={"material": "PETG", "subtype": "Matte", "brand": "Bambu Lab", "color_name": "Charcoal"},
- )
- assert resp.status_code == 200
- assert resp.json()["suppliers"] == []
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_bulk_create_gives_every_copy_its_own_links(
- self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
- ):
- """One donor lookup for the batch, one set of rows per copy."""
- supplier = await supplier_factory(name="Supplier A")
- donor = await spool_factory()
- db_session.add(SpoolSupplier(spool_id=donor.id, supplier_id=supplier.id, supplier_article_number="A-100"))
- await db_session.commit()
- resp = await async_client.post(
- "/api/v1/inventory/spools/bulk",
- json={
- "spool": {"material": "PLA", "subtype": "Matte", "brand": "Bambu Lab", "color_name": "Charcoal"},
- "quantity": 3,
- },
- )
- assert resp.status_code == 200
- created = resp.json()
- assert len(created) == 3
- for row in created:
- assert [link["supplier_name"] for link in row["suppliers"]] == ["Supplier A"]
- assert row["suppliers"][0]["supplier_article_number"] == "A-100"
- assert row["suppliers"][0]["is_purchase_source"] is False
- # Own rows, not shared ones.
- assert len({row["suppliers"][0]["id"] for row in created}) == 3
- class TestSupplierLifecycle:
- """What happens to assignments when the spool they hang on goes away."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_deleting_a_spool_frees_its_supplier(
- self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
- ):
- supplier = await supplier_factory()
- spool = await spool_factory()
- db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id))
- await db_session.commit()
- assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
- assert (await async_client.delete(f"/api/v1/inventory/spools/{spool.id}")).status_code == 200
- # delete-orphan on Spool.supplier_links takes the assignment with it,
- # so the supplier stops being referenced and becomes deletable.
- assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 0
- assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_deleting_a_spoolman_spool_drops_the_twin_rows(
- self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
- ):
- """Spoolman owns the spool, Bambuddy owns the assignment, and nothing
- in the database can cascade it. A leaked row keeps the supplier's
- reference count non-zero, so the delete guard would answer 409 for a
- spool the user can no longer see (#2988)."""
- supplier = await supplier_factory()
- assert (
- await async_client.put(
- "/api/v1/spoolman/inventory/spools/7/suppliers",
- json=[{"supplier_id": supplier.id, "is_purchase_source": True}],
- )
- ).status_code == 200
- assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
- assert (await async_client.delete("/api/v1/spoolman/inventory/spools/7")).status_code == 200
- rows = await db_session.execute(select(SpoolmanSpoolSupplier))
- assert rows.scalars().all() == []
- assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 0
- assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_bulk_delete_drops_the_twin_rows_too(
- self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
- ):
- supplier = await supplier_factory()
- assert (
- await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
- ).status_code == 200
- # The row has to be proven present before the delete, or the empty
- # assertion below holds whether or not the purge did anything.
- assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
- resp = await async_client.post("/api/v1/spoolman/inventory/spools/bulk-delete", json={"ids": [7]})
- assert resp.status_code == 200
- assert resp.json()["deleted"] == 1
- rows = await db_session.execute(select(SpoolmanSpoolSupplier))
- assert rows.scalars().all() == []
- assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 0
- assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_delete_reconciles_a_spool_deleted_in_spoolman_itself(
- self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
- ):
- """Spoolman is a separate application with its own UI, and Bambuddy
- only hears about the deletes it performs itself. A spool removed over
- there leaves its assignment behind, and that phantom reference used to
- make the supplier permanently undeletable with nothing on any screen
- that could show or clear it (#2988)."""
- supplier = await supplier_factory()
- assert (
- await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
- ).status_code == 200
- assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
- # Spool 7 disappears from Spoolman without Bambuddy doing anything.
- mock_spoolman_client.get_all_spools = AsyncMock(return_value=[])
- assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
- rows = await db_session.execute(select(SpoolmanSpoolSupplier))
- assert rows.scalars().all() == []
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_reconcile_keeps_assignments_of_an_archived_spool(
- self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client
- ):
- """Archiving is a soft delete: the spool is still there and its
- assignment has to survive, so the reconcile asks for the archived ones
- too and the delete still answers 409."""
- supplier = await supplier_factory()
- assert (
- await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
- ).status_code == 200
- async def _all_spools(allow_archived: bool = False):
- return [dict(SAMPLE_SPOOLMAN_SPOOL, archived=True)] if allow_archived else []
- mock_spoolman_client.get_all_spools = AsyncMock(side_effect=_all_spools)
- assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_reconcile_keeps_the_rows_when_spoolman_is_unreachable(
- self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
- ):
- """A failed lookup is not evidence that the spool is gone."""
- supplier = await supplier_factory()
- assert (
- await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
- ).status_code == 200
- mock_spoolman_client.get_all_spools = AsyncMock(side_effect=RuntimeError("Cannot reach Spoolman"))
- assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
- rows = await db_session.execute(select(SpoolmanSpoolSupplier))
- assert len(rows.scalars().all()) == 1
- class TestSupplierStats:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_groups_by_purchase_source(
- self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
- ):
- a = await supplier_factory(name="Supplier A")
- b = await supplier_factory(name="Supplier B")
- bought_at_a = await spool_factory(label_weight=1000, weight_used=200)
- alt_only = await spool_factory(color_name="Red")
- db_session.add_all(
- [
- SpoolSupplier(spool_id=bought_at_a.id, supplier_id=a.id, is_purchase_source=True),
- # Alternative source only — must NOT count toward supplier B.
- SpoolSupplier(spool_id=alt_only.id, supplier_id=b.id, is_purchase_source=False),
- SpoolUsageHistory(
- spool_id=bought_at_a.id, weight_used=150, percent_used=15, status="completed", cost=3.0
- ),
- ]
- )
- await db_session.commit()
- resp = await async_client.get("/api/v1/inventory/stats/suppliers")
- assert resp.status_code == 200
- rows = resp.json()
- assert len(rows) == 1
- assert rows[0]["supplier_name"] == "Supplier A"
- assert rows[0]["spool_count"] == 1
- assert rows[0]["remaining_g"] == pytest.approx(800)
- assert rows[0]["consumed_g"] == pytest.approx(150)
- # Cost comes from the recorded usage history (spool.cost_per_kg based),
- # never from quoted_price_per_kg.
- assert rows[0]["cost"] == pytest.approx(3.0)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_usage_half_honours_the_date_window(
- self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
- ):
- """The dashboard timeframe scopes consumption and cost; stock is
- point-in-time and stays out of the window (#2988)."""
- from datetime import datetime, timedelta, timezone
- supplier = await supplier_factory(name="Supplier A")
- spool = await spool_factory(label_weight=1000, weight_used=300)
- now = datetime.now(timezone.utc)
- db_session.add_all(
- [
- SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id, is_purchase_source=True),
- SpoolUsageHistory(
- spool_id=spool.id,
- weight_used=100,
- percent_used=10,
- status="completed",
- cost=2.0,
- created_at=now - timedelta(days=90),
- ),
- SpoolUsageHistory(
- spool_id=spool.id,
- weight_used=200,
- percent_used=20,
- status="completed",
- cost=4.0,
- created_at=now - timedelta(days=2),
- ),
- ]
- )
- await db_session.commit()
- lifetime = (await async_client.get("/api/v1/inventory/stats/suppliers")).json()
- assert lifetime[0]["consumed_g"] == pytest.approx(300)
- assert lifetime[0]["cost"] == pytest.approx(6.0)
- date_from = (now - timedelta(days=30)).date().isoformat()
- windowed = (await async_client.get(f"/api/v1/inventory/stats/suppliers?date_from={date_from}")).json()
- assert windowed[0]["consumed_g"] == pytest.approx(200)
- assert windowed[0]["cost"] == pytest.approx(4.0)
- # Stock is not windowed.
- assert windowed[0]["spool_count"] == 1
- assert windowed[0]["remaining_g"] == pytest.approx(700)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_over_consumed_spool_counts_as_empty_not_negative(
- self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
- ):
- """Each spool is clamped before the sum, as the inventory page shows it:
- a full 1000 g spool next to one over-consumed by 200 g is 1000 g in
- stock, not 800 g (#2988)."""
- supplier = await supplier_factory(name="Supplier A")
- full = await spool_factory(label_weight=1000, weight_used=0)
- over = await spool_factory(label_weight=1000, weight_used=1200, color_name="Red")
- db_session.add_all(
- [
- SpoolSupplier(spool_id=full.id, supplier_id=supplier.id, is_purchase_source=True),
- SpoolSupplier(spool_id=over.id, supplier_id=supplier.id, is_purchase_source=True),
- ]
- )
- await db_session.commit()
- rows = (await async_client.get("/api/v1/inventory/stats/suppliers")).json()
- assert rows[0]["spool_count"] == 2
- assert rows[0]["remaining_g"] == pytest.approx(1000)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_equal_consumption_falls_back_to_the_name(
- self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
- ):
- """A narrow date range leaves most rows at 0 g; they read by name
- instead of in whatever order the GROUP BY produced (#2988)."""
- names = ["zultrat", "Extrudr", "Bambu Store"]
- for name in names:
- supplier = await supplier_factory(name=name)
- spool = await spool_factory(color_name=name)
- db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id, is_purchase_source=True))
- heavy = await supplier_factory(name="Yet Another")
- heavy_spool = await spool_factory(color_name="Heavy")
- db_session.add_all(
- [
- SpoolSupplier(spool_id=heavy_spool.id, supplier_id=heavy.id, is_purchase_source=True),
- SpoolUsageHistory(spool_id=heavy_spool.id, weight_used=50, percent_used=5, status="completed"),
- ]
- )
- await db_session.commit()
- rows = (await async_client.get("/api/v1/inventory/stats/suppliers")).json()
- assert [r["supplier_name"] for r in rows] == ["Yet Another", "Bambu Store", "Extrudr", "zultrat"]
- class TestSupplierCsv:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_export_carries_both_columns(
- self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
- ):
- a = await supplier_factory(name="Supplier A")
- b = await supplier_factory(name="Supplier B")
- spool = await spool_factory()
- db_session.add_all(
- [
- SpoolSupplier(spool_id=spool.id, supplier_id=a.id, is_purchase_source=True),
- SpoolSupplier(spool_id=spool.id, supplier_id=b.id),
- ]
- )
- await db_session.commit()
- export = await async_client.get("/api/v1/inventory/spools/export")
- assert export.status_code == 200
- header, row = export.text.splitlines()[:2]
- columns = header.split(",")
- assert "suppliers" in columns
- assert "purchase_supplier" in columns
- assert "Supplier A; Supplier B" in row
- assert row.split(",")[columns.index("purchase_supplier")].strip('"') == "Supplier A"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_import_matches_by_name_and_creates_nothing(
- self, async_client: AsyncClient, supplier_factory, db_session: AsyncSession
- ):
- await supplier_factory(name="Supplier A")
- await supplier_factory(name="Supplier B")
- csv_text = (
- "material,brand,color_name,suppliers,purchase_supplier\n"
- 'PLA,Bambu Lab,Charcoal," supplier a ; Supplier B",Supplier B\n'
- "PETG,Bambu Lab,Red,Unknown Corp,\n"
- )
- # Dry run: the unknown name is a warning, not a row error.
- preview = await async_client.post(
- "/api/v1/inventory/spools/import?dry_run=true",
- files={"file": ("spools.csv", csv_text.encode(), "text/csv")},
- )
- assert preview.status_code == 200
- body = preview.json()
- assert body["valid_count"] == 2
- assert body["error_count"] == 0
- assert any("Unknown Corp" in w for w in body["warnings"])
- # Real import: both rows land; assignments match by name, trimmed and
- # case-insensitive; the unknown name is dropped and NOT created.
- result = await async_client.post(
- "/api/v1/inventory/spools/import",
- files={"file": ("spools.csv", csv_text.encode(), "text/csv")},
- )
- assert result.status_code == 200
- assert result.json()["created"] == 2
- listing = await async_client.get("/api/v1/inventory/spools")
- by_color = {s["color_name"]: s for s in listing.json()}
- charcoal = by_color["Charcoal"]["suppliers"]
- assert {row["supplier_name"] for row in charcoal} == {"Supplier A", "Supplier B"}
- assert [row["supplier_name"] for row in charcoal if row["is_purchase_source"]] == ["Supplier B"]
- assert by_color["Red"]["suppliers"] == []
- suppliers = (await async_client.get("/api/v1/inventory/suppliers")).json()
- assert {s["name"] for s in suppliers} == {"Supplier A", "Supplier B"}
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_round_trip_preserves_assignments(
- self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
- ):
- a = await supplier_factory(name="Supplier A")
- spool = await spool_factory()
- db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=a.id, is_purchase_source=True))
- await db_session.commit()
- export = await async_client.get("/api/v1/inventory/spools/export")
- result = await async_client.post(
- "/api/v1/inventory/spools/import",
- files={"file": ("spools.csv", export.content, "text/csv")},
- )
- assert result.status_code == 200
- assert result.json()["created"] == 1
- listing = (await async_client.get("/api/v1/inventory/spools")).json()
- assert len(listing) == 2
- for row in listing:
- assert [link["supplier_name"] for link in row["suppliers"]] == ["Supplier A"]
- assert row["suppliers"][0]["is_purchase_source"] is True
- class TestFromSlotInheritance:
- """The RFID "+ Add to inventory" path (#2988).
- POST /spools/from-slot builds the spool through create_spool_from_tray,
- which pre-initialises spool.supplier_links to []. The inheritance rows are
- added afterwards, so the closing query has to repopulate the collection —
- otherwise the identity-mapped instance answers with the stale empty list
- and the caller sees no suppliers until the next fetch.
- """
- @staticmethod
- def _status_for_tray(ams_id: int, tray_id: int, tray: dict):
- status = MagicMock()
- status.raw_data = {"ams": {"ams": [{"id": ams_id, "tray": [{"id": tray_id, **tray}]}]}}
- return status
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_from_slot_response_carries_inherited_suppliers(
- self,
- async_client: AsyncClient,
- printer_factory,
- supplier_factory,
- spool_factory,
- db_session: AsyncSession,
- ):
- printer = await printer_factory(name="X1C-supplier-inherit")
- supplier = await supplier_factory(name="Supplier A")
- donor = await spool_factory(material="PLA", subtype=None, brand="Bambu Lab", color_name="Clear")
- db_session.add(SpoolSupplier(spool_id=donor.id, supplier_id=supplier.id, supplier_article_number="A-100"))
- await db_session.commit()
- # alpha=00 → create_spool_from_tray names the colour "Clear", matching
- # the donor product without needing a colour-catalogue row.
- tray = {
- "tray_type": "PLA",
- "tray_color": "11223300",
- "tag_uid": "1122334455667788",
- "tray_uuid": "0123456789ABCDEF0123456789ABCDEF",
- }
- with patch(
- "backend.app.services.printer_manager.printer_manager.get_status",
- return_value=self._status_for_tray(0, 1, tray),
- ):
- resp = await async_client.post(
- "/api/v1/inventory/spools/from-slot",
- json={"printer_id": printer.id, "ams_id": 0, "tray_id": 1},
- )
- assert resp.status_code == 200, resp.text
- body = resp.json()
- assert [row["supplier_name"] for row in body["suppliers"]] == ["Supplier A"]
- assert body["suppliers"][0]["supplier_article_number"] == "A-100"
- assert body["suppliers"][0]["is_purchase_source"] is False
- # The row was always written — the defect was the response reading a
- # stale collection off the identity-mapped instance.
- rows = await db_session.execute(select(SpoolSupplier).where(SpoolSupplier.spool_id == body["id"]))
- assert [row.supplier_id for row in rows.scalars().all()] == [supplier.id]
|