| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826 |
- """Integration tests for SpoolBuddy API endpoints."""
- from datetime import datetime, timedelta, timezone
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from httpx import AsyncClient
- from sqlalchemy.ext.asyncio import AsyncSession
- import backend.app.services.spoolbuddy_ssh # noqa: F401 — ensures patch() can resolve the dotted path
- from backend.app.api.routes import spoolbuddy as spoolbuddy_routes
- from backend.app.models.spool import Spool
- from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
- from backend.app.services.spoolman import SpoolmanNotFoundError, SpoolmanUnavailableError
- API = "/api/v1/spoolbuddy"
- @pytest.fixture
- def device_factory(db_session: AsyncSession):
- """Factory to create SpoolBuddyDevice records."""
- _counter = [0]
- async def _create(**kwargs):
- _counter[0] += 1
- n = _counter[0]
- defaults = {
- "device_id": f"sb-{n:04d}",
- "hostname": f"spoolbuddy-{n}",
- "ip_address": f"10.0.0.{n}",
- "firmware_version": "1.0.0",
- "has_nfc": True,
- "has_scale": True,
- "tare_offset": 0,
- "calibration_factor": 1.0,
- "last_seen": datetime.now(timezone.utc),
- }
- defaults.update(kwargs)
- device = SpoolBuddyDevice(**defaults)
- db_session.add(device)
- await db_session.commit()
- await db_session.refresh(device)
- return device
- return _create
- @pytest.fixture
- def spool_factory(db_session: AsyncSession):
- """Factory to create Spool records."""
- _counter = [0]
- async def _create(**kwargs):
- _counter[0] += 1
- defaults = {
- "material": "PLA",
- "subtype": "Basic",
- "brand": "Polymaker",
- "color_name": "Red",
- "rgba": "FF0000FF",
- "label_weight": 1000,
- "core_weight": 250,
- "weight_used": 0,
- }
- defaults.update(kwargs)
- spool = Spool(**defaults)
- db_session.add(spool)
- await db_session.commit()
- await db_session.refresh(spool)
- return spool
- return _create
- # ============================================================================
- # Device endpoints
- # ============================================================================
- class TestDeviceEndpoints:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_register_new_device(self, async_client: AsyncClient):
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/devices/register",
- json={
- "device_id": "sb-new",
- "hostname": "spoolbuddy-new",
- "ip_address": "10.0.0.99",
- "firmware_version": "1.2.0",
- },
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["device_id"] == "sb-new"
- assert data["hostname"] == "spoolbuddy-new"
- assert data["online"] is True
- mock_ws.broadcast.assert_called_once()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_online"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_re_register_existing_device(self, async_client: AsyncClient, device_factory):
- device = await device_factory(
- device_id="sb-exist",
- tare_offset=12345,
- calibration_factor=0.0042,
- )
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/devices/register",
- json={
- "device_id": "sb-exist",
- "hostname": "updated-host",
- "ip_address": "10.0.0.200",
- "firmware_version": "2.0.0",
- },
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["id"] == device.id
- assert data["hostname"] == "updated-host"
- assert data["ip_address"] == "10.0.0.200"
- assert data["firmware_version"] == "2.0.0"
- # Calibration preserved on re-register
- assert data["tare_offset"] == 12345
- assert data["calibration_factor"] == pytest.approx(0.0042)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_devices_empty(self, async_client: AsyncClient):
- resp = await async_client.get(f"{API}/devices")
- assert resp.status_code == 200
- assert resp.json() == []
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_devices(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-a", hostname="alpha")
- await device_factory(device_id="sb-b", hostname="beta")
- resp = await async_client.get(f"{API}/devices")
- assert resp.status_code == 200
- devices = resp.json()
- assert len(devices) == 2
- hostnames = {d["hostname"] for d in devices}
- assert hostnames == {"alpha", "beta"}
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_unregister_device(self, async_client: AsyncClient, device_factory, db_session):
- await device_factory(device_id="sb-keep", hostname="keep")
- await device_factory(device_id="sb-drop", hostname="drop")
- spoolbuddy_routes._spoolbuddy_online_last_broadcast["sb-drop"] = 123.0
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.delete(f"{API}/devices/sb-drop")
- assert resp.status_code == 200
- assert resp.json() == {"status": "deleted", "device_id": "sb-drop"}
- assert "sb-drop" not in spoolbuddy_routes._spoolbuddy_online_last_broadcast
- mock_ws.broadcast.assert_called_once()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_unregistered"
- assert msg["device_id"] == "sb-drop"
- # Other device still present
- resp = await async_client.get(f"{API}/devices")
- remaining = {d["device_id"] for d in resp.json()}
- assert remaining == {"sb-keep"}
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_unregister_device_not_found(self, async_client: AsyncClient):
- resp = await async_client.delete(f"{API}/devices/sb-ghost")
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):
- device = await device_factory(device_id="sb-hb")
- spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/devices/sb-hb/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 600},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["tare_offset"] == device.tare_offset
- assert data["calibration_factor"] == pytest.approx(device.calibration_factor)
- mock_ws.broadcast.assert_called_once()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_online"
- assert msg["device_id"] == "sb-hb"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_heartbeat_returns_ssh_public_key(self, async_client: AsyncClient, device_factory):
- """Heartbeat response carries the current SSH public key so the daemon
- can re-deploy it whenever Bambuddy's keypair rotates without waiting
- for a service restart."""
- await device_factory(device_id="sb-ssh-hb")
- fake_key = "ssh-ed25519 AAAATESTKEY bambuddy-spoolbuddy"
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.services.spoolbuddy_ssh.get_public_key",
- AsyncMock(return_value=fake_key),
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/devices/sb-ssh-hb/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
- )
- assert resp.status_code == 200
- assert resp.json()["ssh_public_key"] == fake_key
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_heartbeat_ssh_key_failure_does_not_break_heartbeat(self, async_client: AsyncClient, device_factory):
- """If the backend can't read its own SSH key, the heartbeat must still
- succeed — telemetry/commands are far more critical than key sync."""
- await device_factory(device_id="sb-ssh-fail")
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.services.spoolbuddy_ssh.get_public_key",
- AsyncMock(side_effect=OSError("disk full")),
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/devices/sb-ssh-fail/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
- )
- assert resp.status_code == 200
- assert resp.json()["ssh_public_key"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_heartbeat_returns_pending_command(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-cmd", pending_command="tare")
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/devices/sb-cmd/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
- )
- assert resp.status_code == 200
- assert resp.json()["pending_command"] == "tare"
- # Second heartbeat should have no pending command (cleared)
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp2 = await async_client.post(
- f"{API}/devices/sb-cmd/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
- )
- assert resp2.json()["pending_command"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_heartbeat_unknown_device_404(self, async_client: AsyncClient):
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/devices/nonexistent/heartbeat",
- json={"nfc_ok": False, "scale_ok": False, "uptime_s": 0},
- )
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
- # Create device with last_seen far in the past (offline)
- spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
- await device_factory(
- device_id="sb-offline",
- last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
- )
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/devices/sb-offline/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
- )
- assert resp.status_code == 200
- # Should broadcast online since device was offline
- mock_ws.broadcast.assert_called_once()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_online"
- assert msg["device_id"] == "sb-offline"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_heartbeat_broadcasts_online_when_already_online(self, async_client: AsyncClient, device_factory):
- spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
- await device_factory(
- device_id="sb-already-online",
- last_seen=datetime.now(timezone.utc),
- )
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/devices/sb-already-online/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 42},
- )
- assert resp.status_code == 200
- mock_ws.broadcast.assert_called_once()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_online"
- assert msg["device_id"] == "sb-already-online"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_heartbeat_online_broadcast_is_throttled(self, async_client: AsyncClient, device_factory):
- spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
- await device_factory(
- device_id="sb-throttle",
- last_seen=datetime.now(timezone.utc),
- )
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp1 = await async_client.post(
- f"{API}/devices/sb-throttle/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
- )
- resp2 = await async_client.post(
- f"{API}/devices/sb-throttle/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 11},
- )
- assert resp1.status_code == 200
- assert resp2.status_code == 200
- mock_ws.broadcast.assert_called_once()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_online"
- assert msg["device_id"] == "sb-throttle"
- # ============================================================================
- # NFC endpoints
- # ============================================================================
- class TestNfcEndpoints:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_tag_scanned_matched(self, async_client: AsyncClient, spool_factory):
- spool = await spool_factory(tag_uid="AABB1122", material="PLA")
- mock_spool = MagicMock()
- mock_spool.id = spool.id
- mock_spool.material = spool.material
- mock_spool.subtype = spool.subtype
- mock_spool.color_name = spool.color_name
- mock_spool.rgba = spool.rgba
- mock_spool.brand = spool.brand
- mock_spool.label_weight = spool.label_weight
- mock_spool.core_weight = spool.core_weight
- mock_spool.weight_used = spool.weight_used
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
- ):
- mock_ws.broadcast = AsyncMock()
- mock_lookup.return_value = mock_spool
- resp = await async_client.post(
- f"{API}/nfc/tag-scanned",
- json={"device_id": "sb-1", "tag_uid": "AABB1122"},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["matched"] is True
- assert data["spool_id"] == spool.id
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_tag_matched"
- assert msg["spool"]["id"] == spool.id
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_tag_scanned_unmatched(self, async_client: AsyncClient):
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
- ):
- mock_ws.broadcast = AsyncMock()
- mock_lookup.return_value = None
- resp = await async_client.post(
- f"{API}/nfc/tag-scanned",
- json={"device_id": "sb-1", "tag_uid": "DEADBEEF"},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["matched"] is False
- assert data["spool_id"] is None
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_unknown_tag"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_tag_scanned_spoolman_mode_skips_local_lookup(self, async_client: AsyncClient, db_session):
- """When spoolman_enabled=true, /nfc/tag-scanned must use Spoolman
- exclusively — local DB lookup must not be consulted at all. The
- previous always-local-first behaviour caused stale local rows to
- win over the authoritative Spoolman data (#1228 follow-up).
- """
- 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://127.0.0.1:7912"))
- await db_session.commit()
- # Mock Spoolman match and verify get_spool_by_tag (the local-DB lookup)
- # is never called in Spoolman-enabled mode.
- sm_match = {
- "id": 7,
- "filament": {
- "material": "PLA",
- "name": "PLA Basic Red",
- "color_hex": "FF0000",
- "weight": 1000.0,
- "vendor": {"name": "Bambu Lab"},
- },
- "extra": {"tag": '"AABB1122"'},
- "used_weight": 0.0,
- }
- mock_client = MagicMock()
- mock_client.get_spools = AsyncMock(return_value=[sm_match])
- mock_client.find_spool_by_tag = AsyncMock(return_value=sm_match)
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
- new_callable=AsyncMock,
- ) as mock_get_client,
- patch(
- "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
- new_callable=AsyncMock,
- ) as mock_local_lookup,
- ):
- mock_ws.broadcast = AsyncMock()
- mock_get_client.return_value = mock_client
- # Sentinel so a misrouted call would surface as a wrong spool_id.
- mock_local_lookup.return_value = MagicMock(id=999)
- resp = await async_client.post(
- f"{API}/nfc/tag-scanned",
- json={"device_id": "sb-1", "tag_uid": "AABB1122"},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["matched"] is True
- # Spoolman result, not local DB sentinel — proves the local lookup was skipped.
- assert data["spool_id"] == 7
- mock_local_lookup.assert_not_called()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_write_result_clears_duplicate_tag_binding(
- self, async_client: AsyncClient, db_session, device_factory
- ):
- """Writing a tag for spool B must clear the same tag binding from any
- other spool that currently has it. Without this guard, find_spool_by_tag
- returns whichever spool comes first in the cached list (typically the
- older one), so the dashboard shows the wrong spool when the tag is
- scanned.
- """
- import json as _json
- from backend.app.models.settings import Settings
- from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- db_session.add(Settings(key="spoolman_url", value="http://127.0.0.1:7912"))
- await device_factory(
- device_id="sb-write",
- pending_command="write_tag",
- pending_write_payload=_json.dumps({"spool_id": 22, "ndef_data_hex": "DEAD", "data_origin": "spoolman"}),
- )
- await db_session.commit()
- # Spool A (id=11) currently holds the tag we're about to bind to spool B (id=22).
- spool_a_with_tag = {
- "id": 11,
- "filament": {"material": "PLA", "name": "PLA Old", "color_hex": "AAAAAA", "weight": 1000.0},
- "extra": {"tag": '"DEADBEEF"'},
- }
- mock_client = MagicMock()
- mock_client.get_spools = AsyncMock(return_value=[spool_a_with_tag])
- mock_client.find_spool_by_tag = AsyncMock(return_value=spool_a_with_tag)
- mock_client.merge_spool_extra = AsyncMock(return_value={})
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
- new_callable=AsyncMock,
- ) as mock_get_client,
- ):
- mock_ws.broadcast = AsyncMock()
- mock_get_client.return_value = mock_client
- resp = await async_client.post(
- f"{API}/nfc/write-result",
- json={
- "device_id": "sb-write",
- "spool_id": 22,
- "tag_uid": "DEADBEEF",
- "success": True,
- },
- )
- assert resp.status_code == 200
- # merge_spool_extra was called twice:
- # 1. clear tag from spool A (id=11) — set tag to ""
- # 2. set tag on spool B (id=22) — set tag to "DEADBEEF" (JSON-encoded)
- assert mock_client.merge_spool_extra.await_count == 2
- clear_call, bind_call = mock_client.merge_spool_extra.await_args_list
- assert clear_call.args[0] == 11
- assert clear_call.args[1] == {"tag": ""}
- assert bind_call.args[0] == 22
- assert bind_call.args[1] == {"tag": '"DEADBEEF"'}
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_tag_scanned_other_tag_matches_ams_spool_by_tray_uuid(self, async_client: AsyncClient, spool_factory):
- """A Bambu spool has two tags with different UIDs but one block-9 tray UUID.
- The AMS created the spool from one tag; scanning the other tag on
- SpoolBuddy must find that spool through the tray UUID, not offer a
- duplicate (#984).
- """
- spool = await spool_factory(tag_uid="1E783DA000000100", tray_uuid="5E5498918CBF4B94A25EF669C24DECC3")
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/tag-scanned",
- json={"device_id": "sb-1", "tag_uid": "8E3A00A2", "tray_uuid": "5E5498918CBF4B94A25EF669C24DECC3"},
- )
- assert resp.status_code == 200
- assert resp.json()["spool_id"] == spool.id
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_tag_matched"
- assert msg["tray_uuid"] == "5E5498918CBF4B94A25EF669C24DECC3"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_tag_scanned_unknown_tag_carries_tray_uuid(self, async_client: AsyncClient):
- """The kiosk saves the tray UUID from the unknown-tag event on quick-add and link."""
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/tag-scanned",
- json={"device_id": "sb-1", "tag_uid": "8E3A00A2", "tray_uuid": "9e0b0717bee94d7887eb1d8dfd1a14f3"},
- )
- assert resp.json()["matched"] is False
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_unknown_tag"
- assert msg["tray_uuid"] == "9E0B0717BEE94D7887EB1D8DFD1A14F3"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_tag_scanned_drops_filament_type_sent_as_tray_uuid(self, async_client: AsyncClient, spool_factory):
- """Daemons before #984 sent blocks 4-5 -- the filament type -- as tray_uuid.
- That value is the same for every spool of one type, so it must neither
- match a spool nor reach the kiosk, where quick-add would save it.
- """
- pla_matte = "504C41204D6174746500000000000000" # "PLA Matte"
- await spool_factory(tag_uid="11111111", tray_uuid=pla_matte)
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/tag-scanned",
- json={"device_id": "sb-1", "tag_uid": "22222222", "tray_uuid": pla_matte},
- )
- assert resp.json()["matched"] is False
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_unknown_tag"
- assert msg["tray_uuid"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_tag_scanned_drops_invalid_tray_uuid_but_matches_tag_uid(
- self, async_client: AsyncClient, spool_factory
- ):
- """An old daemon still matches by tag_uid, as before the fix."""
- spool = await spool_factory(tag_uid="22222222")
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/tag-scanned",
- json={"device_id": "sb-1", "tag_uid": "22222222", "tray_uuid": "504C41204D6174746500000000000000"},
- )
- assert resp.json()["spool_id"] == spool.id
- assert mock_ws.broadcast.call_args[0][0]["tray_uuid"] is None
- # Spools added on the kiosk before #984 carry only one tag's UID. A scan that
- # matches one by that exact UID saves the tray UUID read from the same tag.
- TRAY = "9E0B0717BEE94D7887EB1D8DFD1A14F3"
- async def _scan(self, async_client: AsyncClient, tag_uid: str, tray_uuid: str | None):
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/tag-scanned",
- json={"device_id": "sb-1", "tag_uid": tag_uid, "tray_uuid": tray_uuid},
- )
- assert resp.status_code == 200
- return resp.json()
- async def _tray_uuid_of(self, db_session: AsyncSession, spool_id: int) -> str | None:
- from sqlalchemy import select
- db_session.expire_all()
- return (await db_session.execute(select(Spool.tray_uuid).where(Spool.id == spool_id))).scalar_one()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_exact_tag_match_saves_tray_uuid(self, async_client: AsyncClient, spool_factory, db_session):
- spool_id = (await spool_factory(tag_uid="AABB1122")).id
- data = await self._scan(async_client, "AABB1122", self.TRAY)
- assert data["spool_id"] == spool_id
- assert await self._tray_uuid_of(db_session, spool_id) == self.TRAY
- # The spool's other tag now finds it.
- assert (await self._scan(async_client, "8E3A00A2", self.TRAY))["spool_id"] == spool_id
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_fuzzy_tag_match_saves_nothing(self, async_client: AsyncClient, spool_factory, db_session):
- """A first-byte-variance match may be another spool's tag."""
- spool = await spool_factory(tag_uid="BABB1122")
- data = await self._scan(async_client, "AABB1122", self.TRAY)
- assert data["spool_id"] == spool.id
- assert await self._tray_uuid_of(db_session, spool.id) is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_tray_uuid_held_by_another_spool_is_not_copied(
- self, async_client: AsyncClient, spool_factory, db_session
- ):
- from datetime import datetime, timezone
- await spool_factory(tag_uid="11111111", tray_uuid=self.TRAY, archived_at=datetime.now(timezone.utc))
- spool = await spool_factory(tag_uid="AABB1122")
- await self._scan(async_client, "AABB1122", self.TRAY)
- assert await self._tray_uuid_of(db_session, spool.id) is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_existing_tray_uuid_is_kept(self, async_client: AsyncClient, spool_factory, db_session):
- other = "5E5498918CBF4B94A25EF669C24DECC3"
- spool = await spool_factory(tag_uid="AABB1122", tray_uuid=other)
- await self._scan(async_client, "AABB1122", self.TRAY)
- assert await self._tray_uuid_of(db_session, spool.id) == other
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spoolman_exact_tag_match_stores_tray_uuid_as_tag(self, async_client: AsyncClient):
- sm_spool = {
- "id": 7,
- "filament": {"material": "PLA", "name": "PLA Matte", "color_hex": "008080", "weight": 1000.0},
- "extra": {"tag": '"AABB1122"'},
- "used_weight": 0.0,
- }
- mock_client = MagicMock()
- mock_client.get_spools = AsyncMock(return_value=[sm_spool])
- mock_client.find_spool_by_tag = AsyncMock(
- side_effect=lambda tag, cached_spools=None: sm_spool if tag == "AABB1122" else None
- )
- mock_client.merge_spool_extra = AsyncMock(return_value={})
- with patch(
- "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
- new_callable=AsyncMock,
- return_value=mock_client,
- ):
- data = await self._scan(async_client, "AABB1122", self.TRAY)
- assert data["spool_id"] == 7
- mock_client.merge_spool_extra.assert_awaited_once_with(7, {"tag": f'"{self.TRAY}"'})
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spoolman_match_by_tray_uuid_writes_nothing(self, async_client: AsyncClient):
- sm_spool = {
- "id": 7,
- "filament": {"material": "PLA", "name": "PLA Matte", "color_hex": "008080", "weight": 1000.0},
- "extra": {"tag": f'"{self.TRAY}"'},
- "used_weight": 0.0,
- }
- mock_client = MagicMock()
- mock_client.get_spools = AsyncMock(return_value=[sm_spool])
- mock_client.find_spool_by_tag = AsyncMock(return_value=sm_spool)
- mock_client.merge_spool_extra = AsyncMock(return_value={})
- with patch(
- "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
- new_callable=AsyncMock,
- return_value=mock_client,
- ):
- data = await self._scan(async_client, "AABB1122", self.TRAY)
- assert data["spool_id"] == 7
- mock_client.merge_spool_extra.assert_not_awaited()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spoolman_write_failure_still_reports_the_match(self, async_client: AsyncClient):
- sm_spool = {
- "id": 7,
- "filament": {"material": "PLA", "name": "PLA Matte", "color_hex": "008080", "weight": 1000.0},
- "extra": {"tag": '"AABB1122"'},
- "used_weight": 0.0,
- }
- mock_client = MagicMock()
- mock_client.get_spools = AsyncMock(return_value=[sm_spool])
- mock_client.find_spool_by_tag = AsyncMock(
- side_effect=lambda tag, cached_spools=None: sm_spool if tag == "AABB1122" else None
- )
- mock_client.merge_spool_extra = AsyncMock(side_effect=SpoolmanUnavailableError("down"))
- with patch(
- "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
- new_callable=AsyncMock,
- return_value=mock_client,
- ):
- data = await self._scan(async_client, "AABB1122", self.TRAY)
- assert data["matched"] is True
- assert data["spool_id"] == 7
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_tag_removed(self, async_client: AsyncClient):
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/tag-removed",
- json={"device_id": "sb-1", "tag_uid": "AABB1122"},
- )
- assert resp.status_code == 200
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_tag_removed"
- assert msg["device_id"] == "sb-1"
- assert msg["tag_uid"] == "AABB1122"
- # ============================================================================
- # NFC write-tag endpoints
- # ============================================================================
- class TestWriteTagEndpoints:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_write_tag_queues_command(self, async_client: AsyncClient, device_factory, spool_factory):
- device = await device_factory(device_id="sb-wt")
- spool = await spool_factory(material="PLA", brand="Polymaker", color_name="Red", rgba="FF0000FF")
- resp = await async_client.post(
- f"{API}/nfc/write-tag",
- json={"device_id": device.device_id, "spool_id": spool.id},
- )
- assert resp.status_code == 200
- assert resp.json()["status"] == "queued"
- # Verify heartbeat returns write_tag command with payload
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- hb = await async_client.post(
- f"{API}/devices/{device.device_id}/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
- )
- hb_data = hb.json()
- assert hb_data["pending_command"] == "write_tag"
- assert hb_data["pending_write_payload"] is not None
- assert hb_data["pending_write_payload"]["spool_id"] == spool.id
- assert "ndef_data_hex" in hb_data["pending_write_payload"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_write_tag_heartbeat_not_cleared(self, async_client: AsyncClient, device_factory, spool_factory):
- """write_tag command persists across heartbeats until write-result clears it."""
- device = await device_factory(device_id="sb-wt-persist")
- spool = await spool_factory(material="PETG")
- await async_client.post(
- f"{API}/nfc/write-tag",
- json={"device_id": device.device_id, "spool_id": spool.id},
- )
- # First heartbeat — command present
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- hb1 = await async_client.post(
- f"{API}/devices/{device.device_id}/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
- )
- assert hb1.json()["pending_command"] == "write_tag"
- # Second heartbeat — should still be present (not cleared like tare)
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- hb2 = await async_client.post(
- f"{API}/devices/{device.device_id}/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
- )
- assert hb2.json()["pending_command"] == "write_tag"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_write_tag_missing_spool_404(self, async_client: AsyncClient, device_factory):
- device = await device_factory(device_id="sb-wt-nospool")
- resp = await async_client.post(
- f"{API}/nfc/write-tag",
- json={"device_id": device.device_id, "spool_id": 99999},
- )
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_write_tag_missing_device_404(self, async_client: AsyncClient, spool_factory):
- spool = await spool_factory()
- resp = await async_client.post(
- f"{API}/nfc/write-tag",
- json={"device_id": "nonexistent", "spool_id": spool.id},
- )
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_write_result_success_links_tag(self, async_client: AsyncClient, device_factory, spool_factory):
- device = await device_factory(device_id="sb-wr", pending_command="write_tag")
- spool = await spool_factory(material="PLA", tag_uid=None)
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/write-result",
- json={
- "device_id": device.device_id,
- "spool_id": spool.id,
- "tag_uid": "04AABB11223344",
- "success": True,
- },
- )
- assert resp.status_code == 200
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_tag_written"
- assert msg["spool_id"] == spool.id
- assert msg["tag_uid"] == "04AABB11223344"
- # Verify spool got tag linked
- spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
- spool_data = spool_resp.json()
- assert spool_data["tag_uid"] == "04AABB11223344"
- assert spool_data["tag_type"] == "ntag"
- assert spool_data["data_origin"] == "opentag3d"
- assert spool_data["encode_time"] is not None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_write_result_failure_broadcasts_error(
- self, async_client: AsyncClient, device_factory, spool_factory
- ):
- device = await device_factory(device_id="sb-wr-fail", pending_command="write_tag")
- spool = await spool_factory(material="PLA", tag_uid=None)
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/write-result",
- json={
- "device_id": device.device_id,
- "spool_id": spool.id,
- "tag_uid": "04AABBCC",
- "success": False,
- "message": "Write or verification failed",
- },
- )
- assert resp.status_code == 200
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_tag_write_failed"
- assert msg["message"] == "Write or verification failed"
- # Verify spool NOT linked
- spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
- assert spool_resp.json()["tag_uid"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_write_result_clears_pending_command(self, async_client: AsyncClient, device_factory, spool_factory):
- device = await device_factory(
- device_id="sb-wr-clear",
- pending_command="write_tag",
- pending_write_payload='{"spool_id": 1, "ndef_data_hex": "E110120003"}',
- )
- spool = await spool_factory()
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- await async_client.post(
- f"{API}/nfc/write-result",
- json={
- "device_id": device.device_id,
- "spool_id": spool.id,
- "tag_uid": "AABBCCDD",
- "success": True,
- },
- )
- # Heartbeat should have no pending command
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- hb = await async_client.post(
- f"{API}/devices/{device.device_id}/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 30},
- )
- assert hb.json()["pending_command"] is None
- assert hb.json()["pending_write_payload"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_cancel_write(self, async_client: AsyncClient, device_factory, spool_factory):
- device = await device_factory(device_id="sb-cancel")
- spool = await spool_factory()
- # Queue a write
- await async_client.post(
- f"{API}/nfc/write-tag",
- json={"device_id": device.device_id, "spool_id": spool.id},
- )
- # Cancel it
- resp = await async_client.post(f"{API}/devices/{device.device_id}/cancel-write", json={})
- assert resp.status_code == 200
- # Heartbeat should have no pending command
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- hb = await async_client.post(
- f"{API}/devices/{device.device_id}/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
- )
- assert hb.json()["pending_command"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_cancel_write_unknown_device_404(self, async_client: AsyncClient):
- resp = await async_client.post(f"{API}/devices/ghost/cancel-write", json={})
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_write_tag_ndef_data_is_valid(self, async_client: AsyncClient, device_factory, spool_factory):
- """Verify the NDEF data in the heartbeat is a valid OpenTag3D message."""
- device = await device_factory(device_id="sb-wt-ndef")
- spool = await spool_factory(
- material="PLA",
- brand="Polymaker",
- color_name="White",
- rgba="FFFFFFFF",
- label_weight=1000,
- )
- await async_client.post(
- f"{API}/nfc/write-tag",
- json={"device_id": device.device_id, "spool_id": spool.id},
- )
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- hb = await async_client.post(
- f"{API}/devices/{device.device_id}/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
- )
- payload = hb.json()["pending_write_payload"]
- ndef_bytes = bytes.fromhex(payload["ndef_data_hex"])
- # CC bytes
- assert ndef_bytes[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
- # TLV type
- assert ndef_bytes[4] == 0x03
- # NDEF record: TNF=MIME, type=application/opentag3d
- assert ndef_bytes[6] == 0xD2
- assert ndef_bytes[9:30] == b"application/opentag3d"
- # Terminator
- assert ndef_bytes[-1] == 0xFE
- # Total size fits NTAG213
- assert len(ndef_bytes) <= 144
- # ============================================================================
- # Scale endpoints
- # ============================================================================
- class TestScaleEndpoints:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_scale_reading_broadcast(self, async_client: AsyncClient):
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/scale/reading",
- json={
- "device_id": "sb-1",
- "weight_grams": 823.5,
- "stable": True,
- "raw_adc": 456789,
- },
- )
- assert resp.status_code == 200
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_weight"
- assert msg["device_id"] == "sb-1"
- assert msg["weight_grams"] == 823.5
- assert msg["stable"] is True
- assert msg["raw_adc"] == 456789
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_spool_weight_calculates_correctly(self, async_client: AsyncClient, spool_factory):
- # label=1000g, core=250g, scale reads 750g
- # net_filament = max(0, 750 - 250) = 500
- # weight_used = max(0, 1000 - 500) = 500
- spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
- resp = await async_client.post(
- f"{API}/scale/update-spool-weight",
- json={"spool_id": spool.id, "weight_grams": 750},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["weight_used"] == 500
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_spool_weight_full_spool(self, async_client: AsyncClient, spool_factory):
- # label=1000g, core=250g, scale reads 1250g (full spool)
- # net_filament = max(0, 1250 - 250) = 1000
- # weight_used = max(0, 1000 - 1000) = 0
- spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=200)
- resp = await async_client.post(
- f"{API}/scale/update-spool-weight",
- json={"spool_id": spool.id, "weight_grams": 1250},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["weight_used"] == 0
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_spool_weight_stores_scale_reading(self, async_client: AsyncClient, spool_factory):
- """Verify last_scale_weight and last_weighed_at are stored after weight sync."""
- spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
- resp = await async_client.post(
- f"{API}/scale/update-spool-weight",
- json={"spool_id": spool.id, "weight_grams": 750},
- )
- assert resp.status_code == 200
- # Fetch the spool via inventory API to verify stored fields
- spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
- assert spool_resp.status_code == 200
- spool_data = spool_resp.json()
- assert spool_data["last_scale_weight"] == 750
- assert spool_data["last_weighed_at"] is not None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_spool_weight_missing_spool_404(self, async_client: AsyncClient):
- resp = await async_client.post(
- f"{API}/scale/update-spool-weight",
- json={"spool_id": 99999, "weight_grams": 500},
- )
- assert resp.status_code == 404
- # ============================================================================
- # Calibration endpoints
- # ============================================================================
- class TestCalibrationEndpoints:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_tare_queues_command(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-tare")
- resp = await async_client.post(f"{API}/devices/sb-tare/calibration/tare", json={})
- assert resp.status_code == 200
- assert resp.json()["status"] == "ok"
- # Verify pending_command via heartbeat
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- hb = await async_client.post(
- f"{API}/devices/sb-tare/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 1},
- )
- assert hb.json()["pending_command"] == "tare"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_tare_unknown_device_404(self, async_client: AsyncClient):
- resp = await async_client.post(f"{API}/devices/ghost/calibration/tare", json={})
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_set_tare_offset(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-st", calibration_factor=0.005)
- resp = await async_client.post(
- f"{API}/devices/sb-st/calibration/set-tare",
- json={"tare_offset": 54321},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["tare_offset"] == 54321
- assert data["calibration_factor"] == pytest.approx(0.005)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_set_calibration_factor(self, async_client: AsyncClient, device_factory):
- # known_weight=200g, raw_adc=50000, tare=10000 → factor=200/(50000-10000)=0.005
- await device_factory(device_id="sb-cf", tare_offset=10000)
- resp = await async_client.post(
- f"{API}/devices/sb-cf/calibration/set-factor",
- json={"known_weight_grams": 200, "raw_adc": 50000},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["calibration_factor"] == pytest.approx(0.005)
- assert data["tare_offset"] == 10000
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_set_calibration_factor_zero_delta_400(self, async_client: AsyncClient, device_factory):
- # raw_adc == tare_offset → delta is 0 → 400 error
- await device_factory(device_id="sb-zero", tare_offset=5000)
- resp = await async_client.post(
- f"{API}/devices/sb-zero/calibration/set-factor",
- json={"known_weight_grams": 100, "raw_adc": 5000},
- )
- assert resp.status_code == 400
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_calibration(self, async_client: AsyncClient, device_factory):
- await device_factory(
- device_id="sb-gcal",
- tare_offset=11111,
- calibration_factor=0.0042,
- )
- resp = await async_client.get(f"{API}/devices/sb-gcal/calibration")
- assert resp.status_code == 200
- data = resp.json()
- assert data["tare_offset"] == 11111
- assert data["calibration_factor"] == pytest.approx(0.0042)
- # ============================================================================
- # Display endpoints
- # ============================================================================
- class TestDisplayEndpoints:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_display_settings(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-disp", display_brightness=100, display_blank_timeout=0)
- resp = await async_client.put(
- f"{API}/devices/sb-disp/display",
- json={"brightness": 75, "blank_timeout": 300},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["brightness"] == 75
- assert data["blank_timeout"] == 300
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_display_persists_via_heartbeat(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-disp-hb")
- await async_client.put(
- f"{API}/devices/sb-disp-hb/display",
- json={"brightness": 50, "blank_timeout": 600},
- )
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- hb = await async_client.post(
- f"{API}/devices/sb-disp-hb/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
- )
- assert hb.json()["display_brightness"] == 50
- assert hb.json()["display_blank_timeout"] == 600
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_display_unknown_device_404(self, async_client: AsyncClient):
- resp = await async_client.put(
- f"{API}/devices/ghost/display",
- json={"brightness": 50, "blank_timeout": 60},
- )
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_display_validates_brightness(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-disp-val")
- resp = await async_client.put(
- f"{API}/devices/sb-disp-val/display",
- json={"brightness": 150, "blank_timeout": 0},
- )
- assert resp.status_code == 422 # Validation error: brightness > 100
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_display_settings(self, async_client: AsyncClient, device_factory):
- """The kiosk idle watchdog (install/spoolbuddy-idle.sh) reads this
- endpoint on autostart to configure swayidle with the user-selected
- blank timeout before launching. See issue #937."""
- await device_factory(device_id="sb-disp-get", display_brightness=60, display_blank_timeout=450)
- resp = await async_client.get(f"{API}/devices/sb-disp-get/display")
- assert resp.status_code == 200
- data = resp.json()
- assert data["brightness"] == 60
- assert data["blank_timeout"] == 450
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_display_unknown_device_404(self, async_client: AsyncClient):
- resp = await async_client.get(f"{API}/devices/ghost/display")
- assert resp.status_code == 404
- # ============================================================================
- # Update endpoints
- # ============================================================================
- class TestUpdateEndpoints:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_trigger_update_starts_ssh_update(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-upd")
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(f"{API}/devices/sb-upd/update")
- assert resp.status_code == 200
- assert resp.json()["status"] == "ok"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_trigger_update_offline_device_409(self, async_client: AsyncClient, device_factory):
- await device_factory(
- device_id="sb-upd-off",
- last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
- )
- resp = await async_client.post(f"{API}/devices/sb-upd-off/update")
- assert resp.status_code == 409
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_trigger_update_unknown_device_404(self, async_client: AsyncClient):
- resp = await async_client.post(f"{API}/devices/ghost/update")
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_trigger_update_already_updating(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-upd-dup", update_status="updating")
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(f"{API}/devices/sb-upd-dup/update")
- assert resp.status_code == 200
- assert resp.json()["status"] == "already_updating"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_report_update_status_updating(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-upd-st", pending_command="update", update_status="pending")
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/devices/sb-upd-st/update-status",
- json={"status": "updating", "message": "Fetching latest code..."},
- )
- assert resp.status_code == 200
- mock_ws.broadcast.assert_called_once()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_update"
- assert msg["update_status"] == "updating"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_report_update_status_complete_clears_command(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-upd-done", pending_command="update", update_status="updating")
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- await async_client.post(
- f"{API}/devices/sb-upd-done/update-status",
- json={"status": "complete", "message": "Update complete, restarting..."},
- )
- # Heartbeat should have no pending command
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- hb = await async_client.post(
- f"{API}/devices/sb-upd-done/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
- )
- assert hb.json()["pending_command"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_report_update_status_error(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-upd-err", pending_command="update", update_status="updating")
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/devices/sb-upd-err/update-status",
- json={"status": "error", "message": "git fetch failed: network unreachable"},
- )
- assert resp.status_code == 200
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["update_status"] == "error"
- assert "git fetch failed" in msg["update_message"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_report_update_status_unknown_device_404(self, async_client: AsyncClient):
- resp = await async_client.post(
- f"{API}/devices/ghost/update-status",
- json={"status": "updating", "message": "test"},
- )
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_report_update_status_invalid_status_422(self, async_client: AsyncClient, device_factory):
- """Arbitrary status strings must be rejected with 422 (H2: UpdateStatusRequest validation)."""
- await device_factory(device_id="sb-upd-inv")
- resp = await async_client.post(
- f"{API}/devices/sb-upd-inv/update-status",
- json={"status": "hacked", "message": "injected"},
- )
- assert resp.status_code == 422
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_report_update_status_oversized_message_422(self, async_client: AsyncClient, device_factory):
- """Message exceeding 255 chars must be rejected with 422 (H2/M4)."""
- await device_factory(device_id="sb-upd-big")
- resp = await async_client.post(
- f"{API}/devices/sb-upd-big/update-status",
- json={"status": "updating", "message": "x" * 256},
- )
- assert resp.status_code == 422
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_ssh_public_key_error_does_not_leak_exception_text(self, async_client: AsyncClient):
- """SSH public-key 500 must not expose raw exception details (M3)."""
- from backend.app.services.spoolbuddy_ssh import get_public_key
- with patch(
- "backend.app.services.spoolbuddy_ssh.get_public_key",
- AsyncMock(side_effect=RuntimeError("REDACT_ME internal path /data/keys/id_ed25519")),
- ):
- resp = await async_client.get(f"{API}/ssh/public-key")
- assert resp.status_code == 500
- body = resp.json()["detail"]
- assert "REDACT_ME" not in body
- assert "/data/keys" not in body
- assert "id_ed25519" not in body
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_device_response_includes_update_fields(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-upd-resp", update_status="complete", update_message="Done!")
- resp = await async_client.get(f"{API}/devices")
- assert resp.status_code == 200
- device = next(d for d in resp.json() if d["device_id"] == "sb-upd-resp")
- assert device["update_status"] == "complete"
- assert device["update_message"] == "Done!"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_check_returns_version_info(self, async_client: AsyncClient, device_factory):
- """GET /devices/{id}/update-check compares device version against APP_VERSION."""
- await device_factory(device_id="sb-uc", firmware_version="0.1.0")
- resp = await async_client.get(f"{API}/devices/sb-uc/update-check")
- assert resp.status_code == 200
- data = resp.json()
- assert data["current_version"] == "0.1.0"
- assert data["latest_version"] is not None
- assert data["update_available"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_check_up_to_date(self, async_client: AsyncClient, device_factory):
- from backend.app.core.config import APP_VERSION
- await device_factory(device_id="sb-uc2", firmware_version=APP_VERSION)
- resp = await async_client.get(f"{API}/devices/sb-uc2/update-check")
- assert resp.status_code == 200
- assert resp.json()["update_available"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_check_unknown_device_404(self, async_client: AsyncClient):
- resp = await async_client.get(f"{API}/devices/ghost/update-check")
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_trigger_update_broadcasts_websocket(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-upd-ws")
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
- ):
- mock_ws.broadcast = AsyncMock()
- await async_client.post(f"{API}/devices/sb-upd-ws/update")
- mock_ws.broadcast.assert_called_once()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_update"
- assert msg["device_id"] == "sb-upd-ws"
- assert msg["update_status"] == "pending"
- # ============================================================================
- # System command endpoints
- # ============================================================================
- class TestSystemCommandEndpoints:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_queue_reboot(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-reboot")
- resp = await async_client.post(
- f"{API}/devices/sb-reboot/system/command",
- json={"command": "reboot"},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["status"] == "queued"
- assert data["command"] == "reboot"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_queue_shutdown(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-shutdown")
- resp = await async_client.post(
- f"{API}/devices/sb-shutdown/system/command",
- json={"command": "shutdown"},
- )
- assert resp.status_code == 200
- assert resp.json()["command"] == "shutdown"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_queue_restart_daemon(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-rd")
- resp = await async_client.post(
- f"{API}/devices/sb-rd/system/command",
- json={"command": "restart_daemon"},
- )
- assert resp.status_code == 200
- assert resp.json()["command"] == "restart_daemon"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_queue_restart_browser(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-rb")
- resp = await async_client.post(
- f"{API}/devices/sb-rb/system/command",
- json={"command": "restart_browser"},
- )
- assert resp.status_code == 200
- assert resp.json()["command"] == "restart_browser"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_invalid_command_rejected(self, async_client: AsyncClient, device_factory):
- await device_factory(device_id="sb-invalid")
- resp = await async_client.post(
- f"{API}/devices/sb-invalid/system/command",
- json={"command": "format_disk"},
- )
- assert resp.status_code == 400
- assert "Invalid command" in resp.json()["detail"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_command_unknown_device_404(self, async_client: AsyncClient):
- resp = await async_client.post(
- f"{API}/devices/ghost/system/command",
- json={"command": "reboot"},
- )
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_command_offline_device_409(self, async_client: AsyncClient, device_factory):
- await device_factory(
- device_id="sb-offline-cmd",
- last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
- )
- resp = await async_client.post(
- f"{API}/devices/sb-offline-cmd/system/command",
- json={"command": "reboot"},
- )
- assert resp.status_code == 409
- assert "offline" in resp.json()["detail"].lower()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_command_sets_pending_command(self, async_client: AsyncClient, device_factory, db_session):
- device = await device_factory(device_id="sb-pending")
- await async_client.post(
- f"{API}/devices/sb-pending/system/command",
- json={"command": "restart_daemon"},
- )
- await db_session.refresh(device)
- assert device.pending_command == "restart_daemon"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_heartbeat_clears_system_command(self, async_client: AsyncClient, device_factory):
- """System commands (reboot/shutdown/restart_*) are fire-and-forget — heartbeat clears them."""
- await device_factory(device_id="sb-hb-clear")
- # Queue a command
- await async_client.post(
- f"{API}/devices/sb-hb-clear/system/command",
- json={"command": "restart_browser"},
- )
- # Heartbeat should return the command and clear it
- resp = await async_client.post(
- f"{API}/devices/sb-hb-clear/heartbeat",
- json={"nfc_ok": True, "scale_ok": True, "uptime_s": 100},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["pending_command"] == "restart_browser"
- # ============================================================================
- # Spoolman-aware SpoolBuddy endpoints
- # ============================================================================
- @pytest.fixture
- async def spoolman_settings(db_session: AsyncSession):
- """Create Spoolman settings in the database (enabled with URL)."""
- from backend.app.models.settings import Settings
- settings = [
- Settings(key="spoolman_enabled", value="true"),
- Settings(key="spoolman_url", value="http://spoolman.local:7912"),
- ]
- for s in settings:
- db_session.add(s)
- await db_session.commit()
- return settings
- def _mock_spoolman_client(base_url: str = "http://spoolman.local:7912") -> MagicMock:
- client = MagicMock()
- client.base_url = base_url
- client.get_spools = AsyncMock(return_value=[])
- client.get_spool = AsyncMock(return_value={})
- client.find_spool_by_tag = AsyncMock(return_value=None)
- client.update_spool = AsyncMock(return_value=None)
- client.merge_spool_extra = AsyncMock(return_value={"id": 0})
- return client
- def _spoolman_spool_fixture(
- spool_id: int,
- spool_weight: float = 196.0,
- filament_weight: float = 1000.0,
- spool_level_spool_weight=None,
- ) -> dict:
- """Build a minimal Spoolman spool dict with realistic core weight from filament.spool_weight."""
- raw = {
- "id": spool_id,
- "filament": {"weight": filament_weight, "spool_weight": spool_weight},
- "used_weight": 0.0,
- }
- if spool_level_spool_weight is not None:
- raw["spool_weight"] = spool_level_spool_weight
- return raw
- class TestUpdateSpoolWeightSpoolman:
- """update-spool-weight routes to Spoolman when Spoolman mode is active."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spoolman_mode_uses_filament_spool_weight(self, async_client: AsyncClient, spoolman_settings):
- """core_weight comes from filament.spool_weight, not a hardcoded constant."""
- sm_spool = _spoolman_spool_fixture(42, spool_weight=196.0, filament_weight=1000.0)
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(return_value=sm_spool)
- mock_client.update_spool = AsyncMock(return_value=sm_spool)
- with (
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- resp = await async_client.post(
- f"{API}/scale/update-spool-weight",
- json={"spool_id": 42, "weight_grams": 750},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["status"] == "ok"
- # remaining = max(0, 750 - 196) = 554 → weight_used = 1000 - 554 = 446
- assert data["weight_used"] == pytest.approx(446.0)
- mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(554.0))
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spoolman_mode_clamps_remaining_to_zero(self, async_client: AsyncClient, spoolman_settings):
- """Scale weight below core weight → remaining_weight = 0."""
- sm_spool = _spoolman_spool_fixture(7, spool_weight=196.0, filament_weight=1000.0)
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(return_value=sm_spool)
- mock_client.update_spool = AsyncMock(return_value=sm_spool)
- with (
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- resp = await async_client.post(
- f"{API}/scale/update-spool-weight",
- json={"spool_id": 7, "weight_grams": 100},
- )
- assert resp.status_code == 200
- mock_client.update_spool.assert_called_once_with(spool_id=7, remaining_weight=0.0)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spoolman_mode_404_when_spool_not_found(self, async_client: AsyncClient, spoolman_settings):
- """404 when Spoolman doesn't know the spool."""
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 9999 not found"))
- with (
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- resp = await async_client.post(
- f"{API}/scale/update-spool-weight",
- json={"spool_id": 9999, "weight_grams": 500},
- )
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spoolman_mode_503_on_client_failure(self, async_client: AsyncClient, spoolman_settings):
- """503 is returned when Spoolman is unreachable during weight update."""
- sm_spool = _spoolman_spool_fixture(99)
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(return_value=sm_spool)
- mock_client.update_spool = AsyncMock(side_effect=SpoolmanUnavailableError("Spoolman down"))
- with (
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- resp = await async_client.post(
- f"{API}/scale/update-spool-weight",
- json={"spool_id": 99, "weight_grams": 500},
- )
- assert resp.status_code == 503
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_local_mode_unchanged(self, async_client: AsyncClient, spool_factory):
- """When Spoolman is NOT enabled, local DB update still works."""
- spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
- resp = await async_client.post(
- f"{API}/scale/update-spool-weight",
- json={"spool_id": spool.id, "weight_grams": 750},
- )
- assert resp.status_code == 200
- assert resp.json()["weight_used"] == 500
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_stale_local_row_does_not_shadow_spoolman(
- self, async_client: AsyncClient, db_session, spool_factory, spoolman_settings
- ):
- """Regression for #1530: when Spoolman mode is on, a stale local Spool
- sharing the same numeric id must NOT absorb the update — Spoolman is
- the authoritative target."""
- local_spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
- # Spoolman spool with the SAME numeric id as the local stale row.
- sm_spool = _spoolman_spool_fixture(local_spool.id, spool_weight=250.0, filament_weight=1000.0)
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(return_value=sm_spool)
- mock_client.update_spool = AsyncMock(return_value=sm_spool)
- with (
- patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
- patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
- ):
- resp = await async_client.post(
- f"{API}/scale/update-spool-weight",
- json={"spool_id": local_spool.id, "weight_grams": 750},
- )
- assert resp.status_code == 200
- # Spoolman got the update.
- mock_client.update_spool.assert_called_once_with(spool_id=local_spool.id, remaining_weight=pytest.approx(500.0))
- # Local row is untouched — the bug was that the local update silently
- # absorbed the request while Spoolman stayed stale.
- await db_session.refresh(local_spool)
- assert local_spool.weight_used == 0
- assert local_spool.last_scale_weight is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spool_level_spool_weight_takes_priority(self, async_client: AsyncClient, spoolman_settings):
- """spool.spool_weight overrides filament.spool_weight for tare calculation."""
- sm_spool = _spoolman_spool_fixture(42, spool_weight=196.0, filament_weight=1000.0, spool_level_spool_weight=300)
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(return_value=sm_spool)
- mock_client.update_spool = AsyncMock(return_value=sm_spool)
- with (
- patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
- patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
- ):
- resp = await async_client.post(
- f"{API}/scale/update-spool-weight",
- json={"spool_id": 42, "weight_grams": 750},
- )
- assert resp.status_code == 200
- # remaining = 750 - 300 = 450; weight_used = 1000 - 450 = 550
- assert resp.json()["weight_used"] == pytest.approx(550.0)
- mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(450.0))
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spool_level_zero_spool_weight_not_treated_as_missing(
- self, async_client: AsyncClient, spoolman_settings
- ):
- """spool.spool_weight=0 is valid (0g tare), not treated as missing/fallback."""
- sm_spool = _spoolman_spool_fixture(42, spool_weight=196.0, filament_weight=1000.0, spool_level_spool_weight=0)
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(return_value=sm_spool)
- mock_client.update_spool = AsyncMock(return_value=sm_spool)
- with (
- patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
- patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
- ):
- resp = await async_client.post(
- f"{API}/scale/update-spool-weight",
- json={"spool_id": 42, "weight_grams": 750},
- )
- assert resp.status_code == 200
- # remaining = 750 - 0 = 750; weight_used = 1000 - 750 = 250
- assert resp.json()["weight_used"] == pytest.approx(250.0)
- mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(750.0))
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_both_levels_none_uses_250g_fallback_and_warns(self, async_client: AsyncClient, spoolman_settings):
- """When both spool_weight and filament.spool_weight are None, 250g fallback is used with a warning."""
- sm_spool = {"id": 42, "filament": {"weight": 1000.0, "spool_weight": None}, "used_weight": 0.0}
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(return_value=sm_spool)
- mock_client.update_spool = AsyncMock(return_value=sm_spool)
- with (
- patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
- patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
- ):
- resp = await async_client.post(
- f"{API}/scale/update-spool-weight",
- json={"spool_id": 42, "weight_grams": 750},
- )
- assert resp.status_code == 200
- # remaining = 750 - 250 = 500; weight_used = 1000 - 500 = 500
- assert resp.json()["weight_used"] == pytest.approx(500.0)
- assert resp.json().get("warnings")
- class TestTagScannedSpoolmanFallback:
- """nfc/tag-scanned falls back to Spoolman when local DB has no match."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spoolman_fallback_on_local_miss(self, async_client: AsyncClient, spoolman_settings):
- raw_spool = {
- "id": 5,
- "filament": {
- "material": "PETG",
- "name": "PETG Basic",
- "color_hex": "00FF00",
- "weight": 1000,
- "vendor": {"name": "Polymaker"},
- },
- "used_weight": 100.0,
- "archived": False,
- "registered": "2024-01-01T00:00:00+00:00",
- "extra": {"tag": '"DEADBEEF12345678"'},
- }
- mock_client = _mock_spoolman_client()
- mock_client.get_spools = AsyncMock(return_value=[raw_spool])
- mock_client.find_spool_by_tag = AsyncMock(return_value=raw_spool)
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/tag-scanned",
- json={"device_id": "sb-1", "tag_uid": "DEADBEEF12345678"},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["matched"] is True
- assert data["spool_id"] == 5
- mock_ws.broadcast.assert_called_once()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_tag_matched"
- assert msg["spool"]["id"] == 5
- assert msg["spool"]["material"] == "PETG"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spoolman_fallback_unknown_when_no_spoolman_match(self, async_client: AsyncClient, spoolman_settings):
- """Unknown tag broadcast when both local DB and Spoolman miss."""
- mock_client = _mock_spoolman_client()
- mock_client.get_spools = AsyncMock(return_value=[])
- mock_client.find_spool_by_tag = AsyncMock(return_value=None)
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/tag-scanned",
- json={"device_id": "sb-1", "tag_uid": "UNKNOWN0000000FF"},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["matched"] is False
- assert data["spool_id"] is None
- mock_ws.broadcast.assert_called_once()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_unknown_tag"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_malformed_spoolman_data_degrades_gracefully(self, async_client: AsyncClient, spoolman_settings):
- """ValueError from _map_spoolman_spool (e.g. spool_id=0) must return matched=False without broadcasting unknown_tag."""
- bad_spool = {
- "id": 0, # _map_spoolman_spool raises ValueError for id <= 0
- "filament": {"material": "PLA", "name": "PLA Basic", "color_hex": "FF0000", "weight": 1000},
- "used_weight": 0.0,
- "archived": False,
- "registered": "2024-01-01T00:00:00Z",
- "extra": {"tag": '"DEADBEEF12345678"'},
- }
- mock_client = _mock_spoolman_client()
- mock_client.find_spool_by_tag = AsyncMock(return_value=bad_spool)
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/tag-scanned",
- json={"device_id": "sb-1", "tag_uid": "DEADBEEF12345678"},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["matched"] is False
- assert data["spool_id"] is None
- # No broadcast: UI must not get a spurious unknown_tag event on Spoolman data errors
- mock_ws.broadcast.assert_not_called()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_local_match_skips_spoolman(self, async_client: AsyncClient, spool_factory):
- """When local DB matches, Spoolman is never queried."""
- spool = await spool_factory(tag_uid="AABB1122", material="PLA")
- mock_spool = MagicMock()
- mock_spool.id = spool.id
- mock_spool.material = spool.material
- mock_spool.subtype = spool.subtype
- mock_spool.color_name = spool.color_name
- mock_spool.rgba = spool.rgba
- mock_spool.brand = spool.brand
- mock_spool.label_weight = spool.label_weight
- mock_spool.core_weight = spool.core_weight
- mock_spool.weight_used = spool.weight_used
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
- new_callable=AsyncMock,
- return_value=mock_spool,
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/tag-scanned",
- json={"device_id": "sb-1", "tag_uid": "AABB1122"},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["matched"] is True
- assert data["spool_id"] == spool.id
- # ============================================================================
- # NFC write-tag / write-result — Spoolman-aware
- # ============================================================================
- def _full_spoolman_spool(spool_id: int) -> dict:
- """Complete Spoolman spool dict sufficient for NDEF encoding."""
- return {
- "id": spool_id,
- "filament": {
- "material": "PLA",
- "name": "PLA Basic",
- "color_hex": "FF0000",
- "weight": 1000.0,
- "spool_weight": 196.0,
- "vendor": {"name": "Bambu Lab"},
- },
- "used_weight": 0.0,
- "archived": False,
- "registered": "2024-01-01T00:00:00Z",
- }
- class TestNfcWriteTagSpoolman:
- """nfc/write-tag falls back to Spoolman when local DB has no matching spool."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spoolman_spool_queued_when_local_miss(
- self, async_client: AsyncClient, device_factory, spoolman_settings
- ):
- """write-tag encodes NDEF from Spoolman data when spool not in local DB."""
- await device_factory(device_id="sb-write-sm")
- sm_spool = _full_spoolman_spool(77)
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(return_value=sm_spool)
- with (
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- resp = await async_client.post(
- f"{API}/nfc/write-tag",
- json={"device_id": "sb-write-sm", "spool_id": 77},
- )
- assert resp.status_code == 200
- assert resp.json()["status"] == "queued"
- mock_client.get_spool.assert_called_once_with(77)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_data_origin_spoolman_stored_in_payload(
- self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
- ):
- """Pending write payload records data_origin=spoolman for Spoolman spools."""
- import json as _json
- device = await device_factory(device_id="sb-origin")
- sm_spool = _full_spoolman_spool(88)
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(return_value=sm_spool)
- with (
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- await async_client.post(
- f"{API}/nfc/write-tag",
- json={"device_id": "sb-origin", "spool_id": 88},
- )
- await db_session.refresh(device)
- payload = _json.loads(device.pending_write_payload)
- assert payload["data_origin"] == "spoolman"
- assert payload["spool_id"] == 88
- assert "ndef_data_hex" in payload
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_404_when_neither_local_nor_spoolman(
- self, async_client: AsyncClient, device_factory, spoolman_settings
- ):
- """404 returned when spool is missing from both local DB and Spoolman."""
- await device_factory(device_id="sb-miss")
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 9999 not found"))
- with (
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- resp = await async_client.post(
- f"{API}/nfc/write-tag",
- json={"device_id": "sb-miss", "spool_id": 9999},
- )
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_local_spool_used_when_present(self, async_client: AsyncClient, device_factory, spool_factory):
- """Local DB spool is encoded directly without contacting Spoolman."""
- await device_factory(device_id="sb-local-write")
- spool = await spool_factory(material="PETG")
- resp = await async_client.post(
- f"{API}/nfc/write-tag",
- json={"device_id": "sb-local-write", "spool_id": spool.id},
- )
- assert resp.status_code == 200
- assert resp.json()["status"] == "queued"
- class TestNfcWriteResultSpoolman:
- """nfc/write-result updates Spoolman extra.tag on success for Spoolman spools."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_success_updates_spoolman_extra_tag(
- self, async_client: AsyncClient, device_factory, spoolman_settings
- ):
- """Successful write for a Spoolman spool calls merge_spool_extra with extra.tag."""
- import json as _json
- await device_factory(
- device_id="sb-wr-sm",
- pending_command="write_tag",
- pending_write_payload=_json.dumps({"spool_id": 55, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
- )
- mock_client = _mock_spoolman_client()
- mock_client.merge_spool_extra = AsyncMock(return_value={"id": 55})
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/write-result",
- json={
- "device_id": "sb-wr-sm",
- "spool_id": 55,
- "tag_uid": "AABBCCDD11223344",
- "success": True,
- },
- )
- assert resp.status_code == 200
- mock_client.merge_spool_extra.assert_called_once_with(55, {"tag": '"AABBCCDD11223344"'})
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_tag_written"
- assert msg["tag_uid"] == "AABBCCDD11223344"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_failure_does_not_call_spoolman(self, async_client: AsyncClient, device_factory, spoolman_settings):
- """Failed write never calls Spoolman update."""
- import json as _json
- await device_factory(
- device_id="sb-wr-fail",
- pending_command="write_tag",
- pending_write_payload=_json.dumps({"spool_id": 66, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
- )
- mock_client = _mock_spoolman_client()
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/write-result",
- json={
- "device_id": "sb-wr-fail",
- "spool_id": 66,
- "tag_uid": "AABBCCDD11223344",
- "success": False,
- "message": "write timeout",
- },
- )
- assert resp.status_code == 200
- mock_client.update_spool.assert_not_called()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_tag_write_failed"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_success_local_spool_writes_to_db(
- self, async_client: AsyncClient, device_factory, spool_factory, db_session
- ):
- """Successful write for a local spool still updates local DB tag_uid."""
- import json as _json
- spool = await spool_factory()
- await device_factory(
- device_id="sb-wr-local",
- pending_command="write_tag",
- pending_write_payload=_json.dumps(
- {"spool_id": spool.id, "ndef_data_hex": "deadbeef", "data_origin": "local"}
- ),
- )
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/write-result",
- json={
- "device_id": "sb-wr-local",
- "spool_id": spool.id,
- "tag_uid": "DEADBEEF12345678",
- "success": True,
- },
- )
- assert resp.status_code == 200
- await db_session.refresh(spool)
- assert spool.tag_uid == "DEADBEEF12345678"
- assert spool.tag_type == "ntag"
- # ============================================================================
- # Security fix tests — write-tag ValueError + write-result exception safety
- # ============================================================================
- class TestNfcWriteTagSpoolmanSecurityFixes:
- """Regression tests for security fixes in nfc/write-tag Spoolman path."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_invalid_spoolman_spool_id_returns_502(
- self, async_client: AsyncClient, device_factory, spoolman_settings
- ):
- """Malformed Spoolman spool (invalid id=0) raises 502, not 404 — spool exists but is bad data."""
- await device_factory(device_id="sb-invalid-id")
- # Spoolman returns spool with id=0 (invalid — caught by _map_spoolman_spool guard)
- bad_spool = {**_full_spoolman_spool(1), "id": 0}
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(return_value=bad_spool)
- with (
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- resp = await async_client.post(
- f"{API}/nfc/write-tag",
- json={"device_id": "sb-invalid-id", "spool_id": 99},
- )
- # 502: spool exists in Spoolman but its data is malformed — not a "not found"
- assert resp.status_code == 502
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_oversized_label_weight_does_not_crash(
- self, async_client: AsyncClient, device_factory, spoolman_settings
- ):
- """label_weight > 65535 from Spoolman must not crash with struct.error."""
- await device_factory(device_id="sb-overflow")
- big_weight_spool = {
- **_full_spoolman_spool(42),
- "filament": {**_full_spoolman_spool(42)["filament"], "weight": 70000},
- }
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(return_value=big_weight_spool)
- with (
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- resp = await async_client.post(
- f"{API}/nfc/write-tag",
- json={"device_id": "sb-overflow", "spool_id": 42},
- )
- assert resp.status_code == 200
- assert resp.json()["status"] == "queued"
- class TestNfcWriteResultSpoolmanSecurityFixes:
- """Regression tests for transaction safety in nfc/write-result Spoolman path."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spoolman_client_exception_still_clears_device_state(
- self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
- ):
- """If Spoolman client raises, device pending_command is still cleared in DB."""
- import json as _json
- device = await device_factory(
- device_id="sb-exc-safe",
- pending_command="write_tag",
- pending_write_payload=_json.dumps({"spool_id": 77, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
- )
- mock_client = _mock_spoolman_client()
- mock_client.merge_spool_extra = AsyncMock(side_effect=Exception("connection refused"))
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/write-result",
- json={
- "device_id": "sb-exc-safe",
- "spool_id": 77,
- "tag_uid": "AABBCCDD11223344",
- "success": True,
- },
- )
- # 502: tag written to NFC but Spoolman link failed (not best-effort — caller must retry)
- assert resp.status_code == 502
- # Device state must be cleared despite the exception (no spurious re-write)
- await db_session.refresh(device)
- assert device.pending_command is None
- assert device.pending_write_payload is None
- # Failure broadcast fires so the UI can show the error
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_tag_link_failed"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spoolman_not_found_error_broadcasts_link_failed(
- self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
- ):
- """SpoolmanNotFoundError from merge_spool_extra must clear device state and broadcast link_failed."""
- import json as _json
- device = await device_factory(
- device_id="sb-notfound",
- pending_command="write_tag",
- pending_write_payload=_json.dumps({"spool_id": 55, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
- )
- mock_client = _mock_spoolman_client()
- mock_client.merge_spool_extra = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 55 not found"))
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/write-result",
- json={
- "device_id": "sb-notfound",
- "spool_id": 55,
- "tag_uid": "AABBCCDD11223344",
- "success": True,
- },
- )
- assert resp.status_code == 502
- await db_session.refresh(device)
- assert device.pending_command is None
- assert device.pending_write_payload is None
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_tag_link_failed"
- assert msg["spool_id"] == 55
- class TestNfcWriteResultOrphanedSpool:
- """nfc/write-result when the local spool was deleted between write-queue and write-result."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_local_spool_deleted_before_write_back(self, async_client: AsyncClient, device_factory, db_session):
- """When local spool is deleted between write-queue and write-result, return linked=False and broadcast link_failed."""
- import json as _json
- device = await device_factory(
- device_id="sb-orphan",
- pending_command="write_tag",
- pending_write_payload=_json.dumps(
- {
- "spool_id": 99999, # non-existent spool
- "ndef_data_hex": "aabbccdd",
- "data_origin": "local",
- }
- ),
- )
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/write-result",
- json={"device_id": device.device_id, "spool_id": 99999, "success": True, "tag_uid": "AABBCCDD"},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["linked"] is False
- # pending command should be cleared
- await db_session.refresh(device)
- assert device.pending_command is None
- # broadcast should be spoolbuddy_tag_link_failed
- broadcast_calls = mock_ws.broadcast.call_args_list
- link_failed = [c[0][0] for c in broadcast_calls if c[0][0].get("type") == "spoolbuddy_tag_link_failed"]
- assert len(link_failed) >= 1
- class TestNfcWriteResultInputValidation:
- """Input validation and JSON safety for nfc/write-result."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_tag_uid_too_long_rejected(self, async_client: AsyncClient, device_factory):
- """tag_uid longer than 32 chars must be rejected with 422."""
- import json as _json
- await device_factory(
- device_id="sb-uid-long",
- pending_command="write_tag",
- pending_write_payload=_json.dumps({"spool_id": 1, "ndef_data_hex": "dead", "data_origin": "local"}),
- )
- resp = await async_client.post(
- f"{API}/nfc/write-result",
- json={
- "device_id": "sb-uid-long",
- "spool_id": 1,
- "tag_uid": "A" * 65,
- "success": True,
- },
- )
- assert resp.status_code == 422
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_malformed_pending_payload_falls_back_to_local(
- self, async_client: AsyncClient, device_factory, spool_factory, db_session
- ):
- """Corrupted pending_write_payload JSON falls back to local mode gracefully."""
- spool = await spool_factory()
- await device_factory(
- device_id="sb-corrupt-json",
- pending_command="write_tag",
- pending_write_payload="{not valid json!!!",
- )
- with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/write-result",
- json={
- "device_id": "sb-corrupt-json",
- "spool_id": spool.id,
- "tag_uid": "DEADBEEF12345678",
- "success": True,
- },
- )
- # Must return 200, not 500
- assert resp.status_code == 200
- # Falls back to local mode — tag written to DB
- await db_session.refresh(spool)
- assert spool.tag_uid == "DEADBEEF12345678"
- # ============================================================================
- # B1: NFC write-tag warnings appear in response body
- # ============================================================================
- class TestNfcWriteTagWarningsBody:
- """B1: resp.json()['warnings'] is populated when Spoolman fields are absent."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_warnings_returned_for_missing_color_and_temp(
- self, async_client: AsyncClient, device_factory, spoolman_settings
- ):
- """Both color_name=None and settings_extruder_temp=None produce 2 warnings."""
- await device_factory(device_id="sb-warn-b1")
- # Spoolman spool with no color_name or nozzle temp
- sparse_spool = {
- "id": 99,
- "filament": {
- "material": "PLA",
- "name": "PLA Basic",
- "color_hex": "808080",
- # color_name absent → None after mapping
- # settings_extruder_temp absent → nozzle_temp_min=None
- "weight": 1000.0,
- "vendor": {"name": "Bambu Lab"},
- },
- "used_weight": 0.0,
- "archived": False,
- "registered": "2024-01-01T00:00:00Z",
- }
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(return_value=sparse_spool)
- with (
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- resp = await async_client.post(
- f"{API}/nfc/write-tag",
- json={"device_id": "sb-warn-b1", "spool_id": 99},
- )
- assert resp.status_code == 200
- body = resp.json()
- assert "warnings" in body, "Response should contain 'warnings' key when fields are absent"
- warnings = body["warnings"]
- assert len(warnings) >= 2, f"Expected at least 2 warnings for missing color_name + nozzle_temp, got: {warnings}"
- # Confirm the specific fields are mentioned
- warn_text = " ".join(warnings)
- assert "color_name" in warn_text
- assert "nozzle_temp" in warn_text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_no_warnings_key_when_all_fields_present(
- self, async_client: AsyncClient, device_factory, spoolman_settings
- ):
- """No 'warnings' key in response when all fields are populated."""
- await device_factory(device_id="sb-nowarn")
- full_spool = _full_spoolman_spool(100)
- # Add color_name and extruder temp
- full_spool["filament"]["color_name"] = "Red"
- full_spool["filament"]["settings_extruder_temp"] = 220
- mock_client = _mock_spoolman_client()
- mock_client.get_spool = AsyncMock(return_value=full_spool)
- with (
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- resp = await async_client.post(
- f"{API}/nfc/write-tag",
- json={"device_id": "sb-nowarn", "spool_id": 100},
- )
- assert resp.status_code == 200
- body = resp.json()
- assert "warnings" not in body or body["warnings"] == []
- # ============================================================================
- # B5: Exception text scrubbed from WebSocket broadcast message
- # ============================================================================
- class TestNfcWriteResultExceptionScrubbing:
- """B5: Internal exception details must not appear in WebSocket 'message' field."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_exception_text_not_leaked_in_ws_message(
- self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
- ):
- """When Spoolman merge raises, WS message is generic; 'connection refused' absent."""
- import json as _json
- await device_factory(
- device_id="sb-scrub-b5",
- pending_command="write_tag",
- pending_write_payload=_json.dumps({"spool_id": 77, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
- )
- mock_client = _mock_spoolman_client()
- mock_client.merge_spool_extra = AsyncMock(side_effect=Exception("connection refused to 192.168.1.1:7912"))
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{API}/nfc/write-result",
- json={
- "device_id": "sb-scrub-b5",
- "spool_id": 77,
- "tag_uid": "AABBCCDD11223344",
- "success": True,
- },
- )
- assert resp.status_code == 502
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_tag_link_failed"
- # Generic message — no internal exception details leaked
- assert msg["message"] == "Spoolman link failed", f"Expected generic message but got: {msg['message']!r}"
- assert "connection refused" not in str(msg), f"Exception text must not appear in WS message: {msg}"
- assert "192.168.1" not in str(msg), f"Internal IP must not appear in WS message: {msg}"
- # ============================================================================
- # _get_spoolman_client_or_none: graceful degradation on ValueError during reinit
- # ============================================================================
- class TestSpoolmanClientOrNoneGraceful:
- """_get_spoolman_client_or_none returns None when init_spoolman_client raises ValueError."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_returns_none_when_init_raises_value_error(self, async_client: AsyncClient, db_session):
- """_get_spoolman_client_or_none returns None when init_spoolman_client raises ValueError,
- so the device endpoint degrades gracefully instead of propagating a 500 error."""
- 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://spoolman.local:7912"))
- await db_session.commit()
- with (
- patch("backend.app.api.routes._spoolman_helpers.assert_safe_spoolman_url"),
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=None),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(side_effect=ValueError("invalid URL")),
- ),
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- ):
- mock_ws.broadcast = AsyncMock()
- # nfc/tag-scanned calls _get_spoolman_client_or_none; with None returned it
- # must broadcast unknown_tag (not raise 500 due to ValueError propagating).
- resp = await async_client.post(
- f"{API}/nfc/tag-scanned",
- json={"device_id": "sb-vale", "tag_uid": "AABBCCDD"},
- )
- # Must not be 500 — ValueError is caught and client returns None, degrading gracefully
- assert resp.status_code == 200
- data = resp.json()
- assert data["matched"] is False
|