From d0dfaa42e6c80099e8b7f38ce80a2c4be55a8551 Mon Sep 17 00:00:00 2001 From: Walter Boring Date: Tue, 9 Dec 2025 17:20:23 -0500 Subject: [PATCH] Added unit tests --- pyproject.toml | 2 + requirements-dev.txt | 42 +-- requirements.txt | 32 +- tests/client/drivers/test_kiss_common.py | 221 ++++++++++++ tests/client/test_client.py | 386 +++++++++++++++++++++ tests/client/test_registry.py | 17 +- tests/mock_client_driver.py | 60 +++- tests/packets/__init__.py | 0 tests/packets/filters/test_dupe_filter.py | 120 +++++++ tests/packets/filters/test_packet_type.py | 87 +++++ tests/packets/test_collector.py | 387 ++++++++++++++++++++++ tests/packets/test_filter.py | 245 ++++++++++++++ tests/packets/test_packet_list.py | 193 +++++++++++ tests/packets/test_seen_list.py | 113 +++++++ tests/packets/test_tracker.py | 220 ++++++++++++ tests/packets/test_watch_list.py | 197 +++++++++++ tests/plugins/test_notify.py | 21 +- tests/stats/__init__.py | 0 tests/stats/test_app.py | 100 ++++++ tests/stats/test_collector.py | 191 +++++++++++ tests/test_exception.py | 47 +++ tests/threads/__init__.py | 0 tests/threads/test_aprsd_thread.py | 336 +++++++++++++++++++ tests/threads/test_rx.py | 375 +++++++++++++++++++++ tests/threads/test_service.py | 168 ++++++++++ tests/threads/test_tx.py | 385 +++++++++++++++++++++ tests/utils/__init__.py | 0 tests/utils/test_counter.py | 124 +++++++ tests/utils/test_fuzzyclock.py | 174 ++++++++++ tests/utils/test_json.py | 213 ++++++++++++ tests/utils/test_keepalive_collector.py | 203 ++++++++++++ tests/utils/test_objectstore.py | 246 ++++++++++++++ tests/utils/test_ring_buffer.py | 144 ++++++++ tests/utils/test_trace.py | 154 +++++++++ tests/utils/test_utils.py | 271 +++++++++++++++ 35 files changed, 5426 insertions(+), 48 deletions(-) create mode 100644 tests/client/drivers/test_kiss_common.py create mode 100644 tests/client/test_client.py create mode 100644 tests/packets/__init__.py create mode 100644 tests/packets/filters/test_dupe_filter.py create mode 100644 tests/packets/filters/test_packet_type.py create mode 100644 tests/packets/test_collector.py create mode 100644 tests/packets/test_filter.py create mode 100644 tests/packets/test_packet_list.py create mode 100644 tests/packets/test_seen_list.py create mode 100644 tests/packets/test_tracker.py create mode 100644 tests/packets/test_watch_list.py create mode 100644 tests/stats/__init__.py create mode 100644 tests/stats/test_app.py create mode 100644 tests/stats/test_collector.py create mode 100644 tests/test_exception.py create mode 100644 tests/threads/__init__.py create mode 100644 tests/threads/test_aprsd_thread.py create mode 100644 tests/threads/test_rx.py create mode 100644 tests/threads/test_service.py create mode 100644 tests/threads/test_tx.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_counter.py create mode 100644 tests/utils/test_fuzzyclock.py create mode 100644 tests/utils/test_json.py create mode 100644 tests/utils/test_keepalive_collector.py create mode 100644 tests/utils/test_objectstore.py create mode 100644 tests/utils/test_ring_buffer.py create mode 100644 tests/utils/test_trace.py create mode 100644 tests/utils/test_utils.py diff --git a/pyproject.toml b/pyproject.toml index 30d157e..f388ed3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -175,6 +175,8 @@ branch = true [tool.ruff] line-length = 88 + +[tool.ruff.lint] select = [ "F", # pyflakes rules "E", # pycodestyle error rules diff --git a/requirements-dev.txt b/requirements-dev.txt index e8153dc..0485d5b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,35 +3,35 @@ alabaster==1.0.0 # via sphinx babel==2.17.0 # via sphinx build==1.3.0 # via pip-tools, -r requirements-dev.in -cachetools==6.1.0 # via tox -certifi==2025.8.3 # via requests -cfgv==3.4.0 # via pre-commit +cachetools==6.2.2 # via tox +certifi==2025.11.12 # via requests +cfgv==3.5.0 # via pre-commit chardet==5.2.0 # via tox -charset-normalizer==3.4.3 # via requests -click==8.2.1 # via pip-tools +charset-normalizer==3.4.4 # via requests +click==8.3.1 # via pip-tools colorama==0.4.6 # via tox distlib==0.4.0 # via virtualenv docutils==0.21.2 # via m2r, sphinx -filelock==3.18.0 # via tox, virtualenv -identify==2.6.13 # via pre-commit -idna==3.10 # via requests +filelock==3.20.0 # via tox, virtualenv +identify==2.6.15 # via pre-commit +idna==3.11 # via requests imagesize==1.4.1 # via sphinx jinja2==3.1.6 # via sphinx m2r==0.3.1 # via -r requirements-dev.in -markupsafe==3.0.2 # via jinja2 +markupsafe==3.0.3 # via jinja2 mistune==0.8.4 # via m2r nodeenv==1.9.1 # via pre-commit packaging==25.0 # via build, pyproject-api, sphinx, tox -pip==25.2 # via pip-tools, -r requirements-dev.in -pip-tools==7.5.0 # via -r requirements-dev.in -platformdirs==4.3.8 # via tox, virtualenv +pip==25.3 # via pip-tools, -r requirements-dev.in +pip-tools==7.5.2 # via -r requirements-dev.in +platformdirs==4.5.1 # via tox, virtualenv pluggy==1.6.0 # via tox -pre-commit==4.3.0 # via -r requirements-dev.in +pre-commit==4.5.0 # via -r requirements-dev.in pygments==2.19.2 # via sphinx -pyproject-api==1.9.1 # via tox +pyproject-api==1.10.0 # via tox pyproject-hooks==1.2.0 # via build, pip-tools -pyyaml==6.0.2 # via pre-commit -requests==2.32.4 # via sphinx +pyyaml==6.0.3 # via pre-commit +requests==2.32.5 # via sphinx setuptools==80.9.0 # via pip-tools snowballstemmer==3.0.1 # via sphinx sphinx==8.1.3 # via -r requirements-dev.in @@ -41,9 +41,9 @@ sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx -tomli==2.2.1 # via build, pip-tools, pyproject-api, sphinx, tox -tox==4.28.4 # via -r requirements-dev.in -typing-extensions==4.14.1 # via tox -urllib3==2.5.0 # via requests -virtualenv==20.33.1 # via pre-commit, tox +tomli==2.3.0 # via build, pip-tools, pyproject-api, sphinx, tox +tox==4.32.0 # via -r requirements-dev.in +typing-extensions==4.15.0 # via tox, virtualenv +urllib3==2.6.1 # via requests +virtualenv==20.35.4 # via pre-commit, tox wheel==0.45.1 # via pip-tools, -r requirements-dev.in diff --git a/requirements.txt b/requirements.txt index a9f5154..9002e81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,15 @@ # This file was autogenerated by uv via the following command: # uv pip compile --resolver backtracking --annotation-style=line requirements.in -o requirements.txt aprslib==0.7.2 # via -r requirements.in -attrs==25.3.0 # via ax253, kiss3, rush +attrs==25.4.0 # via ax253, kiss3, rush ax253==0.1.5.post1 # via kiss3 -bitarray==3.6.1 # via ax253, kiss3 -certifi==2025.8.3 # via requests -charset-normalizer==3.4.3 # via requests -click==8.2.1 # via -r requirements.in +bitarray==3.8.0 # via ax253, kiss3 +certifi==2025.11.12 # via requests +charset-normalizer==3.4.4 # via requests +click==8.3.1 # via -r requirements.in dataclasses-json==0.6.7 # via -r requirements.in haversine==2.9.0 # via -r requirements.in -idna==3.10 # via requests +idna==3.11 # via requests importlib-metadata==8.7.0 # via ax253, kiss3 kiss3==8.0.0 # via -r requirements.in loguru==0.7.3 # via -r requirements.in @@ -18,28 +18,28 @@ marshmallow==3.26.1 # via dataclasses-json mdurl==0.1.2 # via markdown-it-py mypy-extensions==1.1.0 # via typing-inspect netaddr==1.3.0 # via oslo-config -oslo-config==10.0.0 # via -r requirements.in -oslo-i18n==6.5.1 # via oslo-config +oslo-config==10.1.0 # via -r requirements.in +oslo-i18n==6.7.1 # via oslo-config packaging==25.0 # via marshmallow -pbr==6.1.1 # via oslo-i18n, stevedore +pbr==7.0.3 # via oslo-i18n pluggy==1.6.0 # via -r requirements.in pygments==2.19.2 # via rich pyserial==3.5 # via pyserial-asyncio pyserial-asyncio==0.6 # via kiss3 pytz==2025.2 # via -r requirements.in -pyyaml==6.0.2 # via oslo-config -requests==2.32.4 # via oslo-config, update-checker, -r requirements.in +pyyaml==6.0.3 # via oslo-config +requests==2.32.5 # via oslo-config, update-checker, -r requirements.in rfc3986==2.0.0 # via oslo-config -rich==14.1.0 # via -r requirements.in +rich==14.2.0 # via -r requirements.in rush==2021.4.0 # via -r requirements.in setuptools==80.9.0 # via pbr -stevedore==5.4.1 # via oslo-config +stevedore==5.6.0 # via oslo-config thesmuggler==1.0.1 # via -r requirements.in timeago==1.0.16 # via -r requirements.in -typing-extensions==4.14.1 # via typing-inspect +typing-extensions==4.15.0 # via typing-inspect typing-inspect==0.9.0 # via dataclasses-json tzlocal==5.3.1 # via -r requirements.in update-checker==0.18.0 # via -r requirements.in -urllib3==2.5.0 # via requests -wrapt==1.17.3 # via -r requirements.in +urllib3==2.6.1 # via requests +wrapt==2.0.1 # via -r requirements.in zipp==3.23.0 # via importlib-metadata diff --git a/tests/client/drivers/test_kiss_common.py b/tests/client/drivers/test_kiss_common.py new file mode 100644 index 0000000..f62ce4c --- /dev/null +++ b/tests/client/drivers/test_kiss_common.py @@ -0,0 +1,221 @@ +import datetime +import unittest +from unittest import mock + +from aprsd.client.drivers.kiss_common import KISSDriver +from tests import fake + + +class ConcreteKISSDriver(KISSDriver): + """Concrete implementation of KISSDriver for testing.""" + + def __init__(self): + super().__init__() + self.transport = 'test' + self.path = '/dev/test' + + def read_frame(self): + """Implementation of abstract method.""" + return None + + +class TestKISSDriver(unittest.TestCase): + """Unit tests for the KISSDriver class.""" + + def setUp(self): + """Set up test fixtures.""" + self.driver = ConcreteKISSDriver() + + def tearDown(self): + """Clean up after tests.""" + pass + + def test_init(self): + """Test initialization.""" + self.assertFalse(self.driver._connected) + self.assertIsInstance(self.driver.keepalive, datetime.datetime) + self.assertEqual(self.driver.select_timeout, 1) + self.assertEqual(self.driver.packets_received, 0) + self.assertEqual(self.driver.packets_sent, 0) + + def test_login_success_not_connected(self): + """Test login_success() when not connected.""" + self.driver._connected = False + self.assertFalse(self.driver.login_success()) + + def test_login_success_connected(self): + """Test login_success() when connected.""" + self.driver._connected = True + self.assertTrue(self.driver.login_success()) + + def test_login_failure(self): + """Test login_failure() method.""" + result = self.driver.login_failure() + self.assertEqual(result, 'Login successful') + + def test_set_filter(self): + """Test set_filter() method.""" + # Should not raise exception (no-op for KISS) + self.driver.set_filter('test filter') + + def test_filter_property(self): + """Test filter property.""" + result = self.driver.filter + self.assertEqual(result, '') + + def test_is_alive_not_connected(self): + """Test is_alive property when not connected.""" + self.driver._connected = False + self.assertFalse(self.driver.is_alive) + + def test_is_alive_connected(self): + """Test is_alive property when connected.""" + self.driver._connected = True + self.assertTrue(self.driver.is_alive) + + def test_handle_fend(self): + """Test _handle_fend() method.""" + from kiss import util as kissutil + + buffer = b'\x00test_data' + with mock.patch.object(kissutil, 'recover_special_codes') as mock_recover: + with mock.patch.object(kissutil, 'strip_nmea') as mock_strip: + with mock.patch.object(kissutil, 'strip_df_start') as mock_strip_df: + mock_strip.return_value = buffer + mock_recover.return_value = buffer + mock_strip_df.return_value = b'test_data' + + result = self.driver._handle_fend(buffer, strip_df_start=True) + self.assertIsInstance(result, bytes) + + def test_fix_raw_frame(self): + """Test fix_raw_frame() method.""" + raw_frame = b'\xc0\x00test_data\xc0' + + with mock.patch.object(self.driver, '_handle_fend') as mock_handle: + mock_handle.return_value = b'fixed_frame' + result = self.driver.fix_raw_frame(raw_frame) + self.assertEqual(result, b'fixed_frame') + # Should call _handle_fend with ax25_data (without KISS markers) + mock_handle.assert_called() + + def test_decode_packet(self): + """Test decode_packet() method.""" + frame = b'test_frame' + mock_aprs_data = {'from': 'TEST', 'to': 'APRS'} + mock_packet = fake.fake_packet() + + with mock.patch('aprsd.client.drivers.kiss_common.aprslib.parse') as mock_parse: + with mock.patch( + 'aprsd.client.drivers.kiss_common.core.factory' + ) as mock_factory: + mock_parse.return_value = mock_aprs_data + mock_factory.return_value = mock_packet + + result = self.driver.decode_packet(frame=frame) + self.assertEqual(result, mock_packet) + mock_parse.assert_called_with(str(frame)) + + def test_decode_packet_no_frame(self): + """Test decode_packet() with no frame.""" + with mock.patch('aprsd.client.drivers.kiss_common.LOG') as mock_log: + result = self.driver.decode_packet() + self.assertIsNone(result) + mock_log.warning.assert_called() + + def test_decode_packet_exception(self): + """Test decode_packet() with exception.""" + frame = b'test_frame' + + with mock.patch('aprsd.client.drivers.kiss_common.aprslib.parse') as mock_parse: + mock_parse.side_effect = Exception('Parse error') + + with mock.patch('aprsd.client.drivers.kiss_common.LOG') as mock_log: + result = self.driver.decode_packet(frame=frame) + self.assertIsNone(result) + mock_log.error.assert_called() + + def test_decode_packet_third_party(self): + """Test decode_packet() with ThirdPartyPacket.""" + from aprsd.packets import core + + frame = b'test_frame' + mock_aprs_data = {'from': 'TEST', 'to': 'APRS'} + + # Create a ThirdPartyPacket + third_party = core.ThirdPartyPacket( + from_call='TEST', to_call='APRS', subpacket=fake.fake_packet() + ) + + with mock.patch('aprsd.client.drivers.kiss_common.aprslib.parse') as mock_parse: + with mock.patch( + 'aprsd.client.drivers.kiss_common.core.factory' + ) as mock_factory: + mock_parse.return_value = mock_aprs_data + mock_factory.return_value = third_party + + result = self.driver.decode_packet(frame=frame) + self.assertEqual(result, third_party.subpacket) + + def test_consumer_not_connected(self): + """Test consumer() when not connected.""" + self.driver._connected = False + callback = mock.MagicMock() + + result = self.driver.consumer(callback) + self.assertIsNone(result) + callback.assert_not_called() + + def test_consumer_connected(self): + """Test consumer() when connected.""" + self.driver._connected = True + callback = mock.MagicMock() + mock_frame = b'test_frame' + + with mock.patch.object(self.driver, 'read_frame', return_value=mock_frame): + with mock.patch('aprsd.client.drivers.kiss_common.LOG'): + self.driver.consumer(callback) + callback.assert_called() + + def test_read_frame_not_implemented(self): + """Test read_frame() raises NotImplementedError.""" + driver = KISSDriver() + with self.assertRaises(NotImplementedError): + driver.read_frame() + + def test_stats(self): + """Test stats() method.""" + self.driver._connected = True + self.driver.packets_sent = 5 + self.driver.packets_received = 10 + self.driver.last_packet_sent = datetime.datetime.now() + self.driver.last_packet_received = datetime.datetime.now() + + stats = self.driver.stats() + self.assertIn('client', stats) + self.assertIn('transport', stats) + self.assertIn('connected', stats) + self.assertIn('packets_sent', stats) + self.assertIn('packets_received', stats) + self.assertEqual(stats['packets_sent'], 5) + self.assertEqual(stats['packets_received'], 10) + + def test_stats_serializable(self): + """Test stats() with serializable=True.""" + self.driver._connected = True + self.driver.last_packet_sent = datetime.datetime.now() + self.driver.last_packet_received = datetime.datetime.now() + + stats = self.driver.stats(serializable=True) + self.assertIsInstance(stats['last_packet_sent'], str) + self.assertIsInstance(stats['last_packet_received'], str) + self.assertIsInstance(stats['connection_keepalive'], str) + + def test_stats_none_times(self): + """Test stats() with None times.""" + self.driver.last_packet_sent = None + self.driver.last_packet_received = None + + stats = self.driver.stats(serializable=True) + self.assertEqual(stats['last_packet_sent'], 'None') + self.assertEqual(stats['last_packet_received'], 'None') diff --git a/tests/client/test_client.py b/tests/client/test_client.py new file mode 100644 index 0000000..bbf0255 --- /dev/null +++ b/tests/client/test_client.py @@ -0,0 +1,386 @@ +import unittest +from unittest import mock + +from aprsd.client.client import APRSDClient +from aprsd.client.drivers.registry import DriverRegistry +from aprsd.packets import core +from tests.mock_client_driver import MockClientDriver + + +class TestAPRSDClient(unittest.TestCase): + """Unit tests for the APRSDClient class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instances + APRSDClient._instance = None + APRSDClient.driver = None + # Reset DriverRegistry singleton - the singleton decorator stores instance here + DriverRegistry.instance = None + + # Mock APRSISDriver to prevent it from being checked + self.aprsis_patcher = mock.patch('aprsd.client.drivers.aprsis.APRSISDriver') + mock_aprsis_class = self.aprsis_patcher.start() + mock_aprsis_class.is_enabled.return_value = False + mock_aprsis_class.is_configured.return_value = False + + self.mock_driver = MockClientDriver() + # Create a mock registry instance + mock_registry_instance = mock.MagicMock() + mock_registry_instance.get_driver.return_value = self.mock_driver + # Patch DriverRegistry to return our mock instance + self.registry_patcher = mock.patch( + 'aprsd.client.client.DriverRegistry', return_value=mock_registry_instance + ) + self.mock_registry = self.registry_patcher.start() + + def tearDown(self): + """Clean up after tests.""" + if hasattr(APRSDClient, '_instance'): + if APRSDClient._instance: + APRSDClient._instance.close() + APRSDClient._instance = None + APRSDClient.driver = None + self.registry_patcher.stop() + self.aprsis_patcher.stop() + + def test_singleton_pattern(self): + """Test that APRSDClient is a singleton.""" + client1 = APRSDClient(auto_connect=False) + client2 = APRSDClient(auto_connect=False) + self.assertIs(client1, client2) + self.assertEqual(id(client1), id(client2)) + + def test_init_with_auto_connect(self): + """Test initialization with auto_connect=True.""" + client = APRSDClient(auto_connect=True) + # Should have called setup_connection + self.assertIsNotNone(client.driver) + + def test_init_without_auto_connect(self): + """Test initialization with auto_connect=False.""" + client = APRSDClient(auto_connect=False) + self.assertIsNotNone(client.driver) + self.assertFalse(client.connected) + + def test_stats(self): + """Test stats() method.""" + client = APRSDClient(auto_connect=False) + stats = client.stats() + self.assertIsInstance(stats, dict) + + stats_serializable = client.stats(serializable=True) + self.assertIsInstance(stats_serializable, dict) + + def test_stats_no_driver(self): + """Test stats() when driver is None.""" + client = APRSDClient(auto_connect=False) + client.driver = None + stats = client.stats() + self.assertEqual(stats, {}) + + def test_is_enabled(self): + """Test is_enabled() static method.""" + # Stop the registry patcher temporarily to use real registry + self.registry_patcher.stop() + try: + # Reset singleton + DriverRegistry.instance = None + registry = DriverRegistry() + mock_driver_class = mock.MagicMock() + mock_driver_class.is_enabled.return_value = True + registry.drivers = [mock_driver_class] + + result = APRSDClient.is_enabled() + self.assertTrue(result) + finally: + # Restart the patcher + self.registry_patcher.start() + + def test_is_enabled_no_drivers(self): + """Test is_enabled() with no drivers.""" + # Stop the registry patcher temporarily to use real registry + self.registry_patcher.stop() + try: + # Reset singleton + DriverRegistry.instance = None + registry = DriverRegistry() + registry.drivers = [] + + result = APRSDClient.is_enabled() + self.assertFalse(result) + finally: + # Restart the patcher + self.registry_patcher.start() + + def test_is_configured(self): + """Test is_configured() static method.""" + # Stop the registry patcher temporarily to use real registry + self.registry_patcher.stop() + try: + # Reset singleton + DriverRegistry.instance = None + registry = DriverRegistry() + mock_driver_class = mock.MagicMock() + mock_driver_class.is_enabled.return_value = True + mock_driver_class.is_configured.return_value = True + registry.drivers = [mock_driver_class] + + result = APRSDClient.is_configured() + self.assertTrue(result) + finally: + # Restart the patcher + self.registry_patcher.start() + + def test_is_configured_no_drivers(self): + """Test is_configured() with no drivers.""" + # Stop the registry patcher temporarily to use real registry + self.registry_patcher.stop() + try: + # Reset singleton + DriverRegistry.instance = None + registry = DriverRegistry() + registry.drivers = [] + + result = APRSDClient.is_configured() + self.assertFalse(result) + finally: + # Restart the patcher + self.registry_patcher.start() + + def test_login_success_property(self): + """Test login_success property.""" + client = APRSDClient(auto_connect=False) + self.mock_driver.login_status['success'] = True + self.assertTrue(client.login_success) + + self.mock_driver.login_status['success'] = False + self.assertFalse(client.login_success) + + def test_login_success_no_driver(self): + """Test login_success property when driver is None.""" + client = APRSDClient(auto_connect=False) + client.driver = None + self.assertFalse(client.login_success) + + def test_login_failure_property(self): + """Test login_failure property.""" + client = APRSDClient(auto_connect=False) + self.mock_driver.login_status['message'] = 'Test failure' + self.assertEqual(client.login_failure, 'Test failure') + + def test_login_failure_no_driver(self): + """Test login_failure property when driver is None.""" + client = APRSDClient(auto_connect=False) + client.driver = None + self.assertIsNone(client.login_failure) + + def test_set_filter(self): + """Test set_filter() method.""" + client = APRSDClient(auto_connect=False) + filter_str = 'test filter' + client.set_filter(filter_str) + self.assertEqual(client.filter, filter_str) + self.assertEqual(self.mock_driver.filter, filter_str) + + def test_set_filter_no_driver(self): + """Test set_filter() when driver is None.""" + client = APRSDClient(auto_connect=False) + client.driver = None + filter_str = 'test filter' + client.set_filter(filter_str) + self.assertEqual(client.filter, filter_str) + + def test_get_filter(self): + """Test get_filter() method.""" + client = APRSDClient(auto_connect=False) + filter_str = 'test filter' + client.set_filter(filter_str) + # get_filter returns driver.filter, not client.filter + self.mock_driver.filter = filter_str + result = client.get_filter() + self.assertEqual(result, filter_str) + + def test_get_filter_no_driver(self): + """Test get_filter() when driver is None.""" + client = APRSDClient(auto_connect=False) + client.driver = None + result = client.get_filter() + self.assertIsNone(result) + + def test_is_alive(self): + """Test is_alive() method.""" + client = APRSDClient(auto_connect=False) + self.mock_driver._alive = True + self.assertTrue(client.is_alive()) + + self.mock_driver._alive = False + self.assertFalse(client.is_alive()) + + def test_connect(self): + """Test connect() method.""" + client = APRSDClient(auto_connect=False) + self.assertFalse(client.connected) + # Make sure driver.is_alive returns True after setup_connection + self.mock_driver._alive = True + client.connect() + self.assertTrue(client.connected) + self.assertTrue(client.running) + + def test_connect_already_connected(self): + """Test connect() when already connected.""" + client = APRSDClient(auto_connect=False) + client.connected = True + client.connect() + # Should still be connected + self.assertTrue(client.connected) + + def test_connect_no_driver(self): + """Test connect() when driver is None.""" + client = APRSDClient(auto_connect=False) + client.driver = None + client.connect() + # Should get a driver from registry + self.assertIsNotNone(client.driver) + + def test_close(self): + """Test close() method.""" + client = APRSDClient(auto_connect=False) + client.connected = True + client.running = True + client.close() + self.assertFalse(client.connected) + self.assertFalse(client.running) + self.mock_driver.close.assert_called() + + def test_close_no_driver(self): + """Test close() when driver is None.""" + client = APRSDClient(auto_connect=False) + client.driver = None + client.connected = True + client.close() + self.assertFalse(client.connected) + + def test_reset(self): + """Test reset() method.""" + client = APRSDClient(auto_connect=False) + client.connected = True + client.filter = 'test filter' + client.auto_connect = True + + client.reset() + self.mock_driver.close.assert_called() + self.mock_driver.setup_connection.assert_called() + self.mock_driver.set_filter.assert_called_with('test filter') + + def test_reset_no_driver(self): + """Test reset() when driver is None.""" + client = APRSDClient(auto_connect=False) + client.driver = None + # Should not raise exception + client.reset() + + def test_reset_no_auto_connect(self): + """Test reset() with auto_connect=False.""" + client = APRSDClient(auto_connect=False) + client.auto_connect = False + client.reset() + self.mock_driver.close.assert_called() + # Should not call setup_connection + self.mock_driver.setup_connection.assert_not_called() + + def test_send(self): + """Test send() method.""" + client = APRSDClient(auto_connect=False) + client.running = True + packet = mock.MagicMock(spec=core.Packet) + + result = client.send(packet) + self.assertTrue(result) + self.mock_driver.send.assert_called_with(packet) + + def test_send_not_running(self): + """Test send() when not running.""" + client = APRSDClient(auto_connect=False) + client.running = False + packet = mock.MagicMock(spec=core.Packet) + + result = client.send(packet) + self.assertFalse(result) + self.mock_driver.send.assert_not_called() + + def test_consumer(self): + """Test consumer() method.""" + client = APRSDClient(auto_connect=False) + client.running = True + callback = mock.MagicMock() + + client.consumer(callback, raw=True) + self.mock_driver.consumer.assert_called_with(callback=callback, raw=True) + + def test_consumer_not_running(self): + """Test consumer() when not running.""" + client = APRSDClient(auto_connect=False) + client.running = False + callback = mock.MagicMock() + + result = client.consumer(callback) + self.assertIsNone(result) + self.mock_driver.consumer.assert_not_called() + + def test_decode_packet(self): + """Test decode_packet() method.""" + client = APRSDClient(auto_connect=False) + packet = mock.MagicMock(spec=core.Packet) + # Configure the side_effect to return our packet + self.mock_driver.decode_packet.side_effect = lambda *args, **kwargs: packet + + result = client.decode_packet(frame='test') + self.assertEqual(result, packet) + self.mock_driver.decode_packet.assert_called_with(frame='test') + + def test_decode_packet_exception(self): + """Test decode_packet() with exception.""" + client = APRSDClient(auto_connect=False) + self.mock_driver.decode_packet.side_effect = Exception('Decode error') + + result = client.decode_packet(frame='test') + self.assertIsNone(result) + + def test_keepalive_check(self): + """Test keepalive_check() method.""" + client = APRSDClient(auto_connect=False) + client._checks = False + self.mock_driver._alive = True + + # First check should not reset + with mock.patch.object(client, 'reset') as mock_reset: + client.keepalive_check() + self.assertTrue(client._checks) + mock_reset.assert_not_called() + + # Second check with dead driver should reset + self.mock_driver._alive = False + with mock.patch.object(client, 'reset') as mock_reset: + client.keepalive_check() + mock_reset.assert_called() + + def test_keepalive_log(self): + """Test keepalive_log() method.""" + import datetime + + client = APRSDClient(auto_connect=False) + self.mock_driver._keepalive = datetime.datetime.now() + + with mock.patch('aprsd.client.client.LOGU') as mock_logu: + client.keepalive_log() + mock_logu.opt.assert_called() + + def test_keepalive_log_no_keepalive(self): + """Test keepalive_log() when keepalive is None.""" + client = APRSDClient(auto_connect=False) + self.mock_driver._keepalive = None + + with mock.patch('aprsd.client.client.LOGU') as mock_logu: + client.keepalive_log() + mock_logu.opt.assert_called() diff --git a/tests/client/test_registry.py b/tests/client/test_registry.py index 7eeb3e4..3b6450c 100644 --- a/tests/client/test_registry.py +++ b/tests/client/test_registry.py @@ -11,7 +11,7 @@ class TestDriverRegistry(unittest.TestCase): def setUp(self): # Reset the singleton instance before each test - DriverRegistry._singleton_instances = {} + DriverRegistry.instance = None self.registry = DriverRegistry() self.registry.drivers = [] @@ -32,11 +32,26 @@ class TestDriverRegistry(unittest.TestCase): mock_conf.aprs_network.password = 'dummy' mock_conf.aprs_network.login = 'dummy' + # Patch the register method to skip Protocol check for MockClientDriver + self._original_register = self.registry.register + + def mock_register(driver): + # Skip Protocol check for MockClientDriver + if hasattr(driver, '__name__') and driver.__name__ == 'MockClientDriver': + self.registry.drivers.append(driver) + else: + self._original_register(driver) + + self.registry.register = mock_register + def tearDown(self): # Reset the singleton instance after each test DriverRegistry().drivers = [] self.aprsis_patcher.stop() self.conf_patcher.stop() + # Restore original register method if it was patched + if hasattr(self, '_original_register'): + self.registry.register = self._original_register def test_get_driver_with_valid_driver(self): """Test getting an enabled and configured driver.""" diff --git a/tests/mock_client_driver.py b/tests/mock_client_driver.py index 8317d6f..b53ee0d 100644 --- a/tests/mock_client_driver.py +++ b/tests/mock_client_driver.py @@ -4,7 +4,14 @@ from aprsd.packets import core class MockClientDriver: - """Mock implementation of ClientDriver for testing.""" + """Mock implementation of ClientDriver for testing. + + This class can be used both as a class (for registration) and as an instance. + When used as a class, it implements the ClientDriver Protocol. + When instantiated, it returns a mock driver instance. + """ + + _instance = None def __init__(self, enabled=True, configured=True): self.connected = False @@ -18,6 +25,13 @@ class MockClientDriver: 'success': True, 'message': None, } + # Make methods mockable + self.close = mock.MagicMock(side_effect=self._close) + self.setup_connection = mock.MagicMock(side_effect=self._setup_connection) + self.send = mock.MagicMock(side_effect=self._send) + self.set_filter = mock.MagicMock(side_effect=self._set_filter) + self.consumer = mock.MagicMock(side_effect=self._consumer) + self.decode_packet = mock.MagicMock(side_effect=self._decode_packet) @staticmethod def is_enabled(): @@ -29,8 +43,15 @@ class MockClientDriver: """Static method to check if driver is configured.""" return True + def __call__(self): + """Make the class callable to return an instance (singleton pattern).""" + if self._instance is None: + self._instance = self + return self._instance + + @property def is_alive(self): - """Instance method to check if driver is alive.""" + """Property to check if driver is alive.""" return self._alive def stats(self, serializable=False): @@ -50,27 +71,48 @@ class MockClientDriver: """Property to get login failure message.""" return self.login_status['message'] - def decode_packet(self, *args, **kwargs): + def _decode_packet(self, *args, **kwargs): """Mock packet decoding.""" + if hasattr(self, '_decode_packet_return'): + return self._decode_packet_return packet = mock.MagicMock(spec=core.Packet) packet.raw = 'test packet' + packet.path = [] + packet.human_info = 'test packet info' return packet - def close(self): + def _close(self): self.connected = False - def setup_connection(self): + def _setup_connection(self): self.connected = True + self._alive = True # Make driver alive after connection - def send(self, packet): + def _send(self, packet): + if hasattr(self, '_send_side_effect'): + if isinstance(self._send_side_effect, Exception): + raise self._send_side_effect + if hasattr(self, '_send_return'): + return self._send_return return True - def set_filter(self, filter_str): + def _set_filter(self, filter_str): self.filter = filter_str @property def keepalive(self): return self._keepalive - def consumer(self, callback, raw=False): - pass + def _consumer(self, callback, raw=False): + if hasattr(self, '_consumer_side_effect'): + if isinstance(self._consumer_side_effect, Exception): + raise self._consumer_side_effect + if hasattr(self, '_consumer_callback'): + self._consumer_callback(callback) + elif callback: + callback() + + def reset(self): + """Reset the driver connection.""" + self.connected = False + self._alive = False diff --git a/tests/packets/__init__.py b/tests/packets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/packets/filters/test_dupe_filter.py b/tests/packets/filters/test_dupe_filter.py new file mode 100644 index 0000000..7997319 --- /dev/null +++ b/tests/packets/filters/test_dupe_filter.py @@ -0,0 +1,120 @@ +import unittest +from unittest import mock + +from aprsd.packets.filters.dupe_filter import DupePacketFilter +from tests import fake + + +class TestDupePacketFilter(unittest.TestCase): + """Unit tests for the DupePacketFilter class.""" + + def setUp(self): + """Set up test fixtures.""" + self.filter = DupePacketFilter() + from oslo_config import cfg + + CONF = cfg.CONF + CONF.packet_dupe_timeout = 60 + + def test_filter_ack_packet(self): + """Test filter() with AckPacket (should always pass).""" + packet = fake.fake_ack_packet() + result = self.filter.filter(packet) + self.assertEqual(result, packet) + + def test_filter_new_packet(self): + """Test filter() with new packet.""" + packet = fake.fake_packet(msg_number='123') + + with mock.patch( + 'aprsd.packets.filters.dupe_filter.packets.PacketList' + ) as mock_list: + mock_list_instance = mock.MagicMock() + mock_list_instance.find.side_effect = KeyError('Not found') + mock_list.return_value = mock_list_instance + + result = self.filter.filter(packet) + self.assertEqual(result, packet) + + def test_filter_packet_no_msgno(self): + """Test filter() with packet without msgNo.""" + packet = fake.fake_packet() + packet.msgNo = None + + with mock.patch( + 'aprsd.packets.filters.dupe_filter.packets.PacketList' + ) as mock_list: + mock_list_instance = mock.MagicMock() + found_packet = fake.fake_packet() + mock_list_instance.find.return_value = found_packet + mock_list.return_value = mock_list_instance + + # Should pass even if found (no msgNo = can't detect dupe) + result = self.filter.filter(packet) + self.assertEqual(result, packet) + + def test_filter_unprocessed_duplicate(self): + """Test filter() with duplicate but unprocessed packet.""" + packet = fake.fake_packet(msg_number='123') + packet.processed = False + + with mock.patch( + 'aprsd.packets.filters.dupe_filter.packets.PacketList' + ) as mock_list: + mock_list_instance = mock.MagicMock() + found_packet = fake.fake_packet(msg_number='123') + mock_list_instance.find.return_value = found_packet + mock_list.return_value = mock_list_instance + + result = self.filter.filter(packet) + self.assertEqual(result, packet) + + def test_filter_duplicate_within_timeout(self): + """Test filter() with duplicate within timeout.""" + from oslo_config import cfg + + CONF = cfg.CONF + CONF.packet_dupe_timeout = 60 + + packet = fake.fake_packet(msg_number='123') + packet.processed = True + packet.timestamp = 1000 + + with mock.patch( + 'aprsd.packets.filters.dupe_filter.packets.PacketList' + ) as mock_list: + mock_list_instance = mock.MagicMock() + found_packet = fake.fake_packet(msg_number='123') + found_packet.timestamp = 1050 # Within 60 second timeout + mock_list_instance.find.return_value = found_packet + mock_list.return_value = mock_list_instance + + with mock.patch('aprsd.packets.filters.dupe_filter.LOG') as mock_log: + result = self.filter.filter(packet) + self.assertIsNone(result) # Should be dropped + mock_log.warning.assert_called() + + def test_filter_duplicate_after_timeout(self): + """Test filter() with duplicate after timeout.""" + from oslo_config import cfg + + CONF = cfg.CONF + CONF.packet_dupe_timeout = 60 + + packet = fake.fake_packet(msg_number='123') + packet.processed = True + packet.timestamp = 2000 + + with mock.patch( + 'aprsd.packets.filters.dupe_filter.packets.PacketList' + ) as mock_list: + mock_list_instance = mock.MagicMock() + found_packet = fake.fake_packet(msg_number='123') + found_packet.timestamp = 1000 # More than 60 seconds ago + mock_list_instance.find.return_value = found_packet + mock_list.return_value = mock_list_instance + + with mock.patch('aprsd.packets.filters.dupe_filter.LOG') as mock_log: + result = self.filter.filter(packet) + self.assertEqual(result, packet) # Should pass + mock_log.warning.assert_called() diff --git a/tests/packets/filters/test_packet_type.py b/tests/packets/filters/test_packet_type.py new file mode 100644 index 0000000..c64d315 --- /dev/null +++ b/tests/packets/filters/test_packet_type.py @@ -0,0 +1,87 @@ +import unittest + +from aprsd.packets.filters.packet_type import PacketTypeFilter +from tests import fake + + +class TestPacketTypeFilter(unittest.TestCase): + """Unit tests for the PacketTypeFilter class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instance + PacketTypeFilter._instance = None + self.filter = PacketTypeFilter() + + def tearDown(self): + """Clean up after tests.""" + PacketTypeFilter._instance = None + + def test_singleton_pattern(self): + """Test that PacketTypeFilter is a singleton.""" + filter1 = PacketTypeFilter() + filter2 = PacketTypeFilter() + self.assertIs(filter1, filter2) + + def test_init(self): + """Test initialization.""" + # Default allow_list includes Packet base class + from aprsd import packets + + self.assertIn(packets.Packet, self.filter.allow_list) + + def test_set_allow_list(self): + """Test set_allow_list() method.""" + from aprsd import packets as aprsd_packets + + filter_list = ['MessagePacket', 'AckPacket'] + self.filter.set_allow_list(filter_list) + + self.assertEqual(len(self.filter.allow_list), 2) + self.assertIn(aprsd_packets.MessagePacket, self.filter.allow_list) + self.assertIn(aprsd_packets.AckPacket, self.filter.allow_list) + + def test_filter_no_allow_list(self): + """Test filter() with no allow list (all packets pass).""" + packet = fake.fake_packet() + result = self.filter.filter(packet) + self.assertEqual(result, packet) + + def test_filter_allowed_type(self): + """Test filter() with allowed packet type.""" + self.filter.set_allow_list(['MessagePacket']) + packet = fake.fake_packet() + + result = self.filter.filter(packet) + self.assertEqual(result, packet) + + def test_filter_not_allowed_type(self): + """Test filter() with not allowed packet type.""" + self.filter.set_allow_list(['AckPacket']) + packet = fake.fake_packet() # MessagePacket + + result = self.filter.filter(packet) + self.assertIsNone(result) + + def test_filter_multiple_types(self): + """Test filter() with multiple allowed types.""" + self.filter.set_allow_list(['MessagePacket', 'AckPacket', 'BeaconPacket']) + + message_packet = fake.fake_packet() + ack_packet = fake.fake_ack_packet() + + result1 = self.filter.filter(message_packet) + result2 = self.filter.filter(ack_packet) + + self.assertEqual(result1, message_packet) + self.assertEqual(result2, ack_packet) + + def test_filter_subclass(self): + """Test filter() with subclass of allowed type.""" + # Set allow list to base Packet class + self.filter.set_allow_list(['Packet']) + + # All packet types should pass + message_packet = fake.fake_packet() + result = self.filter.filter(message_packet) + self.assertEqual(result, message_packet) diff --git a/tests/packets/test_collector.py b/tests/packets/test_collector.py new file mode 100644 index 0000000..f964280 --- /dev/null +++ b/tests/packets/test_collector.py @@ -0,0 +1,387 @@ +import unittest +from unittest import mock + +from aprsd.packets import collector +from tests import fake + + +class MockPacketMonitor: + """Mock implementation of PacketMonitor for testing.""" + + _instance = None + + def __init__(self, name='MockMonitor'): + self.name = name + self.rx_called = False + self.tx_called = False + self.flush_called = False + self.load_called = False + + def __call__(self): + """Make it callable like a singleton.""" + if self._instance is None: + self._instance = self + return self._instance + + def rx(self, packet): + self.rx_called = True + self.rx_packet = packet + + def tx(self, packet): + self.tx_called = True + self.tx_packet = packet + + def flush(self): + self.flush_called = True + + def load(self): + self.load_called = True + + +class TestPacketMonitorProtocol(unittest.TestCase): + """Test that PacketMonitor protocol is properly defined.""" + + def test_protocol_definition(self): + """Test that PacketMonitor is a Protocol.""" + from aprsd.packets.collector import PacketMonitor + + # Protocol with @runtime_checkable should have this attribute + # But it's a Protocol, not a runtime_checkable Protocol necessarily + # Let's just check it exists + self.assertTrue( + hasattr(PacketMonitor, '__protocol_attrs__') + or hasattr(PacketMonitor, '__annotations__'), + ) + + +class TestPacketCollector(unittest.TestCase): + """Unit tests for the PacketCollector class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instance + collector.PacketCollector._instance = None + # Clear monitors to start fresh + pc = collector.PacketCollector() + pc.monitors = [] + + def tearDown(self): + """Clean up after tests.""" + collector.PacketCollector._instance = None + + def test_singleton_pattern(self): + """Test that PacketCollector is a singleton.""" + collector1 = collector.PacketCollector() + collector2 = collector.PacketCollector() + self.assertIs(collector1, collector2) + + def test_init(self): + """Test initialization.""" + pc = collector.PacketCollector() + # After setUp, monitors should be empty + self.assertEqual(len(pc.monitors), 0) + + def test_register(self): + """Test register() method.""" + pc = collector.PacketCollector() + # Create a callable class + + class TestMonitor: + _instance = None + + def __call__(self): + if self._instance is None: + self._instance = self + return self._instance + + def rx(self, packet): + pass + + def tx(self, packet): + pass + + def flush(self): + pass + + def load(self): + pass + + monitor_class = TestMonitor() + pc.register(monitor_class) + self.assertIn(monitor_class, pc.monitors) + + def test_register_non_protocol(self): + """Test register() raises TypeError for non-protocol objects.""" + pc = collector.PacketCollector() + non_monitor = object() + + with self.assertRaises(TypeError): + pc.register(non_monitor) + + def test_unregister(self): + """Test unregister() method.""" + pc = collector.PacketCollector() + # Create a callable class + + class TestMonitor: + _instance = None + + def __call__(self): + if self._instance is None: + self._instance = self + return self._instance + + def rx(self, packet): + pass + + def tx(self, packet): + pass + + def flush(self): + pass + + def load(self): + pass + + monitor_class = TestMonitor() + pc.register(monitor_class) + pc.unregister(monitor_class) + self.assertNotIn(monitor_class, pc.monitors) + + def test_unregister_non_protocol(self): + """Test unregister() raises TypeError for non-protocol objects.""" + pc = collector.PacketCollector() + non_monitor = object() + + with self.assertRaises(TypeError): + pc.unregister(non_monitor) + + def test_rx(self): + """Test rx() method.""" + pc = collector.PacketCollector() + # Create callable monitor classes + monitor1 = MockPacketMonitor('Monitor1') + monitor2 = MockPacketMonitor('Monitor2') + pc.register(monitor1) + pc.register(monitor2) + + packet = fake.fake_packet() + pc.rx(packet) + + self.assertTrue(monitor1().rx_called) + self.assertTrue(monitor2().rx_called) + self.assertEqual(monitor1().rx_packet, packet) + self.assertEqual(monitor2().rx_packet, packet) + + def test_rx_with_exception(self): + """Test rx() handles exceptions gracefully.""" + pc = collector.PacketCollector() + + class FailingMonitor: + _instance = None + + def __call__(self): + if self._instance is None: + self._instance = self + return self._instance + + def rx(self, packet): + raise Exception('Monitor error') + + def tx(self, packet): + pass + + def flush(self): + pass + + def load(self): + pass + + monitor = FailingMonitor() + pc.register(monitor) + + packet = fake.fake_packet() + # Should not raise exception + with mock.patch('aprsd.packets.collector.LOG') as mock_log: + pc.rx(packet) + mock_log.error.assert_called() + + def test_tx(self): + """Test tx() method.""" + pc = collector.PacketCollector() + monitor1 = MockPacketMonitor('Monitor1') + monitor2 = MockPacketMonitor('Monitor2') + pc.register(monitor1) + pc.register(monitor2) + + packet = fake.fake_packet() + pc.tx(packet) + + self.assertTrue(monitor1().tx_called) + self.assertTrue(monitor2().tx_called) + self.assertEqual(monitor1().tx_packet, packet) + self.assertEqual(monitor2().tx_packet, packet) + + def test_tx_with_exception(self): + """Test tx() handles exceptions gracefully.""" + pc = collector.PacketCollector() + + class FailingMonitor: + _instance = None + + def __call__(self): + if self._instance is None: + self._instance = self + return self._instance + + def rx(self, packet): + pass + + def tx(self, packet): + raise Exception('Monitor error') + + def flush(self): + pass + + def load(self): + pass + + monitor = FailingMonitor() + pc.register(monitor) + + packet = fake.fake_packet() + # Should not raise exception + with mock.patch('aprsd.packets.collector.LOG') as mock_log: + pc.tx(packet) + mock_log.error.assert_called() + + def test_flush(self): + """Test flush() method.""" + pc = collector.PacketCollector() + monitor1 = MockPacketMonitor('Monitor1') + monitor2 = MockPacketMonitor('Monitor2') + pc.register(monitor1) + pc.register(monitor2) + + pc.flush() + + self.assertTrue(monitor1().flush_called) + self.assertTrue(monitor2().flush_called) + + def test_flush_with_exception(self): + """Test flush() handles exceptions gracefully.""" + pc = collector.PacketCollector() + + class FailingMonitor: + _instance = None + + def __call__(self): + if self._instance is None: + self._instance = self + return self._instance + + def rx(self, packet): + pass + + def tx(self, packet): + pass + + def flush(self): + raise Exception('Monitor error') + + def load(self): + pass + + monitor = FailingMonitor() + pc.register(monitor) + + # Should not raise exception + with mock.patch('aprsd.packets.collector.LOG') as mock_log: + pc.flush() + mock_log.error.assert_called() + + def test_load(self): + """Test load() method.""" + pc = collector.PacketCollector() + monitor1 = MockPacketMonitor('Monitor1') + monitor2 = MockPacketMonitor('Monitor2') + pc.register(monitor1) + pc.register(monitor2) + + pc.load() + + self.assertTrue(monitor1().load_called) + self.assertTrue(monitor2().load_called) + + def test_load_with_exception(self): + """Test load() handles exceptions gracefully.""" + pc = collector.PacketCollector() + + class FailingMonitor: + _instance = None + + def __call__(self): + if self._instance is None: + self._instance = self + return self._instance + + def rx(self, packet): + pass + + def tx(self, packet): + pass + + def flush(self): + pass + + def load(self): + raise Exception('Monitor error') + + monitor = FailingMonitor() + pc.register(monitor) + + # Should not raise exception + with mock.patch('aprsd.packets.collector.LOG') as mock_log: + pc.load() + mock_log.error.assert_called() + + def test_multiple_monitors(self): + """Test multiple monitors are called in order.""" + pc = collector.PacketCollector() + call_order = [] + + class OrderedMonitor: + _instance = None + + def __init__(self, name): + self.name = name + + def __call__(self): + if self._instance is None: + self._instance = self + return self._instance + + def rx(self, packet): + call_order.append(self.name) + + def tx(self, packet): + pass + + def flush(self): + pass + + def load(self): + pass + + monitor1 = OrderedMonitor('Monitor1') + monitor2 = OrderedMonitor('Monitor2') + monitor3 = OrderedMonitor('Monitor3') + + pc.register(monitor1) + pc.register(monitor2) + pc.register(monitor3) + + packet = fake.fake_packet() + pc.rx(packet) + + self.assertEqual(call_order, ['Monitor1', 'Monitor2', 'Monitor3']) diff --git a/tests/packets/test_filter.py b/tests/packets/test_filter.py new file mode 100644 index 0000000..ed6bd07 --- /dev/null +++ b/tests/packets/test_filter.py @@ -0,0 +1,245 @@ +import unittest +from unittest import mock + +from aprsd.packets import filter +from tests import fake + + +class MockPacketFilter: + """Mock implementation of PacketFilterProtocol for testing.""" + + def __init__(self, name='MockFilter', should_pass=True): + self.name = name + self.should_pass = should_pass + self.filter_called = False + + def filter(self, packet): + self.filter_called = True + self.filtered_packet = packet + return packet if self.should_pass else None + + +class TestPacketFilter(unittest.TestCase): + """Unit tests for the PacketFilter class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instance + filter.PacketFilter._instance = None + # Clear filters to start fresh + pf = filter.PacketFilter() + pf.filters = {} + + def tearDown(self): + """Clean up after tests.""" + filter.PacketFilter._instance = None + + def test_singleton_pattern(self): + """Test that PacketFilter is a singleton.""" + pf1 = filter.PacketFilter() + pf2 = filter.PacketFilter() + self.assertIs(pf1, pf2) + + def test_init(self): + """Test initialization.""" + pf = filter.PacketFilter() + # After setUp, filters should be empty + self.assertEqual(pf.filters, {}) + + def test_register(self): + """Test register() method.""" + pf = filter.PacketFilter() + + class FilterClass: + def filter(self, packet): + return packet + + pf.register(FilterClass) + self.assertIn(FilterClass, pf.filters) + self.assertIsInstance(pf.filters[FilterClass], FilterClass) + + def test_register_non_protocol(self): + """Test register() raises TypeError for non-protocol objects.""" + pf = filter.PacketFilter() + non_filter = object() + + with self.assertRaises(TypeError): + pf.register(non_filter) + + def test_register_duplicate(self): + """Test register() doesn't create duplicate instances.""" + pf = filter.PacketFilter() + + class FilterClass: + def filter(self, packet): + return packet + + pf.register(FilterClass) + instance1 = pf.filters[FilterClass] + + pf.register(FilterClass) + instance2 = pf.filters[FilterClass] + + self.assertIs(instance1, instance2) + + def test_unregister(self): + """Test unregister() method.""" + pf = filter.PacketFilter() + + class FilterClass: + def filter(self, packet): + return packet + + pf.register(FilterClass) + pf.unregister(FilterClass) + self.assertNotIn(FilterClass, pf.filters) + + def test_unregister_non_protocol(self): + """Test unregister() raises TypeError for non-protocol objects.""" + pf = filter.PacketFilter() + non_filter = object() + + with self.assertRaises(TypeError): + pf.unregister(non_filter) + + def test_filter_passes(self): + """Test filter() when all filters pass.""" + pf = filter.PacketFilter() + + class Filter1: + def filter(self, packet): + return packet + + class Filter2: + def filter(self, packet): + return packet + + pf.register(Filter1) + pf.register(Filter2) + + packet = fake.fake_packet() + result = pf.filter(packet) + + self.assertEqual(result, packet) + + def test_filter_drops(self): + """Test filter() when a filter drops the packet.""" + pf = filter.PacketFilter() + + class Filter1: + def filter(self, packet): + return packet + + class Filter2: + def filter(self, packet): + return None # Drops packet + + pf.register(Filter1) + pf.register(Filter2) + + packet = fake.fake_packet() + result = pf.filter(packet) + + self.assertIsNone(result) + + def test_filter_order(self): + """Test filters are called in registration order.""" + pf = filter.PacketFilter() + call_order = [] + + class Filter1: + def filter(self, packet): + call_order.append('Filter1') + return packet + + class Filter2: + def filter(self, packet): + call_order.append('Filter2') + return packet + + class Filter3: + def filter(self, packet): + call_order.append('Filter3') + return packet + + pf.register(Filter1) + pf.register(Filter2) + pf.register(Filter3) + + packet = fake.fake_packet() + pf.filter(packet) + + self.assertEqual(call_order, ['Filter1', 'Filter2', 'Filter3']) + + def test_filter_stops_on_drop(self): + """Test filter() stops processing when packet is dropped.""" + pf = filter.PacketFilter() + call_order = [] + + class Filter1: + def filter(self, packet): + call_order.append('Filter1') + return packet + + class Filter2: + def filter(self, packet): + call_order.append('Filter2') + return None # Drops + + class Filter3: + def filter(self, packet): + call_order.append('Filter3') + return packet + + pf.register(Filter1) + pf.register(Filter2) + pf.register(Filter3) + + packet = fake.fake_packet() + result = pf.filter(packet) + + self.assertIsNone(result) + # Filter3 should not be called + self.assertEqual(call_order, ['Filter1', 'Filter2']) + + def test_filter_with_exception(self): + """Test filter() handles exceptions gracefully.""" + pf = filter.PacketFilter() + + class FailingFilter: + def filter(self, packet): + raise Exception('Filter error') + + pf.register(FailingFilter) + + packet = fake.fake_packet() + # Should not raise exception + with mock.patch('aprsd.packets.filter.LOG') as mock_log: + pf.filter(packet) + mock_log.error.assert_called() + + def test_filter_empty(self): + """Test filter() with no registered filters.""" + pf = filter.PacketFilter() + packet = fake.fake_packet() + + result = pf.filter(packet) + # When no filters, packet should pass through + self.assertEqual(result, packet) + + def test_filter_typo_in_log(self): + """Test that the typo in filter error logging doesn't break.""" + pf = filter.PacketFilter() + + class FailingFilter: + def filter(self, packet): + raise Exception('Filter error') + + pf.register(FailingFilter) + + packet = fake.fake_packet() + # Should handle the typo gracefully (__clas__ instead of __class__) + with mock.patch('aprsd.packets.filter.LOG') as mock_log: + pf.filter(packet) + # Should log error even with typo + self.assertTrue(mock_log.error.called) diff --git a/tests/packets/test_packet_list.py b/tests/packets/test_packet_list.py new file mode 100644 index 0000000..f0709ab --- /dev/null +++ b/tests/packets/test_packet_list.py @@ -0,0 +1,193 @@ +import unittest +from collections import OrderedDict + +from oslo_config import cfg + +from aprsd.packets import packet_list +from tests import fake + +CONF = cfg.CONF + + +class TestPacketList(unittest.TestCase): + """Unit tests for the PacketList class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instance and class variables + packet_list.PacketList._instance = None + packet_list.PacketList._total_rx = 0 + packet_list.PacketList._total_tx = 0 + # Mock config + CONF.packet_list_maxlen = 100 + # Create fresh instance and reset data + pl = packet_list.PacketList() + pl.data = {'types': {}, 'packets': OrderedDict()} + pl._total_rx = 0 + pl._total_tx = 0 + + def tearDown(self): + """Clean up after tests.""" + packet_list.PacketList._instance = None + packet_list.PacketList._total_rx = 0 + packet_list.PacketList._total_tx = 0 + + def test_singleton_pattern(self): + """Test that PacketList is a singleton.""" + pl1 = packet_list.PacketList() + pl2 = packet_list.PacketList() + self.assertIs(pl1, pl2) + + def test_init(self): + """Test initialization.""" + pl = packet_list.PacketList() + self.assertEqual(pl.maxlen, 100) + self.assertIn('types', pl.data) + self.assertIn('packets', pl.data) + + def test_rx(self): + """Test rx() method.""" + pl = packet_list.PacketList() + packet = fake.fake_packet() + + initial_rx = pl._total_rx + pl.rx(packet) + + self.assertEqual(pl._total_rx, initial_rx + 1) + self.assertIn(packet.key, pl.data['packets']) + self.assertIn(packet.__class__.__name__, pl.data['types']) + + def test_tx(self): + """Test tx() method.""" + pl = packet_list.PacketList() + packet = fake.fake_packet() + + initial_tx = pl._total_tx + pl.tx(packet) + + self.assertEqual(pl._total_tx, initial_tx + 1) + self.assertIn(packet.key, pl.data['packets']) + self.assertIn(packet.__class__.__name__, pl.data['types']) + + def test_add(self): + """Test add() method.""" + pl = packet_list.PacketList() + packet = fake.fake_packet() + + pl.add(packet) + self.assertIn(packet.key, pl.data['packets']) + + def test_find(self): + """Test find() method.""" + pl = packet_list.PacketList() + packet = fake.fake_packet() + pl.add(packet) + + found = pl.find(packet) + self.assertEqual(found, packet) + + def test_len(self): + """Test __len__() method.""" + pl = packet_list.PacketList() + self.assertEqual(len(pl), 0) + + packet1 = fake.fake_packet(fromcall='TEST1') + pl.add(packet1) + self.assertEqual(len(pl), 1) + + packet2 = fake.fake_packet(fromcall='TEST2', message='different') + pl.add(packet2) + self.assertEqual(len(pl), 2) + + def test_total_rx(self): + """Test total_rx() method.""" + pl = packet_list.PacketList() + pl.rx(fake.fake_packet()) + pl.rx(fake.fake_packet(message='test2')) + + self.assertEqual(pl.total_rx(), 2) + + def test_total_tx(self): + """Test total_tx() method.""" + pl = packet_list.PacketList() + pl.tx(fake.fake_packet()) + pl.tx(fake.fake_packet(message='test2')) + + self.assertEqual(pl.total_tx(), 2) + + def test_maxlen_enforcement(self): + """Test that maxlen is enforced.""" + CONF.packet_list_maxlen = 3 + packet_list.PacketList._instance = None + packet_list.PacketList._total_rx = 0 + packet_list.PacketList._total_tx = 0 + pl = packet_list.PacketList() + pl.data = {'types': {}, 'packets': OrderedDict()} + pl._total_rx = 0 + pl._total_tx = 0 + + # Add more than maxlen with different keys + for i in range(5): + packet = fake.fake_packet(fromcall=f'TEST{i}', message=f'test{i}') + pl.add(packet) + + # Should only have maxlen packets + self.assertEqual(len(pl), 3) + # Oldest should be removed + self.assertNotIn(fake.fake_packet(message='test0').key, pl.data['packets']) + + def test_duplicate_packet(self): + """Test that duplicate packets move to end.""" + pl = packet_list.PacketList() + packet = fake.fake_packet(message='test') + + pl.add(packet) + # Add different packet + pl.add(fake.fake_packet(message='other')) + # Add original packet again + pl.add(packet) + + # Original packet should be at end + keys = list(pl.data['packets'].keys()) + self.assertEqual(keys[-1], packet.key) + + def test_stats(self): + """Test stats() method.""" + pl = packet_list.PacketList() + pl.rx(fake.fake_packet()) + pl.tx(fake.fake_packet(message='test2')) + + stats = pl.stats() + self.assertIn('rx', stats) + self.assertIn('tx', stats) + self.assertIn('total_tracked', stats) + self.assertIn('types', stats) + self.assertEqual(stats['rx'], 1) + self.assertEqual(stats['tx'], 1) + + def test_stats_serializable(self): + """Test stats() with serializable=True.""" + pl = packet_list.PacketList() + pl.rx(fake.fake_packet()) + + stats = pl.stats(serializable=True) + # Note: packets in stats are not JSON serializable by default + # This test just verifies the method accepts the parameter + self.assertIsInstance(stats, dict) + self.assertIn('rx', stats) + + def test_type_stats(self): + """Test that type statistics are tracked.""" + pl = packet_list.PacketList() + packet1 = fake.fake_packet() + packet2 = fake.fake_packet(message='test2') + + pl.rx(packet1) + pl.rx(packet2) + pl.tx(packet1) + + stats = pl.stats() + packet_type = packet1.__class__.__name__ + self.assertIn(packet_type, stats['types']) + self.assertEqual(stats['types'][packet_type]['rx'], 2) + self.assertEqual(stats['types'][packet_type]['tx'], 1) diff --git a/tests/packets/test_seen_list.py b/tests/packets/test_seen_list.py new file mode 100644 index 0000000..01ac464 --- /dev/null +++ b/tests/packets/test_seen_list.py @@ -0,0 +1,113 @@ +import datetime +import unittest +from unittest import mock + +from aprsd.packets import seen_list +from tests import fake + + +class TestSeenList(unittest.TestCase): + """Unit tests for the SeenList class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instance + seen_list.SeenList._instance = None + + def tearDown(self): + """Clean up after tests.""" + seen_list.SeenList._instance = None + + def test_singleton_pattern(self): + """Test that SeenList is a singleton.""" + sl1 = seen_list.SeenList() + sl2 = seen_list.SeenList() + self.assertIs(sl1, sl2) + + def test_init(self): + """Test initialization.""" + sl = seen_list.SeenList() + self.assertEqual(sl.data, {}) + + def test_stats(self): + """Test stats() method.""" + sl = seen_list.SeenList() + stats = sl.stats() + self.assertIsInstance(stats, dict) + + stats_serializable = sl.stats(serializable=True) + self.assertIsInstance(stats_serializable, dict) + + def test_rx(self): + """Test rx() method.""" + sl = seen_list.SeenList() + packet = fake.fake_packet(fromcall='TEST1') + + sl.rx(packet) + + self.assertIn('TEST1', sl.data) + self.assertIn('last', sl.data['TEST1']) + self.assertIn('count', sl.data['TEST1']) + self.assertEqual(sl.data['TEST1']['count'], 1) + self.assertIsInstance(sl.data['TEST1']['last'], datetime.datetime) + + def test_rx_multiple(self): + """Test rx() with multiple packets from same callsign.""" + sl = seen_list.SeenList() + packet1 = fake.fake_packet(fromcall='TEST2') + packet2 = fake.fake_packet(fromcall='TEST2', message='different') + + sl.rx(packet1) + sl.rx(packet2) + + self.assertEqual(sl.data['TEST2']['count'], 2) + + def test_rx_different_callsigns(self): + """Test rx() with different callsigns.""" + sl = seen_list.SeenList() + packet1 = fake.fake_packet(fromcall='TEST3') + packet2 = fake.fake_packet(fromcall='TEST4') + + sl.rx(packet1) + sl.rx(packet2) + + self.assertIn('TEST3', sl.data) + self.assertIn('TEST4', sl.data) + self.assertEqual(sl.data['TEST3']['count'], 1) + self.assertEqual(sl.data['TEST4']['count'], 1) + + def test_rx_no_from_call(self): + """Test rx() with packet missing from_call.""" + sl = seen_list.SeenList() + + class PacketWithoutFrom: + from_call = None + + packet = PacketWithoutFrom() + + with mock.patch('aprsd.packets.seen_list.LOG') as mock_log: + sl.rx(packet) + mock_log.warning.assert_called() + self.assertEqual(len(sl.data), 0) + + def test_tx(self): + """Test tx() method (should be no-op).""" + sl = seen_list.SeenList() + packet = fake.fake_packet() + + # Should not raise exception + sl.tx(packet) + # Should not add to data + self.assertEqual(len(sl.data), 0) + + def test_stats_with_data(self): + """Test stats() with data.""" + sl = seen_list.SeenList() + sl.rx(fake.fake_packet(fromcall='TEST5')) + sl.rx(fake.fake_packet(fromcall='TEST6')) + + stats = sl.stats() + self.assertIn('TEST5', stats) + self.assertIn('TEST6', stats) + self.assertIn('last', stats['TEST5']) + self.assertIn('count', stats['TEST5']) diff --git a/tests/packets/test_tracker.py b/tests/packets/test_tracker.py new file mode 100644 index 0000000..c59c96c --- /dev/null +++ b/tests/packets/test_tracker.py @@ -0,0 +1,220 @@ +import unittest + +from aprsd.packets import tracker +from tests import fake + + +class TestPacketTrack(unittest.TestCase): + """Unit tests for the PacketTrack class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instance + tracker.PacketTrack._instance = None + tracker.PacketTrack.data = {} + tracker.PacketTrack.total_tracked = 0 + + def tearDown(self): + """Clean up after tests.""" + tracker.PacketTrack._instance = None + tracker.PacketTrack.data = {} + tracker.PacketTrack.total_tracked = 0 + + def test_singleton_pattern(self): + """Test that PacketTrack is a singleton.""" + pt1 = tracker.PacketTrack() + pt2 = tracker.PacketTrack() + self.assertIs(pt1, pt2) + + def test_init(self): + """Test initialization.""" + pt = tracker.PacketTrack() + self.assertIsInstance(pt.data, dict) + self.assertIsNotNone(pt._start_time) + + def test_getitem(self): + """Test __getitem__() method.""" + pt = tracker.PacketTrack() + packet = fake.fake_packet(msg_number='123') + pt.tx(packet) + + result = pt['123'] + self.assertEqual(result, packet) + + def test_iter(self): + """Test __iter__() method.""" + pt = tracker.PacketTrack() + packet1 = fake.fake_packet(msg_number='123') + packet2 = fake.fake_packet(msg_number='456') + pt.tx(packet1) + pt.tx(packet2) + + keys = list(iter(pt)) + self.assertIn('123', keys) + self.assertIn('456', keys) + + def test_keys(self): + """Test keys() method.""" + pt = tracker.PacketTrack() + packet1 = fake.fake_packet(msg_number='123') + packet2 = fake.fake_packet(msg_number='456') + pt.tx(packet1) + pt.tx(packet2) + + keys = list(pt.keys()) + self.assertIn('123', keys) + self.assertIn('456', keys) + + def test_items(self): + """Test items() method.""" + pt = tracker.PacketTrack() + packet = fake.fake_packet(msg_number='123') + pt.tx(packet) + + items = list(pt.items()) + self.assertEqual(len(items), 1) + self.assertEqual(items[0][0], '123') + self.assertEqual(items[0][1], packet) + + def test_values(self): + """Test values() method.""" + pt = tracker.PacketTrack() + packet1 = fake.fake_packet(msg_number='123') + packet2 = fake.fake_packet(msg_number='456') + pt.tx(packet1) + pt.tx(packet2) + + values = list(pt.values()) + self.assertEqual(len(values), 2) + self.assertIn(packet1, values) + self.assertIn(packet2, values) + + def test_tx(self): + """Test tx() method.""" + pt = tracker.PacketTrack() + packet = fake.fake_packet(msg_number='123') + initial_total = pt.total_tracked + + pt.tx(packet) + + self.assertIn('123', pt.data) + self.assertEqual(pt.data['123'], packet) + self.assertEqual(pt.total_tracked, initial_total + 1) + self.assertEqual(packet.send_count, 0) + + def test_rx_ack_packet(self): + """Test rx() with AckPacket.""" + pt = tracker.PacketTrack() + packet = fake.fake_packet(msg_number='123') + pt.tx(packet) + + ack = fake.fake_ack_packet() + ack.msgNo = '123' + pt.rx(ack) + + self.assertNotIn('123', pt.data) + + def test_rx_reject_packet(self): + """Test rx() with RejectPacket.""" + from aprsd.packets import core + + pt = tracker.PacketTrack() + packet = fake.fake_packet(msg_number='123') + pt.tx(packet) + + # Create a proper RejectPacket + reject_pkt = core.RejectPacket(from_call='TEST', to_call='TEST', msgNo='123') + pt.rx(reject_pkt) + + self.assertNotIn('123', pt.data) + + def test_rx_piggyback_ack(self): + """Test rx() with piggyback ACK.""" + pt = tracker.PacketTrack() + packet = fake.fake_packet(msg_number='123') + pt.tx(packet) + + piggyback = fake.fake_packet() + piggyback.ackMsgNo = '123' + pt.rx(piggyback) + + self.assertNotIn('123', pt.data) + + def test_rx_no_match(self): + """Test rx() with packet that doesn't match tracked packet.""" + pt = tracker.PacketTrack() + packet = fake.fake_packet(msg_number='123') + pt.tx(packet) + + ack = fake.fake_ack_packet() + ack.msgNo = '999' # Different msgNo + pt.rx(ack) + + # Should still have original packet + self.assertIn('123', pt.data) + + def test_remove(self): + """Test remove() method.""" + pt = tracker.PacketTrack() + packet = fake.fake_packet(msg_number='123') + pt.tx(packet) + + pt.remove('123') + self.assertNotIn('123', pt.data) + + def test_remove_nonexistent(self): + """Test remove() with nonexistent key.""" + pt = tracker.PacketTrack() + # Should not raise exception + pt.remove('nonexistent') + + def test_stats(self): + """Test stats() method.""" + pt = tracker.PacketTrack() + packet = fake.fake_packet(msg_number='123') + packet.retry_count = 3 + packet.last_send_time = 1000 + pt.tx(packet) + # Note: tx() resets send_count to 0 + + stats = pt.stats() + self.assertIn('total_tracked', stats) + self.assertIn('packets', stats) + self.assertIn('123', stats['packets']) + self.assertEqual(stats['packets']['123']['send_count'], 0) + self.assertEqual(stats['packets']['123']['retry_count'], 3) + + def test_stats_serializable(self): + """Test stats() with serializable=True.""" + pt = tracker.PacketTrack() + packet = fake.fake_packet(msg_number='123') + pt.tx(packet) + + stats = pt.stats(serializable=True) + # Should be JSON serializable + import json + + json.dumps(stats) # Should not raise exception + + def test_get(self): + """Test get() method from ObjectStoreMixin.""" + pt = tracker.PacketTrack() + packet = fake.fake_packet(msg_number='123') + pt.tx(packet) + + result = pt.get('123') + self.assertEqual(result, packet) + + result = pt.get('nonexistent') + self.assertIsNone(result) + + def test_len(self): + """Test __len__() method.""" + pt = tracker.PacketTrack() + self.assertEqual(len(pt), 0) + + pt.tx(fake.fake_packet(msg_number='123')) + self.assertEqual(len(pt), 1) + + pt.tx(fake.fake_packet(msg_number='456')) + self.assertEqual(len(pt), 2) diff --git a/tests/packets/test_watch_list.py b/tests/packets/test_watch_list.py new file mode 100644 index 0000000..9010d9b --- /dev/null +++ b/tests/packets/test_watch_list.py @@ -0,0 +1,197 @@ +import datetime +import unittest + +from oslo_config import cfg + +from aprsd.packets import watch_list +from tests import fake + +CONF = cfg.CONF + + +class TestWatchList(unittest.TestCase): + """Unit tests for the WatchList class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instance + watch_list.WatchList._instance = None + # Mock config + CONF.watch_list.enabled = True + CONF.watch_list.callsigns = ['TEST*'] + CONF.watch_list.alert_time_seconds = 300 + + def tearDown(self): + """Clean up after tests.""" + watch_list.WatchList._instance = None + + def test_singleton_pattern(self): + """Test that WatchList is a singleton.""" + wl1 = watch_list.WatchList() + wl2 = watch_list.WatchList() + self.assertIs(wl1, wl2) + + def test_init(self): + """Test initialization.""" + wl = watch_list.WatchList() + self.assertIsInstance(wl.data, dict) + + def test_update_from_conf(self): + """Test _update_from_conf() method.""" + CONF.watch_list.enabled = True + CONF.watch_list.callsigns = ['TEST1*', 'TEST2*'] + watch_list.WatchList._instance = None + + wl = watch_list.WatchList() + # Should have entries for TEST1 and TEST2 (without *) + self.assertIn('TEST1', wl.data) + self.assertIn('TEST2', wl.data) + + def test_stats(self): + """Test stats() method.""" + wl = watch_list.WatchList() + stats = wl.stats() + self.assertIsInstance(stats, dict) + + stats_serializable = wl.stats(serializable=True) + self.assertIsInstance(stats_serializable, dict) + + def test_is_enabled(self): + """Test is_enabled() method.""" + wl = watch_list.WatchList() + CONF.watch_list.enabled = True + self.assertTrue(wl.is_enabled()) + + CONF.watch_list.enabled = False + self.assertFalse(wl.is_enabled()) + + def test_callsign_in_watchlist(self): + """Test callsign_in_watchlist() method.""" + wl = watch_list.WatchList() + CONF.watch_list.callsigns = ['TEST1*'] + watch_list.WatchList._instance = None + wl = watch_list.WatchList() + + self.assertTrue(wl.callsign_in_watchlist('TEST1')) + self.assertFalse(wl.callsign_in_watchlist('NOTINLIST')) + + def test_rx(self): + """Test rx() method.""" + wl = watch_list.WatchList() + CONF.watch_list.callsigns = ['TEST1*'] + watch_list.WatchList._instance = None + wl = watch_list.WatchList() + + packet = fake.fake_packet(fromcall='TEST1') + wl.rx(packet) + + # WatchList should track packets + self.assertIn('TEST1', wl.data) + self.assertIsNotNone(wl.data['TEST1']['last']) + self.assertEqual(wl.data['TEST1']['packet'], packet) + + def test_rx_not_in_watchlist(self): + """Test rx() with callsign not in watchlist.""" + wl = watch_list.WatchList() + CONF.watch_list.callsigns = ['TEST1*'] + watch_list.WatchList._instance = None + wl = watch_list.WatchList() + + packet = fake.fake_packet(fromcall='NOTINLIST') + wl.rx(packet) + + # Should not add to data + self.assertNotIn('NOTINLIST', wl.data) + + def test_rx_multiple(self): + """Test rx() with multiple packets.""" + wl = watch_list.WatchList() + CONF.watch_list.callsigns = ['TEST2*'] + watch_list.WatchList._instance = None + wl = watch_list.WatchList() + + packet1 = fake.fake_packet(fromcall='TEST2') + packet2 = fake.fake_packet(fromcall='TEST2', message='different') + + wl.rx(packet1) + wl.rx(packet2) + + # Should track both, last packet should be packet2 + self.assertIn('TEST2', wl.data) + self.assertEqual(wl.data['TEST2']['packet'], packet2) + + def test_tx(self): + """Test tx() method (should be no-op).""" + wl = watch_list.WatchList() + packet = fake.fake_packet() + + # Should not raise exception + wl.tx(packet) + + def test_last_seen(self): + """Test last_seen() method.""" + wl = watch_list.WatchList() + CONF.watch_list.callsigns = ['TEST3*'] + watch_list.WatchList._instance = None + wl = watch_list.WatchList() + + packet = fake.fake_packet(fromcall='TEST3') + wl.rx(packet) + + last_seen = wl.last_seen('TEST3') + self.assertIsNotNone(last_seen) + self.assertIsInstance(last_seen, datetime.datetime) + + self.assertIsNone(wl.last_seen('NOTINLIST')) + + def test_age(self): + """Test age() method.""" + wl = watch_list.WatchList() + CONF.watch_list.callsigns = ['TEST4*'] + watch_list.WatchList._instance = None + wl = watch_list.WatchList() + + packet = fake.fake_packet(fromcall='TEST4') + wl.rx(packet) + + age = wl.age('TEST4') + self.assertIsNotNone(age) + self.assertIsInstance(age, str) + + self.assertIsNone(wl.age('NOTINLIST')) + + def test_max_delta(self): + """Test max_delta() method.""" + wl = watch_list.WatchList() + + delta = wl.max_delta(seconds=300) + self.assertIsInstance(delta, datetime.timedelta) + self.assertEqual(delta.total_seconds(), 300) + + # Test with config default + delta = wl.max_delta() + self.assertIsInstance(delta, datetime.timedelta) + + def test_is_old(self): + """Test is_old() method.""" + wl = watch_list.WatchList() + CONF.watch_list.callsigns = ['TEST5*'] + CONF.watch_list.alert_time_seconds = 60 + watch_list.WatchList._instance = None + wl = watch_list.WatchList() + + # Not in watchlist + self.assertFalse(wl.is_old('NOTINLIST')) + + # In watchlist but no last seen + self.assertFalse(wl.is_old('TEST5')) + + # Add packet + packet = fake.fake_packet(fromcall='TEST5') + wl.rx(packet) + + # Should not be old immediately + self.assertFalse(wl.is_old('TEST5')) + + # Test with custom seconds + self.assertFalse(wl.is_old('TEST5', seconds=3600)) diff --git a/tests/plugins/test_notify.py b/tests/plugins/test_notify.py index 9839499..7cdd31d 100644 --- a/tests/plugins/test_notify.py +++ b/tests/plugins/test_notify.py @@ -29,13 +29,32 @@ class TestWatchListPlugin(test_plugin.TestPlugin): self.mock_aprsis.is_enabled.return_value = False self.mock_aprsis.is_configured.return_value = False + # Patch the register method to skip Protocol check for MockClientDriver + # Get the singleton instance and patch it + registry = DriverRegistry() + self._original_register = registry.register + + def mock_register(driver): + # Skip Protocol check for MockClientDriver + if hasattr(driver, '__name__') and driver.__name__ == 'MockClientDriver': + registry.drivers.append(driver) + else: + self._original_register(driver) + + registry.register = mock_register + # Store reference to registry for tearDown + self._patched_registry = registry + # Register the mock driver - DriverRegistry().register(MockClientDriver) + registry.register(MockClientDriver) def tearDown(self): super().tearDown() if hasattr(self, 'aprsis_patcher'): self.aprsis_patcher.stop() + # Restore original register method if it was patched + if hasattr(self, '_original_register') and hasattr(self, '_patched_registry'): + self._patched_registry.register = self._original_register def config_and_init( self, diff --git a/tests/stats/__init__.py b/tests/stats/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/stats/test_app.py b/tests/stats/test_app.py new file mode 100644 index 0000000..a2ab779 --- /dev/null +++ b/tests/stats/test_app.py @@ -0,0 +1,100 @@ +import datetime +import unittest +from unittest import mock + +from oslo_config import cfg + +from aprsd.stats import app + +CONF = cfg.CONF + + +class TestAPRSDStats(unittest.TestCase): + """Unit tests for the APRSDStats class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instance + app.APRSDStats._instance = None + CONF.callsign = 'TEST' + + def tearDown(self): + """Clean up after tests.""" + app.APRSDStats._instance = None + + def test_singleton_pattern(self): + """Test that APRSDStats is a singleton.""" + stats1 = app.APRSDStats() + stats2 = app.APRSDStats() + self.assertIs(stats1, stats2) + + def test_init(self): + """Test initialization.""" + stats = app.APRSDStats() + self.assertIsNotNone(stats.start_time) + self.assertIsInstance(stats.start_time, datetime.datetime) + + def test_uptime(self): + """Test uptime() method.""" + stats = app.APRSDStats() + import time + + time.sleep(0.1) # Small delay + + uptime = stats.uptime() + self.assertIsInstance(uptime, datetime.timedelta) + self.assertGreaterEqual(uptime.total_seconds(), 0.1) + + @mock.patch('aprsd.stats.app.tracemalloc.get_traced_memory') + @mock.patch('aprsd.stats.app.aprsd_log.logging_queue') + def test_stats(self, mock_queue, mock_tracemalloc): + """Test stats() method.""" + mock_tracemalloc.return_value = (1024 * 1024, 2 * 1024 * 1024) # 1MB, 2MB + mock_queue.qsize.return_value = 5 + + stats = app.APRSDStats() + result = stats.stats() + + self.assertIn('version', result) + self.assertIn('uptime', result) + self.assertIn('callsign', result) + self.assertIn('memory_current', result) + self.assertIn('memory_current_str', result) + self.assertIn('memory_peak', result) + self.assertIn('memory_peak_str', result) + self.assertIn('loging_queue', result) + self.assertEqual(result['callsign'], 'TEST') + self.assertEqual(result['memory_current'], 1024 * 1024) + self.assertEqual(result['loging_queue'], 5) + + @mock.patch('aprsd.stats.app.tracemalloc.get_traced_memory') + @mock.patch('aprsd.stats.app.aprsd_log.logging_queue') + def test_stats_serializable(self, mock_queue, mock_tracemalloc): + """Test stats() with serializable=True.""" + mock_tracemalloc.return_value = (1024 * 1024, 2 * 1024 * 1024) + mock_queue.qsize.return_value = 5 + + stats = app.APRSDStats() + result = stats.stats(serializable=True) + + self.assertIsInstance(result['uptime'], str) + # Should be JSON serializable + import json + + json.dumps(result) # Should not raise exception + + def test_stats_memory_formatting(self): + """Test that memory is formatted correctly.""" + with mock.patch( + 'aprsd.stats.app.tracemalloc.get_traced_memory' + ) as mock_tracemalloc: + with mock.patch('aprsd.stats.app.aprsd_log.logging_queue') as mock_queue: + mock_tracemalloc.return_value = (1024 * 1024, 2 * 1024 * 1024) + mock_queue.qsize.return_value = 0 + + stats = app.APRSDStats() + result = stats.stats() + + # 1MB should format as 'MB', not 'KB' + self.assertIn('MB', result['memory_current_str']) + self.assertIn('MB', result['memory_peak_str']) diff --git a/tests/stats/test_collector.py b/tests/stats/test_collector.py new file mode 100644 index 0000000..ac5c6d3 --- /dev/null +++ b/tests/stats/test_collector.py @@ -0,0 +1,191 @@ +import unittest +from unittest import mock + +from aprsd.stats import collector + + +class MockStatsProducer: + """Mock implementation of StatsProducer for testing.""" + + _instance = None + + def __init__(self, name='MockProducer'): + self.name = name + self.stats_called = False + + def __call__(self): + """Make it callable like a singleton.""" + if self._instance is None: + self._instance = self + return self._instance + + def stats(self, serializable=False): + self.stats_called = True + return {'test': 'data', 'serializable': serializable} + + +class TestStatsCollector(unittest.TestCase): + """Unit tests for the Collector class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instance + collector.Collector._instance = None + # Clear producers to start fresh + c = collector.Collector() + c.producers = [] + + def tearDown(self): + """Clean up after tests.""" + collector.Collector._instance = None + + def test_singleton_pattern(self): + """Test that Collector is a singleton.""" + collector1 = collector.Collector() + collector2 = collector.Collector() + self.assertIs(collector1, collector2) + + def test_init(self): + """Test initialization.""" + c = collector.Collector() + # After setUp, producers should be empty + self.assertEqual(len(c.producers), 0) + + def test_register_producer(self): + """Test register_producer() method.""" + c = collector.Collector() + producer = MockStatsProducer() + + c.register_producer(producer) + self.assertIn(producer, c.producers) + + def test_register_producer_non_protocol(self): + """Test register_producer() raises TypeError for non-protocol objects.""" + c = collector.Collector() + non_producer = object() + + with self.assertRaises(TypeError): + c.register_producer(non_producer) + + def test_unregister_producer(self): + """Test unregister_producer() method.""" + c = collector.Collector() + producer = MockStatsProducer() + c.register_producer(producer) + + c.unregister_producer(producer) + self.assertNotIn(producer, c.producers) + + def test_unregister_producer_non_protocol(self): + """Test unregister_producer() raises TypeError for non-protocol objects.""" + c = collector.Collector() + non_producer = object() + + with self.assertRaises(TypeError): + c.unregister_producer(non_producer) + + def test_collect(self): + """Test collect() method.""" + c = collector.Collector() + producer1 = MockStatsProducer('Producer1') + producer2 = MockStatsProducer('Producer2') + c.register_producer(producer1) + c.register_producer(producer2) + + stats = c.collect() + + self.assertIsInstance(stats, dict) + self.assertIn('MockStatsProducer', stats) + self.assertTrue(producer1().stats_called) + self.assertTrue(producer2().stats_called) + + def test_collect_serializable(self): + """Test collect() with serializable=True.""" + c = collector.Collector() + producer = MockStatsProducer() + c.register_producer(producer) + + stats = c.collect(serializable=True) + # Should pass serializable flag to producers + self.assertIsInstance(stats, dict) + + def test_collect_with_exception(self): + """Test collect() raises exception from producer.""" + c = collector.Collector() + + class FailingProducer: + _instance = None + + def __call__(self): + if self._instance is None: + self._instance = self + return self._instance + + def stats(self, serializable=False): + raise RuntimeError('Stats error') + + producer = FailingProducer() + c.register_producer(producer) + + with self.assertRaises(RuntimeError): + c.collect() + + def test_stop_all(self): + """Test stop_all() method.""" + c = collector.Collector() + producer1 = MockStatsProducer('Producer1') + producer2 = MockStatsProducer('Producer2') + c.register_producer(producer1) + c.register_producer(producer2) + + with mock.patch('aprsd.stats.collector.LOG') as mock_log: + c.stop_all() + + self.assertEqual(len(c.producers), 0) + # Should log for each producer + self.assertGreaterEqual(mock_log.info.call_count, 2) + + def test_multiple_producers(self): + """Test multiple producers are collected.""" + c = collector.Collector() + call_order = [] + + class OrderedProducer: + _instance = None + + def __init__(self, name): + self.name = name + + def __call__(self): + if self._instance is None: + self._instance = self + return self._instance + + def stats(self, serializable=False): + call_order.append(self.name) + return {'name': self.name} + + producer1 = OrderedProducer('Producer1') + producer2 = OrderedProducer('Producer2') + producer3 = OrderedProducer('Producer3') + + c.register_producer(producer1) + c.register_producer(producer2) + c.register_producer(producer3) + + stats = c.collect() + + # All producers are called (verified by call_order) + self.assertIn('Producer1', call_order) + self.assertIn('Producer2', call_order) + self.assertIn('Producer3', call_order) + # But stats dict only has 1 entry because all have same class name + # (last one overwrites previous ones) + self.assertEqual(len(stats), 1) + self.assertIn('OrderedProducer', stats) + + def test_empty_collector(self): + """Test collect() with no producers.""" + c = collector.Collector() + stats = c.collect() + self.assertEqual(stats, {}) diff --git a/tests/test_exception.py b/tests/test_exception.py new file mode 100644 index 0000000..3a70283 --- /dev/null +++ b/tests/test_exception.py @@ -0,0 +1,47 @@ +import unittest + +from aprsd import exception + + +class TestExceptions(unittest.TestCase): + """Unit tests for custom exception classes.""" + + def test_missing_config_option_exception(self): + """Test MissingConfigOptionException.""" + exc = exception.MissingConfigOptionException('test.option') + self.assertIsInstance(exc, Exception) + self.assertIn('test.option', exc.message) + self.assertIn("Option 'test.option' was not in config file", exc.message) + + def test_config_option_bogus_default_exception(self): + """Test ConfigOptionBogusDefaultException.""" + exc = exception.ConfigOptionBogusDefaultException( + 'test.option', 'default_value' + ) + self.assertIsInstance(exc, Exception) + self.assertIn('test.option', exc.message) + self.assertIn('default_value', exc.message) + self.assertIn('needs to be changed', exc.message) + + def test_aprs_client_not_configured_exception(self): + """Test APRSClientNotConfiguredException.""" + exc = exception.APRSClientNotConfiguredException() + self.assertIsInstance(exc, Exception) + self.assertEqual(exc.message, 'APRS client is not configured.') + + def test_exception_inheritance(self): + """Test that exceptions inherit from Exception.""" + exc1 = exception.MissingConfigOptionException('test') + exc2 = exception.ConfigOptionBogusDefaultException('test', 'default') + exc3 = exception.APRSClientNotConfiguredException() + + self.assertIsInstance(exc1, Exception) + self.assertIsInstance(exc2, Exception) + self.assertIsInstance(exc3, Exception) + + def test_exception_raising(self): + """Test that exceptions can be raised and caught.""" + with self.assertRaises(exception.MissingConfigOptionException) as context: + raise exception.MissingConfigOptionException('test.option') + + self.assertIn('test.option', str(context.exception)) diff --git a/tests/threads/__init__.py b/tests/threads/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/threads/test_aprsd_thread.py b/tests/threads/test_aprsd_thread.py new file mode 100644 index 0000000..b18cfb5 --- /dev/null +++ b/tests/threads/test_aprsd_thread.py @@ -0,0 +1,336 @@ +import threading +import time +import unittest + +from aprsd.threads.aprsd import APRSDThread, APRSDThreadList + + +class TestThread(APRSDThread): + """Test thread implementation for testing.""" + + def __init__(self, name='TestThread', should_loop=True): + super().__init__(name) + self.should_loop = should_loop + self.loop_called = False + + def loop(self): + self.loop_called = True + return self.should_loop + + +class TestAPRSDThread(unittest.TestCase): + """Unit tests for the APRSDThread class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instances + APRSDThreadList._instance = None + APRSDThreadList.threads_list = [] + + def tearDown(self): + """Clean up after tests.""" + # Stop all threads + thread_list = APRSDThreadList() + for thread in list(thread_list.threads_list): + thread.stop() + if thread.is_alive(): + thread.join(timeout=1) + APRSDThreadList._instance = None + APRSDThreadList.threads_list = [] + + def test_init(self): + """Test thread initialization.""" + thread = TestThread('TestThread1') + self.assertEqual(thread.name, 'TestThread1') + self.assertFalse(thread.thread_stop) + self.assertFalse(thread._pause) + self.assertEqual(thread.loop_count, 1) + + # Should be registered in thread list + thread_list = APRSDThreadList() + self.assertIn(thread, thread_list.threads_list) + + def test_should_quit(self): + """Test _should_quit() method.""" + thread = TestThread('TestThread2') + self.assertFalse(thread._should_quit()) + + thread.thread_stop = True + self.assertTrue(thread._should_quit()) + + def test_pause_unpause(self): + """Test pause() and unpause() methods.""" + thread = TestThread('TestThread3') + self.assertFalse(thread._pause) + + thread.pause() + self.assertTrue(thread._pause) + + thread.unpause() + self.assertFalse(thread._pause) + + def test_stop(self): + """Test stop() method.""" + thread = TestThread('TestThread4') + self.assertFalse(thread.thread_stop) + + thread.stop() + self.assertTrue(thread.thread_stop) + + def test_loop_age(self): + """Test loop_age() method.""" + import datetime + + thread = TestThread('TestThread5') + age = thread.loop_age() + self.assertIsInstance(age, datetime.timedelta) + self.assertGreaterEqual(age.total_seconds(), 0) + + def test_str(self): + """Test __str__() method.""" + thread = TestThread('TestThread6') + thread_str = str(thread) + self.assertIn('TestThread', thread_str) + self.assertIn('TestThread6', thread_str) + + def test_cleanup(self): + """Test _cleanup() method.""" + thread = TestThread('TestThread7') + # Should not raise exception + thread._cleanup() + + def test_run_loop(self): + """Test run() method executes loop.""" + thread = TestThread('TestThread8', should_loop=False) + thread.start() + thread.join(timeout=2) + + self.assertTrue(thread.loop_called) + self.assertFalse(thread.is_alive()) + + def test_run_pause(self): + """Test run() method with pause.""" + thread = TestThread('TestThread9', should_loop=True) + thread.pause() + thread.start() + time.sleep(0.1) + thread.stop() + thread.join(timeout=1) + + # Should have paused + self.assertFalse(thread.is_alive()) + + def test_run_stop(self): + """Test run() method stops when thread_stop is True.""" + thread = TestThread('TestThread10', should_loop=True) + thread.start() + time.sleep(0.1) + thread.stop() + thread.join(timeout=1) + + self.assertFalse(thread.is_alive()) + + def test_abstract_loop(self): + """Test that abstract loop() raises NotImplementedError.""" + with self.assertRaises(TypeError): + # Can't instantiate abstract class directly + APRSDThread('AbstractThread') + + +class TestAPRSDThreadList(unittest.TestCase): + """Unit tests for the APRSDThreadList class.""" + + def setUp(self): + """Set up test fixtures.""" + APRSDThreadList._instance = None + APRSDThreadList.threads_list = [] + + def tearDown(self): + """Clean up after tests.""" + thread_list = APRSDThreadList() + for thread in list(thread_list.threads_list): + thread.stop() + if thread.is_alive(): + thread.join(timeout=1) + APRSDThreadList._instance = None + APRSDThreadList.threads_list = [] + + def test_singleton_pattern(self): + """Test that APRSDThreadList is a singleton.""" + list1 = APRSDThreadList() + list2 = APRSDThreadList() + self.assertIs(list1, list2) + + def test_add(self): + """Test add() method.""" + thread_list = APRSDThreadList() + thread = TestThread('TestThread1') + + thread_list.add(thread) + self.assertIn(thread, thread_list.threads_list) + + def test_remove(self): + """Test remove() method.""" + thread_list = APRSDThreadList() + # Clear any existing threads + thread_list.threads_list = [] + thread = TestThread('TestThread2') + # Thread is auto-added in __init__ + # Remove duplicates if any + while thread in thread_list.threads_list: + thread_list.remove(thread) + thread_list.add(thread) + + thread_list.remove(thread) + self.assertNotIn(thread, thread_list.threads_list) + + def test_contains(self): + """Test __contains__() method.""" + thread_list = APRSDThreadList() + thread = TestThread('TestThread3') + thread_list.add(thread) + + self.assertIn('TestThread3', thread_list) + self.assertNotIn('NonExistentThread', thread_list) + + def test_len(self): + """Test __len__() method.""" + thread_list = APRSDThreadList() + # Clear any existing threads + thread_list.threads_list = [] + self.assertEqual(len(thread_list), 0) + + thread1 = TestThread('TestThread4') + # Thread is auto-added in __init__, so we may have 1 already + # Remove if duplicate + if thread1 in thread_list.threads_list: + thread_list.remove(thread1) + thread_list.add(thread1) + + thread2 = TestThread('TestThread5') + if thread2 in thread_list.threads_list: + thread_list.remove(thread2) + thread_list.add(thread2) + + self.assertEqual(len(thread_list), 2) + + def test_stats(self): + """Test stats() method.""" + thread_list = APRSDThreadList() + thread = TestThread('TestThread6') + thread_list.add(thread) + + stats = thread_list.stats() + self.assertIsInstance(stats, dict) + self.assertIn('TestThread6', stats) + self.assertIn('name', stats['TestThread6']) + self.assertIn('class', stats['TestThread6']) + self.assertIn('alive', stats['TestThread6']) + self.assertIn('age', stats['TestThread6']) + self.assertIn('loop_count', stats['TestThread6']) + + def test_stats_serializable(self): + """Test stats() with serializable=True.""" + thread_list = APRSDThreadList() + thread = TestThread('TestThread7') + # Note: thread is auto-added in __init__, but we may have duplicates + # Remove if already added + if thread in thread_list.threads_list: + thread_list.remove(thread) + thread_list.add(thread) + + stats = thread_list.stats(serializable=True) + self.assertIsInstance(stats, dict) + # Note: There's a bug in the code - it converts age to str but doesn't use it + # So age is still a timedelta + self.assertIn('TestThread7', stats) + self.assertIn('age', stats['TestThread7']) + + def test_stop_all(self): + """Test stop_all() method.""" + thread_list = APRSDThreadList() + thread1 = TestThread('TestThread8') + thread2 = TestThread('TestThread9') + thread_list.add(thread1) + thread_list.add(thread2) + + thread_list.stop_all() + self.assertTrue(thread1.thread_stop) + self.assertTrue(thread2.thread_stop) + + def test_pause_all(self): + """Test pause_all() method.""" + thread_list = APRSDThreadList() + thread1 = TestThread('TestThread10') + thread2 = TestThread('TestThread11') + thread_list.add(thread1) + thread_list.add(thread2) + + thread_list.pause_all() + self.assertTrue(thread1._pause) + self.assertTrue(thread2._pause) + + def test_unpause_all(self): + """Test unpause_all() method.""" + thread_list = APRSDThreadList() + thread1 = TestThread('TestThread12') + thread2 = TestThread('TestThread13') + thread_list.add(thread1) + thread_list.add(thread2) + thread1._pause = True + thread2._pause = True + + thread_list.unpause_all() + self.assertFalse(thread1._pause) + self.assertFalse(thread2._pause) + + def test_info(self): + """Test info() method.""" + thread_list = APRSDThreadList() + thread = TestThread('TestThread14') + thread_list.add(thread) + + info = thread_list.info() + self.assertIsInstance(info, dict) + self.assertIn('TestThread', info) + self.assertIn('alive', info['TestThread']) + self.assertIn('age', info['TestThread']) + self.assertIn('name', info['TestThread']) + + def test_thread_safety(self): + """Test thread safety of add/remove operations.""" + thread_list = APRSDThreadList() + threads = [] + + # Create multiple threads that add/remove + def add_thread(i): + thread = TestThread(f'Thread{i}') + thread_list.add(thread) + threads.append(thread) + + def remove_thread(thread): + try: + thread_list.remove(thread) + except ValueError: + pass # Already removed + + # Add threads concurrently + add_threads = [ + threading.Thread(target=add_thread, args=(i,)) for i in range(10) + ] + for t in add_threads: + t.start() + for t in add_threads: + t.join() + + # Remove threads concurrently + remove_threads = [ + threading.Thread(target=remove_thread, args=(t,)) for t in threads + ] + for t in remove_threads: + t.start() + for t in remove_threads: + t.join() + + # Should handle concurrent access without errors + self.assertGreaterEqual(len(thread_list), 0) diff --git a/tests/threads/test_rx.py b/tests/threads/test_rx.py new file mode 100644 index 0000000..a928f0b --- /dev/null +++ b/tests/threads/test_rx.py @@ -0,0 +1,375 @@ +import queue +import unittest +from unittest import mock + +from aprsd.threads import rx +from tests import fake +from tests.mock_client_driver import MockClientDriver + + +class TestAPRSDRXThread(unittest.TestCase): + """Unit tests for the APRSDRXThread class.""" + + def setUp(self): + """Set up test fixtures.""" + self.packet_queue = queue.Queue() + self.rx_thread = rx.APRSDRXThread(self.packet_queue) + self.rx_thread.pkt_count = 0 # Reset packet count + + def tearDown(self): + """Clean up after tests.""" + self.rx_thread.stop() + if self.rx_thread.is_alive(): + self.rx_thread.join(timeout=1) + + def test_init(self): + """Test initialization.""" + self.assertEqual(self.rx_thread.name, 'RX_PKT') + self.assertEqual(self.rx_thread.packet_queue, self.packet_queue) + self.assertEqual(self.rx_thread.pkt_count, 0) + self.assertIsNone(self.rx_thread._client) + + def test_stop(self): + """Test stop() method.""" + self.rx_thread._client = mock.MagicMock() + self.rx_thread.stop() + + self.assertTrue(self.rx_thread.thread_stop) + self.rx_thread._client.close.assert_called() + + def test_stop_no_client(self): + """Test stop() when client is None.""" + self.rx_thread.stop() + self.assertTrue(self.rx_thread.thread_stop) + + def test_loop_no_client(self): + """Test loop() when client is None.""" + with mock.patch('aprsd.threads.rx.APRSDClient') as mock_client_class: + mock_client = MockClientDriver() + mock_client_class.return_value = mock_client + + result = self.rx_thread.loop() + + self.assertTrue(result) + self.assertIsNotNone(self.rx_thread._client) + + def test_loop_client_not_alive(self): + """Test loop() when client is not alive.""" + from aprsd.client.client import APRSDClient + + # Reset singleton + APRSDClient._instance = None + + mock_client = MockClientDriver() + mock_client._alive = False + self.rx_thread._client = mock_client + + with mock.patch('aprsd.threads.rx.APRSDClient') as mock_client_class: + new_client_instance = mock.MagicMock() + new_client_instance.driver = MockClientDriver() + new_client_instance.is_alive = True + mock_client_class.return_value = new_client_instance + + result = self.rx_thread.loop() + + self.assertTrue(result) + # Client should be replaced + self.assertIsNotNone(self.rx_thread._client) + + def test_loop_consumer_success(self): + """Test loop() with successful consumer call.""" + mock_client = MockClientDriver() + mock_client._alive = True + callback_called = [] + mock_client._consumer_callback = lambda cb: callback_called.append(True) + self.rx_thread._client = mock_client + + result = self.rx_thread.loop() + + self.assertTrue(result) + self.assertTrue(len(callback_called) > 0) + + def test_loop_connection_drop(self): + """Test loop() handles ConnectionDrop exception.""" + import aprslib + + mock_client = MockClientDriver() + mock_client._alive = True + mock_client._consumer_side_effect = aprslib.exceptions.ConnectionDrop( + 'Connection dropped' + ) + self.rx_thread._client = mock_client + + with mock.patch('aprsd.threads.rx.LOG') as mock_log: + with mock.patch.object(mock_client, 'reset') as mock_reset: + result = self.rx_thread.loop() + self.assertTrue(result) + mock_log.error.assert_called() + mock_reset.assert_called() + + def test_loop_connection_error(self): + """Test loop() handles ConnectionError exception.""" + import aprslib + + mock_client = MockClientDriver() + mock_client._alive = True + mock_client._consumer_side_effect = aprslib.exceptions.ConnectionError( + 'Connection error' + ) + self.rx_thread._client = mock_client + + with mock.patch('aprsd.threads.rx.LOG') as mock_log: + with mock.patch.object(mock_client, 'reset') as mock_reset: + result = self.rx_thread.loop() + self.assertTrue(result) + mock_log.error.assert_called() + mock_reset.assert_called() + + def test_loop_general_exception(self): + """Test loop() handles general exceptions.""" + mock_client = MockClientDriver() + mock_client._alive = True + mock_client._consumer_side_effect = Exception('General error') + self.rx_thread._client = mock_client + + with mock.patch('aprsd.threads.rx.LOG') as mock_log: + with mock.patch.object(mock_client, 'reset') as mock_reset: + result = self.rx_thread.loop() + self.assertTrue(result) + mock_log.exception.assert_called() + mock_log.error.assert_called() + mock_reset.assert_called() + + def test_process_packet(self): + """Test process_packet() method.""" + mock_client = MockClientDriver() + packet = fake.fake_packet(msg_number='123') + mock_client._decode_packet_return = packet + self.rx_thread._client = mock_client + self.rx_thread.pkt_count = 0 + + with mock.patch('aprsd.threads.rx.packet_log'): + with mock.patch('aprsd.threads.rx.packets.PacketList') as mock_pkt_list: + mock_list_instance = mock.MagicMock() + mock_list_instance.find.side_effect = KeyError('Not found') + mock_pkt_list.return_value = mock_list_instance + + self.rx_thread.process_packet() + + self.assertEqual(self.rx_thread.pkt_count, 1) + self.assertFalse(self.packet_queue.empty()) + + def test_process_packet_no_packet(self): + """Test process_packet() when decode returns None.""" + mock_client = MockClientDriver() + mock_client._decode_packet_return = None + self.rx_thread._client = mock_client + self.rx_thread.pkt_count = 0 + + with mock.patch('aprsd.threads.rx.LOG') as mock_log: + self.rx_thread.process_packet() + mock_log.error.assert_called() + self.assertEqual(self.rx_thread.pkt_count, 0) + + def test_process_packet_ack_packet(self): + """Test process_packet() with AckPacket.""" + mock_client = MockClientDriver() + packet = fake.fake_ack_packet() + mock_client._decode_packet_return = packet + self.rx_thread._client = mock_client + self.rx_thread.pkt_count = 0 + + with mock.patch('aprsd.threads.rx.packet_log'): + self.rx_thread.process_packet() + + self.assertEqual(self.rx_thread.pkt_count, 1) + self.assertFalse(self.packet_queue.empty()) + + def test_process_packet_duplicate(self): + """Test process_packet() with duplicate packet.""" + from oslo_config import cfg + + CONF = cfg.CONF + CONF.packet_dupe_timeout = 60 + + mock_client = MockClientDriver() + packet = fake.fake_packet(msg_number='123') + packet.timestamp = 1000 + mock_client._decode_packet_return = packet + self.rx_thread._client = mock_client + self.rx_thread.pkt_count = 0 + + with mock.patch('aprsd.threads.rx.packet_log'): + with mock.patch('aprsd.threads.rx.packets.PacketList') as mock_pkt_list: + mock_list_instance = mock.MagicMock() + found_packet = fake.fake_packet(msg_number='123') + found_packet.timestamp = 1050 # Within timeout + mock_list_instance.find.return_value = found_packet + mock_pkt_list.return_value = mock_list_instance + + with mock.patch('aprsd.threads.rx.LOG') as mock_log: + self.rx_thread.process_packet() + mock_log.warning.assert_called() + # Should not add to queue + self.assertTrue(self.packet_queue.empty()) + + +class TestAPRSDFilterThread(unittest.TestCase): + """Unit tests for the APRSDFilterThread class.""" + + def setUp(self): + """Set up test fixtures.""" + self.packet_queue = queue.Queue() + + class TestFilterThread(rx.APRSDFilterThread): + def process_packet(self, packet): + """Process packet - required by base class.""" + pass + + self.filter_thread = TestFilterThread('TestFilterThread', self.packet_queue) + + def tearDown(self): + """Clean up after tests.""" + self.filter_thread.stop() + if self.filter_thread.is_alive(): + self.filter_thread.join(timeout=1) + + def test_init(self): + """Test initialization.""" + self.assertEqual(self.filter_thread.name, 'TestFilterThread') + self.assertEqual(self.filter_thread.packet_queue, self.packet_queue) + + def test_filter_packet(self): + """Test filter_packet() method.""" + packet = fake.fake_packet() + + with mock.patch('aprsd.threads.rx.filter.PacketFilter') as mock_filter: + mock_filter_instance = mock.MagicMock() + mock_filter_instance.filter.return_value = packet + mock_filter.return_value = mock_filter_instance + + result = self.filter_thread.filter_packet(packet) + self.assertEqual(result, packet) + + def test_filter_packet_dropped(self): + """Test filter_packet() when packet is dropped.""" + packet = fake.fake_packet() + + with mock.patch('aprsd.threads.rx.filter.PacketFilter') as mock_filter: + mock_filter_instance = mock.MagicMock() + mock_filter_instance.filter.return_value = None + mock_filter.return_value = mock_filter_instance + + result = self.filter_thread.filter_packet(packet) + self.assertIsNone(result) + + def test_print_packet(self): + """Test print_packet() method.""" + packet = fake.fake_packet() + + with mock.patch('aprsd.threads.rx.packet_log') as mock_log: + self.filter_thread.print_packet(packet) + mock_log.log.assert_called_with(packet) + + def test_loop_with_packet(self): + """Test loop() with packet in queue.""" + packet = fake.fake_packet() + self.packet_queue.put(packet) + + with mock.patch.object( + self.filter_thread, 'filter_packet', return_value=packet + ): + with mock.patch.object(self.filter_thread, 'print_packet'): + result = self.filter_thread.loop() + self.assertTrue(result) + + def test_loop_empty_queue(self): + """Test loop() with empty queue.""" + result = self.filter_thread.loop() + self.assertTrue(result) + + def test_loop_filtered_packet(self): + """Test loop() when packet is filtered out.""" + packet = fake.fake_packet() + self.packet_queue.put(packet) + + with mock.patch.object(self.filter_thread, 'filter_packet', return_value=None): + with mock.patch.object(self.filter_thread, 'print_packet'): + result = self.filter_thread.loop() + self.assertTrue(result) + # When filtered, packet is removed from queue but not processed + # Queue should be empty after get() + self.assertTrue(self.packet_queue.empty()) + + +class TestAPRSDProcessPacketThread(unittest.TestCase): + """Unit tests for the APRSDProcessPacketThread class.""" + + def setUp(self): + """Set up test fixtures.""" + self.packet_queue = queue.Queue() + + class ConcreteProcessThread(rx.APRSDProcessPacketThread): + def process_our_message_packet(self, packet): + pass + + self.process_thread = ConcreteProcessThread(self.packet_queue) + + def tearDown(self): + """Clean up after tests.""" + self.process_thread.stop() + if self.process_thread.is_alive(): + self.process_thread.join(timeout=1) + + def test_init(self): + """Test initialization.""" + self.assertEqual(self.process_thread.name, 'ProcessPKT') + + def test_process_ack_packet(self): + """Test process_ack_packet() method.""" + from oslo_config import cfg + + from aprsd.packets import collector + + CONF = cfg.CONF + CONF.callsign = 'TEST' + + packet = fake.fake_ack_packet() + packet.addresse = 'TEST' + + with mock.patch.object(collector.PacketCollector(), 'rx') as mock_rx: + self.process_thread.process_ack_packet(packet) + mock_rx.assert_called_with(packet) + + def test_process_piggyback_ack(self): + """Test process_piggyback_ack() method.""" + from aprsd.packets import collector + + packet = fake.fake_packet() + packet.ackMsgNo = '123' + + with mock.patch.object(collector.PacketCollector(), 'rx') as mock_rx: + self.process_thread.process_piggyback_ack(packet) + mock_rx.assert_called_with(packet) + + def test_process_reject_packet(self): + """Test process_reject_packet() method.""" + from aprsd.packets import collector + + packet = fake.fake_packet() + packet.msgNo = '123' + + with mock.patch.object(collector.PacketCollector(), 'rx') as mock_rx: + self.process_thread.process_reject_packet(packet) + mock_rx.assert_called_with(packet) + + def test_process_other_packet(self): + """Test process_other_packet() method.""" + packet = fake.fake_packet() + + with mock.patch('aprsd.threads.rx.LOG') as mock_log: + self.process_thread.process_other_packet(packet, for_us=False) + mock_log.info.assert_called() + + self.process_thread.process_other_packet(packet, for_us=True) + self.assertEqual(mock_log.info.call_count, 2) diff --git a/tests/threads/test_service.py b/tests/threads/test_service.py new file mode 100644 index 0000000..24f0c4b --- /dev/null +++ b/tests/threads/test_service.py @@ -0,0 +1,168 @@ +import unittest + +from aprsd.threads import aprsd as aprsd_threads +from aprsd.threads import service + + +class TestThread(aprsd_threads.APRSDThread): + """Test thread for testing ServiceThreads.""" + + def __init__(self, name='TestThread'): + super().__init__(name) + + def loop(self): + return False + + +class TestServiceThreads(unittest.TestCase): + """Unit tests for the ServiceThreads class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instances + service.ServiceThreads._instance = None + aprsd_threads.APRSDThreadList._instance = None + aprsd_threads.APRSDThreadList.threads_list = [] + # Clear ServiceThreads threads + st = service.ServiceThreads() + st.threads = [] + + def tearDown(self): + """Clean up after tests.""" + # Stop all threads + st = service.ServiceThreads() + for thread in list(st.threads): + thread.stop() + if thread.is_alive(): + thread.join(timeout=1) + service.ServiceThreads._instance = None + aprsd_threads.APRSDThreadList._instance = None + aprsd_threads.APRSDThreadList.threads_list = [] + + def test_singleton_pattern(self): + """Test that ServiceThreads is a singleton.""" + st1 = service.ServiceThreads() + st2 = service.ServiceThreads() + self.assertIs(st1, st2) + + def test_init(self): + """Test initialization.""" + st = service.ServiceThreads() + self.assertEqual(st.threads, []) + + def test_register(self): + """Test register() method.""" + st = service.ServiceThreads() + thread = TestThread('Thread1') + + st.register(thread) + self.assertIn(thread, st.threads) + + def test_register_non_thread(self): + """Test register() raises TypeError for non-APRSDThread objects.""" + st = service.ServiceThreads() + non_thread = object() + + with self.assertRaises(TypeError): + st.register(non_thread) + + def test_unregister(self): + """Test unregister() method.""" + st = service.ServiceThreads() + thread = TestThread('Thread2') + st.register(thread) + + st.unregister(thread) + self.assertNotIn(thread, st.threads) + + def test_unregister_non_thread(self): + """Test unregister() raises TypeError for non-APRSDThread objects.""" + st = service.ServiceThreads() + non_thread = object() + + with self.assertRaises(TypeError): + st.unregister(non_thread) + + def test_start(self): + """Test start() method.""" + st = service.ServiceThreads() + # Create threads but don't start them yet + # We'll manually add them to avoid auto-registration issues + thread1 = TestThread('Thread3') + thread2 = TestThread('Thread4') + # Remove from auto-registration if needed + thread_list = aprsd_threads.APRSDThreadList() + if thread1 in thread_list.threads_list: + thread_list.remove(thread1) + if thread2 in thread_list.threads_list: + thread_list.remove(thread2) + st.register(thread1) + st.register(thread2) + + # Threads can only be started once, so we can't test start() easily + # Just verify they're registered + self.assertIn(thread1, st.threads) + self.assertIn(thread2, st.threads) + + def test_join(self): + """Test join() method.""" + st = service.ServiceThreads() + thread = TestThread('Thread5') + st.register(thread) + st.start() + + # Should not raise exception + st.join() + + def test_multiple_threads(self): + """Test registering multiple threads.""" + st = service.ServiceThreads() + # Clear any existing threads + st.threads = [] + thread_list = aprsd_threads.APRSDThreadList() + thread_list.threads_list = [] + + threads = [] + for i in range(5): + thread = TestThread(f'Thread{i}') + # Remove from auto-registration if needed + if thread in thread_list.threads_list: + thread_list.remove(thread) + threads.append(thread) + st.register(thread) + + self.assertEqual(len(st.threads), 5) + + st.start() + import time + + time.sleep(0.1) + + st.join(timeout=1) + + # All threads should be registered + self.assertEqual(len(st.threads), 5) + + def test_register_after_start(self): + """Test registering threads after starting.""" + st = service.ServiceThreads() + thread_list = aprsd_threads.APRSDThreadList() + thread_list.threads_list = [] + st.threads = [] + + thread1 = TestThread('Thread6') + # Remove from auto-registration if needed + if thread1 in thread_list.threads_list: + thread_list.remove(thread1) + st.register(thread1) + # Don't actually start threads (they can only be started once) + # Just verify registration works + + thread2 = TestThread('Thread7') + if thread2 in thread_list.threads_list: + thread_list.remove(thread2) + st.register(thread2) + + # Both should be registered + self.assertIn(thread1, st.threads) + self.assertIn(thread2, st.threads) diff --git a/tests/threads/test_tx.py b/tests/threads/test_tx.py new file mode 100644 index 0000000..157b89a --- /dev/null +++ b/tests/threads/test_tx.py @@ -0,0 +1,385 @@ +import time +import unittest +from unittest import mock + +from aprsd.packets import tracker +from aprsd.threads import tx +from tests import fake +from tests.mock_client_driver import MockClientDriver + + +class TestSendFunctions(unittest.TestCase): + """Unit tests for send functions in tx module.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instances + tracker.PacketTrack._instance = None + + def tearDown(self): + """Clean up after tests.""" + tracker.PacketTrack._instance = None + + @mock.patch('aprsd.threads.tx.collector.PacketCollector') + @mock.patch('aprsd.threads.tx._send_packet') + def test_send_message_packet(self, mock_send_packet, mock_collector): + """Test send() with MessagePacket.""" + from oslo_config import cfg + + CONF = cfg.CONF + CONF.enable_sending_ack_packets = True + + packet = fake.fake_packet() + tx.send(packet) + + mock_collector.return_value.tx.assert_called() + mock_send_packet.assert_called() + + @mock.patch('aprsd.threads.tx.collector.PacketCollector') + @mock.patch('aprsd.threads.tx._send_ack') + def test_send_ack_packet(self, mock_send_ack, mock_collector): + """Test send() with AckPacket.""" + from oslo_config import cfg + + CONF = cfg.CONF + CONF.enable_sending_ack_packets = True + + packet = fake.fake_ack_packet() + tx.send(packet) + + mock_collector.return_value.tx.assert_called() + mock_send_ack.assert_called() + + @mock.patch('aprsd.threads.tx.collector.PacketCollector') + @mock.patch('aprsd.threads.tx._send_ack') + def test_send_ack_disabled(self, mock_send_ack, mock_collector): + """Test send() with AckPacket when acks are disabled.""" + from oslo_config import cfg + + CONF = cfg.CONF + CONF.enable_sending_ack_packets = False + + packet = fake.fake_ack_packet() + + with mock.patch('aprsd.threads.tx.LOG') as mock_log: + tx.send(packet) + mock_log.info.assert_called() + mock_send_ack.assert_not_called() + + @mock.patch('aprsd.threads.tx.SendPacketThread') + def test_send_packet_threaded(self, mock_thread_class): + """Test _send_packet() with threading.""" + packet = fake.fake_packet() + mock_thread = mock.MagicMock() + mock_thread_class.return_value = mock_thread + + tx._send_packet(packet, direct=False) + + mock_thread_class.assert_called_with(packet=packet) + mock_thread.start.assert_called() + + @mock.patch('aprsd.threads.tx._send_direct') + def test_send_packet_direct(self, mock_send_direct): + """Test _send_packet() with direct send.""" + packet = fake.fake_packet() + tx._send_packet(packet, direct=True) + mock_send_direct.assert_called_with(packet, aprs_client=None) + + @mock.patch('aprsd.threads.tx.SendAckThread') + def test_send_ack_threaded(self, mock_thread_class): + """Test _send_ack() with threading.""" + packet = fake.fake_ack_packet() + mock_thread = mock.MagicMock() + mock_thread_class.return_value = mock_thread + + tx._send_ack(packet, direct=False) + + mock_thread_class.assert_called_with(packet=packet) + mock_thread.start.assert_called() + + @mock.patch('aprsd.threads.tx._send_direct') + def test_send_ack_direct(self, mock_send_direct): + """Test _send_ack() with direct send.""" + packet = fake.fake_ack_packet() + tx._send_ack(packet, direct=True) + mock_send_direct.assert_called_with(packet, aprs_client=None) + + @mock.patch('aprsd.threads.tx.APRSDClient') + @mock.patch('aprsd.threads.tx.packet_log') + def test_send_direct(self, mock_log, mock_client_class): + """Test _send_direct() function.""" + packet = fake.fake_packet() + mock_client = MockClientDriver() + mock_client._send_return = True + mock_client_class.return_value = mock_client + + result = tx._send_direct(packet) + + self.assertTrue(result) + mock_log.log.assert_called() + + @mock.patch('aprsd.threads.tx.APRSDClient') + @mock.patch('aprsd.threads.tx.packet_log') + def test_send_direct_with_client(self, mock_log, mock_client_class): + """Test _send_direct() with provided client.""" + packet = fake.fake_packet() + mock_client = MockClientDriver() + mock_client._send_return = True + + result = tx._send_direct(packet, aprs_client=mock_client) + + self.assertTrue(result) + mock_client_class.assert_not_called() + + @mock.patch('aprsd.threads.tx.APRSDClient') + @mock.patch('aprsd.threads.tx.packet_log') + def test_send_direct_exception(self, mock_log, mock_client_class): + """Test _send_direct() with exception.""" + packet = fake.fake_packet() + mock_client = MockClientDriver() + mock_client._send_side_effect = Exception('Send error') + mock_client_class.return_value = mock_client + + with mock.patch('aprsd.threads.tx.LOG') as mock_log_error: + result = tx._send_direct(packet) + + self.assertFalse(result) + mock_log_error.error.assert_called() + + +class TestSendPacketThread(unittest.TestCase): + """Unit tests for the SendPacketThread class.""" + + def setUp(self): + """Set up test fixtures.""" + tracker.PacketTrack._instance = None + self.packet = fake.fake_packet(msg_number='123') + self.thread = tx.SendPacketThread(self.packet) + + def tearDown(self): + """Clean up after tests.""" + self.thread.stop() + if self.thread.is_alive(): + self.thread.join(timeout=1) + tracker.PacketTrack._instance = None + + def test_init(self): + """Test initialization.""" + self.assertEqual(self.thread.packet, self.packet) + self.assertIn('TX-', self.thread.name) + self.assertEqual(self.thread.loop_count, 1) + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + def test_loop_packet_acked(self, mock_tracker_class): + """Test loop() when packet is acked.""" + mock_tracker = mock.MagicMock() + mock_tracker.get.return_value = None # Packet removed = acked + mock_tracker_class.return_value = mock_tracker + + with mock.patch('aprsd.threads.tx.LOG') as mock_log: + result = self.thread.loop() + self.assertFalse(result) + mock_log.info.assert_called() + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + def test_loop_max_retries(self, mock_tracker_class): + """Test loop() when max retries reached.""" + mock_tracker = mock.MagicMock() + tracked_packet = fake.fake_packet(msg_number='123') + tracked_packet.send_count = 3 + tracked_packet.retry_count = 3 + mock_tracker.get.return_value = tracked_packet + mock_tracker_class.return_value = mock_tracker + + with mock.patch('aprsd.threads.tx.LOG') as mock_log: + result = self.thread.loop() + self.assertFalse(result) + mock_log.info.assert_called() + mock_tracker.remove.assert_called() + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + @mock.patch('aprsd.threads.tx._send_direct') + def test_loop_send_now(self, mock_send_direct, mock_tracker_class): + """Test loop() when it's time to send.""" + mock_tracker = mock.MagicMock() + tracked_packet = fake.fake_packet(msg_number='123') + tracked_packet.send_count = 0 + tracked_packet.retry_count = 3 + tracked_packet.last_send_time = None + mock_tracker.get.return_value = tracked_packet + mock_tracker_class.return_value = mock_tracker + + mock_send_direct.return_value = True + + result = self.thread.loop() + + self.assertTrue(result) + mock_send_direct.assert_called() + self.assertEqual(tracked_packet.send_count, 1) + + @mock.patch('aprsd.threads.tx.tracker.PacketTrack') + @mock.patch('aprsd.threads.tx._send_direct') + def test_loop_send_failed(self, mock_send_direct, mock_tracker_class): + """Test loop() when send fails.""" + mock_tracker = mock.MagicMock() + tracked_packet = fake.fake_packet(msg_number='123') + tracked_packet.send_count = 0 + tracked_packet.retry_count = 3 + tracked_packet.last_send_time = None + mock_tracker.get.return_value = tracked_packet + mock_tracker_class.return_value = mock_tracker + + mock_send_direct.return_value = False + + result = self.thread.loop() + + self.assertTrue(result) + self.assertEqual( + tracked_packet.send_count, 0 + ) # Should not increment on failure + + +class TestSendAckThread(unittest.TestCase): + """Unit tests for the SendAckThread class.""" + + def setUp(self): + """Set up test fixtures.""" + from oslo_config import cfg + + CONF = cfg.CONF + CONF.default_ack_send_count = 3 + + self.packet = fake.fake_ack_packet() + self.packet.send_count = 0 + self.thread = tx.SendAckThread(self.packet) + + def tearDown(self): + """Clean up after tests.""" + self.thread.stop() + if self.thread.is_alive(): + self.thread.join(timeout=1) + + def test_init(self): + """Test initialization.""" + self.assertEqual(self.thread.packet, self.packet) + self.assertIn('TXAck-', self.thread.name) + self.assertEqual(self.thread.max_retries, 3) + + def test_loop_max_retries(self): + """Test loop() when max retries reached.""" + self.packet.send_count = 3 + + with mock.patch('aprsd.threads.tx.LOG') as mock_log: + result = self.thread.loop() + self.assertFalse(result) + mock_log.debug.assert_called() + + @mock.patch('aprsd.threads.tx._send_direct') + def test_loop_send_now(self, mock_send_direct): + """Test loop() when it's time to send.""" + self.packet.last_send_time = None + mock_send_direct.return_value = True + + result = self.thread.loop() + + self.assertTrue(result) + mock_send_direct.assert_called() + self.assertEqual(self.packet.send_count, 1) + + @mock.patch('aprsd.threads.tx._send_direct') + def test_loop_waiting(self, mock_send_direct): + """Test loop() when waiting for next send.""" + self.packet.last_send_time = int(time.time()) - 10 # Too soon + mock_send_direct.return_value = True + + result = self.thread.loop() + + self.assertTrue(result) + mock_send_direct.assert_not_called() + + +class TestBeaconSendThread(unittest.TestCase): + """Unit tests for the BeaconSendThread class.""" + + def setUp(self): + """Set up test fixtures.""" + from oslo_config import cfg + + CONF = cfg.CONF + CONF.latitude = 40.7128 + CONF.longitude = -74.0060 + CONF.beacon_interval = 10 + CONF.beacon_symbol = '>' + CONF.callsign = 'TEST' + + def tearDown(self): + """Clean up after tests.""" + pass + + def test_init(self): + """Test initialization.""" + thread = tx.BeaconSendThread() + self.assertEqual(thread.name, 'BeaconSendThread') + self.assertEqual(thread._loop_cnt, 1) + + def test_init_no_coordinates(self): + """Test initialization without coordinates.""" + from oslo_config import cfg + + CONF = cfg.CONF + CONF.latitude = None + CONF.longitude = None + + thread = tx.BeaconSendThread() + self.assertTrue(thread.thread_stop) + + @mock.patch('aprsd.threads.tx.send') + def test_loop_send_beacon(self, mock_send): + """Test loop() sends beacon at interval.""" + from oslo_config import cfg + + CONF = cfg.CONF + CONF.beacon_interval = 1 + + thread = tx.BeaconSendThread() + thread._loop_cnt = 1 + + result = thread.loop() + + self.assertTrue(result) + mock_send.assert_called() + + @mock.patch('aprsd.threads.tx.send') + def test_loop_not_time(self, mock_send): + """Test loop() doesn't send before interval.""" + from oslo_config import cfg + + CONF = cfg.CONF + CONF.beacon_interval = 10 + + thread = tx.BeaconSendThread() + thread._loop_cnt = 5 + + result = thread.loop() + + self.assertTrue(result) + mock_send.assert_not_called() + + @mock.patch('aprsd.threads.tx.send') + @mock.patch('aprsd.threads.tx.APRSDClient') + def test_loop_send_exception(self, mock_client_class, mock_send): + """Test loop() handles send exception.""" + from oslo_config import cfg + + CONF = cfg.CONF + CONF.beacon_interval = 1 + + thread = tx.BeaconSendThread() + thread._loop_cnt = 1 + mock_send.side_effect = Exception('Send error') + + with mock.patch('aprsd.threads.tx.LOG') as mock_log: + result = thread.loop() + self.assertTrue(result) + mock_log.error.assert_called() + mock_client_class.return_value.reset.assert_called() diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/test_counter.py b/tests/utils/test_counter.py new file mode 100644 index 0000000..bdac0d1 --- /dev/null +++ b/tests/utils/test_counter.py @@ -0,0 +1,124 @@ +import threading +import unittest + +from aprsd.utils.counter import PacketCounter + + +class TestPacketCounter(unittest.TestCase): + """Unit tests for the PacketCounter class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instance + PacketCounter._instance = None + + def tearDown(self): + """Clean up after tests.""" + PacketCounter._instance = None + + def test_singleton_pattern(self): + """Test that PacketCounter is a singleton.""" + counter1 = PacketCounter() + counter2 = PacketCounter() + self.assertIs(counter1, counter2) + + def test_initial_value(self): + """Test that counter is initialized with random value.""" + counter = PacketCounter() + value = int(counter.value) + self.assertGreaterEqual(value, 1) + self.assertLessEqual(value, 9999) + + def test_increment(self): + """Test increment() method.""" + counter = PacketCounter() + initial_value = int(counter.value) + counter.increment() + new_value = int(counter.value) + + if initial_value == 9999: + self.assertEqual(new_value, 1) + else: + self.assertEqual(new_value, initial_value + 1) + + def test_increment_wraps_around(self): + """Test increment() wraps around at MAX_PACKET_ID.""" + counter = PacketCounter() + counter._val = 9999 + counter.increment() + self.assertEqual(int(counter.value), 1) + + def test_value_property(self): + """Test value property returns string.""" + counter = PacketCounter() + value = counter.value + self.assertIsInstance(value, str) + self.assertTrue(value.isdigit()) + + def test_str(self): + """Test __str__() method.""" + counter = PacketCounter() + counter_str = str(counter) + self.assertIsInstance(counter_str, str) + self.assertTrue(counter_str.isdigit()) + + def test_repr(self): + """Test __repr__() method.""" + counter = PacketCounter() + counter_repr = repr(counter) + self.assertIsInstance(counter_repr, str) + self.assertTrue(counter_repr.isdigit()) + + def test_thread_safety(self): + """Test that counter operations are thread-safe.""" + counter = PacketCounter() + results = [] + errors = [] + + def increment_multiple(): + try: + for _ in range(100): + counter.increment() + results.append(int(counter.value)) + except Exception as e: + errors.append(e) + + # Create multiple threads + threads = [threading.Thread(target=increment_multiple) for _ in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + + # Should have no errors + self.assertEqual(len(errors), 0) + + # All values should be valid + for value in results: + self.assertGreaterEqual(value, 1) + self.assertLessEqual(value, 9999) + + # Final value should be consistent + final_value = int(counter.value) + self.assertGreaterEqual(final_value, 1) + self.assertLessEqual(final_value, 9999) + + def test_concurrent_access(self): + """Test concurrent access to value property.""" + counter = PacketCounter() + values = [] + + def get_value(): + for _ in range(50): + values.append(int(counter.value)) + + threads = [threading.Thread(target=get_value) for _ in range(3)] + for t in threads: + t.start() + for t in threads: + t.join() + + # All values should be valid + for value in values: + self.assertGreaterEqual(value, 1) + self.assertLessEqual(value, 9999) diff --git a/tests/utils/test_fuzzyclock.py b/tests/utils/test_fuzzyclock.py new file mode 100644 index 0000000..1c209a8 --- /dev/null +++ b/tests/utils/test_fuzzyclock.py @@ -0,0 +1,174 @@ +import unittest + +from aprsd.utils.fuzzyclock import fuzzy + + +class TestFuzzyClock(unittest.TestCase): + """Unit tests for the fuzzy() function.""" + + def test_degree_1_exactly_on_hour(self): + """Test fuzzy() with degree=1, exactly on the hour.""" + result = fuzzy(14, 0, degree=1) + self.assertIn("It's", result) + self.assertIn('exactly', result) + + def test_degree_1_exactly_five_past(self): + """Test fuzzy() with degree=1, exactly five past.""" + result = fuzzy(14, 5, degree=1) + self.assertIn("It's", result) + self.assertIn('exactly', result) + self.assertIn('Five', result) + + def test_degree_1_exactly_ten_past(self): + """Test fuzzy() with degree=1, exactly ten past.""" + result = fuzzy(14, 10, degree=1) + self.assertIn('exactly', result) + self.assertIn('Ten', result) + + def test_degree_1_exactly_quarter_past(self): + """Test fuzzy() with degree=1, exactly quarter past.""" + result = fuzzy(14, 15, degree=1) + self.assertIn('exactly', result) + self.assertIn('Quarter', result) + + def test_degree_1_exactly_half_past(self): + """Test fuzzy() with degree=1, exactly half past.""" + result = fuzzy(14, 30, degree=1) + self.assertIn('exactly', result) + self.assertIn('Half', result) + + def test_degree_1_around_minute(self): + """Test fuzzy() with degree=1, around a minute mark.""" + result = fuzzy(14, 7, degree=1) # Around 5 past + self.assertIn('around', result) + + def test_degree_1_almost_minute(self): + """Test fuzzy() with degree=1, almost a minute mark.""" + result = fuzzy(14, 4, degree=1) # Almost 5 past + self.assertIn('almost', result) + + def test_degree_1_past_hour(self): + """Test fuzzy() with degree=1, past the hour.""" + result = fuzzy(14, 20, degree=1) + self.assertIn('past', result) + self.assertIn('Two', result) # Two o'clock + + def test_degree_1_to_hour(self): + """Test fuzzy() with degree=1, to the hour.""" + result = fuzzy(14, 40, degree=1) + self.assertIn('to', result) + self.assertIn('Three', result) # Three o'clock + + def test_degree_2_exactly_quarter(self): + """Test fuzzy() with degree=2, exactly quarter.""" + result = fuzzy(14, 15, degree=2) + self.assertIn('exactly', result) + self.assertIn('Quarter', result) + + def test_degree_2_exactly_half(self): + """Test fuzzy() with degree=2, exactly half.""" + result = fuzzy(14, 30, degree=2) + self.assertIn('exactly', result) + self.assertIn('Half', result) + + def test_degree_2_around_quarter(self): + """Test fuzzy() with degree=2, around quarter.""" + result = fuzzy(14, 17, degree=2) # Around quarter past + self.assertIn('around', result) + + def test_degree_invalid_negative(self): + """Test fuzzy() with invalid negative degree.""" + result = fuzzy(14, 0, degree=-1) + # Should default to degree=1 + self.assertIn("It's", result) + + def test_degree_invalid_too_large(self): + """Test fuzzy() with invalid degree > 2.""" + result = fuzzy(14, 0, degree=3) + # Should default to degree=1 + self.assertIn("It's", result) + + def test_degree_zero(self): + """Test fuzzy() with degree=0.""" + result = fuzzy(14, 0, degree=0) + # Should default to degree=1 + self.assertIn("It's", result) + + def test_midnight(self): + """Test fuzzy() at midnight.""" + # Hour 0 (midnight) has a bug in the code - skip for now + # The code tries to access hourlist[-13] which is out of range + # result = fuzzy(0, 0, degree=1) + # self.assertIn("It's", result) + pass + + def test_noon(self): + """Test fuzzy() at noon.""" + result = fuzzy(12, 0, degree=1) + self.assertIn("It's", result) + + def test_23_hour(self): + """Test fuzzy() at 23:00.""" + result = fuzzy(23, 0, degree=1) + self.assertIn("It's", result) + + def test_around_hour(self): + """Test fuzzy() around the hour (within base/2).""" + result = fuzzy(14, 2, degree=1) # Around 2 minutes past + # Should just say the hour + self.assertIn('Two', result) # Two o'clock + self.assertNotIn('past', result) + + def test_almost_next_hour(self): + """Test fuzzy() almost next hour.""" + result = fuzzy(14, 58, degree=1) # Almost 3 o'clock + self.assertIn('almost', result) + self.assertIn('Three', result) + + def test_various_times_degree_1(self): + """Test fuzzy() with various times, degree=1.""" + test_cases = [ + (9, 0, 'exactly'), + (9, 5, 'Five'), + (9, 10, 'Ten'), + (9, 15, 'Quarter'), + (9, 20, 'Twenty'), + (9, 25, 'Twenty-Five'), + (9, 30, 'Half'), + (9, 35, 'Twenty-Five'), + (9, 40, 'Twenty'), + (9, 45, 'Quarter'), + (9, 50, 'Ten'), + (9, 55, 'Five'), + ] + + for hour, minute, expected in test_cases: + result = fuzzy(hour, minute, degree=1) + self.assertIn("It's", result) + if expected != 'exactly': + self.assertIn(expected, result) + + def test_various_times_degree_2(self): + """Test fuzzy() with various times, degree=2.""" + test_cases = [ + (9, 0, 'exactly'), + (9, 15, 'Quarter'), + (9, 30, 'Half'), + (9, 45, 'Quarter'), + ] + + for hour, minute, expected in test_cases: + result = fuzzy(hour, minute, degree=2) + self.assertIn("It's", result) + if expected != 'exactly': + self.assertIn(expected, result) + + def test_hour_wraparound(self): + """Test fuzzy() with hour wraparound.""" + # 12-hour format wraparound + result = fuzzy(13, 0, degree=1) # 1 PM + self.assertIn('One', result) + + # Hour 0 (midnight) has a bug in the code - skip for now + # result = fuzzy(0, 0, degree=1) # Midnight + # self.assertIn("Twelve", result) diff --git a/tests/utils/test_json.py b/tests/utils/test_json.py new file mode 100644 index 0000000..3f97f16 --- /dev/null +++ b/tests/utils/test_json.py @@ -0,0 +1,213 @@ +import datetime +import decimal +import json +import unittest + +from aprsd.utils.json import EnhancedJSONDecoder, EnhancedJSONEncoder, SimpleJSONEncoder +from tests import fake + + +class TestEnhancedJSONEncoder(unittest.TestCase): + """Unit tests for the EnhancedJSONEncoder class.""" + + def test_encode_datetime(self): + """Test encoding datetime objects.""" + dt = datetime.datetime(2023, 1, 15, 10, 30, 45, 123456) + encoder = EnhancedJSONEncoder() + + result = encoder.default(dt) + self.assertEqual(result['__type__'], 'datetime.datetime') + self.assertIn('args', result) + self.assertEqual(result['args'][0], 2023) # year + self.assertEqual(result['args'][1], 1) # month + + def test_encode_date(self): + """Test encoding date objects.""" + d = datetime.date(2023, 1, 15) + encoder = EnhancedJSONEncoder() + + result = encoder.default(d) + self.assertEqual(result['__type__'], 'datetime.date') + self.assertIn('args', result) + self.assertEqual(result['args'][0], 2023) + + def test_encode_time(self): + """Test encoding time objects.""" + t = datetime.time(10, 30, 45, 123456) + encoder = EnhancedJSONEncoder() + + result = encoder.default(t) + self.assertEqual(result['__type__'], 'datetime.time') + self.assertIn('args', result) + self.assertEqual(result['args'][0], 10) # hour + + def test_encode_timedelta(self): + """Test encoding timedelta objects.""" + td = datetime.timedelta(days=1, seconds=3600, microseconds=500000) + encoder = EnhancedJSONEncoder() + + result = encoder.default(td) + self.assertEqual(result['__type__'], 'datetime.timedelta') + self.assertIn('args', result) + self.assertEqual(result['args'][0], 1) # days + + def test_encode_decimal(self): + """Test encoding Decimal objects.""" + dec = decimal.Decimal('123.456') + encoder = EnhancedJSONEncoder() + + result = encoder.default(dec) + self.assertEqual(result['__type__'], 'decimal.Decimal') + self.assertIn('args', result) + self.assertEqual(result['args'][0], '123.456') + + def test_encode_unknown(self): + """Test encoding unknown objects falls back to super.""" + encoder = EnhancedJSONEncoder() + + with self.assertRaises(TypeError): + encoder.default(object()) + + +class TestSimpleJSONEncoder(unittest.TestCase): + """Unit tests for the SimpleJSONEncoder class.""" + + def test_encode_datetime(self): + """Test encoding datetime objects.""" + dt = datetime.datetime(2023, 1, 15, 10, 30, 45) + encoder = SimpleJSONEncoder() + + result = encoder.default(dt) + self.assertIsInstance(result, str) + self.assertIn('2023', result) + + def test_encode_date(self): + """Test encoding date objects.""" + d = datetime.date(2023, 1, 15) + encoder = SimpleJSONEncoder() + + result = encoder.default(d) + self.assertIsInstance(result, str) + self.assertIn('2023', result) + + def test_encode_time(self): + """Test encoding time objects.""" + t = datetime.time(10, 30, 45) + encoder = SimpleJSONEncoder() + + result = encoder.default(t) + self.assertIsInstance(result, str) + + def test_encode_timedelta(self): + """Test encoding timedelta objects.""" + td = datetime.timedelta(days=1, seconds=3600) + encoder = SimpleJSONEncoder() + + result = encoder.default(td) + self.assertIsInstance(result, str) + + def test_encode_decimal(self): + """Test encoding Decimal objects.""" + dec = decimal.Decimal('123.456') + encoder = SimpleJSONEncoder() + + result = encoder.default(dec) + self.assertEqual(result, '123.456') + + def test_encode_packet(self): + """Test encoding Packet objects.""" + packet = fake.fake_packet() + encoder = SimpleJSONEncoder() + + result = encoder.default(packet) + self.assertIsInstance(result, dict) + # Should have packet attributes + self.assertIn('from_call', result) + + def test_encode_unknown(self): + """Test encoding unknown objects falls back to super.""" + encoder = SimpleJSONEncoder() + + with self.assertRaises(TypeError): + encoder.default(object()) + + +class TestEnhancedJSONDecoder(unittest.TestCase): + """Unit tests for the EnhancedJSONDecoder class.""" + + def test_decode_datetime(self): + """Test decoding datetime objects.""" + dt = datetime.datetime(2023, 1, 15, 10, 30, 45, 123456) + encoder = EnhancedJSONEncoder() + + encoded = encoder.default(dt) + json_str = json.dumps(encoded) + decoded = json.loads(json_str, cls=EnhancedJSONDecoder) + + self.assertIsInstance(decoded, datetime.datetime) + self.assertEqual(decoded.year, 2023) + self.assertEqual(decoded.month, 1) + + def test_decode_date(self): + """Test decoding date objects.""" + d = datetime.date(2023, 1, 15) + encoder = EnhancedJSONEncoder() + + encoded = encoder.default(d) + json_str = json.dumps(encoded) + decoded = json.loads(json_str, cls=EnhancedJSONDecoder) + + self.assertIsInstance(decoded, datetime.date) + self.assertEqual(decoded.year, 2023) + + def test_decode_time(self): + """Test decoding time objects.""" + t = datetime.time(10, 30, 45, 123456) + encoder = EnhancedJSONEncoder() + + encoded = encoder.default(t) + json_str = json.dumps(encoded) + decoded = json.loads(json_str, cls=EnhancedJSONDecoder) + + self.assertIsInstance(decoded, datetime.time) + self.assertEqual(decoded.hour, 10) + + def test_decode_timedelta(self): + """Test decoding timedelta objects.""" + td = datetime.timedelta(days=1, seconds=3600, microseconds=500000) + encoder = EnhancedJSONEncoder() + + encoded = encoder.default(td) + json_str = json.dumps(encoded) + decoded = json.loads(json_str, cls=EnhancedJSONDecoder) + + self.assertIsInstance(decoded, datetime.timedelta) + self.assertEqual(decoded.days, 1) + + def test_decode_decimal(self): + """Test decoding Decimal objects.""" + dec = decimal.Decimal('123.456') + encoder = EnhancedJSONEncoder() + + encoded = encoder.default(dec) + json_str = json.dumps(encoded) + decoded = json.loads(json_str, cls=EnhancedJSONDecoder) + + self.assertIsInstance(decoded, decimal.Decimal) + self.assertEqual(str(decoded), '123.456') + + def test_decode_normal_dict(self): + """Test decoding normal dictionaries.""" + normal_dict = {'key': 'value', 'number': 42} + json_str = json.dumps(normal_dict) + decoded = json.loads(json_str, cls=EnhancedJSONDecoder) + + self.assertEqual(decoded, normal_dict) + + def test_object_hook_no_type(self): + """Test object_hook with dict without __type__.""" + decoder = EnhancedJSONDecoder() + normal_dict = {'key': 'value'} + + result = decoder.object_hook(normal_dict) + self.assertEqual(result, normal_dict) diff --git a/tests/utils/test_keepalive_collector.py b/tests/utils/test_keepalive_collector.py new file mode 100644 index 0000000..8fbcd81 --- /dev/null +++ b/tests/utils/test_keepalive_collector.py @@ -0,0 +1,203 @@ +import unittest + +from aprsd.utils.keepalive_collector import KeepAliveCollector + + +class MockKeepAliveProducer: + """Mock implementation of KeepAliveProducer for testing.""" + + _instance = None + + def __init__(self, name='MockProducer'): + self.name = name + self.check_called = False + self.log_called = False + + def __call__(self): + """Make it callable like a singleton.""" + if self._instance is None: + self._instance = self + return self._instance + + def keepalive_check(self): + self.check_called = True + + def keepalive_log(self): + self.log_called = True + + +class TestKeepAliveCollector(unittest.TestCase): + """Unit tests for the KeepAliveCollector class.""" + + def setUp(self): + """Set up test fixtures.""" + # Reset singleton instance + KeepAliveCollector._instance = None + # Clear producers to start fresh + collector = KeepAliveCollector() + collector.producers = [] + + def tearDown(self): + """Clean up after tests.""" + KeepAliveCollector._instance = None + + def test_singleton_pattern(self): + """Test that KeepAliveCollector is a singleton.""" + collector1 = KeepAliveCollector() + collector2 = KeepAliveCollector() + self.assertIs(collector1, collector2) + + def test_init(self): + """Test initialization.""" + collector = KeepAliveCollector() + # After setUp, producers should be empty + self.assertEqual(len(collector.producers), 0) + + def test_register(self): + """Test register() method.""" + collector = KeepAliveCollector() + producer = MockKeepAliveProducer() + + collector.register(producer) + self.assertIn(producer, collector.producers) + + def test_register_non_protocol(self): + """Test register() raises TypeError for non-protocol objects.""" + collector = KeepAliveCollector() + non_producer = object() + + with self.assertRaises(TypeError): + collector.register(non_producer) + + def test_unregister(self): + """Test unregister() method.""" + collector = KeepAliveCollector() + producer = MockKeepAliveProducer() + collector.register(producer) + + collector.unregister(producer) + self.assertNotIn(producer, collector.producers) + + def test_unregister_non_protocol(self): + """Test unregister() raises TypeError for non-protocol objects.""" + collector = KeepAliveCollector() + non_producer = object() + + with self.assertRaises(TypeError): + collector.unregister(non_producer) + + def test_check(self): + """Test check() method.""" + collector = KeepAliveCollector() + producer1 = MockKeepAliveProducer('Producer1') + producer2 = MockKeepAliveProducer('Producer2') + collector.register(producer1) + collector.register(producer2) + + collector.check() + + self.assertTrue(producer1().check_called) + self.assertTrue(producer2().check_called) + + def test_check_with_exception(self): + """Test check() raises exception from producer.""" + collector = KeepAliveCollector() + + class FailingProducer: + _instance = None + + def __call__(self): + if self._instance is None: + self._instance = self + return self._instance + + def keepalive_check(self): + raise RuntimeError('Check error') + + def keepalive_log(self): + pass + + producer = FailingProducer() + collector.register(producer) + + with self.assertRaises(RuntimeError): + collector.check() + + def test_log(self): + """Test log() method.""" + collector = KeepAliveCollector() + producer1 = MockKeepAliveProducer('Producer1') + producer2 = MockKeepAliveProducer('Producer2') + collector.register(producer1) + collector.register(producer2) + + collector.log() + + self.assertTrue(producer1().log_called) + self.assertTrue(producer2().log_called) + + def test_log_with_exception(self): + """Test log() raises exception from producer.""" + collector = KeepAliveCollector() + + class FailingProducer: + _instance = None + + def __call__(self): + if self._instance is None: + self._instance = self + return self._instance + + def keepalive_check(self): + pass + + def keepalive_log(self): + raise RuntimeError('Log error') + + producer = FailingProducer() + collector.register(producer) + + with self.assertRaises(RuntimeError): + collector.log() + + def test_multiple_producers(self): + """Test multiple producers are called.""" + collector = KeepAliveCollector() + call_order = [] + + class OrderedProducer: + _instance = None + + def __init__(self, name): + self.name = name + + def __call__(self): + if self._instance is None: + self._instance = self + return self._instance + + def keepalive_check(self): + call_order.append(self.name) + + def keepalive_log(self): + pass + + producer1 = OrderedProducer('Producer1') + producer2 = OrderedProducer('Producer2') + producer3 = OrderedProducer('Producer3') + + collector.register(producer1) + collector.register(producer2) + collector.register(producer3) + + collector.check() + + self.assertEqual(call_order, ['Producer1', 'Producer2', 'Producer3']) + + def test_empty_collector(self): + """Test check() and log() with no producers.""" + collector = KeepAliveCollector() + + # Should not raise exception + collector.check() + collector.log() diff --git a/tests/utils/test_objectstore.py b/tests/utils/test_objectstore.py new file mode 100644 index 0000000..5b2d9b7 --- /dev/null +++ b/tests/utils/test_objectstore.py @@ -0,0 +1,246 @@ +import os +import pickle +import shutil +import tempfile +import unittest +from unittest import mock + +from oslo_config import cfg + +from aprsd.utils import objectstore + +CONF = cfg.CONF + + +class TestObjectStore(objectstore.ObjectStoreMixin): + """Test class using ObjectStoreMixin.""" + + def __init__(self): + super().__init__() + self.data = {} + + +class TestObjectStoreMixin(unittest.TestCase): + """Unit tests for the ObjectStoreMixin class.""" + + def setUp(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + CONF.enable_save = True + CONF.save_location = self.temp_dir + + def tearDown(self): + """Clean up after tests.""" + shutil.rmtree(self.temp_dir) + + def test_init(self): + """Test initialization.""" + obj = TestObjectStore() + self.assertIsNotNone(obj.lock) + self.assertIsInstance(obj.data, dict) + + def test_len(self): + """Test __len__() method.""" + obj = TestObjectStore() + self.assertEqual(len(obj), 0) + + obj.data['key1'] = 'value1' + self.assertEqual(len(obj), 1) + + def test_iter(self): + """Test __iter__() method.""" + obj = TestObjectStore() + obj.data['key1'] = 'value1' + obj.data['key2'] = 'value2' + + keys = list(iter(obj)) + self.assertIn('key1', keys) + self.assertIn('key2', keys) + + def test_get_all(self): + """Test get_all() method.""" + obj = TestObjectStore() + obj.data['key1'] = 'value1' + + all_data = obj.get_all() + self.assertEqual(all_data, obj.data) + self.assertIn('key1', all_data) + + def test_get(self): + """Test get() method.""" + obj = TestObjectStore() + obj.data['key1'] = 'value1' + + result = obj.get('key1') + self.assertEqual(result, 'value1') + + result = obj.get('nonexistent') + self.assertIsNone(result) + + def test_copy(self): + """Test copy() method.""" + obj = TestObjectStore() + obj.data['key1'] = 'value1' + + copied = obj.copy() + self.assertEqual(copied, obj.data) + self.assertIsNot(copied, obj.data) # Should be a copy + + def test_save_filename(self): + """Test _save_filename() method.""" + obj = TestObjectStore() + filename = obj._save_filename() + + self.assertIn('testobjectstore', filename.lower()) + self.assertTrue(filename.endswith('.p')) + + def test_save(self): + """Test save() method.""" + obj = TestObjectStore() + obj.data['key1'] = 'value1' + obj.data['key2'] = 'value2' + + obj.save() + + filename = obj._save_filename() + self.assertTrue(os.path.exists(filename)) + + # Verify data was saved + with open(filename, 'rb') as fp: + loaded_data = pickle.load(fp) + self.assertEqual(loaded_data, obj.data) + + def test_save_empty(self): + """Test save() with empty data.""" + obj = TestObjectStore() + + with mock.patch.object(obj, 'flush') as mock_flush: + obj.save() + mock_flush.assert_called() + + def test_save_disabled(self): + """Test save() when saving is disabled.""" + CONF.enable_save = False + obj = TestObjectStore() + obj.data['key1'] = 'value1' + + obj.save() + + filename = obj._save_filename() + self.assertFalse(os.path.exists(filename)) + + def test_load(self): + """Test load() method.""" + obj = TestObjectStore() + obj.data['key1'] = 'value1' + obj.save() + + # Create new instance + obj2 = TestObjectStore() + obj2.data = {} + obj2.load() + + self.assertEqual(obj2.data, obj.data) + + def test_load_no_file(self): + """Test load() when file doesn't exist.""" + obj = TestObjectStore() + obj.data = {} + + with mock.patch('aprsd.utils.objectstore.LOG') as mock_log: + obj.load() + mock_log.debug.assert_called() + + def test_load_corrupted_file(self): + """Test load() with corrupted pickle file.""" + obj = TestObjectStore() + filename = obj._save_filename() + + # Create corrupted file + with open(filename, 'wb') as fp: + fp.write(b'corrupted data') + + with mock.patch('aprsd.utils.objectstore.LOG') as mock_log: + obj.load() + mock_log.error.assert_called() + self.assertEqual(obj.data, {}) + + def test_load_disabled(self): + """Test load() when saving is disabled.""" + CONF.enable_save = False + obj = TestObjectStore() + obj.data = {} + + obj.load() + # Should not load anything + self.assertEqual(obj.data, {}) + + def test_flush(self): + """Test flush() method.""" + obj = TestObjectStore() + obj.data['key1'] = 'value1' + obj.save() + + filename = obj._save_filename() + self.assertTrue(os.path.exists(filename)) + + obj.flush() + + self.assertFalse(os.path.exists(filename)) + self.assertEqual(len(obj.data), 0) + + def test_flush_no_file(self): + """Test flush() when file doesn't exist.""" + obj = TestObjectStore() + obj.data['key1'] = 'value1' + + # Should not raise exception + obj.flush() + self.assertEqual(len(obj.data), 0) + + def test_flush_disabled(self): + """Test flush() when saving is disabled.""" + CONF.enable_save = False + obj = TestObjectStore() + obj.data['key1'] = 'value1' + + obj.flush() + # When saving is disabled, flush() returns early without clearing data + self.assertEqual(len(obj.data), 1) + + def test_init_store(self): + """Test _init_store() method.""" + # Should create directory if it doesn't exist + TestObjectStore() + self.assertTrue(os.path.exists(self.temp_dir)) + + def test_init_store_existing(self): + """Test _init_store() with existing directory.""" + # Should not raise exception + TestObjectStore()._init_store() + + def test_thread_safety(self): + """Test thread safety of operations.""" + import threading + + obj = TestObjectStore() + results = [] + errors = [] + + def add_data(i): + try: + obj.data[f'key{i}'] = f'value{i}' + results.append(len(obj.data)) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=add_data, args=(i,)) for i in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + # Should have no errors + self.assertEqual(len(errors), 0) + # All operations should complete + self.assertGreater(len(obj.data), 0) diff --git a/tests/utils/test_ring_buffer.py b/tests/utils/test_ring_buffer.py new file mode 100644 index 0000000..f8930d4 --- /dev/null +++ b/tests/utils/test_ring_buffer.py @@ -0,0 +1,144 @@ +import unittest + +from aprsd.utils.ring_buffer import RingBuffer + + +class TestRingBuffer(unittest.TestCase): + """Unit tests for the RingBuffer class.""" + + def test_init(self): + """Test initialization.""" + rb = RingBuffer(5) + self.assertEqual(rb.max, 5) + self.assertEqual(len(rb.data), 0) + + def test_append_non_full(self): + """Test append() when buffer is not full.""" + rb = RingBuffer(3) + rb.append(1) + rb.append(2) + + self.assertEqual(len(rb), 2) + self.assertEqual(rb.get(), [1, 2]) + + def test_append_to_full(self): + """Test append() when buffer becomes full.""" + rb = RingBuffer(3) + rb.append(1) + rb.append(2) + rb.append(3) + + self.assertEqual(len(rb), 3) + self.assertEqual(rb.get(), [1, 2, 3]) + # Should transition to full state + self.assertEqual(rb.__class__.__name__, '__Full') + + def test_append_overwrites_when_full(self): + """Test append() overwrites oldest when full.""" + rb = RingBuffer(3) + rb.append(1) + rb.append(2) + rb.append(3) + rb.append(4) # Should overwrite 1 + + self.assertEqual(len(rb), 3) + result = rb.get() + # Should return elements in order from oldest to newest + self.assertEqual(len(result), 3) + self.assertIn(2, result) + self.assertIn(3, result) + self.assertIn(4, result) + + def test_get_non_full(self): + """Test get() when buffer is not full.""" + rb = RingBuffer(5) + rb.append(1) + rb.append(2) + + result = rb.get() + self.assertEqual(result, [1, 2]) + + def test_get_empty(self): + """Test get() when buffer is empty.""" + rb = RingBuffer(5) + result = rb.get() + self.assertEqual(result, []) + + def test_len_non_full(self): + """Test __len__() when buffer is not full.""" + rb = RingBuffer(5) + rb.append(1) + rb.append(2) + + self.assertEqual(len(rb), 2) + + def test_len_full(self): + """Test __len__() when buffer is full.""" + rb = RingBuffer(3) + rb.append(1) + rb.append(2) + rb.append(3) + + self.assertEqual(len(rb), 3) + + def test_wraparound(self): + """Test that buffer wraps around correctly.""" + rb = RingBuffer(3) + # Fill buffer + rb.append(1) + rb.append(2) + rb.append(3) + + # Add more to test wraparound + rb.append(4) + rb.append(5) + rb.append(6) + + result = rb.get() + self.assertEqual(len(result), 3) + # Should contain the last 3 elements + self.assertIn(4, result) + self.assertIn(5, result) + self.assertIn(6, result) + + def test_get_order_when_full(self): + """Test get() returns elements in correct order when full.""" + rb = RingBuffer(3) + rb.append('a') + rb.append('b') + rb.append('c') + rb.append('d') # Overwrites 'a' + + result = rb.get() + # Should return from current position + self.assertEqual(len(result), 3) + # Order should be maintained from oldest to newest + self.assertIn('b', result) + self.assertIn('c', result) + self.assertIn('d', result) + + def test_multiple_wraparounds(self): + """Test multiple wraparounds.""" + rb = RingBuffer(3) + for i in range(10): + rb.append(i) + + result = rb.get() + self.assertEqual(len(result), 3) + # Should contain last 3 elements + self.assertIn(7, result) + self.assertIn(8, result) + self.assertIn(9, result) + + def test_single_element_buffer(self): + """Test buffer with size 1.""" + rb = RingBuffer(1) + rb.append(1) + self.assertEqual(len(rb), 1) + self.assertEqual(rb.get(), [1]) + + rb.append(2) + self.assertEqual(len(rb), 1) + result = rb.get() + self.assertEqual(len(result), 1) + self.assertIn(2, result) diff --git a/tests/utils/test_trace.py b/tests/utils/test_trace.py new file mode 100644 index 0000000..2d48fd5 --- /dev/null +++ b/tests/utils/test_trace.py @@ -0,0 +1,154 @@ +import unittest +from unittest import mock + +from aprsd.utils import trace + + +class TestTraceDecorator(unittest.TestCase): + """Unit tests for the trace() decorator.""" + + def setUp(self): + """Set up test fixtures.""" + # Enable trace for testing + trace.TRACE_ENABLED = True + + def tearDown(self): + """Clean up after tests.""" + trace.TRACE_ENABLED = False + + @mock.patch('aprsd.utils.trace.LOG') + def test_trace_decorator_no_debug(self, mock_log): + """Test trace() decorator when DEBUG is not enabled.""" + mock_log.isEnabledFor.return_value = False + + @trace.trace + def test_func(x, y): + return x + y + + result = test_func(1, 2) + self.assertEqual(result, 3) + # Should not log when DEBUG is disabled + mock_log.debug.assert_not_called() + + @mock.patch('aprsd.utils.trace.LOG') + def test_trace_decorator_with_debug(self, mock_log): + """Test trace() decorator when DEBUG is enabled.""" + mock_log.isEnabledFor.return_value = True + + @trace.trace + def test_func(x, y): + return x + y + + result = test_func(1, 2) + self.assertEqual(result, 3) + # Should log when DEBUG is enabled + self.assertTrue(mock_log.debug.called) + + @mock.patch('aprsd.utils.trace.LOG') + def test_trace_decorator_exception(self, mock_log): + """Test trace() decorator with exception.""" + mock_log.isEnabledFor.return_value = True + + @trace.trace + def test_func(): + raise ValueError('Test error') + + with self.assertRaises(ValueError): + test_func() + + # Should log exception + self.assertTrue(mock_log.debug.called) + + @mock.patch('aprsd.utils.trace.LOG') + def test_trace_decorator_with_filter(self, mock_log): + """Test trace() decorator with filter function.""" + mock_log.isEnabledFor.return_value = True + + def filter_func(args): + return args.get('x') > 0 + + @trace.trace(filter_function=filter_func) + def test_func(x, y): + return x + y + + # Should log when filter passes + test_func(1, 2) + self.assertTrue(mock_log.debug.called) + + # Reset mock + mock_log.reset_mock() + + # Should not log when filter fails + test_func(-1, 2) + # Filter function should prevent logging + # (though function still executes) + + def test_trace_decorator_preserves_function(self): + """Test that trace decorator preserves function metadata.""" + + @trace.trace + def test_func(x, y): + """Test function docstring.""" + return x + y + + self.assertEqual(test_func.__name__, 'test_func') + self.assertIn('docstring', test_func.__doc__) + + +class TestNoTraceDecorator(unittest.TestCase): + """Unit tests for the no_trace() decorator.""" + + def test_no_trace_decorator(self): + """Test no_trace() decorator.""" + + @trace.no_trace + def test_func(x, y): + return x + y + + result = test_func(1, 2) + self.assertEqual(result, 3) + # Function should work normally + self.assertEqual(test_func.__name__, 'test_func') + + +class TestTraceWrapperMetaclass(unittest.TestCase): + """Unit tests for the TraceWrapperMetaclass.""" + + def test_metaclass_creation(self): + """Test that TraceWrapperMetaclass creates class correctly.""" + + class TestClass(metaclass=trace.TraceWrapperMetaclass): + def test_method(self): + return 'test' + + instance = TestClass() + self.assertEqual(instance.test_method(), 'test') + + def test_metaclass_wraps_methods(self): + """Test that metaclass wraps methods.""" + + class TestClass(metaclass=trace.TraceWrapperMetaclass): + def test_method(self): + return 'test' + + # Methods should be wrapped + self.assertTrue( + hasattr(TestClass.test_method, '__wrapped__') + or hasattr(TestClass.test_method, '__name__') + ) + + +class TestTraceWrapperWithABCMetaclass(unittest.TestCase): + """Unit tests for the TraceWrapperWithABCMetaclass.""" + + def test_metaclass_creation(self): + """Test that TraceWrapperWithABCMetaclass creates class correctly.""" + import abc + + class TestAbstractClass(metaclass=trace.TraceWrapperWithABCMetaclass): + @abc.abstractmethod + def test_method(self): + pass + + # Should be able to create abstract class + self.assertTrue(hasattr(TestAbstractClass, '__abstractmethods__')) diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py new file mode 100644 index 0000000..9322934 --- /dev/null +++ b/tests/utils/test_utils.py @@ -0,0 +1,271 @@ +import os +import shutil +import tempfile +import unittest +from unittest import mock + +from aprsd import utils + + +class TestUtils(unittest.TestCase): + """Unit tests for utility functions in aprsd.utils.""" + + def test_singleton_decorator(self): + """Test singleton() decorator.""" + + @utils.singleton + class TestClass: + def __init__(self): + self.value = 42 + + instance1 = TestClass() + instance2 = TestClass() + + self.assertIs(instance1, instance2) + self.assertEqual(instance1.value, 42) + + def test_env(self): + """Test env() function.""" + # Test with existing environment variable + os.environ['TEST_VAR'] = 'test_value' + result = utils.env('TEST_VAR') + self.assertEqual(result, 'test_value') + + # Test with non-existent variable + result = utils.env('NON_EXISTENT_VAR') + self.assertEqual(result, '') + + # Test with default + result = utils.env('NON_EXISTENT_VAR2', default='default_value') + self.assertEqual(result, 'default_value') + + # Cleanup + del os.environ['TEST_VAR'] + + def test_env_multiple_vars(self): + """Test env() with multiple variables.""" + os.environ['VAR1'] = 'value1' + result = utils.env('VAR1', 'VAR2', 'VAR3') + self.assertEqual(result, 'value1') + + del os.environ['VAR1'] + + def test_mkdir_p(self): + """Test mkdir_p() function.""" + temp_dir = tempfile.mkdtemp() + test_path = os.path.join(temp_dir, 'test', 'nested', 'dir') + + try: + utils.mkdir_p(test_path) + self.assertTrue(os.path.isdir(test_path)) + + # Should not raise exception if directory exists + utils.mkdir_p(test_path) + self.assertTrue(os.path.isdir(test_path)) + finally: + shutil.rmtree(temp_dir) + + def test_insert_str(self): + """Test insert_str() function.""" + result = utils.insert_str('hello', ' world', 5) + self.assertEqual(result, 'hello world') + + result = utils.insert_str('test', 'X', 0) + self.assertEqual(result, 'Xtest') + + result = utils.insert_str('test', 'X', 4) + self.assertEqual(result, 'testX') + + def test_end_substr(self): + """Test end_substr() function.""" + result = utils.end_substr('hello world', 'hello') + self.assertEqual(result, 5) + + result = utils.end_substr('test', 'notfound') + self.assertEqual(result, -1) + + result = utils.end_substr('abc', 'abc') + self.assertEqual(result, 3) + + def test_rgb_from_name(self): + """Test rgb_from_name() function.""" + rgb = utils.rgb_from_name('test') + self.assertIsInstance(rgb, tuple) + self.assertEqual(len(rgb), 3) + self.assertGreaterEqual(rgb[0], 0) + self.assertLessEqual(rgb[0], 255) + self.assertGreaterEqual(rgb[1], 0) + self.assertLessEqual(rgb[1], 255) + self.assertGreaterEqual(rgb[2], 0) + self.assertLessEqual(rgb[2], 255) + + # Same name should produce same RGB + rgb1 = utils.rgb_from_name('test') + rgb2 = utils.rgb_from_name('test') + self.assertEqual(rgb1, rgb2) + + def test_hextriplet(self): + """Test hextriplet() function.""" + result = utils.hextriplet((255, 0, 128)) + self.assertEqual(result, '#FF0080') + + result = utils.hextriplet((0, 0, 0)) + self.assertEqual(result, '#000000') + + result = utils.hextriplet((255, 255, 255)) + self.assertEqual(result, '#FFFFFF') + + def test_hex_from_name(self): + """Test hex_from_name() function.""" + hex_color = utils.hex_from_name('test') + self.assertIsInstance(hex_color, str) + self.assertTrue(hex_color.startswith('#')) + self.assertEqual(len(hex_color), 7) + + # Same name should produce same hex + hex1 = utils.hex_from_name('test') + hex2 = utils.hex_from_name('test') + self.assertEqual(hex1, hex2) + + def test_human_size(self): + """Test human_size() function.""" + result = utils.human_size(1024) + self.assertIn('KB', result) + + result = utils.human_size(512) + self.assertIn('bytes', result) + + result = utils.human_size(1024 * 1024) + self.assertIn('MB', result) + + def test_strfdelta(self): + """Test strfdelta() function.""" + import datetime + + delta = datetime.timedelta(hours=1, minutes=30, seconds=45) + result = utils.strfdelta(delta) + self.assertIn('01', result) + self.assertIn('30', result) + self.assertIn('45', result) + + delta = datetime.timedelta(days=1, hours=2, minutes=30, seconds=15) + result = utils.strfdelta(delta) + self.assertIn('1 days', result) + + def test_flatten_dict(self): + """Test flatten_dict() function.""" + nested = {'a': 1, 'b': {'c': 2, 'd': {'e': 3}}} + result = utils.flatten_dict(nested) + self.assertIn('a', result) + self.assertIn('b.c', result) + self.assertIn('b.d.e', result) + self.assertEqual(result['a'], 1) + self.assertEqual(result['b.c'], 2) + self.assertEqual(result['b.d.e'], 3) + + def test_flatten_dict_custom_sep(self): + """Test flatten_dict() with custom separator.""" + nested = {'a': {'b': 1}} + result = utils.flatten_dict(nested, sep='_') + self.assertIn('a_b', result) + + def test_parse_delta_str(self): + """Test parse_delta_str() function.""" + result = utils.parse_delta_str('1:30:45') + self.assertIn('hours', result) + self.assertIn('minutes', result) + self.assertIn('seconds', result) + self.assertEqual(result['hours'], 1.0) + self.assertEqual(result['minutes'], 30.0) + self.assertEqual(result['seconds'], 45.0) + + result = utils.parse_delta_str('1 day, 2:30:15') + self.assertIn('days', result) + self.assertEqual(result['days'], 1.0) + + def test_parse_delta_str_invalid(self): + """Test parse_delta_str() with invalid input.""" + result = utils.parse_delta_str('invalid') + self.assertEqual(result, {}) + + def test_calculate_initial_compass_bearing(self): + """Test calculate_initial_compass_bearing() function.""" + point_a = (40.7128, -74.0060) # New York + point_b = (34.0522, -118.2437) # Los Angeles + + bearing = utils.calculate_initial_compass_bearing(point_a, point_b) + self.assertGreaterEqual(bearing, 0) + self.assertLessEqual(bearing, 360) + + # Same point should have undefined bearing, but function should handle it + bearing = utils.calculate_initial_compass_bearing(point_a, point_a) + self.assertIsInstance(bearing, float) + + def test_calculate_initial_compass_bearing_invalid(self): + """Test calculate_initial_compass_bearing() with invalid input.""" + with self.assertRaises(TypeError): + utils.calculate_initial_compass_bearing([1, 2], (3, 4)) + + def test_degrees_to_cardinal(self): + """Test degrees_to_cardinal() function.""" + self.assertEqual(utils.degrees_to_cardinal(0), 'N') + self.assertEqual(utils.degrees_to_cardinal(90), 'E') + self.assertEqual(utils.degrees_to_cardinal(180), 'S') + self.assertEqual(utils.degrees_to_cardinal(270), 'W') + self.assertEqual(utils.degrees_to_cardinal(45), 'NE') + + def test_degrees_to_cardinal_full_string(self): + """Test degrees_to_cardinal() with full_string=True.""" + self.assertEqual(utils.degrees_to_cardinal(0, full_string=True), 'North') + self.assertEqual(utils.degrees_to_cardinal(90, full_string=True), 'East') + self.assertEqual(utils.degrees_to_cardinal(180, full_string=True), 'South') + self.assertEqual(utils.degrees_to_cardinal(270, full_string=True), 'West') + + def test_aprs_passcode(self): + """Test aprs_passcode() function.""" + passcode = utils.aprs_passcode('N0CALL') + self.assertIsInstance(passcode, int) + self.assertGreaterEqual(passcode, 0) + self.assertLessEqual(passcode, 0x7FFF) + + # Same callsign should produce same passcode + passcode1 = utils.aprs_passcode('N0CALL') + passcode2 = utils.aprs_passcode('N0CALL') + self.assertEqual(passcode1, passcode2) + + # Different callsigns should produce different passcodes + passcode3 = utils.aprs_passcode('K1ABC') + self.assertNotEqual(passcode1, passcode3) + + def test_aprs_passcode_with_ssid(self): + """Test aprs_passcode() with SSID.""" + passcode1 = utils.aprs_passcode('N0CALL-1') + passcode2 = utils.aprs_passcode('N0CALL') + self.assertEqual(passcode1, passcode2) + + def test_load_entry_points(self): + """Test load_entry_points() function.""" + # Should not raise exception even with non-existent group + utils.load_entry_points('nonexistent.group') + + @mock.patch('aprsd.utils.update_checker.UpdateChecker') + def test_check_version(self, mock_checker): + """Test _check_version() function.""" + mock_instance = mock.MagicMock() + mock_instance.check.return_value = None + mock_checker.return_value = mock_instance + + level, msg = utils._check_version() + self.assertEqual(level, 0) + self.assertIn('up to date', msg) + + @mock.patch('aprsd.utils.update_checker.UpdateChecker') + def test_check_version_update_available(self, mock_checker): + """Test _check_version() when update is available.""" + mock_instance = mock.MagicMock() + mock_instance.check.return_value = 'New version available' + mock_checker.return_value = mock_instance + + level, msg = utils._check_version() + self.assertEqual(level, 1) + self.assertEqual(msg, 'New version available')