from unittest.mock import AsyncMock, MagicMock import pytest from ingestor.connection.servers import CONNECTORS, OPC_SERVERS, ServerConfigResolver CONNECTOR = { 'connector_id': 1, 'type': 'opc_ua', 'status': 'Active', 'name': 'kepserver', 'url': 'opc.tcp://kepserver:49320', 'server_uri': 'urn:kepserver', 'use_security': True, } OPC_SERVER = { 'id': '1', 'server_name': 'kepserver', 'url': 'opc.tcp://legacy:49320', 'uri': 'urn:legacy', 'cert_path': '/app/certs/cert.pem', } def make_resolver(connector=None, opc_server=None, pooled=None, ttl_s=60.0): pool = MagicMock() pool.config_for = MagicMock(return_value=pooled) mongo = MagicMock() async def find_one(collection, _filters, _metadata): return {CONNECTORS: connector, OPC_SERVERS: opc_server}[collection] mongo.find_one = AsyncMock(side_effect=find_one) return ServerConfigResolver(pool, mongo, MagicMock(), ttl_s=ttl_s) @pytest.mark.asyncio async def test_command_connection_overrides_every_other_source(): resolver = make_resolver(connector=CONNECTOR) cfg = await resolver.resolve('1', {'url': 'opc.tcp://h:1', 'server_uri': 'urn:h'}) assert cfg == {'url': 'opc.tcp://h:1', 'server_uri': 'urn:h', 'use_security': False} resolver.mongo.find_one.assert_not_awaited() @pytest.mark.asyncio async def test_live_pooled_connection_wins_over_mongo(): """Writes reuse exactly the settings the read path opened the connection with.""" pooled = {'url': 'opc.tcp://read:1', 'server_uri': 'urn:read', 'use_security': True} resolver = make_resolver(connector=CONNECTOR, pooled=pooled) assert await resolver.resolve('1', {}) == pooled resolver.pool.config_for.assert_called_once_with('1') resolver.mongo.find_one.assert_not_awaited() @pytest.mark.asyncio async def test_tag_database_connector_first(): resolver = make_resolver(connector=CONNECTOR, opc_server=OPC_SERVER) cfg = await resolver.resolve('1', {}) assert cfg == { 'url': 'opc.tcp://kepserver:49320', 'server_uri': 'urn:kepserver', 'use_security': True, } collection, filters, _ = resolver.mongo.find_one.await_args_list[0].args assert collection == CONNECTORS # connector_id is an int in the tag database; the command carries a string. assert filters == {'connector_id': {'$in': ['1', 1]}, 'type': 'opc_ua', 'status': 'Active'} @pytest.mark.asyncio async def test_opc_servers_fallback_without_tag_database(): resolver = make_resolver(connector=None, opc_server=OPC_SERVER) cfg = await resolver.resolve('1', {}) # Same rule as the orchestrator's read slot: a cert_path means security. assert cfg == { 'url': 'opc.tcp://legacy:49320', 'server_uri': 'urn:legacy', 'use_security': True, } collection, filters, _ = resolver.mongo.find_one.await_args_list[1].args assert collection == OPC_SERVERS assert filters == {'id': {'$in': ['1', 1]}} @pytest.mark.asyncio async def test_opc_server_without_cert_path_is_insecure(): resolver = make_resolver(opc_server={**OPC_SERVER, 'cert_path': ''}) assert (await resolver.resolve('1', {}))['use_security'] is False @pytest.mark.asyncio async def test_non_numeric_server_id_is_looked_up_as_is(): resolver = make_resolver(opc_server={**OPC_SERVER, 'id': 'kep-a'}) await resolver.resolve('kep-a', {}) _, filters, _ = resolver.mongo.find_one.await_args_list[0].args assert filters['connector_id'] == {'$in': ['kep-a']} @pytest.mark.asyncio async def test_unknown_server_is_cached_briefly(): resolver = make_resolver(ttl_s=60.0) resolver.missing_ttl_s = 10.0 resolver.clock = MagicMock() resolver.clock.elapsed.side_effect = [0.0, 5.0, 11.0] assert await resolver.resolve('9', {}) is None assert await resolver.resolve('9', {}) is None # within missing_ttl_s: no lookup assert await resolver.resolve('9', {}) is None # expired: looked up again assert resolver.mongo.find_one.await_count == 4 # 2 lookups (connectors + opc-servers) assert resolver.logger.warning.call_count == 2 @pytest.mark.asyncio async def test_documents_without_url_are_ignored(): resolver = make_resolver(connector={**CONNECTOR, 'url': ''}, opc_server=OPC_SERVER) assert (await resolver.resolve('1', {}))['url'] == 'opc.tcp://legacy:49320' @pytest.mark.asyncio async def test_lookups_are_cached_for_the_ttl(): resolver = make_resolver(connector=CONNECTOR, ttl_s=60.0) resolver.clock = MagicMock() resolver.clock.elapsed.side_effect = [0.0, 30.0, 61.0] first = await resolver.resolve('1', {}) first['url'] = 'mutated by the caller' second = await resolver.resolve('1', {}) # within the TTL: cached, and a copy await resolver.resolve('1', {}) # expired: looked up again assert second['url'] == 'opc.tcp://kepserver:49320' assert resolver.mongo.find_one.await_count == 2 @pytest.mark.asyncio async def test_mongo_errors_propagate_to_the_caller(): resolver = make_resolver() resolver.mongo.find_one = AsyncMock(side_effect=RuntimeError('mongo down')) with pytest.raises(RuntimeError, match='mongo down'): await resolver.resolve('1', {}) # --- SIENTIAPDE-2202: write options on the server document --- @pytest.mark.asyncio async def test_write_options_come_from_the_connector(): resolver = make_resolver( connector={**CONNECTOR, 'write_timestamp': False, 'write_quality': True}, opc_server={**OPC_SERVER, 'write_timestamp': True}, ) assert await resolver.write_options('1') == {'write_timestamp': False, 'write_quality': True} @pytest.mark.asyncio async def test_write_options_come_from_opc_servers_without_tag_database(): resolver = make_resolver(opc_server={**OPC_SERVER, 'write_quality': False}) assert await resolver.write_options('1') == {'write_quality': False} @pytest.mark.asyncio async def test_only_boolean_write_options_count(): resolver = make_resolver( connector={**CONNECTOR, 'write_timestamp': 'false', 'write_quality': 0} ) assert await resolver.write_options('1') == {} @pytest.mark.asyncio async def test_write_options_are_read_even_with_a_live_pooled_connection(): pooled = {'url': 'opc.tcp://read:1', 'server_uri': 'urn:read', 'use_security': True} resolver = make_resolver(connector={**CONNECTOR, 'write_timestamp': False}, pooled=pooled) assert await resolver.resolve('1', {}) == pooled assert await resolver.write_options('1') == {'write_timestamp': False} @pytest.mark.asyncio async def test_write_options_share_the_lookup_cache(): resolver = make_resolver(connector={**CONNECTOR, 'write_quality': False}) await resolver.resolve('1', {}) await resolver.write_options('1') assert resolver.mongo.find_one.await_count == 1 @pytest.mark.asyncio async def test_write_options_of_an_unknown_server_are_empty(): resolver = make_resolver() assert await resolver.write_options('9') == {} @pytest.mark.asyncio async def test_write_options_never_raise(): resolver = make_resolver() resolver.mongo.find_one = AsyncMock(side_effect=RuntimeError('mongo down')) assert await resolver.write_options('1') == {} assert 'mongo down' in resolver.logger.warning.call_args.args[0]