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>
22 KiB
E2E Scenario Documentation — OPC Gateway
Functional reference for the gateway end-to-end scenarios: slot leasing, subscription reads,
NATS writes, gRPC reads, active reads, certificates and the pod lifecycle. Tests live under
e2e/, are marked with @pytest.mark.integration and run with:
pytest e2e/ -o testpaths=e2e -m integration
Every section below maps 1:1 to an actual test function (or to one pytest.param id of a
parametrized test) — this file describes what the suite verifies today. Adding a scenario here
without a matching test (or vice versa) is a doc bug; fix the mismatch instead of leaving it
for later.
Execution Context
One-time setup: disable the testcontainers reaper
Same as the Laborious and Orchestrator suites. Once per machine:
echo "ryuk.disabled=true" >> ~/.testcontainers.properties
Under Docker Desktop on Linux the reaper ("Ryuk") cannot mount the Docker socket and every
test errors at setup (ERROR, not FAILED). Every fixture stops its own containers in
teardown; after a killed run, clean up by hand:
docker ps -a --filter "label=org.testcontainers=true" -q | xargs -r docker rm -f
What runs for real
| Dependency | How | Scope |
|---|---|---|
MongoDB (mongo:7, user/password) |
testcontainers | session |
Redis (redis:7-alpine) |
testcontainers | session |
PostgreSQL (postgres:15, e2e/db_schema.sql) |
testcontainers | session |
MinIO (bitnamilegacy/minio, client certificates) |
testcontainers | session |
NATS (nats:2.10-alpine) |
testcontainers | session |
| OPC UA servers | in process (e2e/opc_server.py, asyncua Server) |
per test |
- Gateway code under
ingestor/**is not mocked. A pod (e2e/helpers.Pod) is built from the environment exactly likeingestor/app.pybuilds it —Ingestor.prepare_ingestor, thenGatewayRuntimeon the same data manager and connection pool — minus the Prometheus HTTP server.Pod.tick()runs one main loop iteration (Ingestor.loop+GatewayRuntime.idle_sweep) without thePOLL_INTERVALsleep, so every scenario controls exactly how many iterations run. - The environment (
gateway_envine2e/conftest.py) points every setting at the containers; scenarios override single variables per pod (LEASE_TTL,WRITE_TIMESTAMP, ...). - State is reset before every test: Redis
FLUSHDB, Mongo database dropped, Postgres schema recreated frome2e/db_schema.sql, certificate bucket emptied. - Inputs live in
e2e/scenario_inputs/*.jsonand are loaded withload_scenario_input;@server:<name>markers resolve to the URL of that test's in-process OPC server. - Default
POLL_INTERVAL=1and tagfrequency=1000: one OPC "cycle" per loop iteration. - Not covered by the default
pytestrun (testpaths = ["tests"]) nor by the unit coverage.--strict-markersis on: a test without@pytest.mark.integrationis not collected, and a run that collects nothing still exits 0 — check the collected count.
1. Slot Leasing and Load Balancing
Source: e2e/test_slot_leasing.py
1.1.1 Single Pod Leases the Slot and Heartbeats
Summary: A lone pod takes the only slot on its first loop iteration.
Description:
- Slot 1 reads
plant-a; one pod starts and runs one iteration.
Expected Outcome:
- The pod manages slot
1;lease:opc_tags:1 = "pod-a"with TTL ≤LEASE_TTL(10 s). heartbeat:ingestor:pod-a = 1with TTL ≤HEARTBEAT_TTL(20 s).- Readings of the slot reach MongoDB.
1.1.2 Idle Pod Takes a Slot Created After Startup
Summary: A pod without slots picks up a slot the Orchestrator creates later.
Description:
- The pod starts with Redis empty and runs one iteration (nothing to lease).
- Slot 1 is written; the pod runs another iteration.
Expected Outcome:
- The pod manages slot
1, holds its lease and its readings reach MongoDB.
1.2.1 Two Pods Split Two Slots
Summary: The rebalancing hands one slot to each pod.
Description:
- Slot 1 reads
plant-a, slot 2 readsplant-b.pod-astarts alone, thenpod-bstarts. - Iterations:
pod-a,pod-b,pod-a,pod-b.
Expected Outcome:
- Alone,
pod-atakes both slots;pod-bfinds them leased. pod-asees a pod without slots and gives slot2back (unsubscribing first);pod-btakes it.- Leases: slot 1 →
pod-a, slot 2 →pod-b; each pod only has the reader of its own server. - New values of both servers reach MongoDB.
1.2.2 Extra Pod Stays Idle
Summary: With more pods than slots, no slot is read twice.
Description:
- Two slots balanced between
pod-aandpod-b;pod-cjoins and every pod iterates twice.
Expected Outcome:
pod-cmanages nothing;pod-aandpod-bhold disjoint slots covering{1, 2}.- Leases belong to
pod-aandpod-bonly.
1.2.3 Failover After Lease Expiry
Summary: A slot left by a dead pod is taken over once its lease expires.
Description:
pod-a(LEASE_TTL=2) reads slot 1;pod-bruns idle.pod-astops without releasing the lease (crash-like).
Expected Outcome:
- The lease still names
pod-aright after the stop, then expires. - On its next iteration
pod-bleases slot1and new values reach MongoDB.
1.3.1 Deleted Slot Stops the Reads
Summary: Removing a slot from Redis unsubscribes it.
Description:
- A pod reads slot 1; the slot key is deleted; the pod iterates.
Expected Outcome:
- The pod manages no slot and has no OPC reader.
- A value changed afterwards is not persisted.
1.3.2 Invalid Slot Is Notified and Not Read
Summary: A slot v1 (topics, no security/names) is rejected.
Description:
- Slot 1 holds the legacy v1 shape (
slot_v1_invalid.json).
Expected Outcome:
- Notification
OPC_SLOT_CONFIG_ERROR_1innotification_queue. - The pod manages nothing; no reading is persisted.
1.3.3 Fixed Slot Is Read Once Its Lease Expires
Summary: Fixing an invalid slot takes effect after the lease of the failed acquisition expires.
Description:
pod-a(LEASE_TTL=2) leases the invalid slot 1 (the lease is kept, the slot is not managed).- The slot is fixed; the pod iterates before and after the lease expires.
Expected Outcome:
- Before expiry the pod still manages nothing (its own lease blocks the re-acquisition).
- After expiry the pod manages slot
1and its readings reach MongoDB.
2. Subscription Reads (OPC UA → MongoDB)
Source: e2e/test_subscription_reads.py
2.1.1 Value Change Is Persisted
Summary: Each data change becomes one document in the name's collection.
Description:
- Slot 1 subscribes
ns=2;s=Temperatureofplant-aintoraw_plant_a; the value changes.
Expected Outcome:
- The initial value and the new one are persisted.
- Document fields:
tag(address),name(tag_name),model_id,value,timestamp(source timestamp,YYYY-MM-DD HH:MM:SS-0300) andinserted_at— nothing else.
2.1.2 One Address Fans Out to Every Name
Summary: An address with several names is written to each collection.
Description:
ns=2;s=Pressurehas two names:raw_furnace/furnace_pressure/model 10 andraw_boiler/boiler_inlet_pressure/model 20 (slot_multi_name.json).
Expected Outcome:
- One document per collection for the change, each with its own
name/model_id, the sametagand the sametimestamp.
2.1.3 Two Servers in One Slot
Summary: A slot can hold several OPC servers.
Description:
- Slot 1 holds
plant-aandplant-b(slot_two_servers.json); both values change.
Expected Outcome:
- Both changes are persisted; the pod has one reader per server.
2.1.4 Values Keep Their OPC Type
Summary: Double, Int32, Boolean and String values are stored with their native type.
Description:
- Four tags of different OPC types change value (
slot_data_types.json).
Expected Outcome:
- Each document stores the value with the matching Python/BSON type (
float,int,bool,str).
2.2.1 Tag Added to the Slot Is Subscribed
Summary: A new tag in the slot is subscribed on the next iteration.
Description:
- The Orchestrator adds
ns=2;s=Levelto slot 1; the pod iterates.
Expected Outcome:
- Changes of the new tag and of the existing one are persisted.
2.2.2 Tag Removed from the Slot Is No Longer Read
Summary: A tag removed from the slot stops being persisted.
Description:
- Slot 1 reads
TemperatureandLevel;Levelis removed; the pod iterates.
Expected Outcome:
Temperaturekeeps being persisted; a newLevelvalue is not.
2.2.3 Name Added to a Tag Receives Its Readings
Summary: A new name on an existing tag starts receiving the readings.
Description:
- A second name (
raw_dryer/dryer_temperature/model 50) is added toTemperature.
Expected Outcome:
- The next change is persisted in both collections.
2.2.4 Server URL Change Reconnects to the New Server
Summary: Changing the connection settings of a server re-initializes its reader.
Description:
- Slot 1 points
plant-ato a second server (same tags) and the pod iterates.
Expected Outcome:
- Values of the new server are persisted; the session on the old server is closed.
- Changes on the old server are no longer persisted.
2.3.1 Unreachable Server Does Not Stop the Others
Summary: A server that cannot be reached is dropped; the other servers of the slot keep reading.
Description:
- Slot 1 holds
plant-aandplant-b, whose URL has nothing listening.
Expected Outcome:
- Notification
OPC_CONNECTION_ERROR_plant-b; the pod only has theplant-areader. plant-avalues are persisted.
2.3.2 Tag Without Data Raises a Warning
Summary: A tag without data changes for 5 cycles is notified.
Description:
- The value does not change for 5 iterations.
Expected Outcome:
- One WARNING notification
TAG_ns=2;s=Temperature:temperature_LISTENNING_STOPPED.
2.3.3 Silent Server Is Reconnected After 15 Cycles
Summary: A server without any data change for 15 cycles is treated as silently dead.
Description:
- The value does not change for 15 iterations, then the pod iterates once more.
Expected Outcome:
- Notifications
OPC_LISTENNING_STOPPED__1andOPC_CONNECTION_RETRY__1; the reader is dropped (pool connection evicted). - The next iteration re-initializes the reader with a new connection; changes are persisted again.
2.3.4 Server Restart Resumes the Reads
Summary: A server that goes down and comes back is read again.
Description:
- The server stops; the pod iterates 15 times (reader dropped) and once more while it is down; the server starts again on the same port; the pod iterates twice.
Expected Outcome:
- While down, the reconnection fails with notification
OPC_CONNECTION_ERROR_plant-a. - Once back, the reader is re-initialized and changes are persisted again.
3. Writes (NATS C2 → OPC UA → Postgres write_status C3)
Source: e2e/test_writes.py
Common setup: plant-a with a writable Setpoint, its connector in connectors
(connector_plant_a.json, connector_id "1") and a pending row in sientia_data.predictions
(model 2, 2026-10-13T12:00:00-03:00). Commands come from write_command.json and are published
on opc.write; the gateway answers through the row's write_status.
3.1 DataValue Parts: Timestamp and Quality (SIENTIAPDE-2202)
Summary: The value is always written; the prediction timestamp (SourceTimestamp) and a Good
StatusCode are written unless turned off. Each flag comes from the command, else from the env
(WRITE_TIMESTAMP / WRITE_QUALITY), else true.
Description:
test_scenario_3_1_datavalue_parts, one case per id. The timestamp is checked on the value the server stored; the quality on the DataValue encoding mask of the WriteRequest the gateway sent (an omitted StatusCode decodes as Good on the server, so only the wire tells them apart).
| Id | Env | Command flags | SourceTimestamp | StatusCode |
|---|---|---|---|---|
3_1_1_defaults_on |
— | — | sent | sent |
3_1_2_timestamp_off |
WRITE_TIMESTAMP=false |
— | not sent | sent |
3_1_3_quality_off |
WRITE_QUALITY=false |
— | sent | not sent |
3_1_4_both_off |
both false |
— | not sent | not sent |
3_1_5_command_overrides_env |
WRITE_TIMESTAMP=true |
write_timestamp: false |
not sent | sent |
3_1_6_command_turns_on_over_env |
both false |
both true |
sent | sent |
Expected Outcome:
write_status = ok; the server holds1.25.- When sent, the stored
SourceTimestampequals the commandtimestamp; otherwise it is empty.
3.2.1 Pending Row Becomes Ok
Summary: A successful write records ok.
Expected Outcome:
write_status = ok; the server holds the command value.
3.2.2 Null write_status Becomes Ok
Summary: Rows without a status (inserted before the column existed) are updated too.
Expected Outcome:
write_statusgoes from NULL took.
3.2.3 Worse Outcome Is Kept
Summary: ok never replaces a worse outcome (ok < error < session_bad).
Description:
- Parametrized: the row starts as
errororsession_bad; a successful command arrives.
Expected Outcome:
- The OPC write happens, the row keeps its worse status.
3.2.4 Failure Replaces Ok
Summary: A worse outcome replaces a better one.
Description:
- A successful command records
ok; a second command for the same row fails (conversion).
Expected Outcome:
write_status = error.
3.2.5 Missing Row Is Not Created
Summary: The gateway only updates rows; it never inserts one.
Description:
- The command targets model 3, which has no prediction row.
Expected Outcome:
- The OPC write happens; no row for model 3 exists afterwards.
3.3.1 Unknown Server Is an Error
Summary: A server_id with no live connection and no connector fails the write.
Expected Outcome:
write_status = error; nothing is written to the server.
3.3.2 Unusable Connector Is an Error
Summary: Only active opc_ua connectors with a URL and full security are used.
Description:
- Parametrized: connector
inactive,not_opc_ua(type: modbus),no_policy,no_url.
Expected Outcome:
write_status = error; nothing is written to the server.
3.3.3 Invalid Command Is an Error Without OPC Write
Summary: Commands that cannot become a DataValue are rejected before touching the server.
Description:
- Parametrized:
conversion("abc"asint),unknown_data_type(decimal128),missing_addr.
Expected Outcome:
write_status = error; nothing is written to the server.
3.3.4 Bad Timestamp Skips the Write and Keeps the Row
Summary: A non-ISO timestamp (with the timestamp flag on) fails before the OPC write.
Description:
- The command carries
13/10/2026 12:00; a marker command is used to know it was handled.
Expected Outcome:
- Only the marker reaches the server; the row stays
pending— the status cannot be recorded either, since the row is found by that same timestamp.
3.3.5 OPC Rejection Is an Error
Summary: A write the server refuses is recorded as error.
Description:
- Parametrized:
not_writable(read-onlyLocked),unknown_node(ns=2;s=Missing).
Expected Outcome:
write_status = error;Lockedkeeps its value.
3.3.6 Malformed JSON Is Dropped and the Consumer Keeps Going
Summary: A payload that is not JSON is discarded without stopping the consumer.
Expected Outcome:
- The next valid command is written and recorded as
ok.
3.4.1 Write Reuses the Read Connection
Summary: A server read by the pod is written through the same pooled connection.
Description:
plant-ais only known through slot 1 (no connector in Mongo).
Expected Outcome:
write_status = ok; the server has a single session.
3.4.2 Write-Only Server Gets Its Own Connection
Summary: A server only known as a connector is connected on the first write.
Expected Outcome:
- No session before the command; one session after; the pool holds the connector's settings.
3.5.1 Minimum Pause Between Writes
Summary: WRITE_MIN_PAUSE_S spaces consecutive writes.
Description:
WRITE_MIN_PAUSE_S=0.5; three commands are published back to back.
Expected Outcome:
- The server receives the three writes at least ~0.5 s apart.
3.5.2 Queue Group Writes Each Command Once
Summary: Pods share the gateway-writers queue: each command is handled by a single pod.
Description:
- Two pods with the runtime; six commands.
Expected Outcome:
- The server receives exactly six writes.
3.6.1 NATS Down at Startup Connects Later
Summary: The runtime starts without NATS and connects from the main loop once NATS is up.
Description:
NATS_URLpoints to a port with nothing listening; the pod starts and iterates once.- A NATS server starts on that port; the pod iterates again.
Expected Outcome:
- One
GATEWAY_NATS_CONSUMER_START_FAILEDnotification for the whole outage (not one per retry). - After NATS starts, the consumer connects and a published command is recorded as
ok.
4. On-demand gRPC ReadTag
Source: e2e/test_grpc_read_tag.py
Common setup: GRPC_ENABLED=true; slot 1 reads plant-a, so the pod's pool holds a connection.
4.1.1 Reads a Tag of a Connected Server
Expected Outcome:
value(as text),opc_status_code = Good,source_timestamp/server_timestampinYYYY-MM-DD HH:MM:SS-0300.persisted = falseand no collection is created: the slot lookup ofReadTagis not wired yet.
4.1.2 Reads the Current Value
Expected Outcome:
- After a server-side change,
ReadTagreturns the new value.
4.2.1 Unknown Address Is an Internal Error
Expected Outcome:
- gRPC
INTERNALwhose details carryBadNodeIdUnknown.
4.2.2 Server Not Connected by the Reads Is an Internal Error
Summary: ReadTag has no connection settings of its own: it only reads servers the read path
already connected (documents the current limitation).
Description:
plant-aonly exists as a connector; no slot.
Expected Outcome:
- gRPC
INTERNAL; no session is opened on the server.
4.2.3 gRPC Disabled by Default
Expected Outcome:
- Without
GRPC_ENABLED, nothing listens onGRPC_PORT(UNAVAILABLE/DEADLINE_EXCEEDED).
5. Scheduled Active Reads
Source: e2e/test_active_read.py
GatewayRuntime builds ActiveReadEngine on the pod's pool and sink, but no loop calls
scheduled_tick yet; these scenarios call it on the running pod's engine with the parsed slot
(slot_active_read.json).
5.1.1 Enabled Tag Is Read into Every Name
Expected Outcome:
Level(active_read.enabled) is persisted inactive_tank(model 30) andactive_mixer(model 40);Temperature(disabled) is not read.
5.1.2 cycle_multiple Reads Every Nth Tick
Description:
cycle_multiple = 3; six ticks.
Expected Outcome:
- Persisted documents after each tick:
0, 0, 1, 1, 1, 2.
5.2.1 Unreachable Server Does Not Stop the Tick
Expected Outcome:
- The tick does not raise; the unreachable server's tag is not persisted, the other server's is.
5.2.2 Active Read Uses the Slot Connection Settings
Expected Outcome:
- The first tick opens the connection with the slot settings; later ticks reuse it (one session); each tick persists the current value.
6. Client Certificates and OPC Security
Source: e2e/test_certificates.py
Common setup: plant-s exposes only Basic256Sha256 SignAndEncrypt. Client certificates are created
like the platform stores them: cert (DER) and key (PEM) in MinIO bucket opc-certificates, plus an
opc_certificates document (kind: client).
6.1.1 Secured Read with Certificate from MinIO
Expected Outcome:
- Readings are persisted; the certificate is cached as
<CERT_CACHE_DIR>/7/cert.derandkey.pem. - No connection error is notified.
6.1.2 Secured Write Resolved from Connectors and Certificates
Description:
- No slot: the connector (
client_certificate_id: 7) is joined withopc_certificates.
Expected Outcome:
write_status = ok; the server holds the value.
6.2.1 Certificate Missing in MinIO
Expected Outcome:
- Notification
OPC_CONNECTION_ERROR_plant-s; no reader; nothing cached; no readings.
6.2.2 Unsupported Security Policy
Expected Outcome:
- Notification
OPC_CONNECTION_ERROR_plant-swhose message names the unsupported mode/policy.
6.2.3 No Certificate Cannot Reach a Secured-only Server
Description:
- The slot has
certificate: null(connect without security, D5).
Expected Outcome:
- Notification
OPC_CONNECTION_ERROR_plant-s; no readings.
6.2.4 Inactive Certificate Write Fails
Description:
- The connector's certificate is
revoked: the write connects without security.
Expected Outcome:
write_status = error; nothing is written to the server.
6.3.1 Certificate Rotation Reconnects with the New One
Description:
- Slot 1 switches from certificate 7 to certificate 8; the pod iterates.
Expected Outcome:
- The pooled connection is replaced and opened with certificate 8 (cached under
8/). - Readings continue; the server ends with a single session.
7. Pod Lifecycle
Source: e2e/test_lifecycle.py
7.1.1 Shutdown Closes Sessions and Stops Consuming
Expected Outcome:
- After
GatewayRuntime.stop+Ingestor.shutdown, the OPC server has no session. - A command published afterwards is not handled (row stays
pending); value changes are not persisted.
7.1.2 Idle Write Connection Is Closed and Reopened
Description:
IDLE_CONN_MAX_S=1;plant-ais read (slot),plant-bonly written (connector2).
Expected Outcome:
- After the idle time,
idle_sweepcloses theplant-bsession; the subscribedplant-aconnection is pinned and stays open. - The next write to
plant-breconnects and is recorded asok.
7.2.1 Redis Unreachable Fails the Startup
Expected Outcome:
prepare_ingestorraises a connection error (app.pythen stops the process).
7.2.2 Restarted Pod Takes Its Slot Back
Description:
pod-a(LEASE_TTL=2) stops; a newpod-astarts after the lease expired.
Expected Outcome:
- The restarted pod leases slot
1and its readings reach MongoDB.