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:
584
e2e/scenarios.md
Normal file
584
e2e/scenarios.md
Normal file
@@ -0,0 +1,584 @@
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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 like `ingestor/app.py` builds it — `Ingestor.prepare_ingestor`, then
|
||||
`GatewayRuntime` on 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 the `POLL_INTERVAL` sleep, so every scenario controls exactly how many iterations run.
|
||||
- The environment (`gateway_env` in `e2e/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 from `e2e/db_schema.sql`, certificate bucket emptied.
|
||||
- Inputs live in `e2e/scenario_inputs/*.json` and are loaded with `load_scenario_input`;
|
||||
`@server:<name>` markers resolve to the URL of that test's in-process OPC server.
|
||||
- Default `POLL_INTERVAL=1` and tag `frequency=1000`: one OPC "cycle" per loop iteration.
|
||||
- Not covered by the default `pytest` run (`testpaths = ["tests"]`) nor by the unit coverage.
|
||||
`--strict-markers` is on: a test without `@pytest.mark.integration` is 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 = 1` with 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 reads `plant-b`. `pod-a` starts alone, then `pod-b` starts.
|
||||
- Iterations: `pod-a`, `pod-b`, `pod-a`, `pod-b`.
|
||||
|
||||
**Expected Outcome**:
|
||||
- Alone, `pod-a` takes both slots; `pod-b` finds them leased.
|
||||
- `pod-a` sees a pod without slots and gives slot `2` back (unsubscribing first); `pod-b` takes 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-a` and `pod-b`; `pod-c` joins and every pod iterates twice.
|
||||
|
||||
**Expected Outcome**:
|
||||
- `pod-c` manages nothing; `pod-a` and `pod-b` hold disjoint slots covering `{1, 2}`.
|
||||
- Leases belong to `pod-a` and `pod-b` only.
|
||||
|
||||
### 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-b` runs idle.
|
||||
- `pod-a` stops without releasing the lease (crash-like).
|
||||
|
||||
**Expected Outcome**:
|
||||
- The lease still names `pod-a` right after the stop, then expires.
|
||||
- On its next iteration `pod-b` leases slot `1` and 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_1` in `notification_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 `1` and 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=Temperature` of `plant-a` into `raw_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`) and `inserted_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=Pressure` has two names: `raw_furnace`/`furnace_pressure`/model 10 and
|
||||
`raw_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 same
|
||||
`tag` and the same `timestamp`.
|
||||
|
||||
### 2.1.3 Two Servers in One Slot
|
||||
**Summary**: A slot can hold several OPC servers.
|
||||
|
||||
**Description**:
|
||||
- Slot 1 holds `plant-a` and `plant-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=Level` to 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 `Temperature` and `Level`; `Level` is removed; the pod iterates.
|
||||
|
||||
**Expected Outcome**:
|
||||
- `Temperature` keeps being persisted; a new `Level` value 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 to `Temperature`.
|
||||
|
||||
**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-a` to 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-a` and `plant-b`, whose URL has nothing listening.
|
||||
|
||||
**Expected Outcome**:
|
||||
- Notification `OPC_CONNECTION_ERROR_plant-b`; the pod only has the `plant-a` reader.
|
||||
- `plant-a` values 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__1` and `OPC_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 holds `1.25`.
|
||||
- When sent, the stored `SourceTimestamp` equals the command `timestamp`; 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_status` goes from NULL to `ok`.
|
||||
|
||||
### 3.2.3 Worse Outcome Is Kept
|
||||
**Summary**: `ok` never replaces a worse outcome (`ok` < `error` < `session_bad`).
|
||||
|
||||
**Description**:
|
||||
- Parametrized: the row starts as `error` or `session_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"` as `int`), `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-only `Locked`), `unknown_node` (`ns=2;s=Missing`).
|
||||
|
||||
**Expected Outcome**:
|
||||
- `write_status = error`; `Locked` keeps 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-a` is 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_URL` points 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_FAILED` notification 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_timestamp` in
|
||||
`YYYY-MM-DD HH:MM:SS-0300`.
|
||||
- `persisted = false` and no collection is created: the slot lookup of `ReadTag` is not wired yet.
|
||||
|
||||
### 4.1.2 Reads the Current Value
|
||||
**Expected Outcome**:
|
||||
- After a server-side change, `ReadTag` returns the new value.
|
||||
|
||||
### 4.2.1 Unknown Address Is an Internal Error
|
||||
**Expected Outcome**:
|
||||
- gRPC `INTERNAL` whose details carry `BadNodeIdUnknown`.
|
||||
|
||||
### 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-a` only 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 on `GRPC_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 in `active_tank` (model 30) and `active_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.der` and `key.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 with `opc_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-s` whose 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-a` is read (slot), `plant-b` only written (connector `2`).
|
||||
|
||||
**Expected Outcome**:
|
||||
- After the idle time, `idle_sweep` closes the `plant-b` session; the subscribed `plant-a`
|
||||
connection is pinned and stays open.
|
||||
- The next write to `plant-b` reconnects and is recorded as `ok`.
|
||||
|
||||
### 7.2.1 Redis Unreachable Fails the Startup
|
||||
**Expected Outcome**:
|
||||
- `prepare_ingestor` raises a connection error (`app.py` then stops the process).
|
||||
|
||||
### 7.2.2 Restarted Pod Takes Its Slot Back
|
||||
**Description**:
|
||||
- `pod-a` (`LEASE_TTL=2`) stops; a new `pod-a` starts after the lease expired.
|
||||
|
||||
**Expected Outcome**:
|
||||
- The restarted pod leases slot `1` and its readings reach MongoDB.
|
||||
Reference in New Issue
Block a user