SIENTIAPDE-2202

SIENTIAPDE-2202 Add gateway end-to-end test suite (e2e/)

End-to-end suite at the repo root, following the Laborious/Orchestrator
pattern: numbered scenarios per topic in e2e/scenarios.md (1:1 with
test_scenario_* functions), scenario inputs as JSON, Postgres DDL mirrored
from production, `integration` marker, excluded from the default pytest run.

Every dependency runs for real: MongoDB, Redis, PostgreSQL, MinIO and NATS
via testcontainers, OPC UA servers in process (asyncua). Pods are built from
the environment like ingestor/app.py and driven one loop iteration at a time.

69 scenarios across 7 topics: slot leasing and failover, subscription reads,
NATS writes and write_status (incl. the SIENTIAPDE-2202 timestamp/quality
matrix, checked on the server and on the wire), gRPC ReadTag, active reads,
client certificates/security, pod lifecycle.

Replaces tests/functional (legacy slot v1 tests, fully commented out) and
tests/functional/test_write_e2e.py (now scenarios 3.1.x).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 17:31:15 -03:00
parent ca9f4ce188
commit 4ee672a715
30 changed files with 2893 additions and 304 deletions

355
e2e/conftest.py Normal file
View File

@@ -0,0 +1,355 @@
"""Pytest configuration and fixtures for the gateway E2E tests.
Every external dependency runs for real: MongoDB, Redis, PostgreSQL, MinIO and NATS
via testcontainers (session-scoped), OPC UA servers in process (``e2e/opc_server.py``,
function-scoped). Gateway code under ``ingestor/**`` is not mocked: pods are built from
the environment exactly like ``ingestor/app.py`` builds them (``e2e/helpers.Pod``).
State is reset before every test.
"""
import io
import json
import os
from pathlib import Path
import nats
import pytest
import pytest_asyncio
from asyncua import ua
from asyncua.client import ua_client
from minio import Minio
from pymongo import MongoClient
from redis import Redis
from sqlalchemy import create_engine, text
from testcontainers.community.minio import MinioContainer
from testcontainers.community.mongodb import MongoDbContainer
from testcontainers.community.postgres import PostgresContainer
from testcontainers.community.redis import RedisContainer
from testcontainers.core.container import DockerContainer
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
from e2e.helpers import CERT_BUCKET, Pod
from e2e.opc_server import OpcTestServer, free_port, make_certificate
DB_SCHEMA_SQL_PATH = Path(__file__).parent / 'db_schema.sql'
MONGO_USER = MONGO_PASSWORD = 'sientia'
MONGO_DATABASE = 'sientia_e2e'
WRITE_SUBJECT = 'opc.write'
# --------------------------------------------------------------------------------------
# Session infrastructure
# --------------------------------------------------------------------------------------
@pytest.fixture(scope='session')
def mongo_container():
"""MongoDB with root credentials: the gateway always connects as user:password@url."""
mongo = MongoDbContainer('mongo:7', username=MONGO_USER, password=MONGO_PASSWORD)
mongo.start()
yield mongo
mongo.stop()
@pytest.fixture(scope='session')
def redis_container():
redis = RedisContainer('redis:7-alpine')
redis.start()
yield redis
redis.stop()
@pytest.fixture(scope='session')
def postgres_container():
postgres = PostgresContainer(
'postgres:15', username='sientia', password='sientia', dbname='sientia'
)
postgres.start()
yield postgres
postgres.stop()
@pytest.fixture(scope='session')
def minio_container():
"""
MinIO holding the OPC client certificates. Same Bitnami "legacy" image as the
Laborious E2E suite and production: Docker Hub no longer serves ``minio/minio``.
"""
minio = MinioContainer(image='docker.io/bitnamilegacy/minio:2025.7.23-debian-12-r3')
minio.with_env('MINIO_ROOT_USER', minio.access_key)
minio.with_env('MINIO_ROOT_PASSWORD', minio.secret_key)
minio.with_kwargs(user='root')
minio.start()
yield minio
minio.stop()
NATS_IMAGE = 'nats:2.10-alpine'
def nats_server_container(host_port: int | None = None) -> DockerContainer:
"""NATS server; ``host_port`` pins the published port (to start it after the gateway)."""
container = DockerContainer(NATS_IMAGE).waiting_for(LogMessageWaitStrategy('Server is ready'))
if host_port is None:
return container.with_exposed_ports(4222)
return container.with_bind_ports(4222, host_port)
@pytest.fixture(scope='session')
def nats_container():
nats_server = nats_server_container()
nats_server.start()
yield nats_server
nats_server.stop()
@pytest.fixture(scope='session')
def mongo_client(mongo_container):
port = mongo_container.get_exposed_port(27017)
client = MongoClient(f'mongodb://{MONGO_USER}:{MONGO_PASSWORD}@localhost:{port}')
yield client
client.close()
@pytest.fixture(scope='session')
def postgres_engine(postgres_container):
engine = create_engine(postgres_container.get_connection_url())
yield engine
engine.dispose()
@pytest.fixture(scope='session')
def redis_client(redis_container):
client = Redis(
host=redis_container.get_container_host_ip(),
port=int(redis_container.get_exposed_port(6379)),
decode_responses=True,
)
yield client
client.close()
@pytest.fixture(scope='session')
def minio_client(minio_container):
return minio_container.get_client()
@pytest.fixture(scope='session')
def nats_url(nats_container) -> str:
host = nats_container.get_container_host_ip()
return f'nats://{host}:{nats_container.get_exposed_port(4222)}'
# --------------------------------------------------------------------------------------
# Per-test environment and state reset
# --------------------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def gateway_env(
monkeypatch,
tmp_path,
mongo_container,
redis_container,
postgres_container,
minio_container,
nats_url,
):
"""Point every gateway setting at the containers, with the app's defaults otherwise."""
minio_cfg = minio_container.get_config()
env = {
'HOSTNAME': 'pod-a',
'REDIS_HOST': redis_container.get_container_host_ip(),
'REDIS_PORT': str(redis_container.get_exposed_port(6379)),
'LEASE_TTL': '10',
'HEARTBEAT_TTL': '20',
'POLL_INTERVAL': '1',
'MONGODB_URL': f'localhost:{mongo_container.get_exposed_port(27017)}',
'MONGODB_USERNAME': MONGO_USER,
'MONGODB_PASSWORD': MONGO_PASSWORD,
'MONGODB_DATABASE': MONGO_DATABASE,
'POSTGRES_HOST': postgres_container.get_container_host_ip(),
'POSTGRES_PORT': str(postgres_container.get_exposed_port(5432)),
'POSTGRES_USER': 'sientia',
'POSTGRES_PASSWORD': 'sientia',
'POSTGRES_DBNAME': 'sientia',
'POSTGRES_MIN_CONNECTIONS': '1',
'POSTGRES_MAX_CONNECTIONS': '2',
'MINIO_ENDPOINT': minio_cfg['endpoint'],
'MINIO_ACCESS_KEY': minio_cfg['access_key'],
'MINIO_SECRET_KEY': minio_cfg['secret_key'],
'MINIO_SECURE': 'false',
'NATS_URL': nats_url,
'NATS_WRITE_SUBJECT': WRITE_SUBJECT,
'NATS_QUEUE': 'gateway-writers',
'GRPC_ENABLED': 'false',
'GRPC_PORT': str(free_port()),
'CERT_CACHE_DIR': str(tmp_path / 'cert-cache'),
'WRITE_MIN_PAUSE_S': '0',
'IDLE_CONN_MAX_S': '300',
}
for name, value in env.items():
monkeypatch.setenv(name, value)
for name in ('REDIS_USERNAME', 'REDIS_PASSWORD', 'WRITE_TIMESTAMP', 'WRITE_QUALITY'):
monkeypatch.delenv(name, raising=False)
return env
@pytest.fixture(autouse=True)
def reset_state(redis_client, mongo_client, postgres_engine, minio_client):
"""Known-empty baseline: Redis flushed, Mongo database dropped, Postgres schema
recreated from ``e2e/db_schema.sql``, certificate bucket emptied."""
redis_client.flushdb()
mongo_client.drop_database(MONGO_DATABASE)
with postgres_engine.begin() as conn:
conn.execute(text('DROP SCHEMA IF EXISTS sientia_data CASCADE'))
conn.exec_driver_sql(DB_SCHEMA_SQL_PATH.read_text(encoding='utf-8'))
if minio_client.bucket_exists(CERT_BUCKET):
for obj in minio_client.list_objects(CERT_BUCKET, recursive=True):
minio_client.remove_object(CERT_BUCKET, obj.object_name)
else:
minio_client.make_bucket(CERT_BUCKET)
yield
@pytest.fixture
def mongo_db(mongo_client):
return mongo_client[MONGO_DATABASE]
# --------------------------------------------------------------------------------------
# Factories: OPC servers, pods, slots, certificates, NATS commands
# --------------------------------------------------------------------------------------
@pytest_asyncio.fixture
async def opc_servers(tmp_path):
"""
Factory of started in-process OPC servers.
``await opc_servers('plant-a', {'Temperature': 20.0}, secured=False)`` declares the
variables (``{name: value}`` or ``{name: (value, data_type, writable)}``) and starts it.
"""
started: dict[str, OpcTestServer] = {}
async def start(name: str, variables: dict, secured: bool = False) -> OpcTestServer:
server = OpcTestServer(name, cert_dir=tmp_path / 'server-certs', secured=secured)
for var, spec in variables.items():
value, data_type, writable = spec if isinstance(spec, tuple) else (spec, 'float', True)
server.add_variable(var, value, data_type, writable)
await server.start()
started[name] = server
return server
yield start
for server in started.values():
await server.stop()
@pytest_asyncio.fixture
async def pods(monkeypatch):
"""
Factory of started gateway pods: ``await pods('pod-a', runtime=True, **env)``.
Extra keyword arguments are environment overrides applied before the pod is built
(e.g. ``WRITE_TIMESTAMP='false'``). Every pod is stopped at teardown.
"""
started: list[Pod] = []
async def start(name: str = 'pod-a', runtime: bool = True, **env: str) -> Pod:
for key, value in env.items():
monkeypatch.setenv(key, value)
pod = Pod(name, runtime=runtime)
started.append(pod)
return await pod.start()
yield start
for pod in reversed(started):
await pod.stop()
@pytest.fixture
def put_slot(redis_client):
"""Write a slot v2 to Redis (``slot:opc_tags:<n>``), as the Orchestrator does."""
def put(number: int, slot: dict) -> None:
redis_client.set(f'slot:opc_tags:{number}', json.dumps(slot))
return put
@pytest_asyncio.fixture
async def publish_command(nats_url):
"""Publish C2 write commands (dicts) or raw payloads (bytes) on the write subject."""
client = await nats.connect(nats_url)
async def publish(payload) -> None:
data = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
await client.publish(WRITE_SUBJECT, data)
await client.flush()
yield publish
await client.drain()
@pytest_asyncio.fixture
async def client_certificate(tmp_path, minio_client: Minio, mongo_db):
"""
Create an OPC client certificate the way the platform stores it: cert (DER) and
private key (PEM) uploaded to MinIO, and an ``opc_certificates`` document (active,
kind client). Returns the certificate in the slot format (contract C1).
"""
async def create(
certificate_id: int,
upload: bool = True,
status: str = 'active',
app_uri: str | None = None,
) -> dict:
app_uri = app_uri or f'urn:sientia:e2e:client:{certificate_id}'
cert, key = await make_certificate(
tmp_path / 'client-certs', f'client-{certificate_id}', app_uri, server=False
)
objects = {
'cert': {'bucket': CERT_BUCKET, 'object_name': f'{certificate_id}/client.der'},
'private_key': {'bucket': CERT_BUCKET, 'object_name': f'{certificate_id}/client.pem'},
}
if upload:
for path, field in ((cert, 'cert'), (key, 'private_key')):
data = path.read_bytes()
minio_client.put_object(
CERT_BUCKET, objects[field]['object_name'], io.BytesIO(data), len(data)
)
mongo_db['opc_certificates'].insert_one(
{
'certificate_id': certificate_id,
'kind': 'client',
'status': status,
'application_uri': app_uri,
**objects,
}
)
return {'certificate_id': certificate_id, 'application_uri': app_uri, **objects}
return create
@pytest.fixture
def cert_cache_dir() -> Path:
return Path(os.environ['CERT_CACHE_DIR'])
@pytest.fixture
def opc_wire(monkeypatch):
"""Serialized WriteRequests, exactly as the gateway's OPC client sends them."""
sent: list[bytes] = []
original = ua_client.struct_to_binary
def capture(obj):
data = original(obj)
if isinstance(obj, ua.WriteRequest):
sent.append(data)
return data
monkeypatch.setattr(ua_client, 'struct_to_binary', capture)
return sent