Files
sientia-dataops-orchestrato…/e2e/helpers.py
Eduardo Rios 9df151d29d SIENTIAPDE-2012: per-group levels/ignore_models_id filtering
Extends filter_notification_alerts (slot_manager.py) and
filter_notification_reports (formatters.py) to check
receiver_group.get('levels') and receiver_group.get('ignore_models_id'),
mirroring the existing contents/ignore pattern exactly. Closes the gap
SIENTIAPDE-2011 deliberately left open: the query-time union/intersection
filter widens what gets fetched for the union of all active groups, but until
now nothing narrowed each group back down to its own configured slice.

Both checks short-circuit via `not levels_list or ...` / `not
ignore_models_id_list or ...` rather than accessing notification['level']/
['model_id'] directly, so groups that don't configure either field (today's
common case, and every existing unit test fixture) never evaluate the
right-hand side and can't raise KeyError on notifications missing those keys.
ignore_models_id reads via `.get(...) or []`, defensively handling an
explicit `null` in a receiver_groups document from the start (same fix
SIENTIAPDE-2011 needed a follow-up commit for).

Tests:
- 8 new unit tests (4 per file): levels restriction, ignore_models_id
  restriction, neither-configured passthrough, explicit-None safety.
- New e2e test (test_scenario_a_2_1_per_group_levels_and_ignore_models_id)
  against real Mongo/Redis/Temporal: two active groups configured
  differently: SIENTIAPDE-2011's query-time filter widens the fetch to the
  union of both, and this subtask's per-group filter narrows each group back
  to its own levels/ignore_models_id, including the case where a model is
  ignored by only one of the active groups.
- Deduplicated the two e2e suites' identical _extract_html_body helper into
  e2e/helpers.py (extract_html_body), now shared by both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 13:46:03 -03:00

397 lines
13 KiB
Python

"""
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 email.message import EmailMessage, Message
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'
_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 ``put_last_data_timestamp``
(``orchestrator/activities/slot_manager.py``) writes into Redis today:
``DATETIME_FORMAT_MS_WITH_TZ`` (e.g. ``"2026-05-22 16:47:02.000000+0000"``)
— microseconds included, no colon in the timezone offset. ``load_latest_data``
parses this same format back into a ``datetime`` for the ``$gt`` comparison,
so the two sides must agree exactly.
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_MS_WITH_TZ``
(e.g. ``"2024-06-01 10:30:00.000000+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: datetime | 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 native, timezone-aware ``datetime``
(BSON Date) because that is exactly what
``sientia_do.notifications.models.Notification`` writes into
``notification_queue`` in production since SIENTIAPDE-2007
(``self.timestamp = now()`` — a real ``datetime``, no more string
formatting). Tests intentionally use this same representation so we
surface, rather than hide, real production behavior in downstream
activities (e.g. ``put_last_data_timestamp``'s ``DataFrame(...)['timestamp']
.max().strftime(...)``, which requires a real ``datetime``, not a string).
Args:
notification_id: Unique identifier for the notification.
level: Notification level (e.g. ``"ERROR"``, ``"WARNING"``).
timestamp: Optional timezone-aware datetime. ``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)
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_id: 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_id': ignore_models_id or [],
}
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. Shared between
the Alerts and Reports e2e suites, both of which send mail through the
same ``build_email_html``/``send_email`` activities.
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 '')