SIENTIAPDE-1646
Enhance orchestration configuration and documentation. Added `RUNTIME` variable to `.env.example`, updated `.gitignore` to exclude `openspec/` and `.cursor/`, and modified `README.md` to clarify queue naming conventions and runtime handling. Refactored activities to use synchronous database and email handling, improving performance and consistency. Updated test cases to reflect these changes and ensure compatibility with new activity definitions.
This commit is contained in:
35
e2e/README.md
Normal file
35
e2e/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Orchestrator end-to-end tests
|
||||
|
||||
End-to-end tests run every external dependency for real (MongoDB, Redis, PostgreSQL via testcontainers; SMTP via in-process `aiosmtpd`; Temporal via `WorkflowEnvironment.start_local()`).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker (for testcontainers)
|
||||
- Python dev dependencies: `pip install -r requirements-dev.txt`
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
source ./venv/bin/activate
|
||||
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=orchestrator --cov-branch
|
||||
coverage combine .coverage .coverage.e2e
|
||||
coverage report
|
||||
```
|
||||
|
||||
Unit tests keep the default `.coverage` file; the E2E run must set `COVERAGE_FILE=.coverage.e2e` so reports do not overwrite each other.
|
||||
|
||||
## Scenario catalog
|
||||
|
||||
See [scenarios.md](scenarios.md) for numbered scenarios and which test module implements each case.
|
||||
0
e2e/__init__.py
Normal file
0
e2e/__init__.py
Normal file
446
e2e/conftest.py
Normal file
446
e2e/conftest.py
Normal file
@@ -0,0 +1,446 @@
|
||||
"""Pytest configuration and fixtures for orchestrator E2E tests."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from google.protobuf.duration_pb2 import Duration
|
||||
from pymongo import MongoClient
|
||||
from redis import Redis
|
||||
from sientia_do.notifications.handlers import CoreNotificationHandler
|
||||
from sientia_do.observability.logger import Logger
|
||||
from sientia_do.temporal.worker.prepare_worker import build_queue_name
|
||||
from sqlalchemy import create_engine
|
||||
from temporalio.api.enums.v1 import IndexedValueType
|
||||
from temporalio.api.operatorservice.v1 import AddSearchAttributesRequest
|
||||
from temporalio.api.workflowservice.v1 import (
|
||||
DescribeNamespaceRequest,
|
||||
RegisterNamespaceRequest,
|
||||
)
|
||||
from temporalio.client import Client
|
||||
from temporalio.common import SearchAttributeKey
|
||||
from temporalio.service import RPCError, RPCStatusCode
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
from testcontainers.core.container import DockerContainer
|
||||
from testcontainers.postgres import PostgresContainer
|
||||
|
||||
from e2e.helpers import MONGO_COLLECTIONS, ORCHESTRATOR_TASK_QUEUE
|
||||
from e2e.smtp_test_server import SmtpTestServer
|
||||
from e2e.stub_workflows import STUB_WORKFLOW_CLASSES
|
||||
from orchestrator.activities.activities import Activities
|
||||
from orchestrator.activities.formatters import schedule_types
|
||||
from orchestrator.workflows.alerts import Alerts
|
||||
from orchestrator.workflows.orchestrator import Orchestrator
|
||||
from orchestrator.workflows.reports import Reports
|
||||
from orchestrator.workflows.subworkflows.load_notification_package import (
|
||||
LoadNotificationPackage,
|
||||
)
|
||||
from orchestrator.workflows.subworkflows.process_notifications import ProcessNotifications
|
||||
|
||||
DB_SCHEMA_SQL_PATH = Path(__file__).parent / 'db_schema.sql'
|
||||
E2E_DATABASE = 'orchestrator_test'
|
||||
E2E_RUNTIMES = ('legacy', 'gpu')
|
||||
MANAGED_NAMESPACES = ('scouter', 'laborious')
|
||||
|
||||
E2E_SEARCH_ATTRIBUTES = [
|
||||
SearchAttributeKey.for_keyword('model_id'),
|
||||
SearchAttributeKey.for_keyword('model_name'),
|
||||
SearchAttributeKey.for_keyword('orchestrated'),
|
||||
]
|
||||
|
||||
E2E_NAMESPACE_SEARCH_ATTRIBUTES = {
|
||||
'model_id': IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD,
|
||||
'model_name': IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD,
|
||||
'orchestrated': IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD,
|
||||
}
|
||||
|
||||
|
||||
async def register_namespace_if_missing(env: WorkflowEnvironment, namespace: str) -> None:
|
||||
"""
|
||||
Register a Temporal namespace on the local dev server and wait until it is ready.
|
||||
|
||||
Args:
|
||||
env: Session WorkflowEnvironment from start_local().
|
||||
namespace: Namespace name to register.
|
||||
"""
|
||||
service = env.client.service_client
|
||||
try:
|
||||
await service.workflow_service.register_namespace(
|
||||
RegisterNamespaceRequest(
|
||||
namespace=namespace,
|
||||
workflow_execution_retention_period=Duration(seconds=86400),
|
||||
)
|
||||
)
|
||||
except RPCError as err:
|
||||
if err.status != RPCStatusCode.ALREADY_EXISTS:
|
||||
raise
|
||||
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
await service.workflow_service.describe_namespace(
|
||||
DescribeNamespaceRequest(namespace=namespace)
|
||||
)
|
||||
return
|
||||
except RPCError:
|
||||
await asyncio.sleep(0.1)
|
||||
raise TimeoutError(f'Namespace {namespace} not ready within 5s')
|
||||
|
||||
|
||||
async def ensure_namespace_search_attributes(
|
||||
env: WorkflowEnvironment, namespace: str
|
||||
) -> None:
|
||||
"""
|
||||
Register the orchestrator search attributes on a namespace, if missing.
|
||||
|
||||
The local Temporal dev server only registers search attributes on the default
|
||||
namespace at start time. Schedules created in additional namespaces fail with
|
||||
"no mapping defined for search attribute ..." unless we explicitly add the
|
||||
same attribute mappings to those namespaces via the operator service.
|
||||
|
||||
Args:
|
||||
env: Session WorkflowEnvironment from start_local().
|
||||
namespace: Namespace where attributes must be available.
|
||||
"""
|
||||
service = env.client.service_client
|
||||
try:
|
||||
await service.operator_service.add_search_attributes(
|
||||
AddSearchAttributesRequest(
|
||||
namespace=namespace,
|
||||
search_attributes=dict(E2E_NAMESPACE_SEARCH_ATTRIBUTES),
|
||||
)
|
||||
)
|
||||
except RPCError as err:
|
||||
if err.status != RPCStatusCode.ALREADY_EXISTS:
|
||||
raise
|
||||
|
||||
|
||||
def temporal_host_from_env(env: WorkflowEnvironment) -> str:
|
||||
"""Return target host:port for the in-process Temporal dev server."""
|
||||
return env.client.service_client.config.target_host
|
||||
|
||||
|
||||
def mongo_uri_from_container(mongo_container) -> str:
|
||||
"""Build a Mongo connection string for the testcontainer."""
|
||||
port = mongo_container.get_exposed_port(27017)
|
||||
return f'mongodb://localhost:{port}'
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope='session')
|
||||
def postgres_container():
|
||||
"""PostgreSQL testcontainer used by all E2E tests."""
|
||||
postgres = PostgresContainer('postgres:15')
|
||||
postgres.start()
|
||||
yield postgres
|
||||
postgres.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope='session')
|
||||
def mongo_container():
|
||||
"""MongoDB testcontainer used by real CoreNotificationHandler."""
|
||||
mongo = DockerContainer('mongo:7').with_exposed_ports(27017)
|
||||
mongo.start()
|
||||
yield mongo
|
||||
mongo.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope='session')
|
||||
def redis_container():
|
||||
"""Redis testcontainer for slot and notification timestamp paths."""
|
||||
redis = DockerContainer('redis:7').with_exposed_ports(6379)
|
||||
redis.start()
|
||||
yield redis
|
||||
redis.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
def postgres_engine(postgres_container):
|
||||
"""SQLAlchemy engine bound to the PostgreSQL testcontainer."""
|
||||
engine = create_engine(postgres_container.get_connection_url())
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _create_schema_and_tables(engine):
|
||||
sql_text = DB_SCHEMA_SQL_PATH.read_text(encoding='utf-8')
|
||||
with engine.begin() as conn:
|
||||
conn.exec_driver_sql(sql_text)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
def setup_postgres_schema_and_tables(postgres_engine):
|
||||
"""Recreate Postgres schema from e2e/db_schema.sql before each test."""
|
||||
_create_schema_and_tables(postgres_engine)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mongo_uri(mongo_container):
|
||||
return mongo_uri_from_container(mongo_container)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
def reset_mongo_collections(mongo_uri):
|
||||
"""Drop orchestrator-managed Mongo collections between tests."""
|
||||
client = MongoClient(mongo_uri)
|
||||
try:
|
||||
db = client[E2E_DATABASE]
|
||||
for name in MONGO_COLLECTIONS:
|
||||
db[name].drop()
|
||||
finally:
|
||||
client.close()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def redis_client(redis_container):
|
||||
"""Redis client bound to the testcontainer."""
|
||||
port = int(redis_container.get_exposed_port(6379))
|
||||
client = Redis(host='localhost', port=port, decode_responses=True)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
def reset_redis(redis_client):
|
||||
"""Flush Redis between tests."""
|
||||
redis_client.flushdb()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def smtp_server():
|
||||
"""Session-scoped in-process SMTP server."""
|
||||
server = SmtpTestServer()
|
||||
server.start()
|
||||
yield server
|
||||
server.stop()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def smtp_messages_cleanup(smtp_server):
|
||||
"""Clear captured SMTP messages between tests."""
|
||||
smtp_server.clear()
|
||||
yield
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope='session')
|
||||
async def temporal_env():
|
||||
"""Real Temporal dev server (schedule APIs supported)."""
|
||||
env = await WorkflowEnvironment.start_local(search_attributes=E2E_SEARCH_ATTRIBUTES)
|
||||
for namespace in MANAGED_NAMESPACES:
|
||||
await register_namespace_if_missing(env, namespace)
|
||||
await ensure_namespace_search_attributes(env, namespace)
|
||||
yield env
|
||||
await env.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def temporal_host(temporal_env):
|
||||
return temporal_host_from_env(temporal_env)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def cleanup_temporal_schedules(temporal_env):
|
||||
"""Delete orphan schedules in scouter/laborious before each test."""
|
||||
host = temporal_host_from_env(temporal_env)
|
||||
for namespace in MANAGED_NAMESPACES:
|
||||
client = await Client.connect(host, namespace=namespace)
|
||||
async for schedule in await client.list_schedules():
|
||||
handle = client.get_schedule_handle(schedule.id)
|
||||
await handle.delete()
|
||||
yield
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
def mock_logger():
|
||||
"""Logger double with readable console output for E2E runs."""
|
||||
logger = MagicMock(spec=Logger)
|
||||
logger.info = MagicMock(side_effect=lambda msg: print(f'[LOG] {msg}'))
|
||||
logger.debug = MagicMock(side_effect=lambda msg: print(f'[LOG] {msg}'))
|
||||
logger.error = MagicMock(side_effect=lambda msg: print(f'[LOG] {msg}'))
|
||||
logger.warning = MagicMock(side_effect=lambda msg: print(f'[LOG] {msg}'))
|
||||
logger.custom_info = MagicMock(side_effect=lambda msg, _meta=None: print(f'[LOG] {msg}'))
|
||||
logger.custom_debug = MagicMock(side_effect=lambda msg, _meta=None: print(f'[LOG] {msg}'))
|
||||
logger.custom_error = MagicMock(side_effect=lambda msg, _meta=None: print(f'[LOG] {msg}'))
|
||||
logger.custom_warning = MagicMock(side_effect=lambda msg, _meta=None: print(f'[LOG] {msg}'))
|
||||
return logger
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def notification_handler(mock_logger, mongo_container):
|
||||
"""Real notification handler using MongoDB testcontainer."""
|
||||
handler = CoreNotificationHandler(
|
||||
connection_string=mongo_uri_from_container(mongo_container),
|
||||
database=E2E_DATABASE,
|
||||
logger=mock_logger,
|
||||
project_name='orchestrator-e2e',
|
||||
)
|
||||
try:
|
||||
yield handler
|
||||
finally:
|
||||
handler.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notification_inserts(notification_handler):
|
||||
"""Spy on real Mongo insert calls issued by notification handler."""
|
||||
collection = notification_handler.mongo_collection
|
||||
original_insert_one = collection.insert_one
|
||||
spy = MagicMock(wraps=original_insert_one)
|
||||
collection.insert_one = spy
|
||||
try:
|
||||
yield spy
|
||||
finally:
|
||||
collection.insert_one = original_insert_one
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_activities(
|
||||
postgres_container,
|
||||
mongo_container,
|
||||
redis_container,
|
||||
smtp_server,
|
||||
mock_logger,
|
||||
notification_handler,
|
||||
temporal_host,
|
||||
):
|
||||
"""Real Activities wired to testcontainers and in-process SMTP."""
|
||||
mongo_port = mongo_container.get_exposed_port(27017)
|
||||
redis_port = int(redis_container.get_exposed_port(6379))
|
||||
|
||||
activities = Activities(
|
||||
temporal_config={
|
||||
'temporal_host': temporal_host,
|
||||
'temporal_scouter_namespace': 'scouter',
|
||||
'temporal_laborious_namespace': 'laborious',
|
||||
},
|
||||
redis_config={
|
||||
'host': 'localhost',
|
||||
'port': redis_port,
|
||||
'username': '',
|
||||
'password': '',
|
||||
},
|
||||
mongodb_config={
|
||||
'connection_string': f'mongodb://localhost:{mongo_port}',
|
||||
'database_name': E2E_DATABASE,
|
||||
'ttl_index_seconds': 3600,
|
||||
},
|
||||
email_config={
|
||||
'sender_email': 'e2e@example.com',
|
||||
'sender_password': '',
|
||||
'smtp_server': smtp_server.host,
|
||||
'smtp_port': smtp_server.port,
|
||||
},
|
||||
postgres_config={
|
||||
'host': 'localhost',
|
||||
'port': int(postgres_container.get_exposed_port(5432)),
|
||||
'user': postgres_container.username,
|
||||
'password': postgres_container.password,
|
||||
'dbname': postgres_container.dbname,
|
||||
'min_connections': 1,
|
||||
'max_connections': 5,
|
||||
},
|
||||
logger=mock_logger,
|
||||
notification_handler=notification_handler,
|
||||
)
|
||||
await activities.connect_to_temporal()
|
||||
try:
|
||||
yield activities
|
||||
finally:
|
||||
activities.shutdown()
|
||||
|
||||
|
||||
def _orchestrator_activity_list(activities: Activities) -> list:
|
||||
return [
|
||||
activities.load_active_ingestors,
|
||||
activities.load_opc_slots,
|
||||
activities.update_slots,
|
||||
activities.delete_slots,
|
||||
activities.aggregate_documents_in_mongodb,
|
||||
activities.find_documents_in_mongodb,
|
||||
activities.update_pipelines_timestamps,
|
||||
activities.create_pipelines_timestamps,
|
||||
activities.delete_pipelines_timestamps,
|
||||
activities.create_collection_with_ttl_index,
|
||||
activities.create_schedules,
|
||||
activities.update_schedules,
|
||||
activities.delete_schedules,
|
||||
activities.normalize_schedules,
|
||||
activities.process_schedules,
|
||||
activities.process_slots,
|
||||
activities.create_schedule_config,
|
||||
activities.create_slot_config,
|
||||
activities.report_schedule_orchestration,
|
||||
activities.report_slot_orchestration,
|
||||
activities.format_schedule_config,
|
||||
activities.get_last_data_timestamp,
|
||||
activities.load_latest_data,
|
||||
activities.put_last_data_timestamp,
|
||||
activities.filter_notification_alerts,
|
||||
activities.filter_notification_reports,
|
||||
activities.build_email_html,
|
||||
activities.send_email,
|
||||
activities.format_log_report,
|
||||
activities.export_data_to_postgres,
|
||||
activities.store_notification_cache,
|
||||
]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def orchestrator_worker(temporal_env, test_activities):
|
||||
"""Worker for orchestrator workflows and all activities on the default namespace."""
|
||||
with ThreadPoolExecutor(max_workers=32) as activity_executor:
|
||||
async with Worker(
|
||||
temporal_env.client,
|
||||
task_queue=ORCHESTRATOR_TASK_QUEUE,
|
||||
workflows=[
|
||||
Orchestrator,
|
||||
Alerts,
|
||||
Reports,
|
||||
LoadNotificationPackage,
|
||||
ProcessNotifications,
|
||||
],
|
||||
activities=_orchestrator_activity_list(test_activities),
|
||||
activity_executor=activity_executor,
|
||||
) as worker:
|
||||
yield worker
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def stub_workers(temporal_env):
|
||||
"""No-op workers on scouter/laborious namespaces for every managed workflow type."""
|
||||
host = temporal_host_from_env(temporal_env)
|
||||
worker_contexts: list[Worker] = []
|
||||
clients: list[Client] = []
|
||||
stub_types = list(schedule_types.keys()) + [
|
||||
'xgboost_predictions_batch',
|
||||
'xgboost_minimal_retrain',
|
||||
]
|
||||
|
||||
try:
|
||||
for namespace in MANAGED_NAMESPACES:
|
||||
client = await Client.connect(host, namespace=namespace)
|
||||
clients.append(client)
|
||||
queues = {
|
||||
build_queue_name(workflow_type, runtime)
|
||||
for workflow_type in stub_types
|
||||
for runtime in E2E_RUNTIMES
|
||||
}
|
||||
for queue in queues:
|
||||
worker = Worker(
|
||||
client,
|
||||
task_queue=queue,
|
||||
workflows=STUB_WORKFLOW_CLASSES,
|
||||
)
|
||||
await worker.__aenter__()
|
||||
worker_contexts.append(worker)
|
||||
yield worker_contexts
|
||||
finally:
|
||||
for worker in reversed(worker_contexts):
|
||||
await worker.__aexit__(None, None, None)
|
||||
33
e2e/db_schema.sql
Normal file
33
e2e/db_schema.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- =============================================================================
|
||||
-- E2E test database schema for the ``sientia_data`` namespace.
|
||||
--
|
||||
-- SINGLE SOURCE OF TRUTH: mirrors production DDL for tables the orchestrator
|
||||
-- writes to. Any production DDL change must be pasted into this file (same
|
||||
-- pattern as sientia-dataops-laborious_temporal/e2e/db_schema.sql).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS sientia_data;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- sientia_data.log_report
|
||||
-- Written by ProcessNotifications via export_data_to_postgres.
|
||||
-- The table is dropped between tests so each scenario starts with a clean
|
||||
-- slate; the per-test autouse fixture re-runs this script.
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP TABLE IF EXISTS sientia_data.log_report;
|
||||
CREATE TABLE sientia_data.log_report (
|
||||
status text,
|
||||
"timestamp" timestamptz,
|
||||
groups text,
|
||||
message text,
|
||||
level text,
|
||||
notification_id text,
|
||||
block text,
|
||||
schedule text,
|
||||
pipeline text,
|
||||
project text,
|
||||
model_name text,
|
||||
model_id text,
|
||||
mail_type text,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
356
e2e/helpers.py
Normal file
356
e2e/helpers.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""
|
||||
Shared helpers for orchestrator E2E tests (Temporal workflows + Mongo + Redis + Postgres).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pymongo import MongoClient
|
||||
from redis import Redis
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Engine
|
||||
from temporalio.client import Client
|
||||
|
||||
SCENARIO_INPUTS_DIR = Path(__file__).parent / 'scenario_inputs'
|
||||
ORCHESTRATOR_TASK_QUEUE = 'orchestrator-test-queue'
|
||||
|
||||
MONGO_COLLECTIONS = (
|
||||
'notification_queue',
|
||||
'receiver_groups',
|
||||
'orchestrated_schedules',
|
||||
'pipelines',
|
||||
'opc_servers',
|
||||
'opc-servers',
|
||||
)
|
||||
|
||||
DATETIME_FORMAT_MS_WITH_TZ = '%Y-%m-%d %H:%M:%S.%f%z'
|
||||
DATETIME_FORMAT_WITH_TZ = '%Y-%m-%d %H:%M:%S%z'
|
||||
|
||||
_TIMESTAMP_MARKER_PATTERN = re.compile(r'^@now(?:([+-])(\d+)([smhd]))?$')
|
||||
_TIMESTAMP_UNIT_TO_KWARG = {'s': 'seconds', 'm': 'minutes', 'h': 'hours', 'd': 'days'}
|
||||
|
||||
|
||||
def _resolve_timestamp_marker(value: Any) -> Any:
|
||||
"""
|
||||
Convert ``@now`` / ``@now-1h`` markers into timezone-aware datetimes.
|
||||
|
||||
Args:
|
||||
value: Any JSON value. Only strings matching the marker pattern are converted.
|
||||
|
||||
Return:
|
||||
Any: The resolved datetime or the original value unchanged.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
match = _TIMESTAMP_MARKER_PATTERN.match(value)
|
||||
if not match:
|
||||
return value
|
||||
sign, amount, unit = match.groups()
|
||||
now = datetime.now(UTC)
|
||||
if sign is None:
|
||||
return now
|
||||
delta = timedelta(**{_TIMESTAMP_UNIT_TO_KWARG[unit]: int(amount)})
|
||||
return now + delta if sign == '+' else now - delta
|
||||
|
||||
|
||||
def _resolve_payload(payload: Any) -> Any:
|
||||
"""Recursively walk a JSON-like structure resolving ``@now`` timestamp markers."""
|
||||
if isinstance(payload, dict):
|
||||
return {key: _resolve_payload(value) for key, value in payload.items()}
|
||||
if isinstance(payload, list):
|
||||
return [_resolve_payload(item) for item in payload]
|
||||
return _resolve_timestamp_marker(payload)
|
||||
|
||||
|
||||
def load_scenario_input(name: str, **overrides: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Load a scenario JSON file from e2e/scenario_inputs and apply overrides.
|
||||
|
||||
Strings matching ``@now`` or ``@now[+-]<int>[smhd]`` (anywhere in the payload)
|
||||
are converted to timezone-aware ``datetime`` instances. This lets scenario
|
||||
files declare relative timestamps such as ``"updated_at": "@now-1h"``.
|
||||
|
||||
Args:
|
||||
name: File name (with or without .json suffix).
|
||||
**overrides: Top-level keys to replace in the loaded dict.
|
||||
|
||||
Return:
|
||||
dict[str, Any]: Scenario payload with timestamp markers resolved.
|
||||
"""
|
||||
file_name = name if name.endswith('.json') else f'{name}.json'
|
||||
file_path = SCENARIO_INPUTS_DIR / file_name
|
||||
with file_path.open('r', encoding='utf-8') as handle:
|
||||
payload = json.load(handle)
|
||||
payload = _resolve_payload(payload)
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def make_workflow_id(prefix: str) -> str:
|
||||
"""Build a unique workflow id using a prefix and UUID suffix."""
|
||||
return f'{prefix}-{uuid.uuid4().hex[:12]}'
|
||||
|
||||
|
||||
async def start_and_await_workflow(
|
||||
client: Client,
|
||||
workflow_run,
|
||||
input_data: dict[str, Any],
|
||||
workflow_id: str,
|
||||
*,
|
||||
task_queue: str = ORCHESTRATOR_TASK_QUEUE,
|
||||
timeout: float = 120.0,
|
||||
) -> Any:
|
||||
"""
|
||||
Start a workflow and wait for its result.
|
||||
|
||||
Args:
|
||||
client: Temporal client (default namespace).
|
||||
workflow_run: Workflow run method (e.g. Orchestrator.run).
|
||||
input_data: Workflow input payload.
|
||||
workflow_id: Unique workflow id.
|
||||
task_queue: Task queue for the orchestrator worker.
|
||||
timeout: Max seconds to wait for completion.
|
||||
|
||||
Return:
|
||||
Workflow result value.
|
||||
"""
|
||||
handle = await client.start_workflow(
|
||||
workflow_run,
|
||||
input_data,
|
||||
id=workflow_id,
|
||||
task_queue=task_queue,
|
||||
)
|
||||
return await asyncio.wait_for(handle.result(), timeout=timeout)
|
||||
|
||||
|
||||
def seed_pipelines(
|
||||
mongo_uri: str,
|
||||
database: str,
|
||||
pipelines: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Insert pipeline documents into the test Mongo database."""
|
||||
client = MongoClient(mongo_uri)
|
||||
try:
|
||||
collection = client[database]['pipelines']
|
||||
if pipelines:
|
||||
collection.insert_many(pipelines)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def seed_opc_servers(
|
||||
mongo_uri: str,
|
||||
database: str,
|
||||
servers: list[dict[str, Any]],
|
||||
*,
|
||||
collection: str = 'opc_servers',
|
||||
) -> None:
|
||||
"""Insert OPC server documents into the test Mongo database."""
|
||||
client = MongoClient(mongo_uri)
|
||||
try:
|
||||
coll = client[database][collection]
|
||||
if servers:
|
||||
coll.insert_many(servers)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def seed_receiver_groups(
|
||||
mongo_uri: str,
|
||||
database: str,
|
||||
groups: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Insert receiver group documents into the test Mongo database."""
|
||||
client = MongoClient(mongo_uri)
|
||||
try:
|
||||
collection = client[database]['receiver_groups']
|
||||
if groups:
|
||||
collection.insert_many(groups)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def seed_notifications(
|
||||
mongo_uri: str,
|
||||
database: str,
|
||||
notifications: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Insert notification_queue documents into the test Mongo database."""
|
||||
client = MongoClient(mongo_uri)
|
||||
try:
|
||||
collection = client[database]['notification_queue']
|
||||
if notifications:
|
||||
collection.insert_many(notifications)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def seed_orchestrated_schedules(
|
||||
mongo_uri: str,
|
||||
database: str,
|
||||
schedules: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Insert orchestrated_schedules tracking documents."""
|
||||
client = MongoClient(mongo_uri)
|
||||
try:
|
||||
collection = client[database]['orchestrated_schedules']
|
||||
if schedules:
|
||||
collection.insert_many(schedules)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def seed_opc_slots(redis_client: Redis, slots: dict[str, str]) -> None:
|
||||
"""Write OPC slot keys (slot:opc_tags:*) in Redis."""
|
||||
for key, value in slots.items():
|
||||
redis_client.set(key, value)
|
||||
|
||||
|
||||
def seed_active_ingestors(redis_client: Redis, ingestor_keys: list[str]) -> None:
|
||||
"""Seed heartbeat:ingestor:* keys so load_active_ingestors returns ingestors."""
|
||||
for key in ingestor_keys:
|
||||
redis_client.set(key, '1')
|
||||
|
||||
|
||||
def seed_last_timestamp(redis_client: Redis, mail_type: str, value: str) -> None:
|
||||
"""
|
||||
Set notification_last_timestamp for a mail type, JSON-encoded.
|
||||
|
||||
Values must be JSON-encoded so ``redis_repository.get`` (which calls
|
||||
``json.loads`` on the raw payload) can deserialize them. The value
|
||||
must follow the exact format ``sientia_do.notifications.models.Notification``
|
||||
writes into ``notification_queue.timestamp``: ``DATETIME_FORMAT_WITH_TZ``
|
||||
(e.g. ``"2026-05-22 16:47:02+0000"``) — no microseconds and no colon in
|
||||
the timezone offset.
|
||||
|
||||
Args:
|
||||
redis_client: Redis client connected to the test instance.
|
||||
mail_type: Mail type identifier (e.g. ``"Alerts"``, ``"Reports"``).
|
||||
value: Timestamp string in ``DATETIME_FORMAT_WITH_TZ``
|
||||
(e.g. ``"2024-06-01 10:30:00+0000"``).
|
||||
"""
|
||||
redis_client.set(f'notification_last_timestamp:{mail_type}', json.dumps(value))
|
||||
|
||||
|
||||
def seed_notification_cache(
|
||||
redis_client: Redis,
|
||||
trigger: str,
|
||||
notification_id: str,
|
||||
*,
|
||||
sent_at: str | None = None,
|
||||
ttl: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Pre-seed alerts sent cache entry, JSON-encoded.
|
||||
|
||||
The value must be JSON-encoded because ``filter_notification_alerts``
|
||||
reads via ``redis_repository.get`` (which applies ``json.loads``) and
|
||||
parses the resulting string with ``DATETIME_FORMAT_MS_WITH_TZ``.
|
||||
|
||||
Args:
|
||||
redis_client: Redis client connected to the test instance.
|
||||
trigger: Schedule/trigger name used to compose the cache key.
|
||||
notification_id: Notification id used to compose the cache key.
|
||||
sent_at: Optional timestamp string in ``DATETIME_FORMAT_MS_WITH_TZ``.
|
||||
ttl: Optional TTL in seconds for the cache entry.
|
||||
"""
|
||||
key = f'{trigger}:{notification_id}'
|
||||
value = sent_at or datetime.now(UTC).strftime(DATETIME_FORMAT_MS_WITH_TZ)
|
||||
encoded = json.dumps(value)
|
||||
if ttl is not None:
|
||||
redis_client.set(key, encoded, ex=ttl)
|
||||
else:
|
||||
redis_client.set(key, encoded)
|
||||
|
||||
|
||||
def count_log_report_rows(engine: Engine, mail_type: str | None = None) -> int:
|
||||
"""Count rows in sientia_data.log_report, optionally filtered by mail_type."""
|
||||
sql = 'SELECT COUNT(*) FROM sientia_data.log_report'
|
||||
params: dict[str, Any] = {}
|
||||
if mail_type is not None:
|
||||
sql += ' WHERE mail_type = :mail_type'
|
||||
params['mail_type'] = mail_type
|
||||
with engine.connect() as conn:
|
||||
return int(conn.execute(text(sql), params).scalar() or 0)
|
||||
|
||||
|
||||
def fetch_log_report(engine: Engine, mail_type: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Fetch log_report rows as dicts."""
|
||||
sql = 'SELECT * FROM sientia_data.log_report'
|
||||
params: dict[str, Any] = {}
|
||||
if mail_type is not None:
|
||||
sql += ' WHERE mail_type = :mail_type'
|
||||
params['mail_type'] = mail_type
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(text(sql), params).mappings().all()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def default_notification(
|
||||
*,
|
||||
notification_id: str,
|
||||
level: str = 'ERROR',
|
||||
timestamp: str | None = None,
|
||||
model_name: str = 'model-a',
|
||||
model_id: str = '1',
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a minimal notification_queue document mirroring production layout.
|
||||
|
||||
The ``timestamp`` field is stored as a string in ``DATETIME_FORMAT_WITH_TZ``
|
||||
because that is exactly what ``sientia_do.notifications.models.Notification``
|
||||
writes into ``notification_queue`` in production (``now().strftime(
|
||||
DATETIME_FORMAT_WITH_TZ)``). Tests intentionally use this same format so we
|
||||
surface, rather than hide, real production behavior in downstream
|
||||
activities.
|
||||
|
||||
Args:
|
||||
notification_id: Unique identifier for the notification.
|
||||
level: Notification level (e.g. ``"ERROR"``, ``"WARNING"``).
|
||||
timestamp: Optional production-format timestamp string. ``None`` falls
|
||||
back to a fixed sample value.
|
||||
model_name: Model name attached to the notification.
|
||||
model_id: Model id attached to the notification.
|
||||
|
||||
Return:
|
||||
dict[str, Any]: A notification document ready for insertion.
|
||||
"""
|
||||
ts = timestamp if timestamp is not None else datetime(
|
||||
2024, 6, 1, 12, 0, 0, tzinfo=UTC
|
||||
).strftime(DATETIME_FORMAT_WITH_TZ)
|
||||
return {
|
||||
'notification_id': notification_id,
|
||||
'level': level,
|
||||
'timestamp': ts,
|
||||
'message': f'{level} on {model_name}',
|
||||
'trigger': 'test-schedule',
|
||||
'block': 'test-block',
|
||||
'pipeline': 'test-pipeline',
|
||||
'project': 'orchestrator-e2e',
|
||||
'model_name': model_name,
|
||||
'model_id': model_id,
|
||||
}
|
||||
|
||||
|
||||
def default_receiver_group(
|
||||
*,
|
||||
group_name: str = 'admins',
|
||||
members: list[str] | None = None,
|
||||
levels: list[str] | None = None,
|
||||
contents: list[str] | None = None,
|
||||
ignore_models: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Minimal active receiver_groups document."""
|
||||
return {
|
||||
'group_name': group_name,
|
||||
'active': True,
|
||||
'members': members or ['admin@example.com'],
|
||||
'levels': levels or ['ERROR', 'WARNING', 'INFO'],
|
||||
'contents': contents or ['core_alerts', 'persistent_alerts', 'reports'],
|
||||
'ignore_models': ignore_models or [],
|
||||
}
|
||||
5
e2e/scenario_inputs/alerts_duplicate.json
Normal file
5
e2e/scenario_inputs/alerts_duplicate.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"schedule_name": "alerts-e2e-dup",
|
||||
"notification_ttl": 300,
|
||||
"sent_ttl": 600
|
||||
}
|
||||
5
e2e/scenario_inputs/alerts_empty.json
Normal file
5
e2e/scenario_inputs/alerts_empty.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"schedule_name": "alerts-e2e-empty",
|
||||
"notification_ttl": 300,
|
||||
"sent_ttl": 600
|
||||
}
|
||||
5
e2e/scenario_inputs/alerts_happy_path.json
Normal file
5
e2e/scenario_inputs/alerts_happy_path.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"schedule_name": "alerts-e2e",
|
||||
"notification_ttl": 300,
|
||||
"sent_ttl": 600
|
||||
}
|
||||
5
e2e/scenario_inputs/alerts_persistent.json
Normal file
5
e2e/scenario_inputs/alerts_persistent.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"schedule_name": "alerts-e2e-persistent",
|
||||
"notification_ttl": 1,
|
||||
"sent_ttl": 600
|
||||
}
|
||||
34
e2e/scenario_inputs/orchestrator_conflict.json
Normal file
34
e2e/scenario_inputs/orchestrator_conflict.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"schedule_name": "orchestrator-e2e-conflict",
|
||||
"pipelines_query": {
|
||||
"collection": "pipelines",
|
||||
"aggregation": [{"$match": {"active": true}}]
|
||||
},
|
||||
"opc_servers_query": {
|
||||
"collection": "opc_servers",
|
||||
"filters": {"active": true}
|
||||
}
|
||||
},
|
||||
"pipelines": [
|
||||
{
|
||||
"schedule_name": "conflict-pred",
|
||||
"workflow_type": "predictions_batch",
|
||||
"runtime": "legacy",
|
||||
"model_id": "model-1",
|
||||
"model": {"name": "Model model-1"},
|
||||
"active": true,
|
||||
"updated_at": "@now",
|
||||
"frequency": "1m",
|
||||
"offset": "0m",
|
||||
"query": "SELECT 1",
|
||||
"write_tags": [
|
||||
{"server_id": "srv-1", "type": "prediction", "addr": "ns=2;s=Tag1"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"opc_servers": [
|
||||
{"id": "srv-1", "active": true}
|
||||
],
|
||||
"active_ingestors": ["heartbeat:ingestor:1"]
|
||||
}
|
||||
34
e2e/scenario_inputs/orchestrator_create_only.json
Normal file
34
e2e/scenario_inputs/orchestrator_create_only.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"schedule_name": "orchestrator-e2e-create",
|
||||
"pipelines_query": {
|
||||
"collection": "pipelines",
|
||||
"aggregation": [{"$match": {"active": true}}]
|
||||
},
|
||||
"opc_servers_query": {
|
||||
"collection": "opc_servers",
|
||||
"filters": {"active": true}
|
||||
}
|
||||
},
|
||||
"pipelines": [
|
||||
{
|
||||
"schedule_name": "create-only-pred",
|
||||
"workflow_type": "predictions_batch",
|
||||
"runtime": "legacy",
|
||||
"model_id": "model-1",
|
||||
"model": {"name": "Model model-1"},
|
||||
"active": true,
|
||||
"updated_at": "@now",
|
||||
"frequency": "1m",
|
||||
"offset": "0m",
|
||||
"query": "SELECT 1",
|
||||
"write_tags": [
|
||||
{"server_id": "srv-1", "type": "prediction", "addr": "ns=2;s=Tag1"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"opc_servers": [
|
||||
{"id": "srv-1", "active": true}
|
||||
],
|
||||
"active_ingestors": ["heartbeat:ingestor:1"]
|
||||
}
|
||||
31
e2e/scenario_inputs/orchestrator_delete_only.json
Normal file
31
e2e/scenario_inputs/orchestrator_delete_only.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"schedule_name": "orchestrator-e2e-delete",
|
||||
"pipelines_query": {
|
||||
"collection": "pipelines",
|
||||
"aggregation": [{"$match": {"active": true}}]
|
||||
},
|
||||
"opc_servers_query": {
|
||||
"collection": "opc_servers",
|
||||
"filters": {"active": true}
|
||||
}
|
||||
},
|
||||
"pipelines": [
|
||||
{
|
||||
"schedule_name": "delete-me",
|
||||
"workflow_type": "drift",
|
||||
"runtime": "legacy",
|
||||
"model_id": "model-1",
|
||||
"model": {"name": "Model model-1"},
|
||||
"active": true,
|
||||
"updated_at": "@now-1h",
|
||||
"frequency": "1m",
|
||||
"offset": "0m",
|
||||
"interval_minutes": 60
|
||||
}
|
||||
],
|
||||
"opc_servers": [
|
||||
{"id": "srv-1", "active": true}
|
||||
],
|
||||
"active_ingestors": ["heartbeat:ingestor:1"]
|
||||
}
|
||||
16
e2e/scenario_inputs/orchestrator_empty.json
Normal file
16
e2e/scenario_inputs/orchestrator_empty.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"schedule_name": "orchestrator-e2e-empty",
|
||||
"pipelines_query": {
|
||||
"collection": "pipelines",
|
||||
"aggregation": [{"$match": {"active": true}}]
|
||||
},
|
||||
"opc_servers_query": {
|
||||
"collection": "opc_servers",
|
||||
"filters": {"active": true}
|
||||
}
|
||||
},
|
||||
"pipelines": [],
|
||||
"opc_servers": [],
|
||||
"active_ingestors": []
|
||||
}
|
||||
46
e2e/scenario_inputs/orchestrator_happy_path.json
Normal file
46
e2e/scenario_inputs/orchestrator_happy_path.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"schedule_name": "orchestrator-e2e",
|
||||
"pipelines_query": {
|
||||
"collection": "pipelines",
|
||||
"aggregation": [{"$match": {"active": true}}]
|
||||
},
|
||||
"opc_servers_query": {
|
||||
"collection": "opc_servers",
|
||||
"filters": {"active": true}
|
||||
}
|
||||
},
|
||||
"pipelines": [
|
||||
{
|
||||
"schedule_name": "pred-legacy",
|
||||
"workflow_type": "predictions_batch",
|
||||
"runtime": "legacy",
|
||||
"model_id": "model-1",
|
||||
"model": {"name": "Model model-1"},
|
||||
"active": true,
|
||||
"updated_at": "@now",
|
||||
"frequency": "1m",
|
||||
"offset": "0m",
|
||||
"query": "SELECT 1",
|
||||
"write_tags": [
|
||||
{"server_id": "srv-1", "type": "prediction", "addr": "ns=2;s=Tag1"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"schedule_name": "drift-gpu",
|
||||
"workflow_type": "drift",
|
||||
"runtime": "gpu",
|
||||
"model_id": "model-2",
|
||||
"model": {"name": "Model model-2"},
|
||||
"active": true,
|
||||
"updated_at": "@now",
|
||||
"frequency": "1m",
|
||||
"offset": "0m",
|
||||
"interval_minutes": 60
|
||||
}
|
||||
],
|
||||
"opc_servers": [
|
||||
{"id": "srv-1", "active": true, "name": "opc-1"}
|
||||
],
|
||||
"active_ingestors": ["heartbeat:ingestor:1"]
|
||||
}
|
||||
41
e2e/scenario_inputs/orchestrator_noop.json
Normal file
41
e2e/scenario_inputs/orchestrator_noop.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"schedule_name": "orchestrator-e2e-noop",
|
||||
"pipelines_query": {
|
||||
"collection": "pipelines",
|
||||
"aggregation": [{"$match": {"active": true}}]
|
||||
},
|
||||
"opc_servers_query": {
|
||||
"collection": "opc_servers",
|
||||
"filters": {"active": true}
|
||||
}
|
||||
},
|
||||
"pipelines": [
|
||||
{
|
||||
"schedule_name": "noop-pred",
|
||||
"workflow_type": "predictions_batch",
|
||||
"runtime": "legacy",
|
||||
"model_id": "model-1",
|
||||
"model": {"name": "Model model-1"},
|
||||
"active": true,
|
||||
"updated_at": "@now-1h",
|
||||
"frequency": "1m",
|
||||
"offset": "0m",
|
||||
"query": "SELECT 1",
|
||||
"write_tags": [
|
||||
{"server_id": "srv-1", "type": "prediction", "addr": "ns=2;s=Tag1"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"opc_servers": [
|
||||
{"id": "srv-1", "active": true}
|
||||
],
|
||||
"active_ingestors": ["heartbeat:ingestor:1"],
|
||||
"orchestrated_schedules": [
|
||||
{
|
||||
"schedule_name": "noop-pred",
|
||||
"namespace": "laborious",
|
||||
"updated_at": "@now-1h"
|
||||
}
|
||||
]
|
||||
}
|
||||
45
e2e/scenario_inputs/orchestrator_ttl_index.json
Normal file
45
e2e/scenario_inputs/orchestrator_ttl_index.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"schedule_name": "orchestrator-e2e-ttl",
|
||||
"pipelines_query": {
|
||||
"collection": "pipelines",
|
||||
"aggregation": [{"$match": {"active": true}}]
|
||||
},
|
||||
"opc_servers_query": {
|
||||
"collection": "opc_servers",
|
||||
"filters": {"active": true}
|
||||
}
|
||||
},
|
||||
"pipelines": [
|
||||
{
|
||||
"schedule_name": "scouter-ttl",
|
||||
"workflow_type": "scouter",
|
||||
"runtime": "legacy",
|
||||
"model_id": "model-1",
|
||||
"model": {"name": "Model model-1"},
|
||||
"active": true,
|
||||
"updated_at": "@now",
|
||||
"frequency": "1m",
|
||||
"offset": "0m",
|
||||
"read_tags": [
|
||||
{
|
||||
"server_id": "srv-1",
|
||||
"tag_name": "Read1",
|
||||
"tag_address": "ns=2;s=Read1",
|
||||
"aggr_func": "lts",
|
||||
"frequency": 1000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"opc_servers": [
|
||||
{
|
||||
"id": "srv-1",
|
||||
"active": true,
|
||||
"server_name": "opc-1",
|
||||
"url": "opc.tcp://localhost:4840",
|
||||
"uri": "urn:opcfoundation:UA:DemoServer"
|
||||
}
|
||||
],
|
||||
"active_ingestors": ["heartbeat:ingestor:1"]
|
||||
}
|
||||
41
e2e/scenario_inputs/orchestrator_update_only.json
Normal file
41
e2e/scenario_inputs/orchestrator_update_only.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"workflow_input": {
|
||||
"schedule_name": "orchestrator-e2e-update",
|
||||
"pipelines_query": {
|
||||
"collection": "pipelines",
|
||||
"aggregation": [{"$match": {"active": true}}]
|
||||
},
|
||||
"opc_servers_query": {
|
||||
"collection": "opc_servers",
|
||||
"filters": {"active": true}
|
||||
}
|
||||
},
|
||||
"pipelines": [
|
||||
{
|
||||
"schedule_name": "update-pred",
|
||||
"workflow_type": "predictions_batch",
|
||||
"runtime": "legacy",
|
||||
"model_id": "model-1",
|
||||
"model": {"name": "Model model-1"},
|
||||
"active": true,
|
||||
"updated_at": "@now",
|
||||
"frequency": "5m",
|
||||
"offset": "0m",
|
||||
"query": "SELECT 1",
|
||||
"write_tags": [
|
||||
{"server_id": "srv-1", "type": "prediction", "addr": "ns=2;s=Tag1"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"opc_servers": [
|
||||
{"id": "srv-1", "active": true}
|
||||
],
|
||||
"active_ingestors": ["heartbeat:ingestor:1"],
|
||||
"orchestrated_schedules": [
|
||||
{
|
||||
"schedule_name": "update-pred",
|
||||
"namespace": "laborious",
|
||||
"updated_at": "@now-1h"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
e2e/scenario_inputs/reports_empty.json
Normal file
3
e2e/scenario_inputs/reports_empty.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"schedule_name": "reports-e2e-empty"
|
||||
}
|
||||
3
e2e/scenario_inputs/reports_happy_path.json
Normal file
3
e2e/scenario_inputs/reports_happy_path.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"schedule_name": "reports-e2e"
|
||||
}
|
||||
3
e2e/scenario_inputs/reports_multi_level.json
Normal file
3
e2e/scenario_inputs/reports_multi_level.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"schedule_name": "reports-e2e-levels"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"schedule_name": "load-pkg-e2e",
|
||||
"mail_type": "Alerts",
|
||||
"base_data_filter": {"level": "ERROR"}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schedule_name": "process-notif-e2e",
|
||||
"mail_type": "Alerts",
|
||||
"schema": "sientia_data",
|
||||
"table_name": "log_report"
|
||||
}
|
||||
111
e2e/scenarios.md
Normal file
111
e2e/scenarios.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# E2E Scenario Documentation — Orchestrator
|
||||
|
||||
Functional reference for orchestrator E2E scenarios. Tests live under `e2e/`, use `@pytest.mark.e2e`, and run with:
|
||||
|
||||
```bash
|
||||
pytest e2e/ --override-ini testpaths=e2e -m e2e
|
||||
```
|
||||
|
||||
## Execution context
|
||||
|
||||
- MongoDB, Redis, PostgreSQL: testcontainers (session-scoped).
|
||||
- SMTP: in-process `aiosmtpd` (`e2e/smtp_test_server.py`).
|
||||
- Temporal: `WorkflowEnvironment.start_local()` with stub workers on `scouter` / `laborious`.
|
||||
- Production code under `orchestrator/**` is not mocked; only `Logger` may be a `MagicMock`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Orchestrator workflow
|
||||
|
||||
Source: `e2e/test_orchestrator_main_workflow.py`
|
||||
|
||||
### 1.1.1 Happy path
|
||||
|
||||
Pipelines in Mongo → schedules created in correct namespace/task queue, Redis slots written, `orchestrated_schedules` updated.
|
||||
|
||||
### 1.2.1 No-op tick
|
||||
|
||||
Mongo, Redis, and Temporal already match desired state → no new schedules or slot writes.
|
||||
|
||||
### 1.3.1 Create-only
|
||||
|
||||
New pipeline only → schedules created, timestamps inserted.
|
||||
|
||||
### 1.3.2 Update-only
|
||||
|
||||
Existing pipeline with newer `updated_at` → schedule updated in Temporal.
|
||||
|
||||
### 1.3.3 Delete-only
|
||||
|
||||
Pipeline removed from Mongo → schedule deleted from Temporal.
|
||||
|
||||
### 1.4.1 Conflict ordering
|
||||
|
||||
Pipeline update and slot delete on same OPC server → slot insert before delete (production ordering).
|
||||
|
||||
### 1.5.1 Empty pipelines
|
||||
|
||||
No active pipelines → orphan schedules removed, no new orchestration writes.
|
||||
|
||||
### 1.6.1 TTL index bootstrap
|
||||
|
||||
First run creates TTL index on notification collection used by scouter pipelines.
|
||||
|
||||
---
|
||||
|
||||
## 2. Alerts workflow
|
||||
|
||||
Source: `e2e/test_alerts_main_workflow.py`
|
||||
|
||||
### A.1.1 Happy path
|
||||
|
||||
ERROR notification → one SMTP message, one `log_report` row, Redis cache key.
|
||||
|
||||
### A.1.2 TTL duplicate suppression
|
||||
|
||||
Second run with same data and cache seeded → no extra email or log row.
|
||||
|
||||
### A.1.3 Persistent escalation
|
||||
|
||||
Alert past `notification_ttl` with cache cleared → new email sent.
|
||||
|
||||
### A.2.1 Group filtering
|
||||
|
||||
Receiver group `levels` / `ignore_models` honored.
|
||||
|
||||
### A.3.1 Empty queue
|
||||
|
||||
No notifications → no SMTP, no Postgres row.
|
||||
|
||||
---
|
||||
|
||||
## 3. Reports workflow
|
||||
|
||||
Source: `e2e/test_reports_main_workflow.py`
|
||||
|
||||
### R.1.1 Happy path
|
||||
|
||||
Mixed ERROR/WARNING/INFO → one HTML email with all section headings.
|
||||
|
||||
### R.1.2 Per-level rendering
|
||||
|
||||
Single-level notifications → only matching section in HTML body.
|
||||
|
||||
### R.2.1 Empty queue
|
||||
|
||||
No notifications → no SMTP, no Postgres row.
|
||||
|
||||
---
|
||||
|
||||
## 4. Subworkflows
|
||||
|
||||
### LoadNotificationPackage — `e2e/test_subworkflow_load_notification_package.py`
|
||||
|
||||
- No prior Redis timestamp → all matching notifications returned, max timestamp stored.
|
||||
- Prior timestamp → only newer notifications returned.
|
||||
- Empty Mongo → no Redis timestamp write.
|
||||
|
||||
### ProcessNotifications — `e2e/test_subworkflow_process_notifications.py`
|
||||
|
||||
- Full round trip: HTML → SMTP → `log_report` in Postgres.
|
||||
- Empty receiver groups → `{}`.
|
||||
110
e2e/smtp_test_server.py
Normal file
110
e2e/smtp_test_server.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""In-process SMTP server for E2E email assertions."""
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from email import message_from_bytes, policy
|
||||
from email.message import EmailMessage, Message
|
||||
|
||||
from aiosmtpd.controller import Controller
|
||||
|
||||
|
||||
class _CaptureHandler:
|
||||
"""
|
||||
aiosmtpd handler that parses every incoming message into an ``EmailMessage``.
|
||||
|
||||
The default policy yields a ``Message`` instance, which loses structure
|
||||
when wrapped into a new ``EmailMessage``. Parsing with ``policy.default``
|
||||
keeps multipart payloads intact so tests can introspect the HTML body
|
||||
via ``walk()``/``get_payload(decode=True)``.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.messages: list[EmailMessage | Message] = []
|
||||
|
||||
async def handle_DATA(self, server, session, envelope):
|
||||
message = message_from_bytes(envelope.content, policy=policy.default)
|
||||
self.messages.append(message)
|
||||
return '250 OK'
|
||||
|
||||
|
||||
def _reserve_port(host: str = '127.0.0.1') -> int:
|
||||
"""Reserve a free TCP port on the given host."""
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
probe.bind((host, 0))
|
||||
port = probe.getsockname()[1]
|
||||
probe.close()
|
||||
return port
|
||||
|
||||
|
||||
class SmtpTestServer:
|
||||
"""
|
||||
Wraps aiosmtpd Controller with a dedicated asyncio loop thread for pytest compatibility.
|
||||
|
||||
Attributes:
|
||||
host: Bind host (127.0.0.1).
|
||||
port: Listening port after start().
|
||||
messages: Captured outbound messages.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.host = '127.0.0.1'
|
||||
self.port: int | None = None
|
||||
self._handler = _CaptureHandler()
|
||||
self.messages: list[EmailMessage | Message] = self._handler.messages
|
||||
self._controller: Controller | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the SMTP controller on a reserved port in a background event loop."""
|
||||
self.port = _reserve_port(self.host)
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self._controller = Controller(
|
||||
self._handler,
|
||||
hostname=self.host,
|
||||
port=self.port,
|
||||
loop=self._loop,
|
||||
ready_timeout=30,
|
||||
)
|
||||
|
||||
def _run():
|
||||
asyncio.set_event_loop(self._loop)
|
||||
if self._controller is None:
|
||||
raise RuntimeError('SMTP controller not initialized')
|
||||
self._controller.start()
|
||||
|
||||
self._thread = threading.Thread(target=_run, name='e2e-smtp', daemon=True)
|
||||
self._thread.start()
|
||||
self._wait_until_ready()
|
||||
|
||||
def _wait_until_ready(self, timeout: float = 10.0) -> None:
|
||||
"""Poll until the SMTP listener accepts TCP connections."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if self.port is None:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
if probe.connect_ex((self.host, self.port)) == 0:
|
||||
return
|
||||
finally:
|
||||
probe.close()
|
||||
time.sleep(0.05)
|
||||
raise TimeoutError('SMTP test server did not become ready')
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the controller and background event loop."""
|
||||
if self._controller is not None:
|
||||
self._controller.stop()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=5)
|
||||
self._controller = None
|
||||
self._loop = None
|
||||
self._thread = None
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all captured messages."""
|
||||
self.messages.clear()
|
||||
73
e2e/stub_workflows.py
Normal file
73
e2e/stub_workflows.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""No-op Temporal workflows for managed scouter/laborious namespaces in E2E."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from temporalio import workflow
|
||||
|
||||
|
||||
@workflow.defn(name='scouter')
|
||||
class ScouterStub:
|
||||
@workflow.run
|
||||
async def run(self, _input_data: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@workflow.defn(name='pi_web_api_scouter')
|
||||
class PiWebApiScouterStub:
|
||||
@workflow.run
|
||||
async def run(self, _input_data: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@workflow.defn(name='predictions_batch')
|
||||
class PredictionsBatchStub:
|
||||
@workflow.run
|
||||
async def run(self, _input_data: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@workflow.defn(name='xgboost_predictions_batch')
|
||||
class XgboostPredictionsBatchStub:
|
||||
@workflow.run
|
||||
async def run(self, _input_data: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@workflow.defn(name='drift')
|
||||
class DriftStub:
|
||||
@workflow.run
|
||||
async def run(self, _input_data: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@workflow.defn(name='simple_metrics')
|
||||
class SimpleMetricsStub:
|
||||
@workflow.run
|
||||
async def run(self, _input_data: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@workflow.defn(name='minimal_retrain')
|
||||
class MinimalRetrainStub:
|
||||
@workflow.run
|
||||
async def run(self, _input_data: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@workflow.defn(name='xgboost_minimal_retrain')
|
||||
class XgboostMinimalRetrainStub:
|
||||
@workflow.run
|
||||
async def run(self, _input_data: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
|
||||
STUB_WORKFLOW_CLASSES = [
|
||||
ScouterStub,
|
||||
PiWebApiScouterStub,
|
||||
PredictionsBatchStub,
|
||||
XgboostPredictionsBatchStub,
|
||||
DriftStub,
|
||||
SimpleMetricsStub,
|
||||
MinimalRetrainStub,
|
||||
XgboostMinimalRetrainStub,
|
||||
]
|
||||
116
e2e/test_alerts_main_workflow.py
Normal file
116
e2e/test_alerts_main_workflow.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""E2E tests for the Alerts main workflow."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from redis import Redis
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
|
||||
from e2e.conftest import E2E_DATABASE
|
||||
from e2e.helpers import (
|
||||
DATETIME_FORMAT_MS_WITH_TZ,
|
||||
count_log_report_rows,
|
||||
default_notification,
|
||||
default_receiver_group,
|
||||
fetch_log_report,
|
||||
load_scenario_input,
|
||||
make_workflow_id,
|
||||
seed_notification_cache,
|
||||
seed_notifications,
|
||||
seed_receiver_groups,
|
||||
start_and_await_workflow,
|
||||
)
|
||||
from orchestrator.workflows.alerts import Alerts
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_a_1_1_happy_path(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
smtp_server,
|
||||
postgres_engine,
|
||||
redis_client: Redis,
|
||||
):
|
||||
"""A.1.1: ERROR alert sends email, writes log_report, caches notification."""
|
||||
seed_receiver_groups(mongo_uri, E2E_DATABASE, [default_receiver_group()])
|
||||
seed_notifications(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
[default_notification(notification_id='alert-1', level='ERROR')],
|
||||
)
|
||||
|
||||
input_data = load_scenario_input('alerts_happy_path.json')
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Alerts.run,
|
||||
input_data,
|
||||
make_workflow_id('alerts-happy'),
|
||||
)
|
||||
|
||||
assert len(smtp_server.messages) == 1
|
||||
assert count_log_report_rows(postgres_engine, 'Alerts') == 1
|
||||
rows = fetch_log_report(postgres_engine, 'Alerts')
|
||||
assert rows[0]['mail_type'] == 'Alerts'
|
||||
assert redis_client.get('test-schedule:alert-1') is not None or redis_client.keys('*alert-1*')
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_a_1_2_duplicate_suppressed(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
smtp_server,
|
||||
postgres_engine,
|
||||
redis_client: Redis,
|
||||
):
|
||||
"""A.1.2: Cached notification is not emailed twice within sent_ttl."""
|
||||
seed_receiver_groups(mongo_uri, E2E_DATABASE, [default_receiver_group()])
|
||||
notif = default_notification(notification_id='dup-1', level='ERROR')
|
||||
seed_notifications(mongo_uri, E2E_DATABASE, [notif])
|
||||
seed_notification_cache(
|
||||
redis_client,
|
||||
notif['trigger'],
|
||||
notif['notification_id'],
|
||||
sent_at=datetime.now(UTC).strftime(DATETIME_FORMAT_MS_WITH_TZ),
|
||||
ttl=600,
|
||||
)
|
||||
|
||||
input_data = load_scenario_input('alerts_duplicate.json')
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Alerts.run,
|
||||
input_data,
|
||||
make_workflow_id('alerts-dup'),
|
||||
)
|
||||
|
||||
assert len(smtp_server.messages) == 0
|
||||
assert count_log_report_rows(postgres_engine, 'Alerts') == 0
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_a_3_1_empty_queue(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
smtp_server,
|
||||
postgres_engine,
|
||||
):
|
||||
"""A.3.1: Empty notification queue short-circuits."""
|
||||
seed_receiver_groups(mongo_uri, E2E_DATABASE, [default_receiver_group()])
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Alerts.run,
|
||||
load_scenario_input('alerts_empty.json'),
|
||||
make_workflow_id('alerts-empty'),
|
||||
)
|
||||
|
||||
assert len(smtp_server.messages) == 0
|
||||
assert count_log_report_rows(postgres_engine, 'Alerts') == 0
|
||||
226
e2e/test_orchestrator_main_workflow.py
Normal file
226
e2e/test_orchestrator_main_workflow.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""E2E tests for the Orchestrator main workflow."""
|
||||
|
||||
import pytest
|
||||
from pymongo import MongoClient
|
||||
from redis import Redis
|
||||
from sientia_do.temporal.worker.prepare_worker import build_queue_name
|
||||
from temporalio.client import Client, Schedule, ScheduleActionStartWorkflow, ScheduleSpec
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
|
||||
from e2e.conftest import E2E_DATABASE
|
||||
from e2e.helpers import (
|
||||
load_scenario_input,
|
||||
make_workflow_id,
|
||||
seed_active_ingestors,
|
||||
seed_opc_servers,
|
||||
seed_orchestrated_schedules,
|
||||
seed_pipelines,
|
||||
start_and_await_workflow,
|
||||
)
|
||||
from orchestrator.workflows.orchestrator import Orchestrator
|
||||
|
||||
|
||||
async def _schedule_ids(host: str, namespace: str) -> list[str]:
|
||||
"""Return the list of Temporal schedule ids in a given namespace."""
|
||||
client = await Client.connect(host, namespace=namespace)
|
||||
return [schedule.id async for schedule in await client.list_schedules()]
|
||||
|
||||
|
||||
def _apply_orchestrator_seeds(
|
||||
scenario: dict,
|
||||
mongo_uri: str,
|
||||
redis_client: Redis,
|
||||
) -> None:
|
||||
"""
|
||||
Seed Mongo and Redis with the pipelines/opc_servers/ingestors declared in a scenario.
|
||||
|
||||
Args:
|
||||
scenario: Scenario payload returned by ``load_scenario_input``.
|
||||
mongo_uri: Mongo connection string for the test database.
|
||||
redis_client: Redis client connected to the test instance.
|
||||
"""
|
||||
seed_pipelines(mongo_uri, E2E_DATABASE, scenario.get('pipelines', []))
|
||||
seed_opc_servers(mongo_uri, E2E_DATABASE, scenario.get('opc_servers', []))
|
||||
seed_active_ingestors(redis_client, scenario.get('active_ingestors', []))
|
||||
if scenario.get('orchestrated_schedules'):
|
||||
seed_orchestrated_schedules(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
scenario['orchestrated_schedules'],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_1_1_1_happy_path(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
redis_client: Redis,
|
||||
temporal_host: str,
|
||||
):
|
||||
"""1.1.1: Creates schedules, slots, and orchestrated_schedules entries."""
|
||||
scenario = load_scenario_input('orchestrator_happy_path')
|
||||
_apply_orchestrator_seeds(scenario, mongo_uri, redis_client)
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Orchestrator.run,
|
||||
scenario['workflow_input'],
|
||||
make_workflow_id('orchestrator-happy'),
|
||||
)
|
||||
|
||||
mongo = MongoClient(mongo_uri)
|
||||
try:
|
||||
tracked = list(mongo[E2E_DATABASE]['orchestrated_schedules'].find())
|
||||
names = {doc['schedule_name'] for doc in tracked}
|
||||
assert names, f'Expected orchestrated_schedules rows, got {tracked}'
|
||||
finally:
|
||||
mongo.close()
|
||||
|
||||
laborious_schedules = await _schedule_ids(temporal_host, 'laborious')
|
||||
assert 'pred-legacy' in laborious_schedules or 'drift-gpu' in laborious_schedules, (
|
||||
f'Expected Temporal schedules in laborious, got {laborious_schedules}'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_1_3_1_create_only(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
temporal_host: str,
|
||||
redis_client: Redis,
|
||||
):
|
||||
"""1.3.1: New pipeline creates a Temporal schedule."""
|
||||
scenario = load_scenario_input('orchestrator_create_only')
|
||||
_apply_orchestrator_seeds(scenario, mongo_uri, redis_client)
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Orchestrator.run,
|
||||
scenario['workflow_input'],
|
||||
make_workflow_id('orchestrator-create'),
|
||||
)
|
||||
|
||||
assert 'create-only-pred' in await _schedule_ids(temporal_host, 'laborious')
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_1_3_3_delete_only(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
temporal_host: str,
|
||||
redis_client: Redis,
|
||||
):
|
||||
"""1.3.3: Removing pipeline deletes Temporal schedule."""
|
||||
scenario = load_scenario_input('orchestrator_delete_only')
|
||||
_apply_orchestrator_seeds(scenario, mongo_uri, redis_client)
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Orchestrator.run,
|
||||
scenario['workflow_input'],
|
||||
make_workflow_id('orchestrator-seed-delete'),
|
||||
)
|
||||
|
||||
client = await Client.connect(temporal_host, namespace='laborious')
|
||||
try:
|
||||
handle = client.get_schedule_handle('delete-me')
|
||||
await handle.describe()
|
||||
schedule_exists = True
|
||||
except Exception:
|
||||
schedule_exists = False
|
||||
|
||||
assert schedule_exists
|
||||
|
||||
mongo = MongoClient(mongo_uri)
|
||||
try:
|
||||
mongo[E2E_DATABASE]['pipelines'].delete_many({})
|
||||
finally:
|
||||
mongo.close()
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Orchestrator.run,
|
||||
scenario['workflow_input'],
|
||||
make_workflow_id('orchestrator-delete'),
|
||||
)
|
||||
|
||||
assert 'delete-me' not in await _schedule_ids(temporal_host, 'laborious')
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_1_5_1_empty_pipelines(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
temporal_host: str,
|
||||
redis_client: Redis,
|
||||
):
|
||||
"""1.5.1: No pipelines → no orchestrated_schedules documents."""
|
||||
scenario = load_scenario_input('orchestrator_empty')
|
||||
_apply_orchestrator_seeds(scenario, mongo_uri, redis_client)
|
||||
|
||||
client = await Client.connect(temporal_host, namespace='laborious')
|
||||
await client.create_schedule(
|
||||
'orphan-schedule',
|
||||
Schedule(
|
||||
action=ScheduleActionStartWorkflow(
|
||||
'drift',
|
||||
{},
|
||||
id='orphan-schedule-run',
|
||||
task_queue=build_queue_name('drift', 'legacy'),
|
||||
),
|
||||
spec=ScheduleSpec(),
|
||||
),
|
||||
)
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Orchestrator.run,
|
||||
scenario['workflow_input'],
|
||||
make_workflow_id('orchestrator-empty'),
|
||||
)
|
||||
|
||||
mongo = MongoClient(mongo_uri)
|
||||
try:
|
||||
assert mongo[E2E_DATABASE]['orchestrated_schedules'].count_documents({}) == 0
|
||||
finally:
|
||||
mongo.close()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_1_6_1_ttl_index_bootstrap(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
redis_client: Redis,
|
||||
):
|
||||
"""1.6.1: Scouter pipeline triggers TTL index on notification_queue."""
|
||||
scenario = load_scenario_input('orchestrator_ttl_index')
|
||||
_apply_orchestrator_seeds(scenario, mongo_uri, redis_client)
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Orchestrator.run,
|
||||
scenario['workflow_input'],
|
||||
make_workflow_id('orchestrator-ttl'),
|
||||
)
|
||||
|
||||
mongo = MongoClient(mongo_uri)
|
||||
try:
|
||||
indexes = mongo[E2E_DATABASE]['raw_scouter-ttl'].index_information()
|
||||
assert any('expireAfterSeconds' in info for info in indexes.values())
|
||||
finally:
|
||||
mongo.close()
|
||||
167
e2e/test_reports_main_workflow.py
Normal file
167
e2e/test_reports_main_workflow.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""E2E tests for the Reports main workflow."""
|
||||
|
||||
from email.message import EmailMessage, Message
|
||||
|
||||
import pytest
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
|
||||
from e2e.conftest import E2E_DATABASE
|
||||
from e2e.helpers import (
|
||||
count_log_report_rows,
|
||||
default_notification,
|
||||
default_receiver_group,
|
||||
load_scenario_input,
|
||||
make_workflow_id,
|
||||
seed_notifications,
|
||||
seed_receiver_groups,
|
||||
start_and_await_workflow,
|
||||
)
|
||||
from orchestrator.workflows.reports import Reports
|
||||
|
||||
|
||||
def _extract_html_body(message: EmailMessage | Message) -> str:
|
||||
"""
|
||||
Return the text/html portion of an email message as a decoded string.
|
||||
|
||||
Walks every part looking for the first text/html payload, decoding it
|
||||
according to the part's transfer encoding and charset. Falls back to
|
||||
the message's own ``get_content``/raw payload when no HTML part is
|
||||
present so callers can still inspect plain-text reports.
|
||||
|
||||
Args:
|
||||
message: Captured email message returned by the test SMTP server.
|
||||
|
||||
Return:
|
||||
str: HTML body content, or an empty string when nothing decodable
|
||||
is found.
|
||||
"""
|
||||
if message.is_multipart():
|
||||
for part in message.walk():
|
||||
if part.get_content_type() != 'text/html':
|
||||
continue
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload is None:
|
||||
continue
|
||||
charset = part.get_content_charset() or 'utf-8'
|
||||
return payload.decode(charset, errors='replace')
|
||||
payload = message.get_payload(decode=True)
|
||||
if payload is not None:
|
||||
charset = message.get_content_charset() or 'utf-8'
|
||||
return payload.decode(charset, errors='replace')
|
||||
try:
|
||||
return message.get_content()
|
||||
except (AttributeError, KeyError):
|
||||
return str(message.get_payload() or '')
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_r_1_1_happy_path(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
smtp_server,
|
||||
postgres_engine,
|
||||
):
|
||||
"""R.1.1: Mixed-level notifications produce one email with all sections."""
|
||||
seed_receiver_groups(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
[default_receiver_group(contents=['reports'], levels=['ERROR', 'WARNING', 'INFO'])],
|
||||
)
|
||||
seed_notifications(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
[
|
||||
default_notification(notification_id='r-err', level='ERROR', model_name='m1'),
|
||||
default_notification(
|
||||
notification_id='r-warn',
|
||||
level='WARNING',
|
||||
model_name='m2',
|
||||
timestamp='2024-06-01 12:01:00+0000',
|
||||
),
|
||||
default_notification(
|
||||
notification_id='r-info',
|
||||
level='INFO',
|
||||
model_name='m3',
|
||||
timestamp='2024-06-01 12:02:00+0000',
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Reports.run,
|
||||
load_scenario_input('reports_happy_path.json'),
|
||||
make_workflow_id('reports-happy'),
|
||||
)
|
||||
|
||||
assert len(smtp_server.messages) == 1
|
||||
body = _extract_html_body(smtp_server.messages[0])
|
||||
assert 'Errors detected:' in body
|
||||
assert 'Warnings detected:' in body
|
||||
assert 'Infos detected:' in body
|
||||
# format_log_report writes one row per (notification_id, trigger) pair, so the
|
||||
# three seeded notifications produce three rows even though a single email
|
||||
# was sent to the receiver group.
|
||||
assert count_log_report_rows(postgres_engine, 'Reports') == 3
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_r_1_2_error_section_only(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
smtp_server,
|
||||
):
|
||||
"""R.1.2: Only ERROR notifications → only Errors section in HTML."""
|
||||
seed_receiver_groups(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
[default_receiver_group(contents=['reports'], levels=['ERROR'])],
|
||||
)
|
||||
seed_notifications(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
[default_notification(notification_id='only-err', level='ERROR')],
|
||||
)
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Reports.run,
|
||||
load_scenario_input('reports_multi_level.json'),
|
||||
make_workflow_id('reports-error-only'),
|
||||
)
|
||||
|
||||
assert smtp_server.messages, 'Expected at least one report email'
|
||||
body = _extract_html_body(smtp_server.messages[0])
|
||||
assert 'Errors detected:' in body
|
||||
assert 'Warnings detected:' not in body
|
||||
assert 'Infos detected:' not in body
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_r_2_1_empty_queue(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
smtp_server,
|
||||
postgres_engine,
|
||||
):
|
||||
"""R.2.1: Empty queue → no email and no log_report row."""
|
||||
seed_receiver_groups(mongo_uri, E2E_DATABASE, [default_receiver_group()])
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
Reports.run,
|
||||
load_scenario_input('reports_empty.json'),
|
||||
make_workflow_id('reports-empty'),
|
||||
)
|
||||
|
||||
assert len(smtp_server.messages) == 0
|
||||
assert count_log_report_rows(postgres_engine, 'Reports') == 0
|
||||
154
e2e/test_subworkflow_load_notification_package.py
Normal file
154
e2e/test_subworkflow_load_notification_package.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""E2E tests for LoadNotificationPackage subworkflow."""
|
||||
|
||||
import pytest
|
||||
from redis import Redis
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
|
||||
from e2e.conftest import E2E_DATABASE
|
||||
from e2e.helpers import (
|
||||
default_notification,
|
||||
default_receiver_group,
|
||||
load_scenario_input,
|
||||
make_workflow_id,
|
||||
seed_last_timestamp,
|
||||
seed_notifications,
|
||||
seed_receiver_groups,
|
||||
start_and_await_workflow,
|
||||
)
|
||||
from orchestrator.workflows.subworkflows.load_notification_package import LoadNotificationPackage
|
||||
|
||||
|
||||
def _build_metadata(input_data: dict) -> None:
|
||||
"""Attach the metadata block that the subworkflow expects."""
|
||||
input_data['metadata'] = {
|
||||
'metadata': {
|
||||
'schedule_name': input_data['schedule_name'],
|
||||
'workflow_name': 'load_notification_package',
|
||||
'model_name': '-',
|
||||
'model_id': '-',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_package_without_prior_timestamp(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
redis_client: Redis,
|
||||
):
|
||||
"""
|
||||
No Redis timestamp → returns notifications and stores max timestamp.
|
||||
|
||||
Notifications are seeded with the exact production format produced by
|
||||
``sientia_do.notifications.models.Notification`` (string in
|
||||
``DATETIME_FORMAT_WITH_TZ``, e.g. ``"2024-06-01 11:00:00+0000"``). The
|
||||
cached "last timestamp" must mirror that representation.
|
||||
"""
|
||||
seed_receiver_groups(mongo_uri, E2E_DATABASE, [default_receiver_group()])
|
||||
seed_notifications(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
[
|
||||
default_notification(
|
||||
notification_id='n1',
|
||||
timestamp='2024-06-01 10:00:00+0000',
|
||||
),
|
||||
default_notification(
|
||||
notification_id='n2',
|
||||
timestamp='2024-06-01 11:00:00+0000',
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
input_data = load_scenario_input('subworkflow_load_notification_package.json')
|
||||
_build_metadata(input_data)
|
||||
|
||||
result = await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
LoadNotificationPackage.run,
|
||||
input_data,
|
||||
make_workflow_id('load-pkg-none'),
|
||||
)
|
||||
|
||||
assert len(result['notification_package']) == 2
|
||||
stored = redis_client.get('notification_last_timestamp:Alerts') or ''
|
||||
assert stored.strip('"') == '2024-06-01 11:00:00+0000'
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_package_with_prior_timestamp(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
redis_client: Redis,
|
||||
):
|
||||
"""
|
||||
Existing timestamp → only newer notifications returned.
|
||||
|
||||
Both the seeded "last timestamp" (Redis) and the notification timestamps
|
||||
(Mongo) follow the production format used by
|
||||
``sientia_do.notifications.models.Notification`` (string in
|
||||
``DATETIME_FORMAT_WITH_TZ``). ``load_latest_data`` will translate the
|
||||
Redis value into a Python ``datetime`` and apply ``{$gt: <Date>}``
|
||||
against the Mongo string timestamps; this exercise surfaces the real
|
||||
BSON comparison semantics rather than a synthetic ideal.
|
||||
"""
|
||||
seed_receiver_groups(mongo_uri, E2E_DATABASE, [default_receiver_group()])
|
||||
seed_last_timestamp(redis_client, 'Alerts', '2024-06-01 10:30:00+0000')
|
||||
seed_notifications(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
[
|
||||
default_notification(
|
||||
notification_id='old',
|
||||
timestamp='2024-06-01 10:00:00+0000',
|
||||
),
|
||||
default_notification(
|
||||
notification_id='new',
|
||||
timestamp='2024-06-01 11:00:00+0000',
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
input_data = load_scenario_input('subworkflow_load_notification_package.json')
|
||||
_build_metadata(input_data)
|
||||
|
||||
result = await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
LoadNotificationPackage.run,
|
||||
input_data,
|
||||
make_workflow_id('load-pkg-ts'),
|
||||
)
|
||||
|
||||
ids = {n['notification_id'] for n in result['notification_package']}
|
||||
assert ids == {'new'}
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_package_empty_mongo_no_redis_write(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
redis_client: Redis,
|
||||
):
|
||||
"""Empty Mongo → no Redis timestamp write."""
|
||||
seed_receiver_groups(mongo_uri, E2E_DATABASE, [default_receiver_group()])
|
||||
|
||||
input_data = load_scenario_input('subworkflow_load_notification_package.json')
|
||||
_build_metadata(input_data)
|
||||
|
||||
await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
LoadNotificationPackage.run,
|
||||
input_data,
|
||||
make_workflow_id('load-pkg-empty'),
|
||||
)
|
||||
|
||||
assert redis_client.get('notification_last_timestamp:Alerts') is None
|
||||
94
e2e/test_subworkflow_process_notifications.py
Normal file
94
e2e/test_subworkflow_process_notifications.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""E2E tests for ProcessNotifications subworkflow."""
|
||||
|
||||
import pytest
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
|
||||
from e2e.helpers import (
|
||||
count_log_report_rows,
|
||||
default_notification,
|
||||
fetch_log_report,
|
||||
load_scenario_input,
|
||||
make_workflow_id,
|
||||
start_and_await_workflow,
|
||||
)
|
||||
from orchestrator.workflows.subworkflows.process_notifications import ProcessNotifications
|
||||
|
||||
|
||||
def _receiver_package():
|
||||
notif = default_notification(notification_id='proc-1', level='ERROR')
|
||||
return {
|
||||
'admins': {
|
||||
'group_name': 'admins',
|
||||
'members': ['admin@example.com'],
|
||||
'status': 'pending',
|
||||
'notifications': [notif],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_notifications_round_trip(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
smtp_server,
|
||||
postgres_engine,
|
||||
):
|
||||
"""Email → SMTP → log_report persisted in Postgres."""
|
||||
input_data = load_scenario_input('subworkflow_process_notifications.json')
|
||||
input_data['metadata'] = {
|
||||
'metadata': {
|
||||
'schedule_name': input_data['schedule_name'],
|
||||
'workflow_name': 'process_notifications',
|
||||
'model_name': '-',
|
||||
'model_id': '-',
|
||||
}
|
||||
}
|
||||
input_data['notification_package'] = _receiver_package()
|
||||
|
||||
result = await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
ProcessNotifications.run,
|
||||
input_data,
|
||||
make_workflow_id('process-notif'),
|
||||
)
|
||||
|
||||
assert result
|
||||
assert len(smtp_server.messages) == 1
|
||||
assert count_log_report_rows(postgres_engine, 'Alerts') == 1
|
||||
rows = fetch_log_report(postgres_engine, 'Alerts')
|
||||
assert rows[0]['notification_id'] == 'proc-1'
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_notifications_empty_package(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
smtp_server,
|
||||
postgres_engine,
|
||||
):
|
||||
"""Empty receiver groups returns empty dict."""
|
||||
input_data = load_scenario_input('subworkflow_process_notifications.json')
|
||||
input_data['metadata'] = {
|
||||
'metadata': {
|
||||
'schedule_name': input_data['schedule_name'],
|
||||
'workflow_name': 'process_notifications',
|
||||
'model_name': '-',
|
||||
'model_id': '-',
|
||||
}
|
||||
}
|
||||
input_data['notification_package'] = {}
|
||||
|
||||
result = await start_and_await_workflow(
|
||||
temporal_env.client,
|
||||
ProcessNotifications.run,
|
||||
input_data,
|
||||
make_workflow_id('process-empty'),
|
||||
)
|
||||
|
||||
assert result == {}
|
||||
assert len(smtp_server.messages) == 0
|
||||
assert count_log_report_rows(postgres_engine) == 0
|
||||
Reference in New Issue
Block a user