"""Tests for modem.py parsing. Fixtures copy the BGW320-505 (fw 6.35.8) markup with sanitized values.""" import os import sys from unittest.mock import MagicMock import pytest import requests sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) import modem # noqa: E402 def dom_table(heading, current, flags=(0, 0, 0, 0), limits=(-292, -90, -279, -100)): return f'''

{heading}  Currently {current}

 LowHigh
Alarm {flags[0]} (Threshold {limits[0]}) {flags[1]} (Threshold {limits[1]})
Warning {flags[2]} (Threshold {limits[2]}) {flags[3]} (Threshold {limits[3]})
''' def fiber_page(optical='Up', rx=-169, rx_flags=(0, 0, 0, 0), include_rx=True): rx_table = dom_table('Rx Power', rx, rx_flags) if include_rx else '' return f'''Fiber Status Optical WAN Operational Status {optical} Wave Length1270 nm Rx LOS State1 Vendor PNTESTPN {dom_table('Temperature', 34, limits=(-50, 95, -45, 90))} {dom_table('Vcc', 3, limits=(3, 3, 3, 3))} {dom_table('Tx Bias', 6, limits=(0, 500, 0, 400))} {dom_table('Tx Power', 33, limits=(-10, 59, 0, 49))} {rx_table}''' def broadband_page(connection='Up', pon='OPERATION (O5)'): return f'''Broadband Status Broadband Connection SourceFIBER Broadband Connection {connection} Broadband IPv4 Address203.0.113.10 Current Speed (Mbps)10000 Transmit Bytes200 Receive Bytes100 Receive Errors1 Transmit Errors2 Receive Drops3 Transmit Drops4 Transmit Packets7 Transmit Packets999 PON Link Status{pon} UNI Statusup''' SYSINFO = '''Model NumberBGW320-505 Software Version6.35.8 Time Since Last Reboot28951''' class TestBuildStatus: def test_scales_optical_power_from_tenths_of_dbm(self): d = modem.build_status(fiber_page(), broadband_page(), SYSINFO) assert d['optics']['rx_power']['value'] == -16.9 assert d['optics']['tx_power']['value'] == 3.3 assert d['optics']['temperature']['value'] == 34.0 def test_scales_thresholds(self): rx = modem.build_status(fiber_page(), broadband_page())['optics']['rx_power'] assert (rx['alarm_low'], rx['alarm_high'], rx['warn_low'], rx['warn_high']) == (-29.2, -9.0, -27.9, -10.0) def test_unit_ambiguous_readings_are_dropped_but_flags_kept(self): d = modem.build_status(fiber_page(), broadband_page()) assert d['optics']['tx_bias']['value'] is None assert d['optics']['vcc']['alarm_low'] is None assert d['optics']['vcc']['alarm_flag'] is False def test_system_and_wan_fields(self): d = modem.build_status(fiber_page(), broadband_page(), SYSINFO) assert d['model'] == 'BGW320-505' assert d['firmware'] == '6.35.8' assert d['uptime_s'] == 28951 assert d['broadband_state'] == 'Up' assert d['line_speed_mbps'] == 10000 assert d['counters'] == { 'rx_bytes': 100, 'tx_bytes': 200, 'rx_errors': 1, 'tx_errors': 2, 'rx_drops': 3, 'tx_drops': 4, } def test_repeated_label_keeps_first_value(self): assert modem.parse_rows(broadband_page())['Transmit Packets'] == '7' def test_sysinfo_is_optional(self): d = modem.build_status(fiber_page(), broadband_page(), '') assert d['model'] == '' and d['uptime_s'] is None def test_unknown_page_is_invalid(self): with pytest.raises(modem.ModemDataInvalid): modem.build_status('nope', broadband_page()) with pytest.raises(modem.ModemDataInvalid): modem.build_status(fiber_page(), 'nope') def test_up_link_without_rx_power_is_invalid(self): with pytest.raises(modem.ModemDataInvalid): modem.build_status(fiber_page(include_rx=False), broadband_page()) def test_cut_fiber_without_rx_power_is_not_invalid(self): d = modem.build_status(fiber_page(optical='Down', include_rx=False), broadband_page(connection='Down')) assert 'rx_power' not in d['optics'] assert modem.wan_problems(d) class TestWanProblems: def test_healthy_link_has_none(self): assert modem.wan_problems(modem.build_status(fiber_page(), broadband_page())) == [] @pytest.mark.parametrize('field,value,expected', [ ('broadband_state', 'Down', 'broadband connection is Down'), ('optical_state', 'Down', 'optical WAN is Down'), ('pon_state', 'INITIAL (O1)', 'PON link state is INITIAL (O1)'), ]) def test_each_failure_is_reported(self, field, value, expected): d = {'broadband_state': 'Up', 'optical_state': 'Up', 'pon_state': 'OPERATION (O5)', field: value} assert modem.wan_problems(d) == [expected] def test_missing_pon_field_is_not_a_problem(self): assert modem.wan_problems({'broadband_state': 'Up', 'optical_state': 'Up', 'pon_state': ''}) == [] def test_rx_los_pin_is_ignored(self): # Real gateways report Rx LOS State=1 on a healthy link (see fixture). assert modem.wan_problems(modem.build_status(fiber_page(), broadband_page())) == [] class TestMetricStatus: @pytest.mark.parametrize('rx,expected', [ (-169, 'ok'), (-279, 'warning'), (-285, 'warning'), (-292, 'alarm'), (-400, 'alarm'), (-100, 'warning'), (-90, 'alarm'), ]) def test_rx_thresholds(self, rx, expected): rx_metric = modem.parse_dom_metrics(fiber_page(rx=rx))['rx_power'] assert modem.metric_status(rx_metric) == expected def test_module_flags_win_over_reading(self): alarm = modem.parse_dom_metrics(fiber_page(rx_flags=(1, 0, 0, 0)))['rx_power'] warn = modem.parse_dom_metrics(fiber_page(rx_flags=(0, 0, 1, 0)))['rx_power'] assert modem.metric_status(alarm) == 'alarm' assert modem.metric_status(warn) == 'warning' def test_flag_only_metric(self): assert modem.metric_status(modem.parse_dom_metrics(fiber_page())['vcc']) == 'ok' class TestCounterRates: def test_rate_from_delta(self): assert modem.counter_rates({'rx_bytes': 300}, {'rx_bytes': 100}, 100)['rx_bytes'] == 2.0 def test_first_poll_is_none(self): assert modem.counter_rates({'rx_bytes': 300}, None, 100)['rx_bytes'] is None def test_reboot_counter_reset_is_none(self): assert modem.counter_rates({'rx_bytes': 5}, {'rx_bytes': 100}, 100)['rx_bytes'] is None class TestClient: def _client(self, **response): c = modem.ModemClient({'url': 'http://modem.test/'}) resp = MagicMock(status_code=response.get('status', 200), text=response.get('text', '')) c.session.get = MagicMock(return_value=resp) return c def test_network_failure_is_unreachable(self): c = self._client() c.session.get = MagicMock(side_effect=requests.ConnectionError('boom')) with pytest.raises(modem.ModemUnreachable): c.poll() def test_redirect_is_invalid_and_not_followed(self): c = self._client(status=302) with pytest.raises(modem.ModemDataInvalid): c.poll() assert c.session.get.call_args.kwargs['allow_redirects'] is False def test_login_page_is_invalid(self): with pytest.raises(modem.ModemDataInvalid, match='login'): self._client(text='Login').poll() class TestWanHistory: def ev(self, key, ts, device='RPDU'): return {'key': key, 'ts': ts, 'device': device, 'wan': 'WAN1'} def test_outage_opens_and_closes(self): h = {} modem.apply_wan_events(h, [self.ev(modem.LOG_WAN_FAILED, 100), self.ev(modem.LOG_WAN_RESTORED, 200)]) assert h['outages'] == [{'failed_at': 100, 'restored_at': 200, 'wan': 'WAN1'}] def test_events_are_handled_in_time_order(self): h = {} modem.apply_wan_events(h, [self.ev(modem.LOG_WAN_RESTORED, 200), self.ev(modem.LOG_WAN_FAILED, 100)]) assert h['outages'][0]['restored_at'] == 200 def test_outage_spanning_two_batches(self): h = {} modem.apply_wan_events(h, [self.ev(modem.LOG_WAN_FAILED, 100)]) assert h['outages'][0]['restored_at'] is None modem.apply_wan_events(h, [self.ev(modem.LOG_WAN_RESTORED, 200)]) assert h['outages'][0]['restored_at'] == 200 def test_repeat_failure_does_not_open_a_second_outage(self): h = {} modem.apply_wan_events(h, [self.ev(modem.LOG_WAN_FAILED, 100), self.ev(modem.LOG_WAN_FAILED, 150)]) assert len(h['outages']) == 1 def test_restored_without_failure_is_ignored(self): h = {} modem.apply_wan_events(h, [self.ev(modem.LOG_WAN_RESTORED, 200)]) assert h['outages'] == [] def test_blip_is_not_actionable_but_restart_and_instability_are(self): keys = [modem.LOG_WAN_FAILED, modem.LOG_WAN_RESTORED, modem.LOG_MODEM_RESTARTED, modem.LOG_WAN_UNSTABLE] got = modem.apply_wan_events({}, [self.ev(k, i) for i, k in enumerate(keys)]) assert [e['key'] for e in got] == [modem.LOG_MODEM_RESTARTED, modem.LOG_WAN_UNSTABLE] def test_history_is_bounded(self): h = {} for i in range(60): modem.apply_wan_events(h, [ self.ev(modem.LOG_WAN_FAILED, i * 2), self.ev(modem.LOG_WAN_RESTORED, i * 2 + 1), ]) assert len(h['outages']) == 50 def test_uptime_reset_confirms_logged_restart(self): h = {} modem.apply_wan_events(h, [self.ev(modem.LOG_MODEM_RESTARTED, 1000)]) modem.record_uptime_reset(h, 1023) assert h['restarts'] == [{'at': 1000, 'by': 'RPDU', 'confirmed': True}] def test_unexplained_uptime_reset_is_recorded(self): h = {} modem.record_uptime_reset(h, 5000) assert h['restarts'] == [{'at': 5000, 'by': None, 'confirmed': True}]