Zabbix supply backend rebuild + data-driven model supplies

Rewrite the printer Zabbix integration (Bearer auth, host-by-IP, tag-based
supply lookup, ping) and replace the hardcoded toner table with a
modelsupplies table + CRUD + seed. Add mock Zabbix server, live test
harness, and the Playwright screenshot tooling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 08:35:02 -04:00
parent 9d78b363dc
commit f1b3b65532
14 changed files with 1411 additions and 103 deletions

View File

View File

@@ -0,0 +1,121 @@
"""Tests for the model-supplies (toner part-number) management API."""
import pytest
@pytest.fixture
def model(db):
"""A vendor + printer model to attach supplies to."""
from shopdb.core.models import Vendor, Model
vendor = Vendor(vendor='TestVendor')
db.session.add(vendor)
db.session.flush()
model = Model(modelnumber='TestModel C999', vendorid=vendor.vendorid)
db.session.add(model)
db.session.commit()
return model
def test_supplies_meta_lists_allowed_values(client, db):
response = client.get('/api/printers/supplies/meta')
assert response.status_code == 200
data = response.get_json()['data']
assert 'toner' in data['supplytypes']
assert 'black' in data['colors']
assert 'metered' in data['capacitytiers']
def test_create_and_list_model_supply(client, model, auth_headers):
create = client.post(
f'/api/printers/models/{model.modelnumberid}/supplies',
json={
'supplytype': 'toner', 'color': 'black', 'capacitytier': 'standard',
'partnumber': 'W2020A', 'marketingname': '414A Black', 'pageyield': 2400,
},
headers=auth_headers,
)
assert create.status_code == 201
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies')
assert listing.status_code == 200
supplies = listing.get_json()['data']['supplies']
assert len(supplies) == 1
assert supplies[0]['partnumber'] == 'W2020A'
def test_duplicate_partnumber_rejected(client, model, auth_headers):
payload = {'partnumber': 'W2020A', 'color': 'black'}
client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
json=payload, headers=auth_headers)
second = client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
json=payload, headers=auth_headers)
assert second.status_code == 409
def test_invalid_enum_rejected(client, model, auth_headers):
response = client.post(
f'/api/printers/models/{model.modelnumberid}/supplies',
json={'partnumber': 'X1', 'color': 'purple'},
headers=auth_headers,
)
assert response.status_code == 400
def test_create_requires_auth(client, model):
response = client.post(
f'/api/printers/models/{model.modelnumberid}/supplies',
json={'partnumber': 'X1'},
)
assert response.status_code == 401
def test_update_and_delete_supply(client, model, auth_headers):
created = client.post(
f'/api/printers/models/{model.modelnumberid}/supplies',
json={'partnumber': 'W2020A', 'color': 'black', 'capacitytier': 'standard'},
headers=auth_headers,
).get_json()['data']
supplyid = created['modelsupplyid']
updated = client.put(
f'/api/printers/supplies/{supplyid}',
json={'capacitytier': 'high', 'marketingname': '414X Black'},
headers=auth_headers,
)
assert updated.status_code == 200
assert updated.get_json()['data']['capacitytier'] == 'high'
deleted = client.delete(f'/api/printers/supplies/{supplyid}', headers=auth_headers)
assert deleted.status_code == 200
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies')
assert listing.get_json()['data']['supplies'] == []
def test_listmodels_reports_supplycount(client, model, auth_headers):
client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
json={'partnumber': 'W2020A', 'color': 'black'}, headers=auth_headers)
response = client.get('/api/printers/models', query_string={'search': 'C999'})
assert response.status_code == 200
rows = response.get_json()['data']
match = next(r for r in rows if r['modelnumberid'] == model.modelnumberid)
assert match['supplycount'] == 1
def test_seed_supplies_corrected_data(app, db):
"""The seed loads corrected part numbers (C405 colors, no B405 waste)."""
with app.app_context():
from plugins.printers.services import seedsupplies, lookupsupplies
from shopdb.core.models import Model
seedsupplies()
c405 = Model.query.filter(Model.modelnumber.ilike('%C405%')).first()
yellow = lookupsupplies(c405.modelnumberid, 'yellow', 'toner')
assert any(s['partnumber'] == '106R03501' for s in yellow)
b405 = Model.query.filter(Model.modelnumber.ilike('%B405%')).first()
assert lookupsupplies(b405.modelnumberid, 'none', 'waste') == []

View File

@@ -0,0 +1,142 @@
"""End-to-end test of ZabbixService against the mock Zabbix JSON-RPC server.
Exercises the real HTTP path: Bearer auth, host.get by IP, tag-filtered
item.get, supply parsing, ping, and the low-supplies roll-up.
"""
import socket
import pytest
from tools.mock_zabbix import serve_in_thread
def _free_port():
sock = socket.socket()
sock.bind(('127.0.0.1', 0))
port = sock.getsockname()[1]
sock.close()
return port
@pytest.fixture
def mock_zabbix(app):
"""Boot the mock server and point app config at it."""
port = _free_port()
server = serve_in_thread(port, 'testtoken')
app.config['ZABBIX_ENABLED'] = True
app.config['ZABBIX_URL'] = f'http://127.0.0.1:{port}'
app.config['ZABBIX_TOKEN'] = 'testtoken'
yield
server.shutdown()
def test_service_reachable_and_configured(app, db, mock_zabbix):
from plugins.printers.services import ZabbixService
with app.app_context():
service = ZabbixService()
assert service.isconfigured
assert service.isreachable
def test_gethostid_by_ip(app, db, mock_zabbix):
from plugins.printers.services import ZabbixService
with app.app_context():
service = ZabbixService()
assert service.gethostidbyip('10.20.30.40') == '10501'
assert service.gethostidbyip('1.2.3.4') is None
def test_supplies_parsed_with_color_and_status_filter(app, db, mock_zabbix):
from plugins.printers.services import ZabbixService
with app.app_context():
supplies = ZabbixService().getsuppliesbyip('10.20.30.40')
# disabled item (status=1) dropped, three active remain
names = {s['name'] for s in supplies}
assert 'Disabled Drum Level' not in names
assert len(supplies) == 3
black = next(s for s in supplies if s['name'].startswith('Black'))
assert black['color'] == 'black'
assert black['level'] == 4
def test_ping_status(app, db, mock_zabbix):
from plugins.printers.services import ZabbixService
with app.app_context():
assert ZabbixService().getpingstatus('10.20.30.40') == '1'
def test_bad_token_returns_no_data(app, db):
"""Wrong token -> API errors -> service returns nothing, fails soft."""
port = _free_port()
server = serve_in_thread(port, 'rightsecret')
app.config['ZABBIX_ENABLED'] = True
app.config['ZABBIX_URL'] = f'http://127.0.0.1:{port}'
app.config['ZABBIX_TOKEN'] = 'wrongsecret'
try:
with app.app_context():
assert ZabbixService_gethost(app) is None
finally:
server.shutdown()
def ZabbixService_gethost(app):
from plugins.printers.services import ZabbixService
return ZabbixService().gethostidbyip('10.20.30.40')
def test_low_supplies_rollup_flags_waste_and_toner(app, db, mock_zabbix):
"""The mock host has a 4% black toner and a 97%-full waste -> both flagged."""
from shopdb.core.models import (
Vendor, Model, Asset, AssetType, Communication, CommunicationType
)
from plugins.printers.models import Printer
from plugins.printers.api.asset_routes import _get_low_supplies_data
from shopdb.extensions import cache
with app.app_context():
# an HP printer at the mock's known IP
vendor = Vendor(vendor='HP')
db.session.add(vendor)
db.session.flush()
model = Model(modelnumber='HP M454', vendorid=vendor.vendorid)
db.session.add(model)
atype = AssetType.query.filter_by(assettype='printer').first()
if not atype:
atype = AssetType(assettype='printer', pluginname='printers',
tablename='printers')
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber='PRN-1', name='Test Printer',
assettypeid=atype.assettypeid)
db.session.add(asset)
db.session.flush()
printer = Printer(assetid=asset.assetid, vendorid=vendor.vendorid,
modelnumberid=model.modelnumberid)
db.session.add(printer)
comtype = CommunicationType.query.filter_by(comtype='IP').first()
if not comtype:
comtype = CommunicationType(comtype='IP')
db.session.add(comtype)
db.session.flush()
db.session.add(Communication(assetid=asset.assetid,
comtypeid=comtype.comtypeid,
ipaddress='10.20.30.40', isprimary=True))
db.session.commit()
cache.delete('printers_low_supplies')
data = _get_low_supplies_data()
assert data['summary']['total_checked'] == 1
assert len(data['printers']) == 1
row = data['printers'][0]
statuses = {s['name']: s['status'] for s in row['supplies']}
# 4% black toner is critical
assert statuses['Black Toner Level'] == 'critical'
# 97%-full waste (HP, non-inverted) -> 3% remaining -> critical
assert statuses['Waste Cartridge Level'] == 'critical'
# 60% cyan is fine
assert statuses['Cyan Toner Level'] == 'ok'