SIENTIAPDE-1646

SIENTIAPDE-1646 Enhance OPC integration and E2E testing framework

- Updated .gitignore to exclude '.cursor/*' for better file management.
- Added new marker in pyproject.toml for E2E tests using OPC.
- Introduced async OPC server fixtures and improved connection handling in conftest.py.
- Enhanced error handling in OPC activities and added metrics for session management in metrics.py.
- Updated E2E tests to cover new OPC scenarios, including session errors and reconnect handling.
- Refactored helper functions to improve prediction assertion logic in helpers.py.
- Documented new OPC scenarios in scenarios.md for clarity on expected outcomes.
This commit is contained in:
vitor-aignosi
2026-05-18 16:40:56 -03:00
parent b37ec60b5d
commit 83d3a4482c
18 changed files with 2294 additions and 525 deletions

View File

@@ -1,12 +1,20 @@
import concurrent.futures
import json
from datetime import datetime
from unittest.mock import ANY, MagicMock, Mock, patch
import pytest
from asyncua.crypto import security_policies
from asyncua.ua.uaerrors import BadNodeIdUnknown, BadSessionIdInvalid
from sientia_do.notifications.models import NotificationLevel
from laborious.utils.repository.opc_repository import OpcRepository
from laborious.utils.repository.opc_repository import (
OpcClientAlreadyExistsError,
OpcClientNotInitializedError,
OpcRepository,
OpcSessionAlreadyConnectedError,
is_reconnectable_opcua_bad,
)
@pytest.fixture
@@ -31,7 +39,13 @@ def opc_repository(mock_logger):
)
repository.disconnection_interval = 0.1
repository.send_notification = MagicMock()
repository.send_notification = MagicMock()
repository.emit_metric_sync = MagicMock()
repository.info = MagicMock()
repository.error = MagicMock()
repository.warning = MagicMock()
repository.debug = MagicMock()
repository._session_ready.set()
return repository
@@ -39,7 +53,12 @@ def opc_repository(mock_logger):
def mock_client():
with patch('laborious.utils.repository.opc_repository.Client') as mock:
client_instance = MagicMock()
client_instance.aio_obj = MagicMock()
aio = MagicMock()
client_instance.aio_obj = aio
aio.uaclient = MagicMock()
aio.uaclient.protocol = MagicMock(state='closed')
aio.session_timeout = 600_000
aio.secure_channel_timeout = 600_000
mock.return_value = client_instance
yield client_instance
@@ -65,13 +84,13 @@ def test_init(opc_repository):
assert opc_repository.reconnection_interval == 60
assert opc_repository.client is None
assert opc_repository.last_reconnection_time is None
assert opc_repository.error_count == 0
def test_set_security(opc_repository, mock_client):
opc_repository.client = mock_client
opc_repository.set_security()
mock_client.application_uri = 'urn:test:server'
mock_client.set_security.assert_called_once_with(
security_policies.SecurityPolicyBasic256,
'/path/to/cert.pem',
@@ -79,64 +98,115 @@ def test_set_security(opc_repository, mock_client):
None,
'/path/to/server_cert.pem',
)
assert mock_client.aio_obj.secure_channel_timeout == 10000000
assert mock_client.aio_obj.session_timeout == 10000000
assert mock_client.aio_obj.secure_channel_timeout == 600_000
assert mock_client.aio_obj.session_timeout == 600_000
def test_set_security_missing_certificates(opc_repository):
opc_repository.cert_path = None
opc_repository.private_key_path = None
with pytest.raises(ValueError, match='Certificate and private key paths'):
try:
opc_repository.set_security()
except ValueError as e:
assert str(e) == 'Certificate and private key paths must be provided for secure connection.'
def test_set_security_missing_client(opc_repository):
opc_repository.client = None
with pytest.raises(ValueError, match='Client must be initialized'):
try:
opc_repository.set_security()
except ValueError as e:
assert str(e) == 'Client must be initialized before setting security'
def test_connect_with_security(opc_repository, mock_client):
opc_repository.try_connect = MagicMock(return_value=(True, {}))
opc_repository._create_client = MagicMock()
opc_repository._open_session = MagicMock(return_value=(True, {}))
result = opc_repository.connect()
opc_repository.try_connect.assert_called_once()
assert opc_repository.client == mock_client
opc_repository._create_client.assert_called_once()
opc_repository._open_session.assert_called_once()
assert result == (True, {})
def test_connect_without_security(opc_repository, mock_client):
opc_repository.cert_path = None
opc_repository.try_connect = MagicMock(return_value=(True, {}))
opc_repository._create_client = MagicMock()
opc_repository._open_session = MagicMock(return_value=(True, {}))
opc_repository.set_security = MagicMock()
result = opc_repository.connect()
opc_repository.try_connect.assert_called_once()
opc_repository._create_client.assert_called_once()
opc_repository._open_session.assert_called_once()
opc_repository.set_security.assert_not_called()
assert opc_repository.client == mock_client
assert result == (True, {})
def test_try_connect_success(opc_repository):
opc_repository.last_reconnection_time = None
def test_connect_raises_when_session_already_open(opc_repository, mock_client):
opc_repository.client = mock_client
proto = MagicMock()
proto.state = 'open'
mock_client.aio_obj.uaclient.protocol = proto
with pytest.raises(OpcSessionAlreadyConnectedError, match='disconnect'):
opc_repository.connect()
def test_create_client_raises_when_client_exists(opc_repository, mock_client):
opc_repository.client = mock_client
with pytest.raises(OpcClientAlreadyExistsError, match='already exists'):
opc_repository._create_client()
def test_open_session_success(opc_repository):
closed_proto = MagicMock()
closed_proto.state = 'closed'
opc_repository.client = MagicMock()
result = opc_repository.try_connect()
aio = MagicMock()
opc_repository.client.aio_obj = aio
aio.uaclient = MagicMock(protocol=closed_proto)
aio.session_timeout = 600_000
aio.secure_channel_timeout = 600_000
open_proto = MagicMock()
open_proto.state = 'open'
open_proto.authentication_token = 'tok'
def connect_side_effect():
aio.uaclient.protocol = open_proto
opc_repository.client.connect = MagicMock(side_effect=connect_side_effect)
result = opc_repository._open_session()
opc_repository.client.connect.assert_called_once()
assert opc_repository.last_reconnection_time is not None
assert result == (True, {})
assert opc_repository._session_ready.is_set()
def test_try_connect_fail(opc_repository):
opc_repository.last_reconnection_time = None
opc_repository.disconnect = MagicMock()
def test_open_session_raises_when_already_connected(opc_repository, mock_client):
opc_repository.client = mock_client
proto = MagicMock()
proto.state = 'open'
mock_client.aio_obj.uaclient.protocol = proto
with pytest.raises(OpcSessionAlreadyConnectedError, match='disconnect'):
opc_repository._open_session()
def test_open_session_fail(opc_repository):
opc_repository._disconnect_locked = MagicMock()
opc_repository.client = MagicMock()
opc_repository.client.connect.side_effect = Exception('Test error')
aio = MagicMock()
opc_repository.client.aio_obj = aio
aio.uaclient = MagicMock(protocol=MagicMock(state='closed'))
opc_repository.client.connect = MagicMock(side_effect=Exception('Test error'))
is_connected, error_data = opc_repository.try_connect()
is_connected, error_data = opc_repository._open_session()
opc_repository.disconnect.assert_called_once()
opc_repository._disconnect_locked.assert_called_once()
opc_repository.client.connect.assert_called_once()
assert is_connected is False
assert error_data['notification_id'] == f'OPC_CONNECTION_ERROR_{opc_repository.id}'
@@ -146,29 +216,17 @@ def test_try_connect_fail(opc_repository):
assert error_data['attachment_content'] is not None
def test_try_connect_no_client(opc_repository):
def test_open_session_raises_when_no_client(opc_repository):
opc_repository.client = None
result = opc_repository.try_connect()
assert result == (
False,
{
'notification_id': f'OPC_CONNECTION_ERROR_{opc_repository.id}',
'message': 'Client is not initialized',
'block': 'opc_repository',
'level': NotificationLevel.ERROR,
},
)
def test_session_alive_returns_false_when_client_none(opc_repository):
opc_repository.client = None
assert opc_repository._session_alive() is False
with pytest.raises(OpcClientNotInitializedError, match='not initialized'):
opc_repository._open_session()
def test_disconnection_fallback_success(opc_repository, mock_client):
opc_repository.client = mock_client
mock_client.disconnect.return_value = None
result = opc_repository.disconnection_fallback()
mock_client.disconnect.return_value = True
result = opc_repository._disconnection_fallback()
mock_client.disconnect.assert_called_once()
assert result == []
@@ -177,7 +235,7 @@ def test_disconnection_fallback_success(opc_repository, mock_client):
def test_disconnection_fallback_fail(opc_repository, mock_client):
opc_repository.client = mock_client
mock_client.disconnect.side_effect = Exception('Test error')
result = opc_repository.disconnection_fallback()
result = opc_repository._disconnection_fallback()
assert result == [
{'attempt': 1, 'error': 'Test error', 'traceback': ANY},
{'attempt': 2, 'error': 'Test error', 'traceback': ANY},
@@ -190,10 +248,10 @@ def test_disconnection_fallback_fail(opc_repository, mock_client):
def test_disconnect(opc_repository, mock_client):
opc_repository.client = mock_client
opc_repository.disconnection_fallback = MagicMock(return_value=[])
opc_repository._disconnection_fallback = MagicMock(return_value=[])
opc_repository.disconnect()
opc_repository.disconnection_fallback.assert_called_once()
opc_repository._disconnection_fallback.assert_called_once()
assert opc_repository.client is None
@@ -204,12 +262,12 @@ def test_disconnect_no_client(opc_repository):
def test_disconnect_error(opc_repository, mock_client):
opc_repository.client = mock_client
opc_repository.disconnection_fallback = MagicMock(
opc_repository._disconnection_fallback = MagicMock(
return_value=[{'attempt': 1, 'error': 'Test error', 'traceback': 'text'}]
)
opc_repository.disconnect()
opc_repository.disconnection_fallback.assert_called_once()
opc_repository._disconnection_fallback.assert_called_once()
opc_repository.send_notification.assert_called_once_with(
metadata=opc_repository.metadata,
notification_id=f'OPC_DISCONNECTION_ERROR_{opc_repository.id}',
@@ -225,67 +283,25 @@ def test_disconnect_error(opc_repository, mock_client):
def test_validate_connection_none_client(opc_repository):
opc_repository.client = None
opc_repository.connect = MagicMock(return_value=(True, {}))
response = opc_repository.validate_connection()
assert response == (True, {})
opc_repository.connect.assert_called_once()
assert response == (False, opc_repository._not_connected_error())
def test_validate_connection_disconnect_raises(opc_repository):
"""Outer except path when reconnect cleanup fails mid-validation."""
def test_validate_connection_session_not_open(opc_repository):
opc_repository.client = MagicMock()
opc_repository._session_alive = MagicMock(return_value=False)
opc_repository.last_reconnection_time = datetime(2020, 1, 1, 0, 0, 0)
opc_repository.disconnect = MagicMock(side_effect=RuntimeError('disconnect failed'))
opc_repository.client.aio_obj.uaclient.protocol = None
response = opc_repository.validate_connection()
assert response[0] is False
assert response[1]['notification_id'] == f'OPC_CONNECTION_CHECK_ERROR_{opc_repository.id}'
assert 'disconnect failed' in response[1]['message']
@patch('laborious.utils.repository.opc_repository.datetime')
def test_validate_connection_lost_not_time_to_reconnect(_mock_datetime, opc_repository):
_mock_datetime.now = MagicMock(return_value=datetime(2025, 1, 1, 0, 0, 0))
opc_repository.error_count = 0
opc_repository.client = MagicMock()
opc_repository.client.get_root_node.side_effect = RuntimeError('down')
opc_repository.last_reconnection_time = datetime(2025, 1, 1, 0, 0, 0)
opc_repository.connect = MagicMock(return_value=(True, {}))
response = opc_repository.validate_connection()
opc_repository.connect.assert_not_called()
assert response == (
False,
{
'notification_id': f'OPC_CONNECTION_AWAITING_RECONNECTION_WINDOW_{opc_repository.id}',
'message': f'OPC server {opc_repository.id} is not connected, waiting for next reconnection window...',
'block': 'opc_repository',
'level': NotificationLevel.WARNING,
},
)
@patch('laborious.utils.repository.opc_repository.datetime')
def test_validate_connection_lost_time_to_reconnect(mock_datetime, opc_repository):
mock_datetime.now = MagicMock(return_value=datetime(2025, 1, 1, 1, 0, 0))
opc_repository.error_count = 0
opc_repository.client = MagicMock()
opc_repository.client.get_root_node.side_effect = RuntimeError('down')
opc_repository.last_reconnection_time = datetime(2025, 1, 1, 0, 0, 0)
opc_repository.connect = MagicMock(return_value=(True, {}))
response = opc_repository.validate_connection()
opc_repository.connect.assert_called_once()
assert response == opc_repository.connect.return_value
assert response == (False, opc_repository._not_connected_error())
opc_repository.error.assert_called_once()
def test_validate_connection_success(opc_repository):
opc_repository.client = MagicMock()
opc_repository.error_count = 0
opc_repository.client.get_root_node.return_value = MagicMock()
proto = MagicMock()
proto.state = 'open'
opc_repository.client.aio_obj.uaclient.protocol = proto
output = opc_repository.validate_connection()
assert output == (True, {})
@@ -293,28 +309,22 @@ def test_validate_connection_success(opc_repository):
def test_write_data_validate_connection_do_nothing(opc_repository):
opc_repository.validate_connection = MagicMock(return_value=(True, {}))
opc_repository.client = MagicMock()
opc_repository.client = MagicMock(get_node=MagicMock())
mock_node = MagicMock()
opc_repository.client.get_node.return_value = mock_node
result = opc_repository.write_data(
'ns=2;s=TestNode', 42.0, 'float', metadata['metadata']
)
result = opc_repository.write_data('ns=2;s=TestNode', 42.0, 'float', metadata['metadata'])
opc_repository.validate_connection.assert_called_once()
opc_repository.client.get_node.assert_called_once_with('ns=2;s=TestNode')
mock_node.set_value.assert_called_once()
assert result == (True, {'response_time': ANY})
def test_write_data_validate_connection_failed(opc_repository):
opc_repository.validate_connection = MagicMock(return_value=(False, {}))
opc_repository.client = MagicMock()
opc_repository.error_count = 0
result = opc_repository.write_data(
'ns=2;s=TestNode', 42.0, 'float', metadata['metadata']
)
result = opc_repository.write_data('ns=2;s=TestNode', 42.0, 'float', metadata['metadata'])
opc_repository.validate_connection.assert_called_once()
opc_repository.client.get_node.assert_not_called()
@@ -324,7 +334,6 @@ def test_write_data_validate_connection_failed(opc_repository):
def test_write_data_get_node_failed(opc_repository):
opc_repository.validate_connection = MagicMock(return_value=(True, {}))
opc_repository.client = MagicMock()
opc_repository.error_count = 0
opc_repository.client.get_node = MagicMock(side_effect=Exception('Test error'))
is_success, error_data = opc_repository.write_data(
@@ -335,6 +344,13 @@ def test_write_data_get_node_failed(opc_repository):
opc_repository.client.get_node.assert_called_once_with('ns=2;s=TestNode')
assert is_success is False
assert error_data['notification_id'] == f'OPC_WRITE_GET_NODE_ERROR_{opc_repository.id}'
assert (
error_data['message']
== "Failed to get node from OPC server: Test error | metadata: {'model_id': 'test_model', 'model_name': 'test_model', 'workflow_name': 'test_workflow', 'schema_name': 'test_schedule'}"
)
assert error_data['block'] == 'opc_repository'
assert error_data['level'] == NotificationLevel.ERROR
assert error_data['attachment_content'] is not None
def test_write_data_invalid_data_type(opc_repository, mock_client):
@@ -352,6 +368,13 @@ def test_write_data_invalid_data_type(opc_repository, mock_client):
assert is_success is False
assert error_data['notification_id'] == f'OPC_WRITE_DATA_TYPE_ERROR_{opc_repository.id}'
assert (
error_data['message']
== "Unsupported data type: invalid_type | metadata: {'model_id': 'test_model', 'model_name': 'test_model', 'workflow_name': 'test_workflow', 'schema_name': 'test_schedule'}"
)
assert error_data['block'] == 'opc_repository'
assert error_data['level'] == NotificationLevel.ERROR
assert error_data.get('attachment_content') is None
def test_write_data(opc_repository, mock_client):
@@ -360,12 +383,10 @@ def test_write_data(opc_repository, mock_client):
mock_node = MagicMock()
mock_client.get_node = MagicMock(return_value=mock_node)
result = opc_repository.write_data(
'ns=2;s=TestNode', 42.0, 'float', metadata['metadata']
)
result = opc_repository.write_data('ns=2;s=TestNode', 42.0, 'float', metadata['metadata'])
mock_client.get_node.assert_called_once_with('ns=2;s=TestNode')
mock_node.set_value.assert_called_once()
mock_node.write_value.assert_called_once()
assert result == (True, {'response_time': ANY})
@@ -373,9 +394,8 @@ def test_write_data_write_value_failed(opc_repository, mock_client):
opc_repository.validate_connection = MagicMock(return_value=(True, {}))
opc_repository.client = mock_client
mock_node = MagicMock()
opc_repository.error_count = 0
mock_client.get_node = MagicMock(return_value=mock_node)
mock_node.set_value.side_effect = Exception('Test error')
mock_node.write_value.side_effect = Exception('Test error')
is_success, error_data = opc_repository.write_data(
'ns=2;s=TestNode', 42.0, 'float', metadata['metadata']
@@ -383,6 +403,106 @@ def test_write_data_write_value_failed(opc_repository, mock_client):
opc_repository.validate_connection.assert_called_once()
mock_client.get_node.assert_called_once_with('ns=2;s=TestNode')
mock_node.set_value.assert_called_once()
mock_node.write_value.assert_called_once()
assert is_success is False
assert error_data['notification_id'] == f'OPC_WRITE_DATA_ERROR_{opc_repository.id}'
assert (
error_data['message']
== "Failed to write data to OPC server: Test error | metadata: {'model_id': 'test_model', 'model_name': 'test_model', 'workflow_name': 'test_workflow', 'schema_name': 'test_schedule'}"
)
assert error_data['block'] == 'opc_repository'
assert error_data['level'] == NotificationLevel.ERROR
assert error_data['attachment_content'] is not None
def test_is_reconnectable_opcua_bad():
assert is_reconnectable_opcua_bad(BadSessionIdInvalid()) is True
assert is_reconnectable_opcua_bad(BadNodeIdUnknown()) is False
assert is_reconnectable_opcua_bad(Exception('other')) is False
def test_write_data_bad_session_id_invalid_schedules_reconnect(opc_repository, mock_client):
opc_repository.validate_connection = MagicMock(return_value=(True, {}))
opc_repository.client = mock_client
opc_repository._start_reconnect_on_bad = MagicMock()
mock_node = MagicMock()
mock_client.get_node = MagicMock(return_value=mock_node)
mock_node.write_value.side_effect = BadSessionIdInvalid()
is_success, error_data = opc_repository.write_data(
'ns=2;s=TestNode', 42.0, 'float', metadata['metadata']
)
mock_node.write_value.assert_called_once()
opc_repository._start_reconnect_on_bad.assert_called_once()
assert is_success is False
assert error_data['opc_error_kind'] == 'session_bad'
assert error_data['opc_status'] == 'BadSessionIdInvalid'
def test_write_data_reconnect_in_progress_immediate(opc_repository):
opc_repository._session_ready.clear()
opc_repository._reconnect_thread = MagicMock()
opc_repository._reconnect_thread.is_alive.return_value = True
opc_repository.validate_connection = MagicMock()
is_success, error_data = opc_repository.write_data(
'ns=2;s=TestNode', 42.0, 'float', metadata['metadata']
)
opc_repository.validate_connection.assert_not_called()
assert is_success is False
assert error_data['opc_error_kind'] == 'reconnect_in_progress'
def test_start_reconnect_on_bad_skips_within_interval(opc_repository):
opc_repository.last_reconnection_time = datetime.now()
opc_repository.reconnection_interval = 3600
opc_repository._start_reconnect_on_bad('BadSessionIdInvalid', 'tok')
assert opc_repository._reconnect_thread is None
def test_parallel_bad_writes_single_reconnect_task(opc_repository, mock_client):
opc_repository.validate_connection = MagicMock(return_value=(True, {}))
opc_repository.client = mock_client
opc_repository.reconnection_interval = 0
opc_repository.last_reconnection_time = None
mock_node = MagicMock()
mock_client.get_node = MagicMock(return_value=mock_node)
mock_node.write_value.side_effect = BadSessionIdInvalid()
opc_repository._start_reconnect_on_bad = MagicMock()
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(
opc_repository.write_data,
node,
value,
'float',
metadata['metadata'],
)
for node, value in (('ns=2;s=TestNode', 1.0), ('ns=2;s=TestNode2', 2.0))
]
results = [future.result() for future in futures]
assert 1 <= opc_repository._start_reconnect_on_bad.call_count <= 2
assert mock_node.write_value.call_count == 2
error_kinds = [r[1].get('opc_error_kind') for r in results]
assert error_kinds.count('session_bad') >= 1
assert all(k in ('session_bad', 'reconnect_in_progress') for k in error_kinds)
@patch('laborious.utils.repository.opc_repository.datetime')
def test_reconnect_locked_sets_last_reconnection_time(mock_datetime, opc_repository):
mock_datetime.now = MagicMock(return_value=datetime(2025, 1, 1, 12, 0, 0))
opc_repository._disconnect_locked = MagicMock()
opc_repository._connect_locked = MagicMock(return_value=(True, {}))
result = opc_repository._reconnect_locked()
opc_repository._disconnect_locked.assert_called_once()
opc_repository._connect_locked.assert_called_once()
assert result == (True, {})
assert opc_repository.last_reconnection_time == datetime(2025, 1, 1, 12, 0, 0)

View File

@@ -10,8 +10,7 @@ from laborious.utils.connectors_config import (
def test_build_mlflow_config_with_env_vars():
# Arrange
environ['MLFLOW_HOST'] = 'http://test-host'
environ['MLFLOW_PORT'] = '8080'
environ['MLFLOW_URL'] = 'http://test-host:8080'
environ['MLFLOW_USERNAME'] = 'test-user'
environ['MLFLOW_PASSWORD'] = 'test-pass'
@@ -25,8 +24,7 @@ def test_build_mlflow_config_with_env_vars():
def test_build_mlflow_config_host_already_has_port():
environ['MLFLOW_HOST'] = 'http://tracker.example.com:443'
environ['MLFLOW_PORT'] = '8080'
environ['MLFLOW_URL'] = 'http://tracker.example.com:443'
environ['MLFLOW_USERNAME'] = 'u'
environ['MLFLOW_PASSWORD'] = 'p'
@@ -38,8 +36,7 @@ def test_build_mlflow_config_host_already_has_port():
def test_build_mlflow_config_with_defaults():
# Arrange
# Clear any existing env vars
environ.pop('MLFLOW_HOST', None)
environ.pop('MLFLOW_PORT', None)
environ.pop('MLFLOW_URL', None)
environ.pop('MLFLOW_USERNAME', None)
environ.pop('MLFLOW_PASSWORD', None)