Files
shopdb-flask/tests/test_plugins/test_zabbix_history.py
cproudlock 7d66551622
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
Forecast from the right end of the window, and from the level shown
Four defects stacked into one nonsense report: cartridges at 20% claiming four
days, cartridges at 1% claiming weeks.

The root cause is a Zabbix API detail. `limit` caps the whole result set rather
than each item, and the query sorted ascending, so the cap kept the OLDEST rows
in the window. A four-cartridge printer polled every five minutes writes over
100k readings in 90 days; the forecast was fitted to the first few days of that
and nothing since. Every rate was real and every rate described a cartridge
thrown away three months ago. Nothing in the output looks wrong, which is why
it needed pinning in a test rather than a comment.

A 90-day burn rate does not need every individual poll, so a long window now
reads hourly trends - the table meant for this, a tenth of the rows, and kept
longer. Raw history serves short windows and any item a site keeps no trends
for. Both are fetched newest-first with the budget scaled per item.

Second, the countdown was computed from the last stored reading while the level
displayed was the live one, so the two could disagree by a whole cartridge. The
live level is now what the countdown divides. A live level far above the stored
run means it was swapped since the last reading, and that is reported as a
replacement rather than as a collapse in the burn rate.

Third, at or below 5% a cartridge reads as empty rather than as a slow drain.
At 1% losing a tenth of a point a day the arithmetic says ten days. The printer
is out of toner, and it is the first thing to order.

Fourth, the days-left column spanned the printer's rows, so the printer's
soonest figure was printed beside every supply it had. That alone accounts for
the shape of both complaints: a healthy cartridge wearing its neighbour's
deadline, and an empty one wearing a number that belonged to nothing on its row.

Also fixes float-typed supplies vanishing from any printer that also had an
integer-typed one - they live in different history tables and the fetch stopped
at whichever answered first.

Not verified against live data: Zabbix is not reachable from the dev box.
2026-08-13 10:54:23 -04:00

146 lines
5.0 KiB
Python

"""Tests for how level history is fetched out of Zabbix.
These are about the shape of the API call, not about the arithmetic done
afterwards, because that is where the forecast went wrong: the numbers were
computed correctly from the wrong rows.
Zabbix applies `limit` to the whole result set rather than to each item, so a
capped query sorted ascending returns the OLDEST rows in the window. A
four-cartridge printer polled every five minutes writes over 100k rows in 90
days; the report was reading the first few days of that and calling it current.
No arithmetic recovers from it, and nothing about the output looks wrong -
which is why it is pinned here.
"""
from plugins.printers.services.zabbix_service import ZabbixService
class RecordingService(ZabbixService):
"""A service whose API calls are recorded and answered from a script."""
def __init__(self, answers=None):
super().__init__()
self.calls = []
self.answers = answers or {}
def _apicall(self, method, params):
self.calls.append((method, params))
answer = self.answers.get(method)
if callable(answer):
return answer(params)
return answer or []
def paramsfor(self, method):
return [params for name, params in self.calls if name == method]
def rows(itemid, values, start=1780000000, step=3600):
"""history.get rows, newest first, as a DESC query returns them."""
return [{'itemid': str(itemid), 'clock': str(start + i * step),
'value': str(value)} for i, value in enumerate(values)][::-1]
def test_history_asks_for_the_newest_rows_not_the_oldest():
service = RecordingService({'history.get': rows(1, [90, 80, 70])})
service.gethistory(['1'], days=90)
params = service.paramsfor('history.get')[0]
assert params['sortorder'] == 'DESC'
assert params['sortfield'] == 'clock'
def test_history_budget_scales_with_the_number_of_items():
"""One cap shared by four cartridges is a quarter of a cartridge each."""
service = RecordingService({'history.get': rows(1, [90, 80])})
service.gethistory(['1', '2', '3', '4'], days=90)
params = service.paramsfor('history.get')[0]
assert params['limit'] == ZabbixService.HISTORY_PER_ITEM * 4
def test_history_comes_back_oldest_first():
"""The analysis reads a series forwards; DESC is a fetch detail."""
service = RecordingService({'history.get': rows(1, [90, 80, 70])})
result = service.gethistory(['1'], days=90)
clocks = [int(clock) for clock, _ in result['1']]
assert clocks == sorted(clocks)
assert [value for _, value in result['1']] == ['90', '80', '70']
def test_items_split_across_both_history_tables_are_all_returned():
"""A printer can carry an integer-typed supply and a float-typed one.
Stopping at the first table that answered dropped every item in the other,
and a missing cartridge looks exactly like a cartridge with no history.
"""
def answer(params):
if params['history'] == 3:
return rows(1, [90, 80])
return rows(2, [70, 60])
service = RecordingService({'history.get': answer})
result = service.gethistory(['1', '2'], days=90)
assert set(result) == {'1', '2'}
def test_the_second_table_is_only_asked_about_what_is_still_missing():
def answer(params):
return rows(1, [90, 80]) if params['history'] == 3 else []
service = RecordingService({'history.get': answer})
service.gethistory(['1', '2'], days=90)
assert service.paramsfor('history.get')[1]['itemids'] == ['2']
def test_a_long_window_reads_trends_rather_than_every_poll():
"""90 days of raw readings is 26k rows an item and hours of transfer for a
number that is a slope."""
service = RecordingService({'trend.get': [
{'itemid': '1', 'clock': '1780000000', 'value_avg': '80'},
{'itemid': '1', 'clock': '1779996400', 'value_avg': '90'},
]})
result = service.getlevelhistory(['1'], days=90)
assert service.paramsfor('trend.get')
assert not service.paramsfor('history.get')
assert [value for _, value in result['1']] == ['90', '80']
def test_a_short_window_reads_raw_history():
"""Over a few days the hourly average has too few points to fit."""
service = RecordingService({'history.get': rows(1, [90, 80])})
service.getlevelhistory(['1'], days=2)
assert service.paramsfor('history.get')
assert not service.paramsfor('trend.get')
def test_an_item_with_no_trends_falls_back_to_its_raw_history():
"""A site can keep trends for 0 days. That is a setting, not a fault."""
service = RecordingService({
'trend.get': [{'itemid': '1', 'clock': '1780000000', 'value_avg': '80'}],
'history.get': rows(2, [70, 60]),
})
result = service.getlevelhistory(['1', '2'], days=90)
assert service.paramsfor('history.get')[0]['itemids'] == ['2']
assert set(result) == {'1', '2'}
def test_no_items_asks_nothing():
service = RecordingService()
assert service.getlevelhistory([], days=90) == {}
assert service.calls == []