from unittest.mock import AsyncMock, MagicMock, call import pytest import ingestor.metrics as metrics from ingestor.managers.server_reader import ServerReader SERVER_CONFIG = { 'name': 'kepserver', 'server_id': 1, # the slot may carry it as int; the pool keys by str 'url': 'opc.tcp://kepserver:49320', 'subscription_period_ms': 15000.0, 'security': {'mode': 'SignAndEncrypt', 'policy': 'Basic256Sha256'}, 'certificate': { 'certificate_id': 1, 'application_uri': 'urn:sientia:opc-ingestor', 'cert': {'bucket': 'opc-certificates', 'object_name': 'client/p/cert.pem'}, 'private_key': {'bucket': 'opc-certificates', 'object_name': 'client/p/key.pem'}, }, } def make_reader(): pool = MagicMock(get=AsyncMock(), evict=AsyncMock()) reader = ServerReader( name='kepserver', server_config=SERVER_CONFIG, pool=pool, sink=AsyncMock(), logger=MagicMock(), notification_handler=MagicMock(), metrics_controller=AsyncMock(), metadata={}, ) reader.engine = MagicMock( subscriptions={}, pod_id='pod', create_subscription=AsyncMock(), subscribe=AsyncMock(), unsubscribe=AsyncMock(), check_cycles=AsyncMock(), check_opc_listenning=AsyncMock(return_value=False), emit_metric=AsyncMock(), ) return reader, pool def test_server_cfg_keeps_only_connection_fields(): reader, _ = make_reader() assert reader.server_id == '1' assert reader.server_cfg == { key: SERVER_CONFIG[key] for key in ('url', 'subscription_period_ms', 'security', 'certificate') } @pytest.mark.asyncio async def test_connect_opens_pooled_connection(): reader, pool = make_reader() await reader.connect() # With a certificate, the pool fetches it from MinIO (CertProvider) pool.get.assert_awaited_once_with('1', reader.server_cfg) @pytest.mark.asyncio async def test_connect_propagates_failure(): reader, pool = make_reader() pool.get.side_effect = ConnectionError('unreachable') with pytest.raises(ConnectionError): await reader.connect() @pytest.mark.asyncio async def test_subscription_calls_delegate_to_engine_per_slot(): reader, _ = make_reader() nodes = {'ns=2;i=2': {'frequency': '1000', 'names': [{'collection': 'raw_a', 'tag_name': 'a'}]}} await reader.create_subscription('1') await reader.subscribe('1', nodes, 10) await reader.unsubscribe('1') reader.engine.create_subscription.assert_awaited_once_with('1', reader.server_cfg, '1') reader.engine.subscribe.assert_awaited_once_with('1', nodes, 10) reader.engine.unsubscribe.assert_awaited_once_with('1') @pytest.mark.asyncio async def test_supervision_delegates_to_engine(): reader, _ = make_reader() reader.engine.check_opc_listenning.return_value = True await reader.check_cycles() lost = await reader.check_opc_listenning() reader.engine.check_cycles.assert_awaited_once() reader.engine.check_opc_listenning.assert_awaited_once_with('1') assert lost is True def test_subscriptions_are_the_engine_ones(): reader, _ = make_reader() reader.engine.subscriptions['1'] = MagicMock() assert reader.subscriptions is reader.engine.subscriptions @pytest.mark.asyncio async def test_shutdown_unsubscribes_all_slots_evicts_and_zeroes_metrics(): reader, pool = make_reader() reader.engine.subscriptions.update({'1': MagicMock(), '2': MagicMock()}) await reader.shutdown() reader.engine.unsubscribe.assert_has_awaits([call('1'), call('2')]) # Dropping the pooled connection makes the next initialization reconnect. pool.evict.assert_awaited_once_with('1') reader.engine.emit_metric.assert_any_await( metric_object=metrics.OPC_CONNECTION_STATUS, method='set', value=0, tags={'pod_id': 'pod', 'server_name': '1', 'server_url': 'opc.tcp://kepserver:49320'}, ) reader.engine.emit_metric.assert_any_await( metric_object=metrics.OPC_TAGS_SUBSCRIBED, method='set', value=0, tags={'pod_id': 'pod', 'server_name': '1'}, ) @pytest.mark.asyncio async def test_shutdown_without_evict_keeps_the_shared_connection(): """Slot moved to another pod: drop the subscriptions, keep the pooled connection for NATS writes/gRPC (review #7).""" reader, pool = make_reader() reader.engine.subscriptions.update({'1': MagicMock()}) await reader.shutdown(evict=False) reader.engine.unsubscribe.assert_awaited_once_with('1') pool.evict.assert_not_awaited() for c in reader.engine.emit_metric.await_args_list: assert c.kwargs['metric_object'] is not metrics.OPC_CONNECTION_STATUS @pytest.mark.asyncio async def test_shutdown_unsubscribes_every_slot_while_engine_pops_them(): """The real unsubscribe() pops from engine.subscriptions: shutdown must iterate a snapshot (list()), or the loop breaks with "dictionary changed size during iteration" and the remaining slots stay subscribed (SonarQube S7504 does not apply).""" reader, pool = make_reader() subs = {'1': MagicMock(), '2': MagicMock(), '3': MagicMock()} reader.engine.subscriptions = subs async def pop(slot): subs.pop(slot) reader.engine.unsubscribe = AsyncMock(side_effect=pop) await reader.shutdown() assert subs == {} assert reader.engine.unsubscribe.await_count == 3