Code import - branch feature/SIENTIAPDE-1646

This commit is contained in:
2026-06-28 03:03:00 +00:00
commit 1be8c97e5a
87 changed files with 10783 additions and 0 deletions

37
e2e/README.md Normal file
View 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
View File

319
e2e/conftest.py Normal file
View 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
View 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
View File

404
e2e/helpers.py Normal file
View 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('/'),
}

View 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',
)

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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}
}
}
}

View 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}
}
}
}

View 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"]
}

View 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}
}
}
}

View 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}
}
}
}

View File

@@ -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}
}
}
}

View File

@@ -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}
}
}
}

View File

@@ -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}
}
}
}

View File

@@ -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}
}
}
}

View 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"}
}

View File

@@ -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}
]
}
}

View 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"}
}

View 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}
]
}
}

View 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": []}
}

View 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
}
}

View 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}
}

View 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": []
}

View 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"
}
]
}

View 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"
}

View File

@@ -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
View 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
View 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,
)

View 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
)

View 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

View 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
)