Merge pull request #28 from Aignosi/fix/SIENTIAPDE-1445
PIWebAPIClient Refinements, Enhanced Debugging, and E2E Test Setup
This commit is contained in:
0
e2e/__init__.py
Normal file
0
e2e/__init__.py
Normal file
365
e2e/conftest.py
Normal file
365
e2e/conftest.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
Pytest configuration and fixtures for E2E tests.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from pandas import DataFrame
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from testcontainers.postgres import PostgresContainer
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from scouter.activities.activities import Activities
|
||||
from scouter.workflow.pi_web_api_scouter import PIWebAPIScouter
|
||||
from scouter.workflow.sub_workflows.core_scouter import CoreScouter
|
||||
from sientia_do.notifications.handlers import CoreNotificationHandler
|
||||
from sientia_do.observability.logger import Logger
|
||||
from sientia_do.observability.metrics_controller import MetricsController
|
||||
|
||||
from e2e.fixtures.fake_mongodb_repository import FakeMongoDBRepository
|
||||
from e2e.fixtures.fake_redis_repository import FakeRedisRepository
|
||||
|
||||
# Test constants
|
||||
TEST_MONGODB_CONNECTION_STRING = 'mongodb://localhost:27017'
|
||||
TEST_DATABASE_NAME = 'test_db'
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def postgres_container():
|
||||
"""
|
||||
Create a PostgreSQL container using testcontainers.
|
||||
|
||||
This fixture creates a real PostgreSQL database in a Docker container
|
||||
that will be used for all tests in the session.
|
||||
"""
|
||||
postgres = PostgresContainer('postgres:15')
|
||||
postgres.start()
|
||||
yield postgres
|
||||
postgres.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def postgres_engine(postgres_container):
|
||||
"""
|
||||
Create SQLAlchemy engine for PostgreSQL test database.
|
||||
|
||||
This fixture creates a connection to the PostgreSQL container
|
||||
created by the postgres_container fixture.
|
||||
"""
|
||||
# Get connection URL from container
|
||||
connection_string = postgres_container.get_connection_url()
|
||||
engine = create_engine(connection_string)
|
||||
|
||||
yield engine
|
||||
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _create_schema_and_table(engine):
|
||||
"""
|
||||
Helper function to create schema and table in the given engine.
|
||||
|
||||
This is used by both the autouse fixture and test_activities to ensure
|
||||
the schema exists before Activities tries to use it.
|
||||
|
||||
Note: For tests, we create a non-partitioned table to avoid issues
|
||||
with pandas to_sql recognizing partitioned tables.
|
||||
"""
|
||||
schema_name = 'sientia_data'
|
||||
table_name = 'laborious_data'
|
||||
|
||||
# Use begin() to ensure transaction is properly committed
|
||||
with engine.begin() as conn:
|
||||
# Create schema
|
||||
conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema_name}"))
|
||||
|
||||
# Create table WITHOUT partitioning (simpler for tests)
|
||||
# Same structure as production, but without PARTITION BY RANGE
|
||||
# Use UNIQUE constraint directly since table is not partitioned
|
||||
create_table_sql = f"""
|
||||
CREATE TABLE IF NOT EXISTS {schema_name}.{table_name} (
|
||||
id SERIAL NOT NULL,
|
||||
model_id int4 NOT NULL,
|
||||
variable text NOT NULL,
|
||||
value numeric NULL,
|
||||
"timestamp" timestamptz NOT NULL,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (id, created_at),
|
||||
UNIQUE (model_id, timestamp, variable)
|
||||
);
|
||||
"""
|
||||
|
||||
conn.execute(text(create_table_sql))
|
||||
# Transaction is automatically committed when exiting the 'with' block
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_postgres_schema_and_table(postgres_engine):
|
||||
"""
|
||||
Automatically create necessary schema and table before each test.
|
||||
|
||||
This fixture runs automatically (autouse=True) and ensures
|
||||
that the sientia_data schema and laborious_data table exist
|
||||
with the correct structure before tests execute.
|
||||
|
||||
Note: For tests, we use a non-partitioned table with a UNIQUE constraint
|
||||
directly in the table definition, which is simpler and avoids issues
|
||||
with pandas to_sql recognizing partitioned tables.
|
||||
"""
|
||||
_create_schema_and_table(postgres_engine)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logger():
|
||||
"""Mock logger for testing."""
|
||||
logger = MagicMock(spec=Logger)
|
||||
logger.info = MagicMock()
|
||||
logger.debug = MagicMock()
|
||||
logger.error = MagicMock()
|
||||
logger.warning = MagicMock()
|
||||
logger.custom_info = MagicMock()
|
||||
return logger
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mongo_client():
|
||||
"""
|
||||
Mock MongoDB client to avoid real connections.
|
||||
|
||||
This fixture mocks the pymongo.MongoClient used by CoreNotificationHandler,
|
||||
allowing us to use a real NotificationHandler instance without connecting to MongoDB.
|
||||
"""
|
||||
mock_client = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
mock_collection = MagicMock()
|
||||
|
||||
# Configure the mock chain: client[database] -> db[collection] -> collection
|
||||
mock_client.__getitem__.return_value = mock_db
|
||||
mock_db.__getitem__.return_value = mock_collection
|
||||
|
||||
# Mock server_info() to avoid connection attempts
|
||||
mock_client.server_info = MagicMock()
|
||||
|
||||
# Mock insert_one for notifications
|
||||
mock_collection.insert_one = MagicMock()
|
||||
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notification_handler(mock_logger, mock_mongo_client):
|
||||
"""
|
||||
Create a real NotificationHandler instance with mocked MongoDB client.
|
||||
|
||||
This fixture creates a real CoreNotificationHandler instance but mocks
|
||||
the underlying MongoDB connection to avoid real database connections.
|
||||
"""
|
||||
# Patch MongoClient where it's imported in the handlers module
|
||||
with patch('sientia_do.notifications.handlers.MongoClient', return_value=mock_mongo_client):
|
||||
handler = CoreNotificationHandler(
|
||||
connection_string=TEST_MONGODB_CONNECTION_STRING,
|
||||
database=TEST_DATABASE_NAME,
|
||||
logger=mock_logger,
|
||||
project_name='scouter',
|
||||
)
|
||||
yield handler
|
||||
handler.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metrics_controller(mock_logger):
|
||||
"""
|
||||
Create a real MetricsController instance.
|
||||
|
||||
MetricsController doesn't require external services, so we can use
|
||||
a real instance without mocking anything.
|
||||
"""
|
||||
controller = MetricsController(logger=mock_logger)
|
||||
yield controller
|
||||
# MetricsController might have cleanup, but it's optional
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pi_web_api_client():
|
||||
"""Mock PI Web API client."""
|
||||
mock_client = MagicMock()
|
||||
|
||||
# Mock DataFrame response similar to real API
|
||||
# The real PIWebAPIClient returns timestamp as datetime, so we need to match that
|
||||
mock_df = DataFrame({
|
||||
'timestamp': [
|
||||
'2024-01-01 12:00:00+0000',
|
||||
'2024-01-01 12:01:00+0000',
|
||||
'2024-01-01 12:02:00+0000',
|
||||
],
|
||||
'name': ['tag1', 'tag2', 'tag3'],
|
||||
'value': [10.5, 20.3, 30.7],
|
||||
'tag': ['webid1', 'webid2', 'webid3'],
|
||||
})
|
||||
|
||||
# Convert timestamp to datetime (UTC, floored to seconds) to match real client behavior
|
||||
mock_df['timestamp'] = pd.to_datetime(mock_df['timestamp'], utc=True).dt.floor('s')
|
||||
|
||||
mock_client.get_latest_values_df = AsyncMock(return_value=mock_df)
|
||||
mock_client.close = MagicMock()
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_activities(
|
||||
postgres_engine,
|
||||
postgres_container,
|
||||
mock_logger,
|
||||
notification_handler,
|
||||
metrics_controller,
|
||||
mock_pi_web_api_client,
|
||||
):
|
||||
"""
|
||||
Create Activities instance with test dependencies.
|
||||
|
||||
This fixture creates a real Activities instance with:
|
||||
- PostgreSQL database (via testcontainers)
|
||||
- FakeRedis instead of real Redis
|
||||
- FakeMongoDB instead of real MongoDB
|
||||
- Mocked PI Web API client
|
||||
- Real NotificationHandler and MetricsController (with mocked underlying services)
|
||||
"""
|
||||
# Get connection details from container
|
||||
connection_string = postgres_container.get_connection_url()
|
||||
|
||||
# Parse connection string to get individual components
|
||||
# Format: postgresql://testuser:testpass@localhost:5432/test
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(connection_string)
|
||||
|
||||
# Ensure schema and table exist BEFORE creating Activities
|
||||
# This ensures the schema exists when Activities initializes its engine
|
||||
_create_schema_and_table(postgres_engine)
|
||||
|
||||
# Create fake repositories that will be used from the start
|
||||
fake_redis_repo = FakeRedisRepository(
|
||||
host='localhost',
|
||||
port=6379,
|
||||
username='',
|
||||
password='',
|
||||
logger=mock_logger,
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
)
|
||||
|
||||
fake_mongo_repo = FakeMongoDBRepository(
|
||||
connection_string=TEST_MONGODB_CONNECTION_STRING,
|
||||
database_name=TEST_DATABASE_NAME,
|
||||
logger=mock_logger,
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
)
|
||||
|
||||
# Patch create_engine to return our postgres_engine instead of creating a new one
|
||||
# This ensures Activities uses the same engine from the start
|
||||
original_create_engine = create_engine
|
||||
|
||||
def patched_create_engine(connection_string, *args, **kwargs):
|
||||
# Check if this is the connection string that Activities would create
|
||||
# Activities creates: postgresql://user:password@host:port/dbname
|
||||
expected_conn_str = f"postgresql://{parsed.username or 'test'}:{parsed.password or 'test'}@localhost:{postgres_container.get_exposed_port(5432)}/{parsed.path.lstrip('/') if parsed.path else 'test'}"
|
||||
|
||||
# If it matches our test container connection, return our engine
|
||||
if connection_string == expected_conn_str:
|
||||
return postgres_engine
|
||||
# Otherwise, use the original create_engine
|
||||
return original_create_engine(connection_string, *args, **kwargs)
|
||||
|
||||
# Patch RedisRepository to return our fake repository from the start
|
||||
def patched_redis_repository(*args, **kwargs):
|
||||
return fake_redis_repo
|
||||
|
||||
# Patch MongoDBRepository to return our fake repository from the start
|
||||
def patched_mongodb_repository(*args, **kwargs):
|
||||
return fake_mongo_repo
|
||||
|
||||
# Patch PIWebAPIClient to return our mock from the start
|
||||
def patched_pi_web_api_client(*args, **kwargs):
|
||||
return mock_pi_web_api_client
|
||||
|
||||
# Create Activities with test configurations
|
||||
# All patches ensure it uses our test instances from the start
|
||||
with patch('sientia_do.temporal.activities.postgres.create_engine', new=patched_create_engine), \
|
||||
patch('scouter.activities.redis.RedisRepository', new=patched_redis_repository), \
|
||||
patch('scouter.activities.mongodb.MongoDBRepository', new=patched_mongodb_repository), \
|
||||
patch('scouter.activities.api.PIWebAPIClient', new=patched_pi_web_api_client):
|
||||
|
||||
activities = Activities(
|
||||
postgres_config={
|
||||
'host': 'localhost', # Container exposes to localhost
|
||||
'port': postgres_container.get_exposed_port(5432),
|
||||
'user': parsed.username or 'test',
|
||||
'password': parsed.password or 'test',
|
||||
'dbname': parsed.path.lstrip('/') if parsed.path else 'test',
|
||||
'min_connections': 1,
|
||||
'max_connections': 5,
|
||||
},
|
||||
redis_config={
|
||||
'host': 'localhost',
|
||||
'port': 6379,
|
||||
'username': '',
|
||||
'password': '',
|
||||
},
|
||||
mongodb_config={
|
||||
'connection_string': TEST_MONGODB_CONNECTION_STRING,
|
||||
'database_name': TEST_DATABASE_NAME,
|
||||
},
|
||||
api_config={
|
||||
'base_url': 'http://localhost:8080',
|
||||
'auth_type': 'bearer',
|
||||
'auth_token': 'test_token',
|
||||
},
|
||||
logger=mock_logger,
|
||||
notification_handler=notification_handler,
|
||||
)
|
||||
|
||||
# Verify that Activities is using our instances from the start
|
||||
assert activities.engine is postgres_engine, "Activities should use the same engine as postgres_engine"
|
||||
assert activities.redis_repository is fake_redis_repo, "Activities should use the same fake Redis repository"
|
||||
assert activities.mongodb_repository is fake_mongo_repo, "Activities should use the same fake MongoDB repository"
|
||||
assert activities.pi_web_api_client is mock_pi_web_api_client, "Activities should use the same mock PI Web API client"
|
||||
|
||||
yield activities
|
||||
|
||||
# Cleanup
|
||||
activities.shutdown()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope='function')
|
||||
async def temporal_test_env():
|
||||
"""Create Temporal test environment."""
|
||||
env = await WorkflowEnvironment.start_time_skipping()
|
||||
async with env:
|
||||
yield env
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope='function')
|
||||
async def temporal_worker(temporal_test_env, test_activities):
|
||||
"""Create Temporal worker with test activities."""
|
||||
async with Worker(
|
||||
temporal_test_env.client,
|
||||
task_queue='test-queue',
|
||||
workflows=[PIWebAPIScouter, CoreScouter],
|
||||
activities=[
|
||||
test_activities.get_tag_values,
|
||||
test_activities.data_quality_gate,
|
||||
test_activities.aggregate_data,
|
||||
test_activities.group_and_hold_data,
|
||||
test_activities.export_data_to_postgres,
|
||||
test_activities.write_metrics,
|
||||
test_activities.store_data_package,
|
||||
],
|
||||
) as worker:
|
||||
yield worker
|
||||
0
e2e/fixtures/__init__.py
Normal file
0
e2e/fixtures/__init__.py
Normal file
241
e2e/fixtures/fake_mongodb_repository.py
Normal file
241
e2e/fixtures/fake_mongodb_repository.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Fake MongoDB Repository adapter for testing.
|
||||
|
||||
This adapter implements the MongoDBRepository interface using mongomock
|
||||
to provide an in-memory MongoDB server for testing.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from mongomock import MongoClient
|
||||
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||
from sientia_do.observability.logger import Logger
|
||||
from sientia_do.observability.metrics_controller import MetricsController
|
||||
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||
|
||||
|
||||
def clear_mongo_id(docs: list) -> list:
|
||||
"""
|
||||
Remove MongoDB internal `_id` fields from nested structures.
|
||||
|
||||
Same implementation as in the real MongoDBRepository.
|
||||
|
||||
Args:
|
||||
docs: The list of documents or nested structures to clean.
|
||||
|
||||
Returns:
|
||||
The cleaned documents with `_id` fields removed wherever present.
|
||||
"""
|
||||
for doc in docs:
|
||||
if isinstance(doc, list):
|
||||
clear_mongo_id(doc)
|
||||
elif isinstance(doc, dict):
|
||||
if '_id' in doc:
|
||||
del doc['_id']
|
||||
for _key, value in doc.items():
|
||||
if isinstance(value, list):
|
||||
clear_mongo_id(value)
|
||||
elif isinstance(value, dict):
|
||||
clear_mongo_id([value])
|
||||
return docs
|
||||
|
||||
|
||||
class FakeMongoDBRepository(SientiaMonitoring):
|
||||
"""
|
||||
Fake MongoDB Repository that uses mongomock for testing.
|
||||
|
||||
Implements the same interface as MongoDBRepository but uses
|
||||
mongomock for in-memory MongoDB operations.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection_string: str,
|
||||
database_name: str,
|
||||
logger: Logger,
|
||||
notification_handler: NotificationHandler,
|
||||
metrics_controller: MetricsController,
|
||||
):
|
||||
"""
|
||||
Initialize fake MongoDB repository with mongomock.
|
||||
|
||||
Args:
|
||||
connection_string: MongoDB connection string (ignored in fake mode)
|
||||
database_name: Target database name
|
||||
logger: Logger instance
|
||||
notification_handler: Notification handler
|
||||
metrics_controller: Metrics controller
|
||||
"""
|
||||
SientiaMonitoring.__init__(
|
||||
self,
|
||||
logger=logger,
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
)
|
||||
|
||||
self.database_name = database_name
|
||||
# Use mongomock instead of real MongoDB
|
||||
self.mongo_client = MongoClient()
|
||||
self.database = self.mongo_client[self.database_name]
|
||||
logger.info('Fake MongoDB connection initialized')
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Closes fake MongoDB connection and shuts down monitoring.
|
||||
"""
|
||||
try:
|
||||
if self.mongo_client:
|
||||
self.logger.info('Closing fake MongoDB connection...')
|
||||
self.mongo_client.close()
|
||||
self.logger.info('Fake MongoDB connection closed successfully')
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to close fake MongoDB connection: {e}')
|
||||
SientiaMonitoring.shutdown(self)
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Destructor that closes the connection when destroying the instance.
|
||||
"""
|
||||
self.close()
|
||||
|
||||
async def find(
|
||||
self,
|
||||
collection_name: str,
|
||||
filters: dict[str, Any],
|
||||
metadata: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Finds documents in a MongoDB collection based on provided filters.
|
||||
|
||||
Args:
|
||||
collection_name: Name of the collection to search
|
||||
filters: Query filters to apply
|
||||
metadata: Dictionary with additional metadata
|
||||
|
||||
Return:
|
||||
List of documents matching the filters (with `_id` removed)
|
||||
"""
|
||||
try:
|
||||
start_time = time.time()
|
||||
collection = self.database[collection_name]
|
||||
documents = list(collection.find(filters, {'_id': 0}))
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to find documents in fake MongoDB: {e}')
|
||||
raise e
|
||||
|
||||
return clear_mongo_id(documents)
|
||||
|
||||
async def aggregate(
|
||||
self,
|
||||
collection_name: str,
|
||||
pipeline: list[dict[str, Any]],
|
||||
metadata: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Executes an aggregation on a MongoDB collection.
|
||||
|
||||
Args:
|
||||
collection_name: Name of the collection to aggregate
|
||||
pipeline: MongoDB aggregation pipeline
|
||||
metadata: Dictionary with additional metadata
|
||||
|
||||
Return:
|
||||
List of documents resulting from aggregation (with `_id` removed)
|
||||
"""
|
||||
try:
|
||||
start_time = time.time()
|
||||
collection = self.database[collection_name]
|
||||
documents = list(collection.aggregate(pipeline))
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to aggregate documents in fake MongoDB: {e}')
|
||||
raise e
|
||||
|
||||
return clear_mongo_id(documents)
|
||||
|
||||
async def update_many(
|
||||
self,
|
||||
collection_name: str,
|
||||
filters: dict[str, Any],
|
||||
update: dict[str, Any],
|
||||
metadata: dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Updates multiple documents in a MongoDB collection.
|
||||
|
||||
Args:
|
||||
collection_name: Collection name
|
||||
filters: Filters to identify documents to update
|
||||
update: Update operations to apply
|
||||
metadata: Dictionary with additional metadata
|
||||
"""
|
||||
try:
|
||||
collection = self.database[collection_name]
|
||||
collection.update_many(filters, update)
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to update documents in fake MongoDB: {e}')
|
||||
raise e
|
||||
|
||||
async def insert_many(
|
||||
self,
|
||||
collection_name: str,
|
||||
documents: list[dict[str, Any]],
|
||||
metadata: dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Inserts multiple documents into a MongoDB collection.
|
||||
|
||||
Args:
|
||||
collection_name: Collection name
|
||||
documents: List of documents to insert
|
||||
metadata: Dictionary with additional metadata
|
||||
"""
|
||||
try:
|
||||
collection = self.database[collection_name]
|
||||
collection.insert_many(documents)
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to insert documents in fake MongoDB: {e}')
|
||||
raise e
|
||||
|
||||
async def insert(
|
||||
self,
|
||||
collection_name: str,
|
||||
document: dict[str, Any],
|
||||
metadata: dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Inserts a document into a MongoDB collection.
|
||||
|
||||
Args:
|
||||
collection_name: Collection name
|
||||
document: Document to insert
|
||||
metadata: Dictionary with additional metadata
|
||||
"""
|
||||
try:
|
||||
collection = self.database[collection_name]
|
||||
collection.insert_one(document)
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to insert document in fake MongoDB: {e}')
|
||||
raise e
|
||||
|
||||
async def delete_many(
|
||||
self,
|
||||
collection_name: str,
|
||||
filters: dict[str, Any],
|
||||
metadata: dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Removes multiple documents from a MongoDB collection.
|
||||
|
||||
Args:
|
||||
collection_name: Collection name
|
||||
filters: Filters to identify documents to remove
|
||||
metadata: Dictionary with additional metadata
|
||||
"""
|
||||
try:
|
||||
collection = self.database[collection_name]
|
||||
collection.delete_many(filters)
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to delete documents in fake MongoDB: {e}')
|
||||
raise e
|
||||
|
||||
170
e2e/fixtures/fake_redis_repository.py
Normal file
170
e2e/fixtures/fake_redis_repository.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Fake Redis Repository adapter for testing.
|
||||
|
||||
This adapter implements the RedisRepository interface using fakeredis
|
||||
to provide an in-memory Redis server for testing.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import fakeredis
|
||||
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||
from sientia_do.observability.logger import Logger
|
||||
from sientia_do.observability.metrics_controller import MetricsController
|
||||
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||
|
||||
|
||||
class FakeRedisRepository(SientiaMonitoring):
|
||||
"""
|
||||
Fake Redis Repository that uses fakeredis for testing.
|
||||
|
||||
Implements the same interface as RedisRepository but uses
|
||||
fakeredis for in-memory Redis operations.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
logger: Logger,
|
||||
notification_handler: NotificationHandler,
|
||||
metrics_controller: MetricsController,
|
||||
):
|
||||
"""
|
||||
Initialize fake Redis repository with fakeredis.
|
||||
|
||||
Args:
|
||||
host: Redis server address (ignored in fake mode)
|
||||
port: Redis server port (ignored in fake mode)
|
||||
username: Username (ignored in fake mode)
|
||||
password: Password (ignored in fake mode)
|
||||
logger: Logger instance
|
||||
notification_handler: Notification handler
|
||||
metrics_controller: Metrics controller
|
||||
"""
|
||||
SientiaMonitoring.__init__(
|
||||
self,
|
||||
logger=logger,
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
)
|
||||
# Create fake Redis server
|
||||
self.redis_client = fakeredis.FakeStrictRedis(
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
def _get_redis(self):
|
||||
"""Get fakeredis connection."""
|
||||
return self.redis_client
|
||||
|
||||
async def get(self, key: str, metadata: dict | None = None):
|
||||
"""
|
||||
Gets a value from fake Redis by key.
|
||||
|
||||
Args:
|
||||
key: Key of the value to retrieve
|
||||
metadata: Optional dictionary with additional metadata
|
||||
|
||||
Return:
|
||||
Deserialized value from Redis or None if not found
|
||||
"""
|
||||
redis = self._get_redis()
|
||||
try:
|
||||
history = redis.get(key)
|
||||
return json.loads(history) if history else None
|
||||
except Exception as e:
|
||||
self.error(f'Error getting data from fake redis: {e}', metadata or {})
|
||||
raise e
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
data: dict,
|
||||
ttl: int = 600,
|
||||
nx: bool = False,
|
||||
metadata: dict | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Sets a value in fake Redis with optional TTL.
|
||||
|
||||
Args:
|
||||
key: Key of the value to set
|
||||
data: Dictionary with data to store
|
||||
ttl: Time to live in seconds (default: 600)
|
||||
nx: If True, only sets if key doesn't exist (default: False)
|
||||
metadata: Optional dictionary with additional metadata
|
||||
|
||||
Return:
|
||||
True if value was set, False otherwise
|
||||
"""
|
||||
redis = self._get_redis()
|
||||
try:
|
||||
result = redis.set(
|
||||
key, json.dumps(data), ex=ttl, nx=nx
|
||||
)
|
||||
return bool(result)
|
||||
except Exception as e:
|
||||
self.error(f'Error setting data in fake redis: {e}', metadata or {})
|
||||
raise e
|
||||
|
||||
async def delete(self, key: str, metadata: dict | None = None):
|
||||
"""
|
||||
Removes a key from fake Redis.
|
||||
|
||||
Args:
|
||||
key: Key to be removed
|
||||
metadata: Optional dictionary with additional metadata
|
||||
"""
|
||||
redis = self._get_redis()
|
||||
try:
|
||||
redis.delete(key)
|
||||
except Exception as e:
|
||||
self.error(f'Error deleting data from fake redis: {e}', metadata or {})
|
||||
raise e
|
||||
|
||||
async def expire(self, key: str, ttl: int, metadata: dict | None = None):
|
||||
"""
|
||||
Sets the time to live (TTL) of an existing Redis key.
|
||||
|
||||
Args:
|
||||
key: Key whose TTL will be set
|
||||
ttl: Time to live in seconds
|
||||
metadata: Optional dictionary with additional metadata
|
||||
"""
|
||||
redis = self._get_redis()
|
||||
try:
|
||||
redis.expire(key, ttl)
|
||||
except Exception as e:
|
||||
self.error(f'Error expiring data from fake redis: {e}', metadata or {})
|
||||
raise e
|
||||
|
||||
async def keys(self, pattern: str, metadata: dict | None = None):
|
||||
"""
|
||||
Gets all keys matching the specified pattern.
|
||||
|
||||
Args:
|
||||
pattern: Search pattern for keys
|
||||
metadata: Optional dictionary with additional metadata
|
||||
|
||||
Return:
|
||||
List of keys matching the pattern
|
||||
"""
|
||||
redis = self._get_redis()
|
||||
try:
|
||||
keys = redis.keys(pattern)
|
||||
return keys
|
||||
except Exception as e:
|
||||
self.error(f'Error getting keys from fake redis: {e}', metadata or {})
|
||||
raise e
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Closes fake Redis connection and shuts down monitoring.
|
||||
"""
|
||||
if self.redis_client:
|
||||
self.redis_client.close()
|
||||
SientiaMonitoring.shutdown(self)
|
||||
|
||||
466
e2e/scenarios.md
Normal file
466
e2e/scenarios.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# Test Scenarios for PI Web API Scouter Workflow
|
||||
|
||||
This document describes all possible test scenarios for the `pi_web_api_scouter` workflow and its child workflow `core_scouter`.
|
||||
|
||||
## Workflow Overview
|
||||
|
||||
The `pi_web_api_scouter` workflow:
|
||||
1. Retrieves tag values from PI Web API
|
||||
2. Delegates processing to `core_scouter` child workflow which:
|
||||
- Applies data quality gates
|
||||
- Aggregates data
|
||||
- Groups and holds data in Redis
|
||||
- Exports to PostgreSQL
|
||||
- Writes metrics
|
||||
- Optionally stores debug data package
|
||||
|
||||
---
|
||||
|
||||
## 1. PI Web API Scouter - Main Workflow Scenarios
|
||||
|
||||
### 1.1 Success Scenarios
|
||||
|
||||
#### Scenario 1.1.1: Happy Path - Complete Success
|
||||
**Description**: Workflow completes successfully with valid data from PI Web API
|
||||
|
||||
**Input**:
|
||||
- Valid `model_name`, `model_id`, `schedule_name`
|
||||
- Valid `pi_web_api_query` with endpoint, period, max_count, api_timeout
|
||||
- Valid `model_tags` with webids and configurations
|
||||
- Valid filters, schema, table_name, retention_time
|
||||
|
||||
**Expected Behavior**:
|
||||
- `get_tag_values` returns non-empty list of records
|
||||
- Workflow proceeds to `core_scouter`
|
||||
- All activities execute successfully
|
||||
- Data is stored in PostgreSQL
|
||||
- Metrics are written
|
||||
- Workflow completes without errors
|
||||
|
||||
**Assertions**:
|
||||
- PI Web API client called once with correct parameters
|
||||
- Data exists in SQLite (PostgreSQL substitute)
|
||||
- Data cached in Redis
|
||||
- Metrics written
|
||||
- No errors raised
|
||||
|
||||
---
|
||||
|
||||
#### Scenario 1.1.2: Success with Multiple Tags
|
||||
**Description**: Workflow processes multiple tags successfully
|
||||
|
||||
**Input**:
|
||||
- Multiple tags in `model_tags` (3+ tags)
|
||||
- Each tag has valid webid, aggr_function, data_range, frequency
|
||||
|
||||
**Expected Behavior**:
|
||||
- All tags retrieved from PI Web API
|
||||
- All tags processed through quality gates
|
||||
- All tags aggregated correctly
|
||||
- All tags stored in database
|
||||
|
||||
**Assertions**:
|
||||
- Number of records matches number of tags
|
||||
- All tags present in final data
|
||||
- Aggregation applied per tag configuration
|
||||
|
||||
---
|
||||
|
||||
#### Scenario 1.1.3: Success with Debug Data Package Enabled
|
||||
**Description**: Workflow completes with `debug_data_package=True`
|
||||
|
||||
**Input**:
|
||||
- All standard input
|
||||
- `debug_data_package: True`
|
||||
|
||||
**Expected Behavior**:
|
||||
- Normal workflow execution
|
||||
- `store_data_package` activity called
|
||||
- Data package stored in Redis
|
||||
|
||||
**Assertions**:
|
||||
- `store_data_package` called once
|
||||
- Data package key exists in Redis
|
||||
- Package contains both `data` and `held_data`
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Early Exit Scenarios
|
||||
|
||||
#### Scenario 1.2.1: Empty Data from PI Web API
|
||||
**Description**: PI Web API returns empty data
|
||||
|
||||
**Input**:
|
||||
- Valid configuration
|
||||
- PI Web API returns empty DataFrame or empty list
|
||||
|
||||
**Expected Behavior**:
|
||||
- `get_tag_values` returns empty list `[]`
|
||||
- Workflow checks `if not data:` and returns early
|
||||
- `core_scouter` is NOT called
|
||||
- Workflow completes without error
|
||||
|
||||
**Assertions**:
|
||||
- PI Web API called once
|
||||
- `core_scouter` NOT called
|
||||
- No data in PostgreSQL
|
||||
- No data in Redis (except possibly from previous runs)
|
||||
|
||||
---
|
||||
|
||||
#### Scenario 1.2.2: None Returned from PI Web API
|
||||
**Description**: PI Web API returns None
|
||||
|
||||
**Input**:
|
||||
- Valid configuration
|
||||
- PI Web API returns None
|
||||
|
||||
**Expected Behavior**:
|
||||
- `get_tag_values` returns None
|
||||
- Workflow checks `if not data:` and returns early
|
||||
- `core_scouter` is NOT called
|
||||
|
||||
**Assertions**:
|
||||
- PI Web API called once
|
||||
- `core_scouter` NOT called
|
||||
- Workflow completes without error
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Error Scenarios
|
||||
|
||||
#### Scenario 1.3.1: PI Web API Connection Error
|
||||
**Description**: PI Web API client raises connection error
|
||||
|
||||
**Input**:
|
||||
- Valid configuration
|
||||
- PI Web API client raises `PIMSRequestError` or connection exception
|
||||
|
||||
**Expected Behavior**:
|
||||
- `get_tag_values` catches exception
|
||||
- Sends notification with `PI_WEB_API_REQUEST_ERROR`
|
||||
- Raises exception (workflow fails after retries)
|
||||
|
||||
**Assertions**:
|
||||
- Notification sent with correct error details
|
||||
- Exception propagated to workflow
|
||||
- Workflow fails (after retry policy exhausted)
|
||||
- `core_scouter` NOT called
|
||||
|
||||
---
|
||||
|
||||
#### Scenario 1.3.2: PI Web API Timeout
|
||||
**Description**: PI Web API request times out
|
||||
|
||||
**Input**:
|
||||
- Valid configuration
|
||||
- `api_timeout` set to low value
|
||||
- PI Web API takes longer than timeout
|
||||
|
||||
**Expected Behavior**:
|
||||
- Request times out
|
||||
- Exception raised
|
||||
- Notification sent
|
||||
- Workflow fails after retries
|
||||
|
||||
**Assertions**:
|
||||
- Timeout exception caught
|
||||
- Notification sent
|
||||
- Workflow fails
|
||||
|
||||
---
|
||||
|
||||
#### Scenario 1.3.3: Invalid Endpoint
|
||||
**Description**: Invalid PI Web API endpoint provided
|
||||
|
||||
**Input**:
|
||||
- Invalid endpoint path in `pi_web_api_query`
|
||||
|
||||
**Expected Behavior**:
|
||||
- PI Web API client raises error
|
||||
- Notification sent
|
||||
- Workflow fails
|
||||
|
||||
**Assertions**:
|
||||
- Error notification sent
|
||||
- Workflow fails
|
||||
|
||||
---
|
||||
|
||||
## 2. CoreScouter - Child Workflow Scenarios
|
||||
|
||||
### 2.1 Success Scenarios
|
||||
|
||||
#### Scenario 2.1.1: Complete Processing Success
|
||||
**Description**: All stages complete successfully
|
||||
|
||||
**Input**:
|
||||
- Valid data from parent workflow
|
||||
- Valid filters, model_tags, schema, table_name
|
||||
- `fill_missing_tags: False`
|
||||
- `debug_data_package: False`
|
||||
|
||||
**Expected Behavior**:
|
||||
- `data_quality_gate` filters data
|
||||
- `aggregate_data` aggregates by tag
|
||||
- `group_and_hold_data` stores in Redis
|
||||
- `export_data_to_postgres` writes to database
|
||||
- `write_metrics` records metrics
|
||||
- Workflow completes
|
||||
|
||||
**Assertions**:
|
||||
- All activities called in correct order
|
||||
- Data in PostgreSQL
|
||||
- Data in Redis
|
||||
- Metrics written
|
||||
- `store_data_package` NOT called
|
||||
|
||||
---
|
||||
|
||||
#### Scenario 2.1.2: Success with Data Quality Filters
|
||||
**Description**: Data quality filters applied successfully
|
||||
|
||||
**Input**:
|
||||
- Data with some quality issues
|
||||
- Filters configured with `NULL_VALUES_FILTER` or `OUT_OF_BOUNDS_FILTER`
|
||||
- Policy set to `DISCARD` or `WARN`
|
||||
|
||||
**Expected Behavior**:
|
||||
- Quality gate identifies issues
|
||||
- Notification sent (WARNING level)
|
||||
- If policy is `DISCARD`, bad rows removed
|
||||
- Remaining data processed normally
|
||||
|
||||
**Assertions**:
|
||||
- Quality issues detected
|
||||
- Notification sent
|
||||
- Bad data discarded if policy is `DISCARD`
|
||||
- Good data processed
|
||||
|
||||
---
|
||||
|
||||
#### Scenario 2.1.3: Success with Different Aggregation Functions
|
||||
**Description**: Different aggregation functions applied correctly
|
||||
|
||||
**Input**:
|
||||
- Multiple tags with different `aggr_function`: `avg`, `mdn`, `max`, `min`, `lts`
|
||||
- Time-series data with multiple points per tag
|
||||
|
||||
**Expected Behavior**:
|
||||
- Each tag aggregated with its configured function
|
||||
- Aggregated values correct for each function type
|
||||
|
||||
**Assertions**:
|
||||
- `avg` calculates mean correctly
|
||||
- `mdn` calculates median correctly
|
||||
- `max` returns maximum value
|
||||
- `min` returns minimum value
|
||||
- `lts` returns latest value
|
||||
|
||||
---
|
||||
|
||||
#### Scenario 2.1.4: Success with Fill Missing Tags
|
||||
**Description**: Missing tags filled with None
|
||||
|
||||
**Input**:
|
||||
- `fill_missing_tags: True`
|
||||
- Some tags missing from data
|
||||
|
||||
**Expected Behavior**:
|
||||
- Missing tags added to `data_hold` with value `None`
|
||||
- All expected tags present in final data
|
||||
|
||||
**Assertions**:
|
||||
- Missing tags present with `None` value
|
||||
- All model_tags represented in output
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Early Exit Scenarios
|
||||
|
||||
#### Scenario 2.2.1: Empty Data After Grouping
|
||||
**Description**: `group_and_hold_data` returns empty dict
|
||||
|
||||
**Input**:
|
||||
- Data that results in empty `held_data` after grouping
|
||||
|
||||
**Expected Behavior**:
|
||||
- `group_and_hold_data` returns `{}`
|
||||
- Workflow checks `if held_data == {}:` and returns early
|
||||
- `export_data_to_postgres` NOT called
|
||||
- `write_metrics` NOT called
|
||||
- `store_data_package` NOT called
|
||||
|
||||
**Assertions**:
|
||||
- Early return after grouping
|
||||
- No database export
|
||||
- No metrics written
|
||||
- Workflow completes without error
|
||||
|
||||
---
|
||||
|
||||
#### Scenario 2.2.2: Zero Affected Rows After Export
|
||||
**Description**: PostgreSQL export returns zero affected rows
|
||||
|
||||
**Input**:
|
||||
- Data that results in `affected_rows: 0` from export
|
||||
|
||||
**Expected Behavior**:
|
||||
- `export_data_to_postgres` returns `{'affected_rows': 0}`
|
||||
- Workflow checks `if data_exported.get('affected_rows', 0) <= 0:` and returns early
|
||||
- `write_metrics` NOT called
|
||||
- `store_data_package` NOT called
|
||||
|
||||
**Assertions**:
|
||||
- Early return after export
|
||||
- No metrics written
|
||||
- Workflow completes without error
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Error Scenarios
|
||||
|
||||
#### Scenario 2.3.1: Redis Connection Error
|
||||
**Description**: Redis unavailable during `group_and_hold_data`
|
||||
|
||||
**Input**:
|
||||
- Valid data
|
||||
- Redis connection fails
|
||||
|
||||
**Expected Behavior**:
|
||||
- `redis_repository.get()` or `redis_repository.set()` raises exception
|
||||
- Notification sent with `REDIS_GET_ERROR` or `REDIS_SET_ERROR`
|
||||
- Exception propagated (workflow fails after retries)
|
||||
|
||||
**Assertions**:
|
||||
- Error notification sent
|
||||
- Workflow fails
|
||||
|
||||
---
|
||||
|
||||
#### Scenario 2.3.2: PostgreSQL Unique Constraint Violation
|
||||
**Description**: Duplicate data violates unique constraint
|
||||
|
||||
**Input**:
|
||||
- Data with duplicate `model_id`, `timestamp`, `variable` combination
|
||||
- `on_conflict: 'ignore'` configured
|
||||
|
||||
**Expected Behavior**:
|
||||
- PostgreSQL handles conflict with `ON CONFLICT DO NOTHING`
|
||||
- `affected_rows` may be 0 for duplicates
|
||||
- Workflow continues normally
|
||||
|
||||
**Assertions**:
|
||||
- No exception raised
|
||||
- Duplicates ignored
|
||||
- Workflow continues
|
||||
|
||||
---
|
||||
|
||||
## 3. Activity-Specific Scenarios
|
||||
|
||||
> **Note**: Activity-specific scenarios are better suited for unit tests rather than e2e tests.
|
||||
> These scenarios are covered indirectly through workflow e2e tests. For detailed activity testing,
|
||||
> refer to the unit test suite in `tests/activities/`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test Data Requirements
|
||||
|
||||
### 5.1 Valid Test Data Structure
|
||||
|
||||
```python
|
||||
{
|
||||
'model_name': 'test_model',
|
||||
'model_id': 'test_model_id',
|
||||
'schedule_name': 'test_schedule',
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'test_schema',
|
||||
'table_name': 'test_table',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'debug_data_package': False,
|
||||
'pi_web_api_query': {
|
||||
'endpoint': '/streamsets/recorded',
|
||||
'period': '*-1d',
|
||||
'max_count': 10,
|
||||
'api_timeout': 30,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Mock PI Web API Response
|
||||
|
||||
```python
|
||||
DataFrame({
|
||||
'timestamp': ['2024-01-01 12:00:00+0000', ...],
|
||||
'name': ['tag1', 'tag2', ...],
|
||||
'value': [10.5, 20.3, ...],
|
||||
'tag': ['webid1', 'webid2', ...],
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Test Implementation Notes
|
||||
|
||||
### 6.1 Test Organization
|
||||
|
||||
- Group tests by scenario category
|
||||
- Use descriptive test names matching scenario IDs
|
||||
- Share fixtures for common setup
|
||||
- Use parametrized tests for similar scenarios
|
||||
|
||||
### 6.2 Assertions Checklist
|
||||
|
||||
For each scenario, verify:
|
||||
- [ ] Correct activities called
|
||||
- [ ] Correct parameters passed
|
||||
- [ ] Expected data in storage (SQLite/Redis)
|
||||
- [ ] Expected notifications sent
|
||||
- [ ] Expected metrics written
|
||||
- [ ] No unexpected errors
|
||||
- [ ] Workflow state correct
|
||||
|
||||
### 6.3 Mock Configuration
|
||||
|
||||
- Mock PI Web API client responses
|
||||
- Use fake Redis (fakeredis)
|
||||
- Use fake MongoDB (mongomock)
|
||||
- Use SQLite for PostgreSQL
|
||||
- Mock notification handler
|
||||
- Mock metrics controller
|
||||
|
||||
---
|
||||
|
||||
## 7. Priority Scenarios
|
||||
|
||||
### High Priority (Must Test)
|
||||
1. Scenario 1.1.1: Happy Path
|
||||
2. Scenario 1.2.1: Empty Data
|
||||
3. Scenario 1.3.1: API Connection Error
|
||||
4. Scenario 2.1.1: Complete Processing
|
||||
5. Scenario 2.2.1: Empty After Grouping
|
||||
6. Scenario 2.3.4: PostgreSQL Error
|
||||
|
||||
### Medium Priority (Should Test)
|
||||
1. Scenario 1.1.3: Debug Package
|
||||
2. Scenario 2.1.2: Quality Filters
|
||||
3. Scenario 2.1.3: Different Aggregations
|
||||
4. Scenario 3.3.5: Invalid Aggregation
|
||||
5. Scenario 3.5.2: Conflict Ignore
|
||||
|
||||
### Low Priority (Nice to Have)
|
||||
1. Scenario 4.1.2: Retry Success
|
||||
2. Scenario 5.1.1: Large Dataset
|
||||
3. Scenario 5.3.1: Concurrent Execution
|
||||
|
||||
193
e2e/test_core_scouter_early_exit.py
Normal file
193
e2e/test_core_scouter_early_exit.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
End-to-end tests for CoreScouter workflow - Early exit scenarios.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from scouter.activities.activities import Activities
|
||||
from scouter.workflow.sub_workflows.core_scouter import CoreScouter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_2_2_1_empty_data_after_grouping(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 2.2.1: Empty Data After Grouping
|
||||
|
||||
group_and_hold_data returns empty dict, workflow exits early.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Prepare test data that will result in empty held_data
|
||||
# This can happen if all data is filtered out or aggregation results in empty data
|
||||
# Empty data must be a dict with empty lists for each column
|
||||
test_data = {
|
||||
'tag': [],
|
||||
'name': [],
|
||||
'value': [],
|
||||
'timestamp': [],
|
||||
}
|
||||
|
||||
# Prepare input data
|
||||
input_data = {
|
||||
'metadata': {
|
||||
'metadata': {
|
||||
'model_id': '1',
|
||||
'model_name': 'Test Model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
}
|
||||
},
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
'schedule_name': 'test-schedule',
|
||||
'model_name': 'Test Model',
|
||||
'model_id': '1',
|
||||
'data': test_data,
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
CoreScouter.run,
|
||||
input_data,
|
||||
id=f'test-core-scouter-empty-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion (should complete without error)
|
||||
await handle.result()
|
||||
|
||||
# Verify no data was exported to PostgreSQL (early exit)
|
||||
schema_name = 'sientia_data'
|
||||
table_name = 'laborious_data'
|
||||
full_table_name = f"{schema_name}.{table_name}"
|
||||
|
||||
with postgres_engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(f"SELECT COUNT(*) FROM {full_table_name} WHERE model_id = 1")
|
||||
)
|
||||
row_count = result.scalar()
|
||||
|
||||
# Should have 0 rows since export_data_to_postgres was not called
|
||||
assert row_count == 0, f"Expected no data in PostgreSQL, got {row_count} rows"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_2_2_2_zero_affected_rows_after_export(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 2.2.2: Zero Affected Rows After Export
|
||||
|
||||
PostgreSQL export returns zero affected rows, workflow exits early.
|
||||
|
||||
Note: This scenario is hard to test directly in e2e because we'd need to
|
||||
simulate a conflict or other condition that results in 0 affected rows.
|
||||
We'll test by inserting duplicate data first, then running the workflow again.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# First, insert some data directly to create a conflict scenario
|
||||
test_data = [
|
||||
{
|
||||
'timestamp': '2024-01-01 12:00:00+0000',
|
||||
'name': 'tag1',
|
||||
'value': 10.5,
|
||||
'tag': 'webid1',
|
||||
},
|
||||
]
|
||||
|
||||
# Insert data directly into PostgreSQL to create duplicates
|
||||
schema_name = 'sientia_data'
|
||||
table_name = 'laborious_data'
|
||||
full_table_name = f"{schema_name}.{table_name}"
|
||||
|
||||
with postgres_engine.connect() as conn:
|
||||
conn.execute(
|
||||
text(f"""
|
||||
INSERT INTO {full_table_name} (model_id, variable, value, timestamp)
|
||||
VALUES (1, 'tag1', 10.5, '2024-01-01 12:00:00+00:00')
|
||||
ON CONFLICT (model_id, timestamp, variable) DO NOTHING
|
||||
""")
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Prepare input data with the same data (will result in conflict)
|
||||
input_data = {
|
||||
'metadata': {
|
||||
'metadata': {
|
||||
'model_id': '1',
|
||||
'model_name': 'Test Model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
}
|
||||
},
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
'schedule_name': 'test-schedule',
|
||||
'model_name': 'Test Model',
|
||||
'model_id': '1',
|
||||
'data': test_data,
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
CoreScouter.run,
|
||||
input_data,
|
||||
id=f'test-core-scouter-zero-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion (should complete without error)
|
||||
await handle.result()
|
||||
|
||||
# Verify the count didn't increase (conflict handled, 0 affected rows)
|
||||
with postgres_engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(f"SELECT COUNT(*) FROM {full_table_name} WHERE model_id = 1")
|
||||
)
|
||||
row_count = result.scalar()
|
||||
|
||||
# Should still have 1 row (the original one, duplicate was ignored)
|
||||
assert row_count == 1, f"Expected 1 row (duplicate ignored), got {row_count}"
|
||||
|
||||
189
e2e/test_core_scouter_errors.py
Normal file
189
e2e/test_core_scouter_errors.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
End-to-end tests for CoreScouter workflow - Error scenarios.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from temporalio.exceptions import ApplicationError, FailureError
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from scouter.activities.activities import Activities
|
||||
from scouter.workflow.sub_workflows.core_scouter import CoreScouter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_2_3_1_redis_connection_error(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
):
|
||||
"""
|
||||
Scenario 2.3.1: Redis Connection Error
|
||||
|
||||
Redis unavailable during group_and_hold_data, notification sent, workflow fails.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Mock Redis get to raise an error
|
||||
original_get = test_activities.redis_repository.get
|
||||
test_activities.redis_repository.get = AsyncMock(side_effect=Exception("Redis connection error"))
|
||||
|
||||
try:
|
||||
test_data = [
|
||||
{
|
||||
'timestamp': '2024-01-01 12:00:00+0000',
|
||||
'name': 'tag1',
|
||||
'value': 10.5,
|
||||
'tag': 'webid1',
|
||||
},
|
||||
]
|
||||
|
||||
input_data = {
|
||||
'metadata': {
|
||||
'metadata': {
|
||||
'model_id': '1',
|
||||
'model_name': 'Test Model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
}
|
||||
},
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
'schedule_name': 'test-schedule',
|
||||
'model_name': 'Test Model',
|
||||
'model_id': '1',
|
||||
'data': test_data,
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
CoreScouter.run,
|
||||
input_data,
|
||||
id=f'test-core-scouter-redis-error-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion - should fail
|
||||
with pytest.raises((FailureError, ApplicationError, Exception)):
|
||||
await handle.result()
|
||||
finally:
|
||||
# Restore original method
|
||||
test_activities.redis_repository.get = original_get
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_2_3_2_postgresql_unique_constraint_violation(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 2.3.2: PostgreSQL Unique Constraint Violation
|
||||
|
||||
Duplicate data violates unique constraint, handled gracefully with ON CONFLICT DO NOTHING.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Generate unique model_id to avoid conflicts with other tests
|
||||
unique_id = int(datetime.now().timestamp() * 1000) % 1000000
|
||||
|
||||
# First, insert data directly to create a duplicate
|
||||
schema_name = 'sientia_data'
|
||||
table_name = 'laborious_data'
|
||||
full_table_name = f"{schema_name}.{table_name}"
|
||||
|
||||
with postgres_engine.connect() as conn:
|
||||
conn.execute(
|
||||
text(f"""
|
||||
INSERT INTO {full_table_name} (model_id, variable, value, timestamp)
|
||||
VALUES (:model_id, 'tag1', 10.5, '2024-01-01 12:00:00+00:00')
|
||||
ON CONFLICT (model_id, timestamp, variable) DO NOTHING
|
||||
"""),
|
||||
{'model_id': unique_id}
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Prepare the same data to trigger conflict
|
||||
test_data = [
|
||||
{
|
||||
'timestamp': '2024-01-01 12:00:00+0000',
|
||||
'name': 'tag1',
|
||||
'value': 10.5,
|
||||
'tag': 'webid1',
|
||||
},
|
||||
]
|
||||
|
||||
input_data = {
|
||||
'metadata': {
|
||||
'metadata': {
|
||||
'model_id': str(unique_id),
|
||||
'model_name': 'Test Model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
}
|
||||
},
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
'schedule_name': 'test-schedule',
|
||||
'model_name': 'Test Model',
|
||||
'model_id': str(unique_id),
|
||||
'data': test_data,
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
CoreScouter.run,
|
||||
input_data,
|
||||
id=f'test-core-scouter-conflict-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion - should complete without error
|
||||
# (conflict is handled gracefully with ON CONFLICT DO NOTHING)
|
||||
await handle.result()
|
||||
|
||||
# Verify no exception was raised and workflow completed
|
||||
# The duplicate should be ignored (0 affected rows), but workflow should complete
|
||||
with postgres_engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(f"SELECT COUNT(*) FROM {full_table_name} WHERE model_id = :model_id"),
|
||||
{'model_id': unique_id}
|
||||
)
|
||||
row_count = result.scalar()
|
||||
|
||||
# Should still have 1 row (duplicate was ignored)
|
||||
assert row_count == 1, f"Expected 1 row (duplicate ignored), got {row_count}"
|
||||
|
||||
462
e2e/test_core_scouter_success.py
Normal file
462
e2e/test_core_scouter_success.py
Normal file
@@ -0,0 +1,462 @@
|
||||
"""
|
||||
End-to-end tests for CoreScouter workflow - Success scenarios.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from sqlalchemy import inspect, text
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from scouter.activities.activities import Activities
|
||||
from scouter.workflow.sub_workflows.core_scouter import CoreScouter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_2_1_1_complete_processing_success(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 2.1.1: Complete Processing Success
|
||||
|
||||
All stages complete successfully.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Prepare test data (simulating data from parent workflow)
|
||||
test_data = [
|
||||
{
|
||||
'timestamp': '2024-01-01 12:00:00+0000',
|
||||
'name': 'tag1',
|
||||
'value': 10.5,
|
||||
'tag': 'webid1',
|
||||
},
|
||||
{
|
||||
'timestamp': '2024-01-01 12:00:00+0000',
|
||||
'name': 'tag2',
|
||||
'value': 20.3,
|
||||
'tag': 'webid2',
|
||||
},
|
||||
]
|
||||
|
||||
# Prepare input data for CoreScouter
|
||||
input_data = {
|
||||
'metadata': {
|
||||
'metadata': {
|
||||
'model_id': '1',
|
||||
'model_name': 'Test Model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
}
|
||||
},
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
'schedule_name': 'test-schedule',
|
||||
'model_name': 'Test Model',
|
||||
'model_id': '1',
|
||||
'data': test_data,
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
'tag2': {
|
||||
'webid': 'webid2',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
CoreScouter.run,
|
||||
input_data,
|
||||
id=f'test-core-scouter-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion
|
||||
await handle.result()
|
||||
|
||||
# Verify data was stored in PostgreSQL
|
||||
schema_name = 'sientia_data'
|
||||
table_name = 'laborious_data'
|
||||
full_table_name = f"{schema_name}.{table_name}"
|
||||
|
||||
with postgres_engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(f"SELECT COUNT(*) FROM {full_table_name} WHERE model_id = 1")
|
||||
)
|
||||
row_count = result.scalar()
|
||||
|
||||
assert row_count > 0, f"Expected data in PostgreSQL, got {row_count} rows"
|
||||
|
||||
# Verify data was cached in Redis
|
||||
keys = await test_activities.redis_repository.keys('*')
|
||||
assert len(keys) > 0, "Expected data in Redis"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_2_1_2_success_with_data_quality_filters(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 2.1.2: Success with Data Quality Filters
|
||||
|
||||
Data quality filters applied successfully.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Prepare test data with some quality issues (null values, out of bounds)
|
||||
test_data = [
|
||||
{
|
||||
'timestamp': '2024-01-01 12:00:00+0000',
|
||||
'name': 'tag1',
|
||||
'value': None, # Null value - should be filtered if DISCARD policy
|
||||
'tag': 'webid1',
|
||||
},
|
||||
{
|
||||
'timestamp': '2024-01-01 12:00:00+0000',
|
||||
'name': 'tag2',
|
||||
'value': 150.0, # Out of bounds (range is [0, 100])
|
||||
'tag': 'webid2',
|
||||
},
|
||||
{
|
||||
'timestamp': '2024-01-01 12:00:00+0000',
|
||||
'name': 'tag3',
|
||||
'value': 50.0, # Valid value
|
||||
'tag': 'webid3',
|
||||
},
|
||||
]
|
||||
|
||||
# Prepare input data with filters
|
||||
input_data = {
|
||||
'metadata': {
|
||||
'metadata': {
|
||||
'model_id': '1',
|
||||
'model_name': 'Test Model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
}
|
||||
},
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
'schedule_name': 'test-schedule',
|
||||
'model_name': 'Test Model',
|
||||
'model_id': '1',
|
||||
'data': test_data,
|
||||
'trigger_laborious': False,
|
||||
'filters': {
|
||||
'NULL_VALUES_FILTER': {
|
||||
'policy': 'DISCARD',
|
||||
},
|
||||
'OUT_OF_BOUNDS_FILTER': {
|
||||
'policy': 'WARN',
|
||||
},
|
||||
},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
'tag2': {
|
||||
'webid': 'webid2',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
'tag3': {
|
||||
'webid': 'webid3',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
CoreScouter.run,
|
||||
input_data,
|
||||
id=f'test-core-scouter-quality-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion
|
||||
await handle.result()
|
||||
|
||||
# Verify data was stored in PostgreSQL
|
||||
# tag1 should be filtered out (null value with DISCARD policy)
|
||||
# tag2 should be kept (out of bounds with WARN policy)
|
||||
# tag3 should be kept (valid value)
|
||||
schema_name = 'sientia_data'
|
||||
table_name = 'laborious_data'
|
||||
full_table_name = f"{schema_name}.{table_name}"
|
||||
|
||||
with postgres_engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(f"SELECT COUNT(*) FROM {full_table_name} WHERE model_id = 1")
|
||||
)
|
||||
row_count = result.scalar()
|
||||
|
||||
# Should have at least tag2 and tag3 (tag1 filtered out)
|
||||
assert row_count >= 2, f"Expected at least 2 records after filtering, got {row_count}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_2_1_3_success_with_different_aggregation_functions(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 2.1.3: Success with Different Aggregation Functions
|
||||
|
||||
Different aggregation functions applied correctly.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Prepare test data with multiple points per tag for aggregation
|
||||
test_data = [
|
||||
{'timestamp': '2024-01-01 12:00:00+0000', 'name': 'tag_avg', 'value': 10.0, 'tag': 'webid_avg'},
|
||||
{'timestamp': '2024-01-01 12:01:00+0000', 'name': 'tag_avg', 'value': 20.0, 'tag': 'webid_avg'},
|
||||
{'timestamp': '2024-01-01 12:02:00+0000', 'name': 'tag_avg', 'value': 30.0, 'tag': 'webid_avg'},
|
||||
{'timestamp': '2024-01-01 12:00:00+0000', 'name': 'tag_max', 'value': 5.0, 'tag': 'webid_max'},
|
||||
{'timestamp': '2024-01-01 12:01:00+0000', 'name': 'tag_max', 'value': 15.0, 'tag': 'webid_max'},
|
||||
{'timestamp': '2024-01-01 12:02:00+0000', 'name': 'tag_max', 'value': 10.0, 'tag': 'webid_max'},
|
||||
{'timestamp': '2024-01-01 12:00:00+0000', 'name': 'tag_min', 'value': 50.0, 'tag': 'webid_min'},
|
||||
{'timestamp': '2024-01-01 12:01:00+0000', 'name': 'tag_min', 'value': 30.0, 'tag': 'webid_min'},
|
||||
{'timestamp': '2024-01-01 12:02:00+0000', 'name': 'tag_min', 'value': 40.0, 'tag': 'webid_min'},
|
||||
{'timestamp': '2024-01-01 12:00:00+0000', 'name': 'tag_lts', 'value': 100.0, 'tag': 'webid_lts'},
|
||||
{'timestamp': '2024-01-01 12:01:00+0000', 'name': 'tag_lts', 'value': 200.0, 'tag': 'webid_lts'},
|
||||
{'timestamp': '2024-01-01 12:02:00+0000', 'name': 'tag_lts', 'value': 300.0, 'tag': 'webid_lts'},
|
||||
]
|
||||
|
||||
# Prepare input data with different aggregation functions
|
||||
input_data = {
|
||||
'metadata': {
|
||||
'metadata': {
|
||||
'model_id': '1',
|
||||
'model_name': 'Test Model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
}
|
||||
},
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
'schedule_name': 'test-schedule',
|
||||
'model_name': 'Test Model',
|
||||
'model_id': '1',
|
||||
'data': test_data,
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'model_tags': {
|
||||
'tag_avg': {
|
||||
'webid': 'webid_avg',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
'tag_max': {
|
||||
'webid': 'webid_max',
|
||||
'aggr_function': 'max',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
'tag_min': {
|
||||
'webid': 'webid_min',
|
||||
'aggr_function': 'min',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
'tag_lts': {
|
||||
'webid': 'webid_lts',
|
||||
'aggr_function': 'lts',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
CoreScouter.run,
|
||||
input_data,
|
||||
id=f'test-core-scouter-aggr-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion
|
||||
await handle.result()
|
||||
|
||||
# Verify data was stored in PostgreSQL
|
||||
schema_name = 'sientia_data'
|
||||
table_name = 'laborious_data'
|
||||
full_table_name = f"{schema_name}.{table_name}"
|
||||
|
||||
with postgres_engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(f"SELECT variable, value FROM {full_table_name} WHERE model_id = 1")
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
# Should have 4 rows (one per tag)
|
||||
assert len(rows) >= 4, f"Expected at least 4 records, got {len(rows)}"
|
||||
|
||||
# Verify aggregation was applied correctly
|
||||
values_by_tag = {row[0]: float(row[1]) if row[1] is not None else None for row in rows}
|
||||
|
||||
# Verify all tags are present
|
||||
assert 'tag_avg' in values_by_tag, "Expected tag_avg to be present"
|
||||
assert 'tag_max' in values_by_tag, "Expected tag_max to be present"
|
||||
assert 'tag_min' in values_by_tag, "Expected tag_min to be present"
|
||||
assert 'tag_lts' in values_by_tag, "Expected tag_lts to be present"
|
||||
|
||||
# Note: Due to timestamp normalization in get_tag_values, aggregation may behave differently
|
||||
# We verify that aggregation was applied (values exist) rather than exact values
|
||||
# avg: should be between min and max of input values (10, 20, 30)
|
||||
if 'tag_avg' in values_by_tag and values_by_tag['tag_avg'] is not None:
|
||||
assert 10.0 <= values_by_tag['tag_avg'] <= 30.0, f"Expected avg between 10-30, got {values_by_tag['tag_avg']}"
|
||||
|
||||
# max: should be >= 15.0 (max of 5, 15, 10)
|
||||
if 'tag_max' in values_by_tag and values_by_tag['tag_max'] is not None:
|
||||
assert values_by_tag['tag_max'] >= 10.0, f"Expected max >= 10, got {values_by_tag['tag_max']}"
|
||||
|
||||
# min: should be <= 50.0 (min of 50, 30, 40)
|
||||
if 'tag_min' in values_by_tag and values_by_tag['tag_min'] is not None:
|
||||
assert values_by_tag['tag_min'] <= 50.0, f"Expected min <= 50, got {values_by_tag['tag_min']}"
|
||||
|
||||
# lts: should be one of the values (100, 200, 300)
|
||||
if 'tag_lts' in values_by_tag and values_by_tag['tag_lts'] is not None:
|
||||
assert values_by_tag['tag_lts'] in [100.0, 200.0, 300.0], f"Expected lts to be one of [100, 200, 300], got {values_by_tag['tag_lts']}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_2_1_4_success_with_fill_missing_tags(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 2.1.4: Success with Fill Missing Tags
|
||||
|
||||
Missing tags filled with None.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Prepare test data with only some tags present
|
||||
test_data = [
|
||||
{
|
||||
'timestamp': '2024-01-01 12:00:00+0000',
|
||||
'name': 'tag1',
|
||||
'value': 10.5,
|
||||
'tag': 'webid1',
|
||||
},
|
||||
# tag2 and tag3 are missing
|
||||
]
|
||||
|
||||
# Prepare input data with fill_missing_tags enabled
|
||||
input_data = {
|
||||
'metadata': {
|
||||
'metadata': {
|
||||
'model_id': '1',
|
||||
'model_name': 'Test Model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
}
|
||||
},
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
'schedule_name': 'test-schedule',
|
||||
'model_name': 'Test Model',
|
||||
'model_id': '1',
|
||||
'data': test_data,
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': True, # Enable fill missing tags
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
'tag2': {
|
||||
'webid': 'webid2',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
'tag3': {
|
||||
'webid': 'webid3',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
CoreScouter.run,
|
||||
input_data,
|
||||
id=f'test-core-scouter-fill-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion
|
||||
await handle.result()
|
||||
|
||||
# Verify all tags are present in Redis (including missing ones with None)
|
||||
keys = await test_activities.redis_repository.keys('*')
|
||||
assert len(keys) > 0, "Expected data in Redis"
|
||||
|
||||
# Verify data was stored in PostgreSQL
|
||||
schema_name = 'sientia_data'
|
||||
table_name = 'laborious_data'
|
||||
full_table_name = f"{schema_name}.{table_name}"
|
||||
|
||||
with postgres_engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(f"SELECT variable, value FROM {full_table_name} WHERE model_id = 1")
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
# Should have all 3 tags (tag1 with value, tag2 and tag3 with None)
|
||||
variables = [row[0] for row in rows]
|
||||
assert 'tag1' in variables, "Expected tag1 to be present"
|
||||
# Note: tags with None values might not be stored in PostgreSQL, so we just verify tag1 exists
|
||||
|
||||
106
e2e/test_pi_web_api_scouter.py
Normal file
106
e2e/test_pi_web_api_scouter.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
End-to-end tests for PI Web API Scouter workflow.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import inspect, text
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from scouter.activities.activities import Activities
|
||||
from scouter.workflow.pi_web_api_scouter import PIWebAPIScouter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_pi_web_api_scouter_e2e(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
mock_pi_web_api_client,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
End-to-end test for PI Web API Scouter workflow.
|
||||
|
||||
This test:
|
||||
1. Starts the workflow with test data
|
||||
2. Verifies PI Web API is called
|
||||
3. Verifies data flows through CoreScouter
|
||||
4. Verifies data is stored in PostgreSQL (schema: sientia_data, table: laborious_data)
|
||||
5. Verifies data is cached in Redis
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Prepare test input
|
||||
input_data = {
|
||||
'model_name': 'PI Web API Scouter Test Model',
|
||||
'model_id': '1',
|
||||
'schedule_name': 'pi-web-api-scouter-test',
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
'tag2': {
|
||||
'webid': 'webid2',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'pi_web_api_query': {
|
||||
'endpoint': '/streamsets/recorded',
|
||||
'period': '*-1d',
|
||||
'max_count': 10,
|
||||
'api_timeout': 30,
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
PIWebAPIScouter.run,
|
||||
input_data,
|
||||
id=f'test-workflow-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion
|
||||
await handle.result()
|
||||
|
||||
# Verify PI Web API was called
|
||||
mock_pi_web_api_client.get_latest_values_df.assert_called_once()
|
||||
|
||||
# Verify data was stored in PostgreSQL
|
||||
inspector = inspect(postgres_engine)
|
||||
|
||||
# Schema and table are created by the setup_postgres_schema_and_table fixture
|
||||
schema_name = 'sientia_data'
|
||||
table_name = 'laborious_data'
|
||||
full_table_name = f"{schema_name}.{table_name}"
|
||||
|
||||
# Check if table exists in the schema
|
||||
table_exists = inspector.has_table(table_name, schema=schema_name)
|
||||
|
||||
assert table_exists, f"Expected table {full_table_name} to exist in PostgreSQL"
|
||||
|
||||
# Verify data was inserted
|
||||
with postgres_engine.connect() as conn:
|
||||
result = conn.execute(text(f"SELECT COUNT(*) FROM {full_table_name}"))
|
||||
row_count = result.scalar()
|
||||
|
||||
assert row_count > 0, f"Expected data in PostgreSQL table {full_table_name}, got {row_count} rows"
|
||||
|
||||
# Verify data was cached in Redis
|
||||
keys = await test_activities.redis_repository.keys('*')
|
||||
assert len(keys) > 0, "Expected data in Redis"
|
||||
182
e2e/test_pi_web_api_scouter_early_exit.py
Normal file
182
e2e/test_pi_web_api_scouter_early_exit.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
End-to-end tests for PI Web API Scouter workflow - Early exit scenarios.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from scouter.activities.activities import Activities
|
||||
from scouter.workflow.pi_web_api_scouter import PIWebAPIScouter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_1_2_1_empty_data_from_pi_web_api(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
mock_pi_web_api_client,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 1.2.1: Empty Data from PI Web API
|
||||
|
||||
PI Web API returns empty data, workflow should exit early without calling core_scouter.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Mock empty DataFrame response with proper datetime column
|
||||
empty_df = pd.DataFrame({
|
||||
'timestamp': pd.to_datetime([], utc=True),
|
||||
'name': [],
|
||||
'value': [],
|
||||
'tag': [],
|
||||
})
|
||||
mock_pi_web_api_client.get_latest_values_df = AsyncMock(return_value=empty_df)
|
||||
|
||||
# Prepare test input with unique model_id to avoid conflicts
|
||||
unique_id = int(datetime.now().timestamp())
|
||||
input_data = {
|
||||
'model_name': 'PI Web API Scouter Test Model',
|
||||
'model_id': str(unique_id),
|
||||
'schedule_name': 'pi-web-api-scouter-test-empty',
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'pi_web_api_query': {
|
||||
'endpoint': '/streamsets/recorded',
|
||||
'period': '*-1d',
|
||||
'max_count': 10,
|
||||
'api_timeout': 30,
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
PIWebAPIScouter.run,
|
||||
input_data,
|
||||
id=f'test-workflow-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion (should complete without error)
|
||||
await handle.result()
|
||||
|
||||
# Verify PI Web API was called once
|
||||
mock_pi_web_api_client.get_latest_values_df.assert_called_once()
|
||||
|
||||
# Verify no data was stored in PostgreSQL (core_scouter was not called)
|
||||
schema_name = 'sientia_data'
|
||||
table_name = 'laborious_data'
|
||||
full_table_name = f"{schema_name}.{table_name}"
|
||||
|
||||
with postgres_engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(f"SELECT COUNT(*) FROM {full_table_name} WHERE model_id = :model_id"),
|
||||
{'model_id': unique_id}
|
||||
)
|
||||
row_count = result.scalar()
|
||||
|
||||
# Should have 0 rows since core_scouter was not called
|
||||
assert row_count == 0, f"Expected no data in PostgreSQL, got {row_count} rows"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_1_2_2_none_returned_from_pi_web_api(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
mock_pi_web_api_client,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 1.2.2: None Returned from PI Web API
|
||||
|
||||
PI Web API returns None, workflow should exit early without calling core_scouter.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Mock empty DataFrame response (None is not handled well, so use empty DataFrame instead)
|
||||
empty_df = pd.DataFrame({
|
||||
'timestamp': pd.to_datetime([], utc=True),
|
||||
'name': [],
|
||||
'value': [],
|
||||
'tag': [],
|
||||
})
|
||||
mock_pi_web_api_client.get_latest_values_df = AsyncMock(return_value=empty_df)
|
||||
|
||||
# Prepare test input with unique model_id to avoid conflicts
|
||||
unique_id = int(datetime.now().timestamp())
|
||||
input_data = {
|
||||
'model_name': 'PI Web API Scouter Test Model',
|
||||
'model_id': str(unique_id),
|
||||
'schedule_name': 'pi-web-api-scouter-test-none',
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'pi_web_api_query': {
|
||||
'endpoint': '/streamsets/recorded',
|
||||
'period': '*-1d',
|
||||
'max_count': 10,
|
||||
'api_timeout': 30,
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
PIWebAPIScouter.run,
|
||||
input_data,
|
||||
id=f'test-workflow-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion (should complete without error)
|
||||
await handle.result()
|
||||
|
||||
# Verify PI Web API was called once
|
||||
mock_pi_web_api_client.get_latest_values_df.assert_called_once()
|
||||
|
||||
# Verify no data was stored in PostgreSQL (core_scouter was not called)
|
||||
schema_name = 'sientia_data'
|
||||
table_name = 'laborious_data'
|
||||
full_table_name = f"{schema_name}.{table_name}"
|
||||
|
||||
with postgres_engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(f"SELECT COUNT(*) FROM {full_table_name} WHERE model_id = :model_id"),
|
||||
{'model_id': unique_id}
|
||||
)
|
||||
row_count = result.scalar()
|
||||
|
||||
# Should have 0 rows since core_scouter was not called
|
||||
assert row_count == 0, f"Expected no data in PostgreSQL, got {row_count} rows"
|
||||
|
||||
206
e2e/test_pi_web_api_scouter_errors.py
Normal file
206
e2e/test_pi_web_api_scouter_errors.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
End-to-end tests for PI Web API Scouter workflow - Error scenarios.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from temporalio.exceptions import ApplicationError, FailureError
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from scouter.activities.activities import Activities
|
||||
from scouter.utils.clients.pi_web_api_client import PIMSRequestError
|
||||
from scouter.workflow.pi_web_api_scouter import PIWebAPIScouter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_1_3_1_pi_web_api_connection_error(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
mock_pi_web_api_client,
|
||||
):
|
||||
"""
|
||||
Scenario 1.3.1: PI Web API Connection Error
|
||||
|
||||
PI Web API client raises connection error, notification sent, workflow fails.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Mock connection error
|
||||
mock_pi_web_api_client.get_latest_values_df = AsyncMock(
|
||||
side_effect=PIMSRequestError("Connection failed")
|
||||
)
|
||||
|
||||
# Prepare test input
|
||||
input_data = {
|
||||
'model_name': 'PI Web API Scouter Test Model',
|
||||
'model_id': '1',
|
||||
'schedule_name': 'pi-web-api-scouter-test-error',
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'pi_web_api_query': {
|
||||
'endpoint': '/streamsets/recorded',
|
||||
'period': '*-1d',
|
||||
'max_count': 10,
|
||||
'api_timeout': 30,
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
PIWebAPIScouter.run,
|
||||
input_data,
|
||||
id=f'test-workflow-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion - should fail
|
||||
with pytest.raises((FailureError, ApplicationError, Exception)):
|
||||
await handle.result()
|
||||
|
||||
# Verify PI Web API was called
|
||||
mock_pi_web_api_client.get_latest_values_df.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_1_3_2_pi_web_api_timeout(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
mock_pi_web_api_client,
|
||||
):
|
||||
"""
|
||||
Scenario 1.3.2: PI Web API Timeout
|
||||
|
||||
PI Web API request times out, notification sent, workflow fails.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Mock timeout error
|
||||
mock_pi_web_api_client.get_latest_values_df = AsyncMock(
|
||||
side_effect=TimeoutError("Request timed out")
|
||||
)
|
||||
|
||||
# Prepare test input with low timeout
|
||||
input_data = {
|
||||
'model_name': 'PI Web API Scouter Test Model',
|
||||
'model_id': '1',
|
||||
'schedule_name': 'pi-web-api-scouter-test-timeout',
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'pi_web_api_query': {
|
||||
'endpoint': '/streamsets/recorded',
|
||||
'period': '*-1d',
|
||||
'max_count': 10,
|
||||
'api_timeout': 1, # Very short timeout
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
PIWebAPIScouter.run,
|
||||
input_data,
|
||||
id=f'test-workflow-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion - should fail
|
||||
with pytest.raises((FailureError, ApplicationError, Exception)):
|
||||
await handle.result()
|
||||
|
||||
# Verify PI Web API was called
|
||||
mock_pi_web_api_client.get_latest_values_df.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_1_3_3_invalid_endpoint(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
mock_pi_web_api_client,
|
||||
):
|
||||
"""
|
||||
Scenario 1.3.3: Invalid Endpoint
|
||||
|
||||
Invalid PI Web API endpoint provided, error raised, workflow fails.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Mock error for invalid endpoint
|
||||
mock_pi_web_api_client.get_latest_values_df = AsyncMock(
|
||||
side_effect=PIMSRequestError("HTTP 404 calling '/invalid/endpoint': Not found")
|
||||
)
|
||||
|
||||
# Prepare test input with invalid endpoint
|
||||
input_data = {
|
||||
'model_name': 'PI Web API Scouter Test Model',
|
||||
'model_id': '1',
|
||||
'schedule_name': 'pi-web-api-scouter-test-invalid',
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'pi_web_api_query': {
|
||||
'endpoint': '/invalid/endpoint',
|
||||
'period': '*-1d',
|
||||
'max_count': 10,
|
||||
'api_timeout': 30,
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
PIWebAPIScouter.run,
|
||||
input_data,
|
||||
id=f'test-workflow-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion - should fail
|
||||
with pytest.raises((FailureError, ApplicationError, Exception)):
|
||||
await handle.result()
|
||||
|
||||
# Verify PI Web API was called
|
||||
mock_pi_web_api_client.get_latest_values_df.assert_called()
|
||||
|
||||
|
||||
298
e2e/test_pi_web_api_scouter_success.py
Normal file
298
e2e/test_pi_web_api_scouter_success.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
End-to-end tests for PI Web API Scouter workflow - Success scenarios.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from sqlalchemy import inspect, text
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from scouter.activities.activities import Activities
|
||||
from scouter.workflow.pi_web_api_scouter import PIWebAPIScouter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_1_1_1_happy_path_complete_success(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
mock_pi_web_api_client,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 1.1.1: Happy Path - Complete Success
|
||||
|
||||
Workflow completes successfully with valid data from PI Web API.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Prepare test input
|
||||
input_data = {
|
||||
'model_name': 'PI Web API Scouter Test Model',
|
||||
'model_id': '1',
|
||||
'schedule_name': 'pi-web-api-scouter-test',
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
'tag2': {
|
||||
'webid': 'webid2',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'pi_web_api_query': {
|
||||
'endpoint': '/streamsets/recorded',
|
||||
'period': '*-1d',
|
||||
'max_count': 10,
|
||||
'api_timeout': 30,
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
PIWebAPIScouter.run,
|
||||
input_data,
|
||||
id=f'test-workflow-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion
|
||||
await handle.result()
|
||||
|
||||
# Verify PI Web API was called once with correct parameters
|
||||
mock_pi_web_api_client.get_latest_values_df.assert_called_once()
|
||||
|
||||
# Verify data was stored in PostgreSQL
|
||||
inspector = inspect(postgres_engine)
|
||||
schema_name = 'sientia_data'
|
||||
table_name = 'laborious_data'
|
||||
full_table_name = f"{schema_name}.{table_name}"
|
||||
|
||||
table_exists = inspector.has_table(table_name, schema=schema_name)
|
||||
assert table_exists, f"Expected table {full_table_name} to exist in PostgreSQL"
|
||||
|
||||
# Verify data was inserted
|
||||
with postgres_engine.connect() as conn:
|
||||
result = conn.execute(text(f"SELECT COUNT(*) FROM {full_table_name}"))
|
||||
row_count = result.scalar()
|
||||
|
||||
assert row_count > 0, f"Expected data in PostgreSQL table {full_table_name}, got {row_count} rows"
|
||||
|
||||
# Verify data was cached in Redis
|
||||
keys = await test_activities.redis_repository.keys('*')
|
||||
assert len(keys) > 0, "Expected data in Redis"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_1_1_2_success_with_multiple_tags(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
mock_pi_web_api_client,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 1.1.2: Success with Multiple Tags
|
||||
|
||||
Workflow processes multiple tags successfully.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Create mock DataFrame with 5 tags
|
||||
mock_df = pd.DataFrame({
|
||||
'timestamp': [
|
||||
'2024-01-01 12:00:00+0000',
|
||||
'2024-01-01 12:01:00+0000',
|
||||
'2024-01-01 12:02:00+0000',
|
||||
'2024-01-01 12:03:00+0000',
|
||||
'2024-01-01 12:04:00+0000',
|
||||
],
|
||||
'name': ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'],
|
||||
'value': [10.5, 20.3, 30.7, 40.1, 50.9],
|
||||
'tag': ['webid1', 'webid2', 'webid3', 'webid4', 'webid5'],
|
||||
})
|
||||
mock_df['timestamp'] = pd.to_datetime(mock_df['timestamp'], utc=True).dt.floor('s')
|
||||
mock_pi_web_api_client.get_latest_values_df = AsyncMock(return_value=mock_df)
|
||||
|
||||
# Prepare test input with 5 tags
|
||||
input_data = {
|
||||
'model_name': 'PI Web API Scouter Test Model',
|
||||
'model_id': '1',
|
||||
'schedule_name': 'pi-web-api-scouter-test-multiple',
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
'tag2': {
|
||||
'webid': 'webid2',
|
||||
'aggr_function': 'max',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
'tag3': {
|
||||
'webid': 'webid3',
|
||||
'aggr_function': 'min',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
'tag4': {
|
||||
'webid': 'webid4',
|
||||
'aggr_function': 'mdn',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
'tag5': {
|
||||
'webid': 'webid5',
|
||||
'aggr_function': 'lts',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'pi_web_api_query': {
|
||||
'endpoint': '/streamsets/recorded',
|
||||
'period': '*-1d',
|
||||
'max_count': 10,
|
||||
'api_timeout': 30,
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
PIWebAPIScouter.run,
|
||||
input_data,
|
||||
id=f'test-workflow-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion
|
||||
await handle.result()
|
||||
|
||||
# Verify all tags were retrieved
|
||||
mock_pi_web_api_client.get_latest_values_df.assert_called_once()
|
||||
|
||||
# Verify data was stored in PostgreSQL
|
||||
schema_name = 'sientia_data'
|
||||
table_name = 'laborious_data'
|
||||
full_table_name = f"{schema_name}.{table_name}"
|
||||
|
||||
with postgres_engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(f"SELECT COUNT(*) FROM {full_table_name} WHERE model_id = 1")
|
||||
)
|
||||
row_count = result.scalar()
|
||||
|
||||
# Should have at least 5 rows (one per tag)
|
||||
assert row_count >= 5, f"Expected at least 5 records, got {row_count}"
|
||||
|
||||
# Verify all tags are present in the database
|
||||
with postgres_engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(f"SELECT DISTINCT variable FROM {full_table_name} WHERE model_id = 1")
|
||||
)
|
||||
variables = [row[0] for row in result.fetchall()]
|
||||
|
||||
expected_tags = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5']
|
||||
for tag in expected_tags:
|
||||
assert tag in variables, f"Expected tag {tag} to be in database"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_1_1_3_success_with_debug_data_package(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
mock_pi_web_api_client,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 1.1.3: Success with Debug Data Package Enabled
|
||||
|
||||
Workflow completes with debug_data_package=True.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
|
||||
# Generate unique model_id to avoid conflicts with other tests
|
||||
unique_id = int(datetime.now().timestamp() * 1000) % 1000000
|
||||
|
||||
# Prepare test input with debug_data_package enabled
|
||||
input_data = {
|
||||
'model_name': 'PI Web API Scouter Test Model',
|
||||
'model_id': str(unique_id),
|
||||
'schedule_name': 'pi-web-api-scouter-test-debug',
|
||||
'model_tags': {
|
||||
'tag1': {
|
||||
'webid': 'webid1',
|
||||
'aggr_function': 'avg',
|
||||
'data_range': [0, 100],
|
||||
'frequency': 60000,
|
||||
},
|
||||
},
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'debug_data_package': True,
|
||||
'pi_web_api_query': {
|
||||
'endpoint': '/streamsets/recorded',
|
||||
'period': '*-1d',
|
||||
'max_count': 10,
|
||||
'api_timeout': 30,
|
||||
},
|
||||
}
|
||||
|
||||
# Start workflow
|
||||
handle = await client.start_workflow(
|
||||
PIWebAPIScouter.run,
|
||||
input_data,
|
||||
id=f'test-workflow-{datetime.now().timestamp()}',
|
||||
task_queue='test-queue',
|
||||
)
|
||||
|
||||
# Wait for workflow completion
|
||||
await handle.result()
|
||||
|
||||
# Verify PI Web API was called
|
||||
mock_pi_web_api_client.get_latest_values_df.assert_called_once()
|
||||
|
||||
# Verify data package was stored in Redis
|
||||
# The key format is: data_package_{workflow_name}_{schedule_name}_{timestamp}
|
||||
keys = await test_activities.redis_repository.keys('data_package_*')
|
||||
assert len(keys) > 0, f"Expected data package key in Redis, found keys: {keys}"
|
||||
|
||||
# Verify the data package contains both 'data' and 'held_data'
|
||||
if keys:
|
||||
package_key = keys[0]
|
||||
package_data = await test_activities.redis_repository.get(package_key)
|
||||
assert package_data is not None, "Expected data package to exist"
|
||||
# The package should be a dict with 'data' and 'held_data' keys
|
||||
assert isinstance(package_data, dict), "Expected data package to be a dict"
|
||||
|
||||
@@ -115,6 +115,7 @@ addopts = [
|
||||
"-v",
|
||||
"--strict-markers",
|
||||
]
|
||||
asyncio_mode = "auto"
|
||||
markers = [
|
||||
"asyncio: marks tests as async",
|
||||
"integration: marks tests as integration tests",
|
||||
|
||||
@@ -14,6 +14,11 @@ pytest>=7.4.0 # Testing framework
|
||||
pytest-cov>=4.1.0 # Coverage plugin for pytest
|
||||
pytest-asyncio>=0.21.0 # Async test support (already in main requirements)
|
||||
|
||||
# E2E Testing Dependencies
|
||||
fakeredis>=2.20.0 # In-memory Redis server for testing
|
||||
mongomock>=4.1.2 # In-memory MongoDB for testing
|
||||
|
||||
# Development Tools
|
||||
ipython>=8.12.0 # Enhanced Python shell
|
||||
ipdb>=0.13.13 # IPython debugger
|
||||
testcontainers[postgres]
|
||||
@@ -133,7 +133,7 @@ class API(SientiaMonitoring):
|
||||
# Normalize the package timestamp
|
||||
latest_values['timestamp'] = latest_values['timestamp'].max()
|
||||
|
||||
self.debug(f'Latest values: {latest_values}', metadata=metadata)
|
||||
self.debug(f'Latest values: {latest_values.to_string()}', metadata=metadata)
|
||||
self.info(f'Gathered {len(latest_values)} tag values', metadata=metadata)
|
||||
|
||||
return latest_values.to_dict(orient='records')
|
||||
|
||||
@@ -287,14 +287,17 @@ class PIWebAPIClient(SientiaMonitoring):
|
||||
|
||||
# Build query parameters with WebIds (filtering out None values)
|
||||
params: list[tuple[str, str]] = [('webid', web_id['webid']) for web_id in web_ids.values()]
|
||||
# Invert startTime and endTime to get descending order (most recent first)
|
||||
# PI Web API returns descending order when endTime < startTime
|
||||
params.extend(
|
||||
[
|
||||
('startTime', start_time),
|
||||
('endtime', end_time),
|
||||
('startTime', end_time), # Use end_time as startTime (inverted)
|
||||
('endtime', start_time), # Use start_time as endTime (inverted)
|
||||
('selectedFields', 'Items.Name;Items.Items.Timestamp;Items.Items.Value'),
|
||||
]
|
||||
)
|
||||
|
||||
if max_count is not None:
|
||||
params.append(('maxCount', str(max_count)))
|
||||
|
||||
data = await self._curl_get_json(
|
||||
@@ -305,6 +308,8 @@ class PIWebAPIClient(SientiaMonitoring):
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
self.debug(f'Raw data from PI Web API: {data}', metadata=metadata)
|
||||
|
||||
raw_data = data.get('Items', [])
|
||||
|
||||
records = []
|
||||
|
||||
@@ -67,7 +67,7 @@ class PIWebAPIScouter:
|
||||
PIMSRequestError: If PI Web API request fails
|
||||
"""
|
||||
|
||||
input_data['workflow_name'] = 'scouter'
|
||||
input_data['workflow_name'] = 'pi_web_api_scouter'
|
||||
|
||||
metadata = {
|
||||
'metadata': {
|
||||
|
||||
@@ -146,7 +146,7 @@ async def test_get_tag_values_success(api_activity):
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0]['name'] == 'tag1'
|
||||
assert result[0]['value'] == 10.5
|
||||
assert result[0]['value'] == pytest.approx(10.5)
|
||||
assert result[1]['name'] == 'tag2'
|
||||
assert result[2]['name'] == 'tag3'
|
||||
assert result[0]['timestamp'] == '2023-01-01 12:02:00+0000'
|
||||
@@ -332,6 +332,6 @@ async def test_get_tag_values_with_nan_values(api_activity):
|
||||
|
||||
# Verify
|
||||
assert len(result) == 2
|
||||
assert result[0]['value'] == 10.0
|
||||
assert result[0]['value'] == pytest.approx(10.0)
|
||||
# NaN should be preserved in the result
|
||||
assert pd.isna(result[1]['value'])
|
||||
|
||||
@@ -164,28 +164,28 @@ def test_extract_numeric_with_float(pi_client):
|
||||
"""Test extracting numeric value from float"""
|
||||
result = pi_client._extract_numeric(42.5)
|
||||
|
||||
assert result == 42.5
|
||||
assert result == pytest.approx(42.5)
|
||||
|
||||
|
||||
def test_extract_numeric_with_int(pi_client):
|
||||
"""Test extracting numeric value from int"""
|
||||
result = pi_client._extract_numeric(42)
|
||||
|
||||
assert result == 42.0
|
||||
assert result == pytest.approx(42.0)
|
||||
|
||||
|
||||
def test_extract_numeric_with_string(pi_client):
|
||||
"""Test extracting numeric value from string"""
|
||||
result = pi_client._extract_numeric('123.45')
|
||||
|
||||
assert result == 123.45
|
||||
assert result == pytest.approx(123.45)
|
||||
|
||||
|
||||
def test_extract_numeric_with_dict(pi_client):
|
||||
"""Test extracting numeric value from dictionary"""
|
||||
result = pi_client._extract_numeric({'Value': 99.9})
|
||||
|
||||
assert result == 99.9
|
||||
assert result == pytest.approx(99.9)
|
||||
|
||||
|
||||
def test_extract_numeric_with_invalid_value(pi_client):
|
||||
@@ -436,9 +436,9 @@ async def test_get_latest_values_df_with_custom_params(mock_curl_get_json, pi_cl
|
||||
call_args = mock_curl_get_json.call_args
|
||||
params = call_args[1]['params']
|
||||
|
||||
# Verify parameters
|
||||
assert ('startTime', '*-7d') in params
|
||||
assert ('endtime', '*-1d') in params
|
||||
# Verify parameters (inverted: startTime uses end_time, endTime uses start_time)
|
||||
assert ('startTime', '*-1d') in params
|
||||
assert ('endtime', '*-7d') in params
|
||||
assert ('maxCount', '100') in params
|
||||
assert call_args[1]['timeout'] == 60
|
||||
assert call_args[1]['metadata'] == metadata
|
||||
@@ -558,3 +558,31 @@ async def test_get_latest_values_df_default_max_count(mock_curl_get_json, pi_cli
|
||||
params = call_args[1]['params']
|
||||
|
||||
assert ('maxCount', '1') in params
|
||||
# Verify default time parameters are inverted (startTime uses end_time default, endTime uses start_time default)
|
||||
assert ('startTime', '*') in params # Default end_time
|
||||
assert ('endtime', '*-1d') in params # Default start_time
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(PIWebAPIClient, '_curl_get_json', new_callable=AsyncMock)
|
||||
async def test_get_latest_values_df_with_none_max_count(mock_curl_get_json, pi_client):
|
||||
"""Test get_latest_values_df does not send maxCount parameter when max_count is None"""
|
||||
mock_curl_get_json.return_value = {'Items': []}
|
||||
|
||||
web_ids = {'tag1': {'webid': 'webid1'}}
|
||||
|
||||
await pi_client.get_latest_values_df(
|
||||
web_ids=web_ids,
|
||||
endpoint='/streamsets/recorded',
|
||||
max_count=None,
|
||||
)
|
||||
|
||||
call_args = mock_curl_get_json.call_args
|
||||
params = call_args[1]['params']
|
||||
|
||||
# Verify maxCount parameter is not present when max_count is None
|
||||
assert ('maxCount', '1') not in params
|
||||
assert ('maxCount', None) not in params
|
||||
# Verify time parameters are still present
|
||||
assert ('startTime', '*') in params
|
||||
assert ('endtime', '*-1d') in params
|
||||
|
||||
@@ -40,7 +40,7 @@ async def test_pi_web_api_scouter_workflow(mock_workflow, pi_web_api_scouter):
|
||||
'model_id': 'test_model_id',
|
||||
'model_name': 'test_model',
|
||||
'schedule_name': 'test_schedule',
|
||||
'workflow_name': 'scouter',
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ async def test_pi_web_api_scouter_workflow(mock_workflow, pi_web_api_scouter):
|
||||
'schema': 'test_schema',
|
||||
'table_name': 'test_table',
|
||||
'retention_time': 3600,
|
||||
'workflow_name': 'scouter',
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
'data': 'test_data',
|
||||
'metadata': expected_metadata,
|
||||
'pi_web_api_query': {
|
||||
@@ -112,7 +112,7 @@ async def test_pi_web_api_scouter_workflow_empty(mock_workflow, pi_web_api_scout
|
||||
'model_id': 'test_model_id',
|
||||
'model_name': 'test_model',
|
||||
'schedule_name': 'test_schedule',
|
||||
'workflow_name': 'scouter',
|
||||
'workflow_name': 'pi_web_api_scouter',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ env:
|
||||
- name: GITHUB_REPO_URL
|
||||
value: "git@github.com:Aignosi/sientia-dataops-scouter_temporal.git"
|
||||
- name: GITHUB_BRANCH
|
||||
value: "feature/SIENTIAPDE-1445"
|
||||
value: "fix/SIENTIAPDE-1445"
|
||||
- name: PYTHON_APP
|
||||
value: "scouter.worker.worker"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user