Code import - branch feature/SIENTIAPDE-1646
This commit is contained in:
37
e2e/README.md
Normal file
37
e2e/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Scouter end-to-end tests
|
||||
|
||||
Production-faithful E2E tests for `Scouter`, `PIWebAPIScouter`, and `CoreScouter` against real backing services.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker (for testcontainers: MongoDB, Redis, PostgreSQL)
|
||||
- Python 3.11+ with dev dependencies: `pip install -r requirements-dev.txt`
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
pytest e2e/ --override-ini testpaths=e2e -m e2e -v
|
||||
```
|
||||
|
||||
Stop on first failure:
|
||||
|
||||
```bash
|
||||
pytest e2e/ --override-ini testpaths=e2e -m e2e -x
|
||||
```
|
||||
|
||||
## Coverage (separate from unit tests)
|
||||
|
||||
```bash
|
||||
COVERAGE_FILE=.coverage.e2e pytest e2e/ --override-ini testpaths=e2e -m e2e --cov=scouter --cov-branch
|
||||
coverage combine .coverage .coverage.e2e && coverage report
|
||||
```
|
||||
|
||||
## Scenario catalog
|
||||
|
||||
See [scenarios.md](scenarios.md) for numbered scenarios and how they map to `test_scenario_*` functions. Section `## 0` of that file lists the harness smoke tests in `test_harness_smoke.py` (infra liveness checks, not business scenarios).
|
||||
|
||||
## Production code is not mocked
|
||||
|
||||
E2E uses real testcontainers, an in-process PI Web API HTTP server, `WorkflowEnvironment.start_local()`, and production `Activities` wiring. The only stand-ins are `mock_logger` and the optional `notification_inserts` spy. If a scenario fails due to a production defect, it is marked `xfail(strict=True)` and tracked in `openspec/changes/standardize-and-complete-e2e-tests/notes.md` when applicable.
|
||||
|
||||
Unit tests under `tests/` remain Docker-free and run with the default `pytest` invocation.
|
||||
0
e2e/__init__.py
Normal file
0
e2e/__init__.py
Normal file
319
e2e/conftest.py
Normal file
319
e2e/conftest.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Pytest configuration and fixtures for production-faithful Scouter E2E tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from pymongo import MongoClient
|
||||
from redis import Redis
|
||||
from sqlalchemy import create_engine, text
|
||||
from testcontainers.mongodb import MongoDbContainer
|
||||
from testcontainers.postgres import PostgresContainer
|
||||
from testcontainers.redis import RedisContainer
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import PollerBehaviorAutoscaling, Worker
|
||||
|
||||
from e2e.helpers import SCOUTER_TASK_QUEUE, postgres_connection_parts
|
||||
from e2e.pi_web_api_test_server import PIWebAPITestServer
|
||||
from scouter.activities.activities import Activities
|
||||
from scouter.workflow.pi_web_api_scouter import PIWebAPIScouter
|
||||
from scouter.workflow.scouter import Scouter
|
||||
from scouter.workflow.sub_workflows.core_scouter import CoreScouter
|
||||
from sientia_do.notifications.handlers import CoreNotificationHandler
|
||||
from sientia_do.observability.logger import Logger
|
||||
|
||||
E2E_DATABASE = 'scouter_e2e_test'
|
||||
E2E_NOTIFICATION_COLLECTION = 'notification_queue'
|
||||
DB_SCHEMA_PATH = Path(__file__).resolve().parent / 'db_schema.sql'
|
||||
|
||||
def _activity_list(activities: Activities) -> list:
|
||||
"""Return bound activity callables for the E2E worker."""
|
||||
return [
|
||||
activities.load_latest_data,
|
||||
activities.get_last_data_timestamp,
|
||||
activities.put_last_data_timestamp,
|
||||
activities.get_tag_values,
|
||||
activities.data_quality_gate,
|
||||
activities.aggregate_data,
|
||||
activities.group_and_hold_data,
|
||||
activities.export_data_to_postgres,
|
||||
activities.write_metrics,
|
||||
activities.store_data_package,
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def postgres_container():
|
||||
"""
|
||||
Session-scoped PostgreSQL testcontainer.
|
||||
|
||||
Return:
|
||||
Running PostgresContainer instance
|
||||
"""
|
||||
container = PostgresContainer('postgres:15')
|
||||
container.start()
|
||||
yield container
|
||||
container.stop()
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def mongo_container():
|
||||
"""
|
||||
Session-scoped MongoDB testcontainer.
|
||||
|
||||
Return:
|
||||
Running MongoDbContainer instance
|
||||
"""
|
||||
container = MongoDbContainer('mongo:7')
|
||||
container.start()
|
||||
yield container
|
||||
container.stop()
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def redis_container():
|
||||
"""
|
||||
Session-scoped Redis testcontainer.
|
||||
|
||||
Return:
|
||||
Running RedisContainer instance
|
||||
"""
|
||||
container = RedisContainer('redis:7')
|
||||
container.start()
|
||||
yield container
|
||||
container.stop()
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def postgres_engine(postgres_container):
|
||||
"""
|
||||
SQLAlchemy engine bound to the Postgres testcontainer for the session.
|
||||
|
||||
Return:
|
||||
SQLAlchemy Engine
|
||||
"""
|
||||
engine = create_engine(postgres_container.get_connection_url())
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def mongo_uri(mongo_container):
|
||||
"""
|
||||
MongoDB connection string for the testcontainer.
|
||||
|
||||
Return:
|
||||
Connection URI string
|
||||
"""
|
||||
return mongo_container.get_connection_url()
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def redis_client(redis_container):
|
||||
"""
|
||||
Redis client connected to the testcontainer.
|
||||
|
||||
Return:
|
||||
redis.Redis client with decode_responses=True
|
||||
"""
|
||||
host = redis_container.get_container_host_ip()
|
||||
port = int(redis_container.get_exposed_port(6379))
|
||||
client = Redis(host=host, port=port, decode_responses=True)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_postgres_schema_and_table(postgres_engine):
|
||||
"""
|
||||
Apply db_schema.sql before each test so laborious_data is empty and current.
|
||||
"""
|
||||
sql = DB_SCHEMA_PATH.read_text(encoding='utf-8')
|
||||
with postgres_engine.begin() as conn:
|
||||
conn.exec_driver_sql(sql)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_mongo_collections(mongo_uri):
|
||||
"""
|
||||
Drop all collections in the E2E Mongo database between tests.
|
||||
"""
|
||||
client = MongoClient(mongo_uri)
|
||||
try:
|
||||
db = client[E2E_DATABASE]
|
||||
for name in db.list_collection_names():
|
||||
db.drop_collection(name)
|
||||
finally:
|
||||
client.close()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_redis(redis_client):
|
||||
"""
|
||||
Flush the Redis testcontainer between tests.
|
||||
"""
|
||||
redis_client.flushdb()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logger():
|
||||
"""
|
||||
Logger stand-in (only permitted MagicMock in the E2E harness).
|
||||
|
||||
Return:
|
||||
MagicMock with Logger spec
|
||||
"""
|
||||
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 notification_handler(mock_logger, mongo_uri):
|
||||
"""
|
||||
Real CoreNotificationHandler backed by the Mongo testcontainer.
|
||||
|
||||
Return:
|
||||
CoreNotificationHandler instance
|
||||
"""
|
||||
handler = CoreNotificationHandler(
|
||||
connection_string=mongo_uri,
|
||||
database=E2E_DATABASE,
|
||||
logger=mock_logger,
|
||||
project_name='scouter-e2e',
|
||||
notification_topic=E2E_NOTIFICATION_COLLECTION,
|
||||
)
|
||||
yield handler
|
||||
handler.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notification_inserts(notification_handler):
|
||||
"""
|
||||
Spy wrapper around notification collection insert_one (still writes to Mongo).
|
||||
|
||||
Return:
|
||||
MagicMock wrapping insert_one
|
||||
"""
|
||||
collection = notification_handler.mongo_collection
|
||||
spy = MagicMock(wraps=collection.insert_one)
|
||||
collection.insert_one = spy
|
||||
return spy
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def pi_web_api_server():
|
||||
"""
|
||||
Session-scoped in-process PI Web API HTTP stub.
|
||||
|
||||
Return:
|
||||
Started PIWebAPITestServer instance
|
||||
"""
|
||||
server = PIWebAPITestServer()
|
||||
server.start()
|
||||
yield server
|
||||
server.stop()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_pi_web_api_server(pi_web_api_server):
|
||||
"""
|
||||
Reset PI Web API stub state between tests.
|
||||
"""
|
||||
pi_web_api_server.clear()
|
||||
yield
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope='session')
|
||||
async def temporal_env():
|
||||
"""
|
||||
Session-scoped WorkflowEnvironment using the real local Temporal dev server.
|
||||
|
||||
Return:
|
||||
WorkflowEnvironment from start_local()
|
||||
"""
|
||||
async with await WorkflowEnvironment.start_local() as env:
|
||||
yield env
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_activities(
|
||||
postgres_container,
|
||||
mongo_uri,
|
||||
redis_container,
|
||||
pi_web_api_server,
|
||||
mock_logger,
|
||||
notification_handler,
|
||||
):
|
||||
"""
|
||||
Production Activities wired to testcontainers and the PI Web API stub.
|
||||
|
||||
Return:
|
||||
Live Activities instance (no unittest.mock.patch)
|
||||
"""
|
||||
pg_parts = postgres_connection_parts(postgres_container.get_connection_url())
|
||||
redis_host = redis_container.get_container_host_ip()
|
||||
redis_port = int(redis_container.get_exposed_port(6379))
|
||||
|
||||
activities = Activities(
|
||||
postgres_config={
|
||||
**pg_parts,
|
||||
'min_connections': 1,
|
||||
'max_connections': 5,
|
||||
},
|
||||
redis_config={
|
||||
'host': redis_host,
|
||||
'port': redis_port,
|
||||
'username': '',
|
||||
'password': '',
|
||||
},
|
||||
mongodb_config={
|
||||
'connection_string': mongo_uri,
|
||||
'database_name': E2E_DATABASE,
|
||||
},
|
||||
api_config={
|
||||
'base_url': pi_web_api_server.base_url,
|
||||
'auth_type': 'bearer',
|
||||
'auth_token': 'e2e-test-token',
|
||||
},
|
||||
logger=mock_logger,
|
||||
notification_handler=notification_handler,
|
||||
)
|
||||
yield activities
|
||||
activities.shutdown()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def temporal_worker(temporal_env, test_activities):
|
||||
"""
|
||||
Temporal worker registering all Scouter workflows and activities on the E2E queue.
|
||||
|
||||
Return:
|
||||
Running temporalio.worker.Worker
|
||||
"""
|
||||
async with Worker(
|
||||
temporal_env.client,
|
||||
task_queue=SCOUTER_TASK_QUEUE,
|
||||
workflows=[Scouter, PIWebAPIScouter, CoreScouter],
|
||||
activities=_activity_list(test_activities),
|
||||
activity_executor=ThreadPoolExecutor(max_workers=50, thread_name_prefix='e2e-activity'),
|
||||
max_concurrent_workflow_tasks=50,
|
||||
max_concurrent_activities=50,
|
||||
max_concurrent_local_activities=50,
|
||||
workflow_task_poller_behavior=PollerBehaviorAutoscaling(minimum=1, initial=2, maximum=10),
|
||||
activity_task_poller_behavior=PollerBehaviorAutoscaling(minimum=1, initial=2, maximum=10),
|
||||
) as worker:
|
||||
yield worker
|
||||
16
e2e/db_schema.sql
Normal file
16
e2e/db_schema.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Single source of truth for E2E Postgres DDL (non-partitioned mirror of production sientia_data.laborious_data).
|
||||
|
||||
DROP TABLE IF EXISTS sientia_data.laborious_data;
|
||||
DROP SCHEMA IF EXISTS sientia_data CASCADE;
|
||||
|
||||
CREATE SCHEMA sientia_data;
|
||||
|
||||
CREATE TABLE sientia_data.laborious_data (
|
||||
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)
|
||||
);
|
||||
0
e2e/fixtures/__init__.py
Normal file
0
e2e/fixtures/__init__.py
Normal file
404
e2e/helpers.py
Normal file
404
e2e/helpers.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
Shared helpers for Scouter end-to-end tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pymongo import MongoClient
|
||||
from redis import Redis
|
||||
from sientia_do.temporal.constants import DATETIME_FORMAT_MS_WITH_TZ
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from temporalio.client import Client
|
||||
|
||||
SCENARIO_INPUTS_DIR = Path(__file__).resolve().parent / 'scenario_inputs'
|
||||
SCOUTER_TASK_QUEUE = 'scouter-test-queue'
|
||||
|
||||
_NOW_MARKER = re.compile(r'^@now(?:([+-])(\d+)([smhd]))?$')
|
||||
|
||||
|
||||
def _resolve_timestamp_marker(value: str) -> datetime:
|
||||
"""
|
||||
Resolve @now relative timestamp markers to timezone-aware datetimes.
|
||||
|
||||
Args:
|
||||
- value: Marker string such as @now, @now-1h, or @now+30m
|
||||
|
||||
Return:
|
||||
Resolved datetime in UTC
|
||||
"""
|
||||
match = _NOW_MARKER.match(value.strip())
|
||||
if not match:
|
||||
raise ValueError(f'Invalid timestamp marker: {value}')
|
||||
|
||||
now = datetime.now(UTC)
|
||||
if match.group(1) is None:
|
||||
return now
|
||||
|
||||
sign, amount, unit = match.group(1), int(match.group(2)), match.group(3)
|
||||
delta_kwargs = {'seconds': 0, 'minutes': 0, 'hours': 0, 'days': 0}
|
||||
if unit == 's':
|
||||
delta_kwargs['seconds'] = amount
|
||||
elif unit == 'm':
|
||||
delta_kwargs['minutes'] = amount
|
||||
elif unit == 'h':
|
||||
delta_kwargs['hours'] = amount
|
||||
elif unit == 'd':
|
||||
delta_kwargs['days'] = amount
|
||||
|
||||
delta = timedelta(**delta_kwargs)
|
||||
return now - delta if sign == '-' else now + delta
|
||||
|
||||
|
||||
def _resolve_payload(node: Any) -> Any:
|
||||
"""
|
||||
Recursively resolve @now markers inside JSON-loaded structures.
|
||||
|
||||
Args:
|
||||
- node: JSON node (dict, list, or scalar)
|
||||
|
||||
Return:
|
||||
Structure with markers replaced by datetimes or formatted strings
|
||||
"""
|
||||
if isinstance(node, dict):
|
||||
return {key: _resolve_payload(value) for key, value in node.items()}
|
||||
if isinstance(node, list):
|
||||
return [_resolve_payload(item) for item in node]
|
||||
if isinstance(node, str) and node.startswith('@now'):
|
||||
resolved = _resolve_timestamp_marker(node)
|
||||
return resolved.strftime('%Y-%m-%d %H:%M:%S.%f%z')
|
||||
return node
|
||||
|
||||
|
||||
def load_scenario_input(name: str, **overrides: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Load a scenario JSON file and apply optional overrides.
|
||||
|
||||
Args:
|
||||
- name: Scenario slug with or without .json suffix
|
||||
- overrides: Top-level keys merged into the loaded document
|
||||
|
||||
Return:
|
||||
Parsed scenario document with @now markers resolved
|
||||
"""
|
||||
slug = name.removesuffix('.json')
|
||||
path = SCENARIO_INPUTS_DIR / f'{slug}.json'
|
||||
with path.open(encoding='utf-8') as handle:
|
||||
payload = json.load(handle)
|
||||
resolved = _resolve_payload(payload)
|
||||
if overrides:
|
||||
resolved.update(overrides)
|
||||
return resolved
|
||||
|
||||
|
||||
def make_workflow_id(prefix: str) -> str:
|
||||
"""
|
||||
Build a unique Temporal workflow id for E2E runs.
|
||||
|
||||
Args:
|
||||
- prefix: Human-readable prefix for the workflow id
|
||||
|
||||
Return:
|
||||
Unique workflow id string
|
||||
"""
|
||||
return f'{prefix}-{uuid.uuid4().hex[:12]}'
|
||||
|
||||
|
||||
async def start_and_await_workflow(
|
||||
client: Client,
|
||||
workflow_run: Any,
|
||||
input_data: dict[str, Any],
|
||||
workflow_id: str,
|
||||
*,
|
||||
task_queue: str = SCOUTER_TASK_QUEUE,
|
||||
timeout: float = 300.0,
|
||||
) -> Any:
|
||||
"""
|
||||
Start a workflow on the E2E task queue and await its result.
|
||||
|
||||
Args:
|
||||
- client: Temporal client from WorkflowEnvironment
|
||||
- workflow_run: Workflow run method (e.g. Scouter.run)
|
||||
- input_data: Workflow input payload
|
||||
- workflow_id: Unique workflow id
|
||||
- task_queue: Task queue name
|
||||
- timeout: Maximum seconds to wait for completion
|
||||
|
||||
Return:
|
||||
Workflow result (None for Scouter-family workflows)
|
||||
"""
|
||||
return await client.execute_workflow(
|
||||
workflow_run,
|
||||
input_data,
|
||||
id=workflow_id,
|
||||
task_queue=task_queue,
|
||||
execution_timeout=timedelta(seconds=timeout),
|
||||
)
|
||||
|
||||
|
||||
def _coerce_mongo_document(document: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Convert string timestamps in seed documents to BSON datetimes for Mongo filters.
|
||||
|
||||
Args:
|
||||
- document: Raw document dict from scenario JSON
|
||||
|
||||
Return:
|
||||
Document with inserted_at as datetime when present
|
||||
"""
|
||||
doc = dict(document)
|
||||
inserted_at = doc.get('inserted_at')
|
||||
if isinstance(inserted_at, str):
|
||||
doc['inserted_at'] = datetime.strptime(inserted_at, DATETIME_FORMAT_MS_WITH_TZ).replace(
|
||||
tzinfo=UTC
|
||||
)
|
||||
return doc
|
||||
|
||||
|
||||
def seed_raw_collection(
|
||||
mongo_uri: str,
|
||||
database: str,
|
||||
schedule_name: str,
|
||||
documents: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""
|
||||
Insert raw Mongo documents into raw_<schedule_name>.
|
||||
|
||||
Args:
|
||||
- mongo_uri: MongoDB connection string
|
||||
- database: Database name
|
||||
- schedule_name: Schedule slug used in collection name
|
||||
- documents: Documents to insert
|
||||
"""
|
||||
collection_name = f'raw_{schedule_name}'
|
||||
client = MongoClient(mongo_uri)
|
||||
try:
|
||||
collection = client[database][collection_name]
|
||||
if documents:
|
||||
collection.insert_many([_coerce_mongo_document(doc) for doc in documents])
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def seed_last_data_timestamp(
|
||||
redis_client: Redis,
|
||||
workflow_name: str,
|
||||
schedule_name: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
"""
|
||||
Pre-seed last_data_timestamp Redis key the same way RedisRepository.set stores strings.
|
||||
|
||||
Args:
|
||||
- redis_client: Connected Redis client
|
||||
- workflow_name: Workflow name segment in the key
|
||||
- schedule_name: Schedule name segment in the key
|
||||
- value: Timestamp string to store
|
||||
"""
|
||||
key = f'last_data_timestamp:{workflow_name}:{schedule_name}'
|
||||
redis_client.set(key, json.dumps(value))
|
||||
|
||||
|
||||
def count_laborious_rows(engine: Engine, model_id: str | int | None = None) -> int:
|
||||
"""
|
||||
Count rows in sientia_data.laborious_data, optionally filtered by model_id.
|
||||
|
||||
Args:
|
||||
- engine: SQLAlchemy engine bound to the Postgres testcontainer
|
||||
- model_id: Optional model id filter
|
||||
|
||||
Return:
|
||||
Row count
|
||||
"""
|
||||
query = 'SELECT COUNT(*) FROM sientia_data.laborious_data'
|
||||
params: dict[str, Any] = {}
|
||||
if model_id is not None:
|
||||
query += ' WHERE model_id = :model_id'
|
||||
params['model_id'] = int(model_id)
|
||||
|
||||
with engine.connect() as conn:
|
||||
return conn.execute(text(query), params).scalar() or 0
|
||||
|
||||
|
||||
def fetch_laborious_rows(engine: Engine, model_id: str | int | None = None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Fetch laborious_data rows as plain dicts.
|
||||
|
||||
Args:
|
||||
- engine: SQLAlchemy engine bound to the Postgres testcontainer
|
||||
- model_id: Optional model id filter
|
||||
|
||||
Return:
|
||||
List of row dicts with variable and value keys
|
||||
"""
|
||||
query = 'SELECT model_id, variable, value, timestamp FROM sientia_data.laborious_data'
|
||||
params: dict[str, Any] = {}
|
||||
if model_id is not None:
|
||||
query += ' WHERE model_id = :model_id'
|
||||
params['model_id'] = int(model_id)
|
||||
query += ' ORDER BY variable'
|
||||
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(text(query), params).mappings().all()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def count_held_data_keys(redis_client: Redis) -> int:
|
||||
"""
|
||||
Count Redis keys matching held_data_*.
|
||||
|
||||
Args:
|
||||
- redis_client: Connected Redis client
|
||||
|
||||
Return:
|
||||
Number of matching keys
|
||||
"""
|
||||
return len(redis_client.keys('held_data_*'))
|
||||
|
||||
|
||||
def count_notifications(
|
||||
mongo_uri: str,
|
||||
database: str,
|
||||
*,
|
||||
notification_id: str | None = None,
|
||||
level: str | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Count notification documents in the E2E notification collection.
|
||||
|
||||
Args:
|
||||
- mongo_uri: MongoDB connection string
|
||||
- database: Database name
|
||||
- notification_id: Optional notification_id filter
|
||||
- level: Optional level filter (WARNING, ERROR, ...)
|
||||
|
||||
Return:
|
||||
Matching document count
|
||||
"""
|
||||
client = MongoClient(mongo_uri)
|
||||
try:
|
||||
collection = client[database]['notification_queue']
|
||||
query: dict[str, Any] = {}
|
||||
if notification_id:
|
||||
query['notification_id'] = notification_id
|
||||
if level:
|
||||
query['level'] = level
|
||||
return collection.count_documents(query)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def default_model_tags(
|
||||
*,
|
||||
names: list[str],
|
||||
aggr: str = 'avg',
|
||||
data_range: list[float] | None = None,
|
||||
frequency: int = 60000,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Build a minimal model_tags map for E2E scenarios.
|
||||
|
||||
Args:
|
||||
- names: Tag names to include
|
||||
- aggr: Aggregation function (aggr_func field)
|
||||
- data_range: Optional [min, max] validation range
|
||||
- frequency: Collection frequency in milliseconds
|
||||
|
||||
Return:
|
||||
model_tags dict keyed by tag name
|
||||
"""
|
||||
if data_range is None:
|
||||
data_range = [0, 100]
|
||||
return {
|
||||
name: {
|
||||
'webid': f'webid_{name}',
|
||||
'aggr_func': aggr,
|
||||
'data_range': data_range,
|
||||
'frequency': frequency,
|
||||
}
|
||||
for name in names
|
||||
}
|
||||
|
||||
|
||||
def default_scouter_input(
|
||||
*,
|
||||
model_id: str = '1',
|
||||
model_name: str = 'Test Model',
|
||||
schedule_name: str = 'test-schedule',
|
||||
workflow_name: str = 'scouter',
|
||||
**overrides: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Return a base workflow input dict for Scouter / CoreScouter E2E runs.
|
||||
|
||||
Args:
|
||||
- model_id: Model identifier
|
||||
- model_name: Human-readable model name
|
||||
- schedule_name: Schedule slug
|
||||
- workflow_name: Parent workflow name
|
||||
- overrides: Additional keys merged into the payload
|
||||
|
||||
Return:
|
||||
Workflow input dictionary
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'topic': 'e2e-topic',
|
||||
'model_id': model_id,
|
||||
'model_name': model_name,
|
||||
'schedule_name': schedule_name,
|
||||
'workflow_name': workflow_name,
|
||||
'trigger_laborious': False,
|
||||
'filters': {},
|
||||
'schema': 'sientia_data',
|
||||
'table_name': 'laborious_data',
|
||||
'retention_time': 3600,
|
||||
'fill_missing_tags': False,
|
||||
'model_tags': default_model_tags(names=['tag1']),
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def apply_pi_web_api_server_config(server: Any, config: dict[str, Any] | None) -> None:
|
||||
"""
|
||||
Configure the in-process PI Web API stub from a scenario pi_web_api_server block.
|
||||
|
||||
Args:
|
||||
- server: PIWebAPITestServer instance
|
||||
- config: Optional mode/rows/timeout_sleep_seconds dict from scenario JSON
|
||||
"""
|
||||
if not config:
|
||||
return
|
||||
server.set_mode(
|
||||
config.get('mode', 'success'),
|
||||
rows=config.get('rows'),
|
||||
timeout_sleep_seconds=config.get('timeout_sleep_seconds', 60),
|
||||
)
|
||||
|
||||
|
||||
def postgres_connection_parts(connection_url: str) -> dict[str, Any]:
|
||||
"""
|
||||
Parse a SQLAlchemy Postgres URL into Activities postgres_config fields.
|
||||
|
||||
Args:
|
||||
- connection_url: SQLAlchemy connection URL from testcontainers
|
||||
|
||||
Return:
|
||||
Dict with host, port, user, password, dbname keys
|
||||
"""
|
||||
parsed = urlparse(connection_url)
|
||||
return {
|
||||
'host': parsed.hostname or 'localhost',
|
||||
'port': parsed.port or 5432,
|
||||
'user': parsed.username or 'test',
|
||||
'password': parsed.password or 'test',
|
||||
'dbname': (parsed.path or '/test').lstrip('/'),
|
||||
}
|
||||
124
e2e/pi_web_api_test_server.py
Normal file
124
e2e/pi_web_api_test_server.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
In-process HTTP server emulating PI Web API streamsets/recorded responses for E2E tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Literal
|
||||
|
||||
from pytest_httpserver import HTTPServer
|
||||
from werkzeug import Request
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
PIWebAPIMode = Literal['success', 'empty', 'error', 'timeout']
|
||||
STREAMSETS_RECORDED_PATH = '/streamsets/recorded'
|
||||
|
||||
|
||||
class PIWebAPITestServer:
|
||||
"""
|
||||
Thread-backed PI Web API stub using pytest-httpserver (real HTTP for pycurl clients).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._httpserver = HTTPServer(host='127.0.0.1', port=0)
|
||||
self._mode: PIWebAPIMode = 'success'
|
||||
self._rows: list[dict[str, Any]] = []
|
||||
self._timeout_sleep_seconds = 60
|
||||
self.requests: list[Request] = []
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self._httpserver.host
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._httpserver.port
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return f'http://{self.host}:{self.port}'
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the HTTP server and register the streamsets handler."""
|
||||
self._httpserver.start()
|
||||
self._register_handler()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the HTTP server."""
|
||||
self._httpserver.stop()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear recorded requests and reset mode to success with no rows."""
|
||||
self.requests.clear()
|
||||
self._mode = 'success'
|
||||
self._rows = []
|
||||
self._httpserver.clear()
|
||||
self._register_handler()
|
||||
|
||||
def set_mode(
|
||||
self,
|
||||
mode: PIWebAPIMode,
|
||||
rows: list[dict[str, Any]] | None = None,
|
||||
*,
|
||||
timeout_sleep_seconds: int = 60,
|
||||
) -> None:
|
||||
"""
|
||||
Configure the next responses from the stub server.
|
||||
|
||||
Args:
|
||||
- mode: Response mode (success, empty, error, timeout)
|
||||
- rows: Optional list of row dicts with keys name, webid, timestamp, value
|
||||
- timeout_sleep_seconds: Sleep duration for timeout mode (must exceed client timeout)
|
||||
"""
|
||||
self._mode = mode
|
||||
if rows is not None:
|
||||
self._rows = rows
|
||||
self._timeout_sleep_seconds = timeout_sleep_seconds
|
||||
self._register_handler()
|
||||
|
||||
def _register_handler(self) -> None:
|
||||
self._httpserver.expect_request(
|
||||
STREAMSETS_RECORDED_PATH,
|
||||
method='GET',
|
||||
).respond_with_handler(self._handle_streamsets_recorded)
|
||||
|
||||
def _handle_streamsets_recorded(self, request: Request):
|
||||
self.requests.append(request)
|
||||
|
||||
if self._mode == 'timeout':
|
||||
time.sleep(self._timeout_sleep_seconds)
|
||||
return self._json_response({'Items': []}, status=200)
|
||||
|
||||
if self._mode == 'error':
|
||||
return self._json_response({'error': 'internal'}, status=500)
|
||||
|
||||
if self._mode == 'empty' or not self._rows:
|
||||
return self._json_response({'Items': []}, status=200)
|
||||
|
||||
items_by_name: dict[str, list[dict[str, Any]]] = {}
|
||||
for row in self._rows:
|
||||
name = row['name']
|
||||
items_by_name.setdefault(name, []).append(
|
||||
{
|
||||
'Timestamp': row['timestamp'],
|
||||
'Value': row['value'],
|
||||
'Good': True,
|
||||
'Questionable': False,
|
||||
}
|
||||
)
|
||||
|
||||
items = [
|
||||
{'Name': name, 'Items': points}
|
||||
for name, points in items_by_name.items()
|
||||
]
|
||||
return self._json_response({'Items': items}, status=200)
|
||||
|
||||
@staticmethod
|
||||
def _json_response(payload: dict[str, Any], *, status: int) -> Response:
|
||||
return Response(
|
||||
json.dumps(payload),
|
||||
status=status,
|
||||
mimetype='application/json',
|
||||
)
|
||||
24
e2e/scenario_inputs/core_scouter_aggregation_avg.json
Normal file
24
e2e/scenario_inputs/core_scouter_aggregation_avg.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"metadata": {"metadata": {"model_id": "24", "model_name": "Core Avg", "schedule_name": "core-avg", "workflow_name": "pi_web_api_scouter"}},
|
||||
"workflow_name": "pi_web_api_scouter",
|
||||
"schedule_name": "core-avg",
|
||||
"model_name": "Core Avg",
|
||||
"model_id": "24",
|
||||
"data": [
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag_avg", "value": 10.0, "tag": "w1"},
|
||||
{"timestamp": "2024-01-01 12:01:00+0000", "name": "tag_avg", "value": 20.0, "tag": "w1"},
|
||||
{"timestamp": "2024-01-01 12:02:00+0000", "name": "tag_avg", "value": 30.0, "tag": "w1"}
|
||||
],
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag_avg": {"webid": "w1", "aggr_func": "avg", "data_range": [0, 1000], "frequency": 60000}
|
||||
}
|
||||
},
|
||||
"expected_value": 20.0
|
||||
}
|
||||
24
e2e/scenario_inputs/core_scouter_aggregation_lts.json
Normal file
24
e2e/scenario_inputs/core_scouter_aggregation_lts.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"metadata": {"metadata": {"model_id": "28", "model_name": "Core Lts", "schedule_name": "core-lts", "workflow_name": "pi_web_api_scouter"}},
|
||||
"workflow_name": "pi_web_api_scouter",
|
||||
"schedule_name": "core-lts",
|
||||
"model_name": "Core Lts",
|
||||
"model_id": "28",
|
||||
"data": [
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag_lts", "value": 100.0, "tag": "w1"},
|
||||
{"timestamp": "2024-01-01 12:01:00+0000", "name": "tag_lts", "value": 200.0, "tag": "w1"},
|
||||
{"timestamp": "2024-01-01 12:02:00+0000", "name": "tag_lts", "value": 300.0, "tag": "w1"}
|
||||
],
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag_lts": {"webid": "w1", "aggr_func": "lts", "data_range": [0, 1000], "frequency": 60000}
|
||||
}
|
||||
},
|
||||
"expected_value": 300.0
|
||||
}
|
||||
24
e2e/scenario_inputs/core_scouter_aggregation_max.json
Normal file
24
e2e/scenario_inputs/core_scouter_aggregation_max.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"metadata": {"metadata": {"model_id": "26", "model_name": "Core Max", "schedule_name": "core-max", "workflow_name": "pi_web_api_scouter"}},
|
||||
"workflow_name": "pi_web_api_scouter",
|
||||
"schedule_name": "core-max",
|
||||
"model_name": "Core Max",
|
||||
"model_id": "26",
|
||||
"data": [
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag_max", "value": 5.0, "tag": "w1"},
|
||||
{"timestamp": "2024-01-01 12:01:00+0000", "name": "tag_max", "value": 15.0, "tag": "w1"},
|
||||
{"timestamp": "2024-01-01 12:02:00+0000", "name": "tag_max", "value": 10.0, "tag": "w1"}
|
||||
],
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag_max": {"webid": "w1", "aggr_func": "max", "data_range": [0, 1000], "frequency": 60000}
|
||||
}
|
||||
},
|
||||
"expected_value": 15.0
|
||||
}
|
||||
24
e2e/scenario_inputs/core_scouter_aggregation_mdn.json
Normal file
24
e2e/scenario_inputs/core_scouter_aggregation_mdn.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"metadata": {"metadata": {"model_id": "25", "model_name": "Core Mdn", "schedule_name": "core-mdn", "workflow_name": "pi_web_api_scouter"}},
|
||||
"workflow_name": "pi_web_api_scouter",
|
||||
"schedule_name": "core-mdn",
|
||||
"model_name": "Core Mdn",
|
||||
"model_id": "25",
|
||||
"data": [
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag_mdn", "value": 1.0, "tag": "w1"},
|
||||
{"timestamp": "2024-01-01 12:01:00+0000", "name": "tag_mdn", "value": 9.0, "tag": "w1"},
|
||||
{"timestamp": "2024-01-01 12:02:00+0000", "name": "tag_mdn", "value": 5.0, "tag": "w1"}
|
||||
],
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag_mdn": {"webid": "w1", "aggr_func": "mdn", "data_range": [0, 1000], "frequency": 60000}
|
||||
}
|
||||
},
|
||||
"expected_value": 5.0
|
||||
}
|
||||
24
e2e/scenario_inputs/core_scouter_aggregation_min.json
Normal file
24
e2e/scenario_inputs/core_scouter_aggregation_min.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"metadata": {"metadata": {"model_id": "27", "model_name": "Core Min", "schedule_name": "core-min", "workflow_name": "pi_web_api_scouter"}},
|
||||
"workflow_name": "pi_web_api_scouter",
|
||||
"schedule_name": "core-min",
|
||||
"model_name": "Core Min",
|
||||
"model_id": "27",
|
||||
"data": [
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag_min", "value": 50.0, "tag": "w1"},
|
||||
{"timestamp": "2024-01-01 12:01:00+0000", "name": "tag_min", "value": 30.0, "tag": "w1"},
|
||||
{"timestamp": "2024-01-01 12:02:00+0000", "name": "tag_min", "value": 40.0, "tag": "w1"}
|
||||
],
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag_min": {"webid": "w1", "aggr_func": "min", "data_range": [0, 1000], "frequency": 60000}
|
||||
}
|
||||
},
|
||||
"expected_value": 30.0
|
||||
}
|
||||
22
e2e/scenario_inputs/core_scouter_debug_data_package.json
Normal file
22
e2e/scenario_inputs/core_scouter_debug_data_package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"metadata": {"metadata": {"model_id": "30", "model_name": "Core Debug", "schedule_name": "core-debug", "workflow_name": "subworkflow.core_scouter"}},
|
||||
"workflow_name": "subworkflow.core_scouter",
|
||||
"schedule_name": "core-debug",
|
||||
"model_name": "Core Debug",
|
||||
"model_id": "30",
|
||||
"data": [
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag1", "value": 11.0, "tag": "webid1"}
|
||||
],
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"debug_data_package": true,
|
||||
"model_tags": {
|
||||
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
e2e/scenario_inputs/core_scouter_empty_after_grouping.json
Normal file
24
e2e/scenario_inputs/core_scouter_empty_after_grouping.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"metadata": {"metadata": {"model_id": "31", "model_name": "Core Empty Group", "schedule_name": "core-empty-group", "workflow_name": "pi_web_api_scouter"}},
|
||||
"workflow_name": "pi_web_api_scouter",
|
||||
"schedule_name": "core-empty-group",
|
||||
"model_name": "Core Empty Group",
|
||||
"model_id": "31",
|
||||
"data": {
|
||||
"timestamp": [],
|
||||
"name": [],
|
||||
"value": [],
|
||||
"tag": []
|
||||
},
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
e2e/scenario_inputs/core_scouter_fill_missing_tags.json
Normal file
24
e2e/scenario_inputs/core_scouter_fill_missing_tags.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"metadata": {"metadata": {"model_id": "29", "model_name": "Core Fill Tags", "schedule_name": "core-fill", "workflow_name": "pi_web_api_scouter"}},
|
||||
"workflow_name": "pi_web_api_scouter",
|
||||
"schedule_name": "core-fill",
|
||||
"model_name": "Core Fill Tags",
|
||||
"model_id": "29",
|
||||
"data": [
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag1", "value": 10.0, "tag": "webid1"}
|
||||
],
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": true,
|
||||
"model_tags": {
|
||||
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000},
|
||||
"tag2": {"webid": "webid2", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000},
|
||||
"tag3": {"webid": "webid3", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
|
||||
}
|
||||
},
|
||||
"expected_missing_tags": ["tag2", "tag3"]
|
||||
}
|
||||
28
e2e/scenario_inputs/core_scouter_happy_path.json
Normal file
28
e2e/scenario_inputs/core_scouter_happy_path.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"metadata": {
|
||||
"metadata": {
|
||||
"model_id": "20",
|
||||
"model_name": "Core Happy",
|
||||
"schedule_name": "core-happy",
|
||||
"workflow_name": "pi_web_api_scouter"
|
||||
}
|
||||
},
|
||||
"workflow_name": "pi_web_api_scouter",
|
||||
"schedule_name": "core-happy",
|
||||
"model_name": "Core Happy",
|
||||
"model_id": "20",
|
||||
"data": [
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag1", "value": 10.5, "tag": "webid1"}
|
||||
],
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
e2e/scenario_inputs/core_scouter_invalid_aggregation.json
Normal file
23
e2e/scenario_inputs/core_scouter_invalid_aggregation.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"metadata": {"metadata": {"model_id": "32", "model_name": "Core Bad Aggr", "schedule_name": "core-bad-aggr", "workflow_name": "pi_web_api_scouter"}},
|
||||
"workflow_name": "pi_web_api_scouter",
|
||||
"schedule_name": "core-bad-aggr",
|
||||
"model_name": "Core Bad Aggr",
|
||||
"model_id": "32",
|
||||
"data": [
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag_ok", "value": 10.0, "tag": "w1"},
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag_bad", "value": 20.0, "tag": "w2"}
|
||||
],
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag_ok": {"webid": "w1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000},
|
||||
"tag_bad": {"webid": "w2", "aggr_func": "bogus", "data_range": [0, 100], "frequency": 60000}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"metadata": {"metadata": {"model_id": "21", "model_name": "Core Null Discard", "schedule_name": "core-null-discard", "workflow_name": "pi_web_api_scouter"}},
|
||||
"workflow_name": "pi_web_api_scouter",
|
||||
"schedule_name": "core-null-discard",
|
||||
"model_name": "Core Null Discard",
|
||||
"model_id": "21",
|
||||
"data": [
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag1", "value": null, "tag": "webid1"},
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag2", "value": 50.0, "tag": "webid2"}
|
||||
],
|
||||
"trigger_laborious": false,
|
||||
"filters": {"NULL_VALUES_FILTER": {"policy": "DISCARD"}},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000},
|
||||
"tag2": {"webid": "webid2", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"metadata": {"metadata": {"model_id": "22", "model_name": "Core Null Warn", "schedule_name": "core-null-warn", "workflow_name": "pi_web_api_scouter"}},
|
||||
"workflow_name": "pi_web_api_scouter",
|
||||
"schedule_name": "core-null-warn",
|
||||
"model_name": "Core Null Warn",
|
||||
"model_id": "22",
|
||||
"data": [
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag1", "value": null, "tag": "webid1"},
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag2", "value": 50.0, "tag": "webid2"}
|
||||
],
|
||||
"trigger_laborious": false,
|
||||
"filters": {"NULL_VALUES_FILTER": {"policy": "WARN"}},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000},
|
||||
"tag2": {"webid": "webid2", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"metadata": {"metadata": {"model_id": "23", "model_name": "Core OOB Discard", "schedule_name": "core-oob-discard", "workflow_name": "pi_web_api_scouter"}},
|
||||
"workflow_name": "pi_web_api_scouter",
|
||||
"schedule_name": "core-oob-discard",
|
||||
"model_name": "Core OOB Discard",
|
||||
"model_id": "23",
|
||||
"data": [
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag1", "value": 150.0, "tag": "webid1"},
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag2", "value": 50.0, "tag": "webid2"}
|
||||
],
|
||||
"trigger_laborious": false,
|
||||
"filters": {"OUT_OF_BOUNDS_FILTER": {"policy": "DISCARD"}},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000},
|
||||
"tag2": {"webid": "webid2", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"metadata": {"metadata": {"model_id": "33", "model_name": "Core PG Fail", "schedule_name": "core-pg-fail", "workflow_name": "pi_web_api_scouter"}},
|
||||
"workflow_name": "pi_web_api_scouter",
|
||||
"schedule_name": "core-pg-fail",
|
||||
"model_name": "Core PG Fail",
|
||||
"model_id": "33",
|
||||
"data": [
|
||||
{"timestamp": "2024-01-01 12:00:00+0000", "name": "tag1", "value": 10.0, "tag": "webid1"}
|
||||
],
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
e2e/scenario_inputs/pi_web_api_scouter_connection_error.json
Normal file
23
e2e/scenario_inputs/pi_web_api_scouter_connection_error.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"model_id": "14",
|
||||
"model_name": "PI Error",
|
||||
"schedule_name": "pi-error",
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
|
||||
},
|
||||
"pi_web_api_query": {
|
||||
"endpoint": "/streamsets/recorded",
|
||||
"period": "*-1d",
|
||||
"max_count": 1,
|
||||
"api_timeout": 30
|
||||
}
|
||||
},
|
||||
"pi_web_api_server": {"mode": "error"}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"model_id": "12",
|
||||
"model_name": "PI Debug Package",
|
||||
"schedule_name": "pi-debug",
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"debug_data_package": true,
|
||||
"model_tags": {
|
||||
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
|
||||
},
|
||||
"pi_web_api_query": {
|
||||
"endpoint": "/streamsets/recorded",
|
||||
"period": "*-1d",
|
||||
"max_count": 5,
|
||||
"api_timeout": 30
|
||||
}
|
||||
},
|
||||
"pi_web_api_server": {
|
||||
"mode": "success",
|
||||
"rows": [
|
||||
{"name": "tag1", "timestamp": "2024-06-01T12:00:00Z", "value": 7.5}
|
||||
]
|
||||
}
|
||||
}
|
||||
23
e2e/scenario_inputs/pi_web_api_scouter_empty_response.json
Normal file
23
e2e/scenario_inputs/pi_web_api_scouter_empty_response.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"model_id": "13",
|
||||
"model_name": "PI Empty",
|
||||
"schedule_name": "pi-empty",
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
|
||||
},
|
||||
"pi_web_api_query": {
|
||||
"endpoint": "/streamsets/recorded",
|
||||
"period": "*-1d",
|
||||
"max_count": 1,
|
||||
"api_timeout": 30
|
||||
}
|
||||
},
|
||||
"pi_web_api_server": {"mode": "empty"}
|
||||
}
|
||||
30
e2e/scenario_inputs/pi_web_api_scouter_happy_path.json
Normal file
30
e2e/scenario_inputs/pi_web_api_scouter_happy_path.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"model_id": "10",
|
||||
"model_name": "PI Web API Happy",
|
||||
"schedule_name": "pi-happy",
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000},
|
||||
"tag2": {"webid": "webid2", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
|
||||
},
|
||||
"pi_web_api_query": {
|
||||
"endpoint": "/streamsets/recorded",
|
||||
"period": "*-1d",
|
||||
"max_count": 10,
|
||||
"api_timeout": 30
|
||||
}
|
||||
},
|
||||
"pi_web_api_server": {
|
||||
"mode": "success",
|
||||
"rows": [
|
||||
{"name": "tag1", "timestamp": "2024-06-01T12:00:00Z", "value": 10.5},
|
||||
{"name": "tag2", "timestamp": "2024-06-01T12:00:00Z", "value": 20.3}
|
||||
]
|
||||
}
|
||||
}
|
||||
23
e2e/scenario_inputs/pi_web_api_scouter_invalid_endpoint.json
Normal file
23
e2e/scenario_inputs/pi_web_api_scouter_invalid_endpoint.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"model_id": "16",
|
||||
"model_name": "PI Invalid Endpoint",
|
||||
"schedule_name": "pi-invalid",
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
|
||||
},
|
||||
"pi_web_api_query": {
|
||||
"endpoint": "/invalid/endpoint",
|
||||
"period": "*-1d",
|
||||
"max_count": 1,
|
||||
"api_timeout": 30
|
||||
}
|
||||
},
|
||||
"pi_web_api_server": {"mode": "success", "rows": []}
|
||||
}
|
||||
53
e2e/scenario_inputs/pi_web_api_scouter_multiple_tags.json
Normal file
53
e2e/scenario_inputs/pi_web_api_scouter_multiple_tags.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"model_id": "11",
|
||||
"model_name": "PI Multi Tag",
|
||||
"schedule_name": "pi-multi",
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag_avg": {"webid": "w1", "aggr_func": "avg", "data_range": [0, 1000], "frequency": 60000},
|
||||
"tag_mdn": {"webid": "w2", "aggr_func": "mdn", "data_range": [0, 1000], "frequency": 60000},
|
||||
"tag_max": {"webid": "w3", "aggr_func": "max", "data_range": [0, 1000], "frequency": 60000},
|
||||
"tag_min": {"webid": "w4", "aggr_func": "min", "data_range": [0, 1000], "frequency": 60000},
|
||||
"tag_lts": {"webid": "w5", "aggr_func": "lts", "data_range": [0, 1000], "frequency": 60000}
|
||||
},
|
||||
"pi_web_api_query": {
|
||||
"endpoint": "/streamsets/recorded",
|
||||
"period": "*-1d",
|
||||
"max_count": 3,
|
||||
"api_timeout": 30
|
||||
}
|
||||
},
|
||||
"pi_web_api_server": {
|
||||
"mode": "success",
|
||||
"rows": [
|
||||
{"name": "tag_avg", "timestamp": "2024-06-01T12:00:00Z", "value": 10},
|
||||
{"name": "tag_avg", "timestamp": "2024-06-01T12:01:00Z", "value": 20},
|
||||
{"name": "tag_avg", "timestamp": "2024-06-01T12:02:00Z", "value": 30},
|
||||
{"name": "tag_mdn", "timestamp": "2024-06-01T12:00:00Z", "value": 1},
|
||||
{"name": "tag_mdn", "timestamp": "2024-06-01T12:01:00Z", "value": 9},
|
||||
{"name": "tag_mdn", "timestamp": "2024-06-01T12:02:00Z", "value": 5},
|
||||
{"name": "tag_max", "timestamp": "2024-06-01T12:00:00Z", "value": 5},
|
||||
{"name": "tag_max", "timestamp": "2024-06-01T12:01:00Z", "value": 15},
|
||||
{"name": "tag_max", "timestamp": "2024-06-01T12:02:00Z", "value": 10},
|
||||
{"name": "tag_min", "timestamp": "2024-06-01T12:00:00Z", "value": 50},
|
||||
{"name": "tag_min", "timestamp": "2024-06-01T12:01:00Z", "value": 30},
|
||||
{"name": "tag_min", "timestamp": "2024-06-01T12:02:00Z", "value": 40},
|
||||
{"name": "tag_lts", "timestamp": "2024-06-01T12:00:00Z", "value": 100},
|
||||
{"name": "tag_lts", "timestamp": "2024-06-01T12:01:00Z", "value": 200},
|
||||
{"name": "tag_lts", "timestamp": "2024-06-01T12:02:00Z", "value": 300}
|
||||
]
|
||||
},
|
||||
"expected_values": {
|
||||
"tag_avg": 20.0,
|
||||
"tag_mdn": 5.0,
|
||||
"tag_max": 15.0,
|
||||
"tag_min": 30.0,
|
||||
"tag_lts": 300.0
|
||||
}
|
||||
}
|
||||
23
e2e/scenario_inputs/pi_web_api_scouter_timeout.json
Normal file
23
e2e/scenario_inputs/pi_web_api_scouter_timeout.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"model_id": "15",
|
||||
"model_name": "PI Timeout",
|
||||
"schedule_name": "pi-timeout",
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {"webid": "webid1", "aggr_func": "avg", "data_range": [0, 100], "frequency": 60000}
|
||||
},
|
||||
"pi_web_api_query": {
|
||||
"endpoint": "/streamsets/recorded",
|
||||
"period": "*-1d",
|
||||
"max_count": 1,
|
||||
"api_timeout": 2
|
||||
}
|
||||
},
|
||||
"pi_web_api_server": {"mode": "timeout", "timeout_sleep_seconds": 5}
|
||||
}
|
||||
23
e2e/scenario_inputs/scouter_empty_mongo.json
Normal file
23
e2e/scenario_inputs/scouter_empty_mongo.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"topic": "e2e-topic",
|
||||
"model_id": "3",
|
||||
"model_name": "Scouter Empty Mongo",
|
||||
"schedule_name": "scouter-empty",
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {
|
||||
"webid": "webid1",
|
||||
"aggr_func": "avg",
|
||||
"data_range": [0, 100],
|
||||
"frequency": 60000
|
||||
}
|
||||
}
|
||||
},
|
||||
"raw_documents": []
|
||||
}
|
||||
38
e2e/scenario_inputs/scouter_happy_path.json
Normal file
38
e2e/scenario_inputs/scouter_happy_path.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"topic": "e2e-topic",
|
||||
"model_id": "1",
|
||||
"model_name": "Scouter E2E Model",
|
||||
"schedule_name": "scouter-happy",
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {
|
||||
"webid": "webid1",
|
||||
"aggr_func": "avg",
|
||||
"data_range": [0, 100],
|
||||
"frequency": 60000
|
||||
}
|
||||
}
|
||||
},
|
||||
"raw_documents": [
|
||||
{
|
||||
"inserted_at": "2024-06-01 12:00:00.000000+0000",
|
||||
"timestamp": "2024-06-01 12:00:00+0000",
|
||||
"name": "tag1",
|
||||
"value": 10.5,
|
||||
"tag": "webid1"
|
||||
},
|
||||
{
|
||||
"inserted_at": "2024-06-01 12:01:00.000000+0000",
|
||||
"timestamp": "2024-06-01 12:01:00+0000",
|
||||
"name": "tag1",
|
||||
"value": 20.0,
|
||||
"tag": "webid1"
|
||||
}
|
||||
]
|
||||
}
|
||||
43
e2e/scenario_inputs/scouter_incremental_load.json
Normal file
43
e2e/scenario_inputs/scouter_incremental_load.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"topic": "e2e-topic",
|
||||
"model_id": "2",
|
||||
"model_name": "Scouter Incremental",
|
||||
"schedule_name": "scouter-incremental",
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {
|
||||
"webid": "webid1",
|
||||
"aggr_func": "avg",
|
||||
"data_range": [0, 100],
|
||||
"frequency": 60000
|
||||
}
|
||||
}
|
||||
},
|
||||
"redis_seed": {
|
||||
"last_data_timestamp": "2024-06-01 10:00:00.000000+0000"
|
||||
},
|
||||
"raw_documents": [
|
||||
{
|
||||
"inserted_at": "2024-06-01 09:00:00.000000+0000",
|
||||
"timestamp": "2024-06-01 09:00:00+0000",
|
||||
"name": "tag1",
|
||||
"value": 1.0,
|
||||
"tag": "webid1"
|
||||
},
|
||||
{
|
||||
"inserted_at": "2024-06-01 11:00:00.000000+0000",
|
||||
"timestamp": "2024-06-01 11:00:00+0000",
|
||||
"name": "tag1",
|
||||
"value": 99.0,
|
||||
"tag": "webid1"
|
||||
}
|
||||
],
|
||||
"expected_newer_count": 1,
|
||||
"expected_last_timestamp": "2024-06-01 11:00:00.000000+0000"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"topic": "e2e-topic",
|
||||
"model_id": "4",
|
||||
"model_name": "Scouter First Run",
|
||||
"schedule_name": "scouter-first-run",
|
||||
"trigger_laborious": false,
|
||||
"filters": {},
|
||||
"schema": "sientia_data",
|
||||
"table_name": "laborious_data",
|
||||
"retention_time": 3600,
|
||||
"fill_missing_tags": false,
|
||||
"model_tags": {
|
||||
"tag1": {
|
||||
"webid": "webid1",
|
||||
"aggr_func": "lts",
|
||||
"data_range": [0, 100],
|
||||
"frequency": 60000
|
||||
}
|
||||
}
|
||||
},
|
||||
"raw_documents": [
|
||||
{
|
||||
"inserted_at": "2024-06-01 08:00:00.000000+0000",
|
||||
"timestamp": "2024-06-01 08:00:00+0000",
|
||||
"name": "tag1",
|
||||
"value": 42.0,
|
||||
"tag": "webid1"
|
||||
}
|
||||
]
|
||||
}
|
||||
138
e2e/scenarios.md
Normal file
138
e2e/scenarios.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Scouter E2E scenario catalog
|
||||
|
||||
## Execution context
|
||||
|
||||
- **MongoDB**, **Redis**, and **PostgreSQL** run as session-scoped testcontainers with autouse cleanup between tests.
|
||||
- **PI Web API** is an in-process HTTP server (`e2e/pi_web_api_test_server.py`) speaking the wire format consumed by `PIWebAPIClient`.
|
||||
- **Temporal** uses `WorkflowEnvironment.start_local()` and a single worker on `scouter-test-queue`.
|
||||
- **Production code is not mocked** (except `Logger` and optional notification insert spy).
|
||||
|
||||
---
|
||||
|
||||
## 0. Harness smoke tests
|
||||
|
||||
Diagnostic-only checks under `e2e/test_harness_smoke.py`. They are not business scenarios; they exist to fail fast when the harness itself (Docker / containers / Temporal worker wiring) is broken, before the numbered suite runs.
|
||||
|
||||
### 0.0.1 Postgres schema ready (`test_postgres_schema_ready`)
|
||||
|
||||
Confirms the autouse fixture executed `db_schema.sql` and `sientia_data.laborious_data` exists in the Postgres testcontainer.
|
||||
|
||||
### 0.0.2 Activities construct (`test_activities_construct`)
|
||||
|
||||
Confirms the production `Activities` instance initializes against the Mongo/Redis/Postgres testcontainers without hanging (no `patch(...)` involved).
|
||||
|
||||
### 0.0.3 Temporal PI happy path (`test_temporal_pi_happy_path`)
|
||||
|
||||
End-to-end liveness check: `WorkflowEnvironment.start_local()` + worker + in-process PI server + `PIWebAPIScouter` complete without raising. Functional assertions for this flow live in scenario **2.1.1**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scouter workflow
|
||||
|
||||
### 1.1.1 Happy path
|
||||
|
||||
Seed `raw_<schedule>` with multiple documents, run `Scouter`, assert Postgres rows and Redis `last_data_timestamp:scouter:<schedule>`.
|
||||
|
||||
### 1.2.1 Incremental load
|
||||
|
||||
Pre-seed Redis timestamp; seed older and newer Mongo docs; assert only newer rows export and timestamp advances.
|
||||
|
||||
### 1.3.1 Empty Mongo early exit
|
||||
|
||||
Empty `raw_<schedule>`; workflow exits without Postgres rows or Redis timestamp key.
|
||||
|
||||
### 1.3.2 No Redis timestamp first run
|
||||
|
||||
No prior Redis key; all seeded Mongo docs load and timestamp is written after success.
|
||||
|
||||
---
|
||||
|
||||
## 2. PIWebAPIScouter workflow
|
||||
|
||||
### 2.1.1 Happy path
|
||||
|
||||
PI server `success` mode with two tags; assert Postgres rows, Redis hold key, one HTTP request recorded.
|
||||
|
||||
### 2.1.2 Multiple tags
|
||||
|
||||
Five tags with `avg` / `mdn` / `max` / `min` / `lts`; assert five distinct `variable` values and exact aggregated numbers in Postgres.
|
||||
|
||||
### 2.1.3 Debug data package
|
||||
|
||||
`debug_data_package=True`; assert `data_package_pi_web_api_scouter_*` Redis key with `data` and `held_data`.
|
||||
|
||||
### 2.2.1 Empty response early exit
|
||||
|
||||
Server `empty` mode; zero Postgres rows for `model_id`, one request recorded.
|
||||
|
||||
### 2.3.1 PI Web API connection error
|
||||
|
||||
Server `error` mode (HTTP 5xx); workflow fails; `PI_WEB_API_REQUEST_ERROR` notification in Mongo.
|
||||
|
||||
### 2.3.2 PI Web API timeout
|
||||
|
||||
Server `timeout` mode; workflow fails; `PI_WEB_API_REQUEST_ERROR` notification sent.
|
||||
|
||||
### 2.3.3 Invalid endpoint
|
||||
|
||||
Workflow uses `/invalid/endpoint` (404); workflow fails; `PI_WEB_API_REQUEST_ERROR` notification sent.
|
||||
|
||||
---
|
||||
|
||||
## 3. CoreScouter subworkflow
|
||||
|
||||
### 3.1.1 Complete processing success
|
||||
|
||||
Single tag, no filters; Postgres row and `held_data_*` Redis key; no `data_package_*` key.
|
||||
|
||||
### 3.1.2 Null values filter discard
|
||||
|
||||
`NULL_VALUES_FILTER` DISCARD; one WARNING notification; only valid row in Postgres.
|
||||
|
||||
### 3.1.3 Null values filter warn
|
||||
|
||||
`NULL_VALUES_FILTER` WARN; notification sent; both rows in Postgres.
|
||||
|
||||
### 3.1.4 Out of bounds filter discard
|
||||
|
||||
`OUT_OF_BOUNDS_FILTER` DISCARD; in-range row only; WARNING notification.
|
||||
|
||||
### 3.1.5 Aggregation avg
|
||||
|
||||
Three points; Postgres `value` equals arithmetic mean (20.0).
|
||||
|
||||
### 3.1.6 Aggregation mdn
|
||||
|
||||
Median equals 5.0 in Postgres.
|
||||
|
||||
### 3.1.7 Aggregation max
|
||||
|
||||
Maximum equals 15.0 in Postgres.
|
||||
|
||||
### 3.1.8 Aggregation min
|
||||
|
||||
Minimum equals 30.0 in Postgres.
|
||||
|
||||
### 3.1.9 Aggregation lts
|
||||
|
||||
Last-by-timestamp value equals 300.0 in Postgres.
|
||||
|
||||
### 3.1.10 Fill missing tags
|
||||
|
||||
`fill_missing_tags=True`; `held_data_*` contains missing tag keys with `None`.
|
||||
|
||||
### 3.1.11 Debug data package
|
||||
|
||||
`debug_data_package=True`; `data_package_*` Redis key decodes to dict with `data` and `held_data`.
|
||||
|
||||
### 3.2.1 Empty after grouping early exit
|
||||
|
||||
Empty column-oriented `data`; no Postgres rows; no populated `held_data_*`.
|
||||
|
||||
### 3.3.1 Invalid aggregation function
|
||||
|
||||
`aggr_func= bogus`; `AGGREGATION_ISSUES` ERROR notification; bogus tag absent from Postgres.
|
||||
|
||||
### 3.3.2 Postgres export failure surfaces
|
||||
|
||||
Drop `value` column before run; workflow fails; ERROR notification in Mongo.
|
||||
51
e2e/test_harness_smoke.py
Normal file
51
e2e/test_harness_smoke.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Fast smoke checks for E2E fixture wiring."""
|
||||
|
||||
import pytest
|
||||
|
||||
from e2e.helpers import (
|
||||
apply_pi_web_api_server_config,
|
||||
load_scenario_input,
|
||||
make_workflow_id,
|
||||
start_and_await_workflow,
|
||||
)
|
||||
from e2e.pi_web_api_test_server import PIWebAPITestServer
|
||||
from scouter.workflow.pi_web_api_scouter import PIWebAPIScouter
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_postgres_schema_ready(postgres_engine):
|
||||
"""Verify autouse schema setup created laborious_data."""
|
||||
with postgres_engine.connect() as conn:
|
||||
count = conn.exec_driver_sql(
|
||||
'SELECT COUNT(*) FROM information_schema.tables '
|
||||
"WHERE table_schema = 'sientia_data' AND table_name = 'laborious_data'"
|
||||
).scalar()
|
||||
assert count == 1
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_activities_construct(test_activities):
|
||||
"""Verify Activities initializes against testcontainers without hanging."""
|
||||
assert test_activities.redis_repository is not None
|
||||
assert test_activities.mongodb_repository is not None
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporal_pi_happy_path(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
pi_web_api_server: PIWebAPITestServer,
|
||||
):
|
||||
"""Minimal Temporal path: PIWebAPIScouter happy path completes."""
|
||||
scenario = load_scenario_input('pi_web_api_scouter_happy_path')
|
||||
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
PIWebAPIScouter.run,
|
||||
scenario['workflow_input'],
|
||||
make_workflow_id('smoke-pi-happy'),
|
||||
timeout=60.0,
|
||||
)
|
||||
222
e2e/test_pi_web_api_scouter_main_workflow.py
Normal file
222
e2e/test_pi_web_api_scouter_main_workflow.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
End-to-end tests for the PIWebAPIScouter main workflow.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from redis import Redis
|
||||
from temporalio.client import WorkflowFailureError
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from e2e.conftest import E2E_DATABASE
|
||||
from e2e.helpers import (
|
||||
apply_pi_web_api_server_config,
|
||||
count_laborious_rows,
|
||||
count_notifications,
|
||||
fetch_laborious_rows,
|
||||
load_scenario_input,
|
||||
make_workflow_id,
|
||||
start_and_await_workflow,
|
||||
)
|
||||
from e2e.pi_web_api_test_server import PIWebAPITestServer
|
||||
from scouter.workflow.pi_web_api_scouter import PIWebAPIScouter
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_2_1_1_happy_path(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
pi_web_api_server: PIWebAPITestServer,
|
||||
postgres_engine,
|
||||
redis_client: Redis,
|
||||
):
|
||||
scenario = load_scenario_input('pi_web_api_scouter_happy_path')
|
||||
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
PIWebAPIScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('pi-happy'),
|
||||
)
|
||||
|
||||
model_id = workflow_input['model_id']
|
||||
assert count_laborious_rows(postgres_engine, model_id) >= 1
|
||||
assert len(redis_client.keys('held_data_*')) >= 1
|
||||
assert len(pi_web_api_server.requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_2_1_2_multiple_tags(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
pi_web_api_server: PIWebAPITestServer,
|
||||
postgres_engine,
|
||||
):
|
||||
scenario = load_scenario_input('pi_web_api_scouter_multiple_tags')
|
||||
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
PIWebAPIScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('pi-multi'),
|
||||
)
|
||||
|
||||
rows = {
|
||||
row['variable']: float(row['value'])
|
||||
for row in fetch_laborious_rows(postgres_engine, workflow_input['model_id'])
|
||||
}
|
||||
for tag, expected in scenario['expected_values'].items():
|
||||
assert tag in rows
|
||||
assert rows[tag] == pytest.approx(expected)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_2_1_3_debug_data_package(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
pi_web_api_server: PIWebAPITestServer,
|
||||
redis_client: Redis,
|
||||
):
|
||||
scenario = load_scenario_input('pi_web_api_scouter_debug_data_package')
|
||||
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
PIWebAPIScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('pi-debug'),
|
||||
)
|
||||
|
||||
keys = redis_client.keys('data_package_pi_web_api_scouter_*')
|
||||
assert len(keys) >= 1
|
||||
payload = json.loads(redis_client.get(keys[0]))
|
||||
assert 'data' in payload and 'held_data' in payload
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason='Empty PI DataFrame lacks timestamp column in get_tag_values; tracked in fix-pi-empty-response-handling',
|
||||
)
|
||||
async def test_scenario_2_2_1_empty_response_early_exit(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
pi_web_api_server: PIWebAPITestServer,
|
||||
postgres_engine,
|
||||
):
|
||||
scenario = load_scenario_input('pi_web_api_scouter_empty_response')
|
||||
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
PIWebAPIScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('pi-empty'),
|
||||
)
|
||||
|
||||
assert count_laborious_rows(postgres_engine, workflow_input['model_id']) == 0
|
||||
assert len(pi_web_api_server.requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_2_3_1_pi_web_api_connection_error(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
pi_web_api_server: PIWebAPITestServer,
|
||||
mongo_uri: str,
|
||||
):
|
||||
scenario = load_scenario_input('pi_web_api_scouter_connection_error')
|
||||
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
with pytest.raises(WorkflowFailureError):
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
PIWebAPIScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('pi-conn-error'),
|
||||
)
|
||||
|
||||
assert (
|
||||
count_notifications(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
notification_id='PI_WEB_API_REQUEST_ERROR',
|
||||
level='ERROR',
|
||||
)
|
||||
>= 1
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_2_3_2_pi_web_api_timeout(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
pi_web_api_server: PIWebAPITestServer,
|
||||
mongo_uri: str,
|
||||
):
|
||||
scenario = load_scenario_input('pi_web_api_scouter_timeout')
|
||||
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
with pytest.raises(WorkflowFailureError):
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
PIWebAPIScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('pi-timeout'),
|
||||
timeout=180.0,
|
||||
)
|
||||
|
||||
assert (
|
||||
count_notifications(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
notification_id='PI_WEB_API_REQUEST_ERROR',
|
||||
)
|
||||
>= 1
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_2_3_3_invalid_endpoint(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
pi_web_api_server: PIWebAPITestServer,
|
||||
mongo_uri: str,
|
||||
):
|
||||
scenario = load_scenario_input('pi_web_api_scouter_invalid_endpoint')
|
||||
apply_pi_web_api_server_config(pi_web_api_server, scenario.get('pi_web_api_server'))
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
with pytest.raises(WorkflowFailureError):
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
PIWebAPIScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('pi-invalid-endpoint'),
|
||||
)
|
||||
|
||||
assert (
|
||||
count_notifications(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
notification_id='PI_WEB_API_REQUEST_ERROR',
|
||||
)
|
||||
>= 1
|
||||
)
|
||||
154
e2e/test_scouter_main_workflow.py
Normal file
154
e2e/test_scouter_main_workflow.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
End-to-end tests for the Scouter main workflow (Mongo load path).
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from redis import Redis
|
||||
|
||||
from e2e.conftest import E2E_DATABASE
|
||||
from e2e.helpers import (
|
||||
count_laborious_rows,
|
||||
load_scenario_input,
|
||||
make_workflow_id,
|
||||
seed_last_data_timestamp,
|
||||
seed_raw_collection,
|
||||
start_and_await_workflow,
|
||||
)
|
||||
from scouter.workflow.scouter import Scouter
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_1_1_1_happy_path(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
mongo_uri: str,
|
||||
redis_client: Redis,
|
||||
postgres_engine,
|
||||
):
|
||||
scenario = load_scenario_input('scouter_happy_path')
|
||||
workflow_input = scenario['workflow_input']
|
||||
seed_raw_collection(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
workflow_input['schedule_name'],
|
||||
scenario['raw_documents'],
|
||||
)
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Scouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('scouter-happy'),
|
||||
)
|
||||
|
||||
model_id = workflow_input['model_id']
|
||||
assert count_laborious_rows(postgres_engine, model_id) >= 1
|
||||
|
||||
key = f"last_data_timestamp:scouter:{workflow_input['schedule_name']}"
|
||||
assert redis_client.get(key) is not None
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_1_2_1_incremental_load(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
mongo_uri: str,
|
||||
redis_client: Redis,
|
||||
postgres_engine,
|
||||
):
|
||||
scenario = load_scenario_input('scouter_incremental_load')
|
||||
workflow_input = scenario['workflow_input']
|
||||
redis_seed = scenario['redis_seed']
|
||||
seed_last_data_timestamp(
|
||||
redis_client,
|
||||
'scouter',
|
||||
workflow_input['schedule_name'],
|
||||
redis_seed['last_data_timestamp'],
|
||||
)
|
||||
seed_raw_collection(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
workflow_input['schedule_name'],
|
||||
scenario['raw_documents'],
|
||||
)
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Scouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('scouter-incremental'),
|
||||
)
|
||||
|
||||
model_id = workflow_input['model_id']
|
||||
assert count_laborious_rows(postgres_engine, model_id) == scenario['expected_newer_count']
|
||||
|
||||
stored = json.loads(
|
||||
redis_client.get(f"last_data_timestamp:scouter:{workflow_input['schedule_name']}")
|
||||
)
|
||||
assert stored == scenario['expected_last_timestamp']
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_1_3_1_empty_mongo_early_exit(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
mongo_uri: str,
|
||||
redis_client: Redis,
|
||||
postgres_engine,
|
||||
):
|
||||
scenario = load_scenario_input('scouter_empty_mongo')
|
||||
workflow_input = scenario['workflow_input']
|
||||
seed_raw_collection(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
workflow_input['schedule_name'],
|
||||
scenario['raw_documents'],
|
||||
)
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Scouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('scouter-empty'),
|
||||
)
|
||||
|
||||
assert count_laborious_rows(postgres_engine, workflow_input['model_id']) == 0
|
||||
key = f"last_data_timestamp:scouter:{workflow_input['schedule_name']}"
|
||||
assert redis_client.get(key) is None
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_1_3_2_no_redis_timestamp_first_run(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
mongo_uri: str,
|
||||
redis_client: Redis,
|
||||
postgres_engine,
|
||||
):
|
||||
scenario = load_scenario_input('scouter_no_redis_timestamp_first_run')
|
||||
workflow_input = scenario['workflow_input']
|
||||
seed_raw_collection(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
workflow_input['schedule_name'],
|
||||
scenario['raw_documents'],
|
||||
)
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Scouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('scouter-first-run'),
|
||||
)
|
||||
|
||||
assert count_laborious_rows(postgres_engine, workflow_input['model_id']) >= 1
|
||||
key = f"last_data_timestamp:scouter:{workflow_input['schedule_name']}"
|
||||
assert redis_client.get(key) is not None
|
||||
360
e2e/test_subworkflow_core_scouter.py
Normal file
360
e2e/test_subworkflow_core_scouter.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""
|
||||
End-to-end tests for the CoreScouter subworkflow.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from redis import Redis
|
||||
from sqlalchemy import text
|
||||
from temporalio.client import WorkflowFailureError
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from e2e.conftest import E2E_DATABASE
|
||||
from e2e.helpers import (
|
||||
count_laborious_rows,
|
||||
count_notifications,
|
||||
fetch_laborious_rows,
|
||||
load_scenario_input,
|
||||
make_workflow_id,
|
||||
start_and_await_workflow,
|
||||
)
|
||||
from scouter.activities.activities import Activities
|
||||
from scouter.workflow.sub_workflows.core_scouter import CoreScouter
|
||||
|
||||
|
||||
def _held_data_blob(test_activities: Activities, workflow_input: dict) -> dict | None:
|
||||
"""
|
||||
Read held_data Redis payload via the production RedisRepository.
|
||||
|
||||
Return:
|
||||
Decoded held-data dict or None
|
||||
"""
|
||||
key = (
|
||||
f"held_data_{workflow_input['workflow_name']}_{workflow_input['schedule_name']}"
|
||||
)
|
||||
metadata = workflow_input['metadata']['metadata']
|
||||
return test_activities.redis_repository.get(key, metadata=metadata)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_1_1_complete_processing_success(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
postgres_engine,
|
||||
redis_client: Redis,
|
||||
):
|
||||
scenario = load_scenario_input('core_scouter_happy_path')
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
CoreScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('core-happy'),
|
||||
)
|
||||
|
||||
assert count_laborious_rows(postgres_engine, workflow_input['model_id']) >= 1
|
||||
assert _held_data_blob(test_activities, workflow_input) is not None
|
||||
assert len(redis_client.keys('data_package_*')) == 0
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_1_2_null_values_filter_discard(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
mongo_uri: str,
|
||||
postgres_engine,
|
||||
):
|
||||
scenario = load_scenario_input('core_scouter_null_values_filter_discard')
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
CoreScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('core-null-discard'),
|
||||
)
|
||||
|
||||
assert (
|
||||
count_notifications(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
notification_id='DATA_QUALITY_GATE_ISSUES__NULL_VALUES_FILTER',
|
||||
level='WARNING',
|
||||
)
|
||||
== 1
|
||||
)
|
||||
rows = fetch_laborious_rows(postgres_engine, workflow_input['model_id'])
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['variable'] == 'tag2'
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_1_3_null_values_filter_warn(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
mongo_uri: str,
|
||||
postgres_engine,
|
||||
):
|
||||
scenario = load_scenario_input('core_scouter_null_values_filter_warn')
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
CoreScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('core-null-warn'),
|
||||
)
|
||||
|
||||
assert (
|
||||
count_notifications(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
notification_id='DATA_QUALITY_GATE_ISSUES__NULL_VALUES_FILTER',
|
||||
level='WARNING',
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert count_laborious_rows(postgres_engine, workflow_input['model_id']) == 2
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_1_4_out_of_bounds_filter_discard(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
mongo_uri: str,
|
||||
postgres_engine,
|
||||
):
|
||||
scenario = load_scenario_input('core_scouter_out_of_bounds_filter_discard')
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
CoreScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('core-oob-discard'),
|
||||
)
|
||||
|
||||
assert (
|
||||
count_notifications(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
notification_id='DATA_QUALITY_GATE_ISSUES__OUT_OF_BOUNDS_FILTER',
|
||||
level='WARNING',
|
||||
)
|
||||
== 1
|
||||
)
|
||||
rows = fetch_laborious_rows(postgres_engine, workflow_input['model_id'])
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['variable'] == 'tag2'
|
||||
|
||||
|
||||
async def _run_aggregation_scenario(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
postgres_engine,
|
||||
slug: str,
|
||||
workflow_id_prefix: str,
|
||||
) -> None:
|
||||
scenario = load_scenario_input(slug)
|
||||
workflow_input = scenario['workflow_input']
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
CoreScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id(workflow_id_prefix),
|
||||
)
|
||||
rows = fetch_laborious_rows(postgres_engine, workflow_input['model_id'])
|
||||
tag_name = next(iter(workflow_input['model_tags']))
|
||||
value = next(row['value'] for row in rows if row['variable'] == tag_name)
|
||||
assert float(value) == pytest.approx(scenario['expected_value'])
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_1_5_aggregation_avg(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
postgres_engine,
|
||||
):
|
||||
await _run_aggregation_scenario(
|
||||
temporal_env, postgres_engine, 'core_scouter_aggregation_avg', 'core-avg'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_1_6_aggregation_mdn(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
postgres_engine,
|
||||
):
|
||||
await _run_aggregation_scenario(
|
||||
temporal_env, postgres_engine, 'core_scouter_aggregation_mdn', 'core-mdn'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_1_7_aggregation_max(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
postgres_engine,
|
||||
):
|
||||
await _run_aggregation_scenario(
|
||||
temporal_env, postgres_engine, 'core_scouter_aggregation_max', 'core-max'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_1_8_aggregation_min(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
postgres_engine,
|
||||
):
|
||||
await _run_aggregation_scenario(
|
||||
temporal_env, postgres_engine, 'core_scouter_aggregation_min', 'core-min'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_1_9_aggregation_lts(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
postgres_engine,
|
||||
):
|
||||
await _run_aggregation_scenario(
|
||||
temporal_env, postgres_engine, 'core_scouter_aggregation_lts', 'core-lts'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_1_10_fill_missing_tags(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
):
|
||||
scenario = load_scenario_input('core_scouter_fill_missing_tags')
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
CoreScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('core-fill-tags'),
|
||||
)
|
||||
|
||||
held = _held_data_blob(test_activities, workflow_input)
|
||||
assert held is not None
|
||||
for tag in scenario['expected_missing_tags']:
|
||||
assert tag in held
|
||||
assert held[tag] is None
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_1_11_debug_data_package(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
redis_client: Redis,
|
||||
):
|
||||
scenario = load_scenario_input('core_scouter_debug_data_package')
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
CoreScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('core-debug-pkg'),
|
||||
)
|
||||
|
||||
keys = redis_client.keys('data_package_*')
|
||||
assert len(keys) >= 1
|
||||
payload = json.loads(redis_client.get(keys[0]))
|
||||
assert 'data' in payload and 'held_data' in payload
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_2_1_empty_after_grouping_early_exit(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
postgres_engine,
|
||||
):
|
||||
scenario = load_scenario_input('core_scouter_empty_after_grouping')
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
CoreScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('core-empty-group'),
|
||||
)
|
||||
|
||||
assert count_laborious_rows(postgres_engine, workflow_input['model_id']) == 0
|
||||
assert _held_data_blob(test_activities, workflow_input) is None
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_3_1_invalid_aggregation_function(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
mongo_uri: str,
|
||||
postgres_engine,
|
||||
):
|
||||
scenario = load_scenario_input('core_scouter_invalid_aggregation')
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
CoreScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('core-bad-aggr'),
|
||||
)
|
||||
|
||||
variables = {row['variable'] for row in fetch_laborious_rows(postgres_engine, workflow_input['model_id'])}
|
||||
assert 'tag_bad' not in variables
|
||||
assert 'tag_ok' in variables
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_3_2_postgres_export_failure_surfaces(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
mongo_uri: str,
|
||||
postgres_engine,
|
||||
):
|
||||
scenario = load_scenario_input('core_scouter_postgres_export_failure')
|
||||
workflow_input = scenario['workflow_input']
|
||||
|
||||
with postgres_engine.begin() as conn:
|
||||
conn.execute(text('ALTER TABLE sientia_data.laborious_data DROP COLUMN value'))
|
||||
|
||||
with pytest.raises(WorkflowFailureError):
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
CoreScouter.run,
|
||||
workflow_input,
|
||||
make_workflow_id('core-pg-fail'),
|
||||
)
|
||||
|
||||
assert (
|
||||
count_notifications(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
notification_id='ERROR_EXPORTING_DATA_TO_POSTGRES',
|
||||
)
|
||||
>= 1
|
||||
)
|
||||
Reference in New Issue
Block a user