Files
sientia-dataops-scouter_tem…/tests/activities/test_api.py
Eduardo Rios f79d51336f fix: remove timestamp normalization instead of gating it by max_count
Review feedback (Vitor): gating the batch-max normalization behind
max_count == 1 just hid the same collapsing behavior for the single-point
case, where it's redundant anyway (aggregation/export don't need it) while
still being the wrong fix conceptually. Removed the normalization block
entirely so every record always keeps its own real timestamp from the PI
Web API, regardless of max_count.

Updated unit tests accordingly and confirmed no e2e scenario needs
adjustment: aggregate_data groups by (tag, name) and always collapses to
one output row per tag per execution, independent of per-record timestamp
values, so pi_web_api_scouter_multiple_tags' aggregated-value assertions
are unaffected.

SIENTIAPDE-1993

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 16:05:37 -03:00

388 lines
12 KiB
Python

from unittest.mock import ANY, MagicMock, Mock, patch
import pandas as pd
import pytest
from sientia_do.notifications.models import NotificationLevel
from scouter.activities.api import API
@pytest.fixture
@patch('scouter.activities.api.PIWebAPIClient')
def api_activity(mock_pi_web_api_client):
"""Fixture to create an API activity instance with mocked dependencies."""
logger = MagicMock()
notification_handler = MagicMock()
metrics_controller = MagicMock()
activity = API(
base_url='https://pi.example.com',
auth_type='basic',
auth_token='test_token',
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
activity.logger = logger
activity.notification_handler = notification_handler
activity.metrics_controller = metrics_controller
activity.pod_id = 'test_pod_id'
return activity
metadata = {
'metadata': {
'model_id': 'test_model_id',
'model_name': 'test_model',
'schedule_name': 'test_schedule',
'workflow_name': 'pi_web_api_scouter',
}
}
@patch('scouter.activities.api.PIWebAPIClient')
def test_api_initialization(mock_pi_web_api_client):
"""Test API activity initialization."""
logger = MagicMock()
notification_handler = MagicMock()
metrics_controller = MagicMock()
activity = API(
base_url='https://pi.example.com',
auth_type='basic',
auth_token='test_token',
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
mock_pi_web_api_client.assert_called_once_with(
base_url='https://pi.example.com',
auth_config={
'type': 'basic',
'token': 'test_token',
},
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
headers_config={
'Content-Type': 'application/json',
'Accept': 'application/json',
'x-requested-with': 'piwebapistreams',
'User-Agent': 'Aig-Scouter-Agent/1.0',
},
)
assert activity.pi_web_api_client is not None
@patch('scouter.activities.api.SientiaMonitoring')
def test_close(mock_sientia_monitoring, api_activity):
"""Test close method."""
api_activity.close()
api_activity.pi_web_api_client.close.assert_called_once()
mock_sientia_monitoring.shutdown.assert_called_once()
def test_get_tag_values_success(api_activity):
"""Test get_tag_values with successful data retrieval preserves each record's real timestamp."""
# Setup test data
test_data = {
**metadata,
'endpoint': '/streamsets/recorded',
'web_ids': {
'tag1': {
'webid': 'webid1',
'aggr_function': 'avg',
'data_range': [0, 100],
},
'tag2': {
'webid': 'webid2',
'aggr_function': 'avg',
'data_range': [0, 100],
},
'tag3': {
'webid': 'webid3',
'aggr_function': 'avg',
'data_range': [0, 100],
},
},
'period': '*-1d',
'max_count': 10,
'api_timeout': 30,
}
# Mock DataFrame response
mock_df = pd.DataFrame(
{
'timestamp': [
'2023-01-01 12:00:00+0000',
'2023-01-01 12:01:00+0000',
'2023-01-01 12:02:00+0000',
],
'name': ['tag1', 'tag2', 'tag3'],
'value': [10.5, 20.3, 30.7],
'tag': ['webid1', 'webid2', 'webid3'],
}
)
mock_df['timestamp'] = pd.to_datetime(mock_df['timestamp'])
api_activity.pi_web_api_client.get_latest_values_df = Mock(return_value=mock_df)
# Execute
result = api_activity.get_tag_values(test_data)
# Verify
api_activity.pi_web_api_client.get_latest_values_df.assert_called_once_with(
endpoint='/streamsets/recorded',
web_ids={
'tag1': {'webid': 'webid1', 'aggr_function': 'avg', 'data_range': [0, 100]},
'tag2': {'webid': 'webid2', 'aggr_function': 'avg', 'data_range': [0, 100]},
'tag3': {'webid': 'webid3', 'aggr_function': 'avg', 'data_range': [0, 100]},
},
start_time='*-1d',
end_time='*',
max_count=10,
metadata=metadata['metadata'],
request_timeout=30,
)
assert len(result) == 3
assert result[0]['name'] == 'tag1'
assert result[0]['value'] == pytest.approx(10.5)
assert result[1]['name'] == 'tag2'
assert result[2]['name'] == 'tag3'
# Each record keeps its own real timestamp, no cross-tag normalization
assert result[0]['timestamp'] == '2023-01-01 12:00:00+0000'
assert result[1]['timestamp'] == '2023-01-01 12:01:00+0000'
assert result[2]['timestamp'] == '2023-01-01 12:02:00+0000'
def test_get_tag_values_with_max_count_greater_than_one_preserves_timestamps(api_activity):
"""Regression test for SIENTIAPDE-1993: with max_count > 1, each historical point's
real timestamp must be preserved, otherwise distinct records collide on the
(model_id, timestamp, variable) unique constraint downstream and are silently
dropped."""
test_data = {
**metadata,
'endpoint': '/streamsets/recorded',
'web_ids': {
'tag1': {
'webid': 'webid1',
'aggr_function': 'avg',
'data_range': [0, 100],
},
},
'period': '*-1d',
'max_count': 3,
'api_timeout': 30,
}
# Multiple historical points for the SAME tag, with distinct timestamps
mock_df = pd.DataFrame(
{
'timestamp': [
'2023-01-01 12:00:00+0000',
'2023-01-01 12:01:00+0000',
'2023-01-01 12:02:00+0000',
],
'name': ['tag1', 'tag1', 'tag1'],
'value': [10.5, 20.3, 30.7],
'tag': ['webid1', 'webid1', 'webid1'],
}
)
mock_df['timestamp'] = pd.to_datetime(mock_df['timestamp'])
api_activity.pi_web_api_client.get_latest_values_df = Mock(return_value=mock_df)
result = api_activity.get_tag_values(test_data)
assert len(result) == 3
assert result[0]['timestamp'] == '2023-01-01 12:00:00+0000'
assert result[1]['timestamp'] == '2023-01-01 12:01:00+0000'
assert result[2]['timestamp'] == '2023-01-01 12:02:00+0000'
def test_get_tag_values_with_default_max_count(api_activity):
"""Test get_tag_values with default max_count value."""
# Setup test data without max_count
test_data = {
**metadata,
'endpoint': '/streamsets/recorded',
'web_ids': {
'tag1': {
'webid': 'webid1',
'aggr_function': 'avg',
'data_range': [0, 100],
}
},
'period': '*-1h',
'api_timeout': 15,
}
mock_df = pd.DataFrame(
{
'timestamp': ['2023-01-01 12:00:00+0000'],
'name': ['tag1'],
'value': [42.0],
'tag': ['webid1'],
}
)
mock_df['timestamp'] = pd.to_datetime(mock_df['timestamp'])
api_activity.pi_web_api_client.get_latest_values_df = Mock(return_value=mock_df)
# Execute
result = api_activity.get_tag_values(test_data)
# Verify default max_count is 1
api_activity.pi_web_api_client.get_latest_values_df.assert_called_once_with(
endpoint='/streamsets/recorded',
web_ids={'tag1': {'webid': 'webid1', 'aggr_function': 'avg', 'data_range': [0, 100]}},
start_time='*-1h',
end_time='*',
max_count=1,
metadata=metadata['metadata'],
request_timeout=15,
)
assert len(result) == 1
def test_get_tag_values_with_none_webids(api_activity):
"""Test get_tag_values with some None WebIds."""
# Setup test data with None values
test_data = {
**metadata,
'endpoint': '/streamsets/recorded',
'web_ids': {
'tag1': {
'webid': 'webid1',
'aggr_function': 'avg',
'data_range': [0, 100],
},
'tag2': None,
'tag3': {
'webid': 'webid3',
'aggr_function': 'max',
'data_range': [0, 200],
},
},
'period': '*-1h',
'max_count': 5,
'api_timeout': 20,
}
mock_df = pd.DataFrame(
{
'timestamp': ['2023-01-01 12:00:00+0000', '2023-01-01 12:01:00+0000'],
'name': ['tag1', 'tag3'],
'value': [10.5, 30.7],
'tag': ['webid1', 'webid3'],
}
)
mock_df['timestamp'] = pd.to_datetime(mock_df['timestamp'])
api_activity.pi_web_api_client.get_latest_values_df = Mock(return_value=mock_df)
# Execute
result = api_activity.get_tag_values(test_data)
# Verify - should only query non-None WebIds
assert len(result) == 2
assert all(r['name'] in ['tag1', 'tag3'] for r in result)
def test_get_tag_values_api_error(api_activity):
"""Test get_tag_values when PI Web API client raises an error and sends notification."""
# Setup test data
test_data = {
**metadata,
'endpoint': '/streamsets/recorded',
'web_ids': {
'tag1': {
'webid': 'webid1',
'aggr_function': 'avg',
'data_range': [0, 100],
}
},
'period': '*-1d',
'max_count': 1,
'api_timeout': 30,
}
# Mock API error
api_activity.pi_web_api_client.get_latest_values_df = Mock(
side_effect=Exception('PI Web API connection error')
)
api_activity.send_notification = MagicMock()
# Execute and verify exception is raised
with pytest.raises(Exception) as exc_info:
api_activity.get_tag_values(test_data)
assert str(exc_info.value) == 'PI Web API connection error'
# Verify notification was sent
api_activity.send_notification.assert_called_once_with(
metadata=metadata['metadata'],
notification_id='PI_WEB_API_REQUEST_ERROR',
message='Error getting tag values from PI Web API: PI Web API connection error',
block='get_tag_values',
level=NotificationLevel.ERROR,
attachment_content=ANY,
)
def test_get_tag_values_with_nan_values(api_activity):
"""Test get_tag_values handling NaN values in the DataFrame."""
# Setup test data
test_data = {
**metadata,
'endpoint': '/streamsets/recorded',
'web_ids': {
'tag1': {
'webid': 'webid1',
'aggr_function': 'avg',
'data_range': [0, 100],
},
'tag2': {
'webid': 'webid2',
'aggr_function': 'avg',
'data_range': [0, 100],
},
},
'period': '*-1d',
'max_count': 1,
'api_timeout': 30,
}
# Mock DataFrame with NaN values
mock_df = pd.DataFrame(
{
'timestamp': ['2023-01-01 12:00:00+0000', '2023-01-01 12:00:00+0000'],
'name': ['tag1', 'tag2'],
'value': [10.0, float('nan')],
'tag': ['webid1', 'webid2'],
}
)
mock_df['timestamp'] = pd.to_datetime(mock_df['timestamp'])
api_activity.pi_web_api_client.get_latest_values_df = Mock(return_value=mock_df)
# Execute
result = api_activity.get_tag_values(test_data)
# Verify
assert len(result) == 2
assert result[0]['value'] == pytest.approx(10.0)
# NaN should be preserved in the result
assert pd.isna(result[1]['value'])