- write_timestamp/write_quality come from the command, then the server document (connectors/opc-servers, read even when the reads have the connection open, same 60 s cache), then the WRITE_TIMESTAMP/WRITE_QUALITY env defaults. - The quality comes from the command's `quality` (the prediction status: Good/Bad/Uncertain, Good when absent); an unknown one is a bad_quality error. - A server that refuses them (BadWriteNotSupported, e.g. Kepware) gets the value written again alone and is remembered until restart: one warning and opc_write_value_only_fallback_total per server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
7.2 KiB
Python
216 lines
7.2 KiB
Python
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]
|