Files
sientia-dataops-opc-ingestor/tests/unit/connection/test_servers.py
Eduardo Rios bf287a688c SIENTIAPDE-1975: align the gateway write path with the Laborious
- GW1: write commands (C2) carry only the server_id; ServerConfigResolver
  resolves the connection (command override, live pooled connection,
  connectors, opc-servers) with a 60 s cache and a 10 s negative cache.
- GW2: write_status keeps the worst outcome (ok < error < session_bad,
  pending/NULL replaced), same rule as the Laborious, built with SQLAlchemy
  Core (no SQL formatting).
- GW3: update_write_status never raises; outcome (updated, kept,
  row_not_found, invalid, error) logged and counted
  (opc_write_status_update_total); runs off the event loop.
- GW4: the reason of a failed write is logged; opc_write_status_by_model
  shows the last outcome; a bad timestamp is a failed write, not an
  exception.
- GW5: the NATS first connect gives up after 5 s (nats-py's defaults block
  the main loop for about 4 minutes while NATS is down); reconnection after
  it is unbounded; idle_sweep restarts a connection closed for good; stop()
  is safe on a closed connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:43:33 -03:00

152 lines
5.1 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', {})