Merge branch 'feat/mqtt-sparkplug-driver'
# Conflicts: # src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj # src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
This commit is contained in:
+2
-1
@@ -23,11 +23,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<!-- Core hosts DriverFactoryRegistry; the eight driver assemblies each ship the
|
||||
<!-- Core hosts DriverFactoryRegistry; the driver assemblies below each ship the
|
||||
*DriverFactoryExtensions.Register entry point the guard test drives to build the
|
||||
authoritative registered-factory set (mirrors the Host's DriverFactoryBootstrap). -->
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Fixture credential + TLS material produced by gen-fixture-material.sh.
|
||||
#
|
||||
# NEVER commit anything in here: it holds the broker password hash, the fixture CA's
|
||||
# PRIVATE key and the server's private key. The generator script is the artifact in
|
||||
# git; its output is regenerated per machine.
|
||||
secrets/
|
||||
|
||||
# Build output of publish-simulator.sh — the `sparkplug-sim` service's bind-mounted app
|
||||
# directory. Regenerated per machine from the SparkplugSimulator project, exactly like
|
||||
# secrets/ is regenerated by gen-fixture-material.sh; the SCRIPT is the artifact in git.
|
||||
simulator/app/
|
||||
|
||||
# Belt-and-braces: catch key/cert material written directly into this directory by a
|
||||
# hand-run openssl command that forgot the secrets/ prefix.
|
||||
*.key
|
||||
*.crt
|
||||
*.csr
|
||||
*.srl
|
||||
passwd
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# MQTT integration-test fixture — Eclipse Mosquitto (auth + TLS) + a JSON publisher
|
||||
# + an optional Sparkplug B edge-node simulator.
|
||||
#
|
||||
# PREREQUISITE — run this first, in this directory:
|
||||
#
|
||||
# MQTT_FIXTURE_PASSWORD='<pick-one>' ./gen-fixture-material.sh
|
||||
#
|
||||
# It writes ./secrets/ (password file + CA + server certificate/key), which is
|
||||
# gitignored and which both services below mount. Without it the broker will not
|
||||
# start: there is no anonymous fallback, by design.
|
||||
#
|
||||
# Bring up (on the shared Docker host — see infra/README.md §1):
|
||||
#
|
||||
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt \
|
||||
# && MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD=<same> docker compose up -d'
|
||||
#
|
||||
# Endpoints: 10.100.0.35:8883 (TLS + auth) · 10.100.0.35:1883 (plaintext + auth, smoke)
|
||||
#
|
||||
# `mosquitto` + `publisher` carry no profile: they are always wanted together (a broker
|
||||
# with nothing publishing into it tests nothing) and they bind different ports so nothing
|
||||
# contends. `sparkplug-sim` DOES carry one (`--profile sparkplug`), because it is a
|
||||
# manual-driving aid rather than a test dependency — see its own banner.
|
||||
services:
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:2.0.22
|
||||
container_name: otopcua-mosquitto
|
||||
restart: unless-stopped
|
||||
# Every lmxopcua fixture container carries this so the shared Docker host stays
|
||||
# discoverable with `docker ps --filter label=project=lmxopcua`.
|
||||
labels:
|
||||
project: lmxopcua
|
||||
ports:
|
||||
- "1883:1883"
|
||||
- "8883:8883"
|
||||
volumes:
|
||||
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||
- ./secrets:/mosquitto/secrets:ro
|
||||
healthcheck:
|
||||
# Authenticated round-trip against the broker's own $SYS tree ($$ escapes the
|
||||
# compose variable syntax). Proves the password file loaded and the listener
|
||||
# accepts a real session — a bare port check would go green on a broker that
|
||||
# rejects every CONNECT.
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- >-
|
||||
mosquitto_sub -h 127.0.0.1 -p 1883
|
||||
-u "$$MQTT_FIXTURE_USERNAME" -P "$$MQTT_FIXTURE_PASSWORD"
|
||||
-t '$$SYS/broker/uptime' -C 1 -W 3
|
||||
interval: 5s
|
||||
timeout: 8s
|
||||
retries: 12
|
||||
start_period: 5s
|
||||
environment:
|
||||
# Consumed only by the healthcheck above; the broker itself reads the hashed
|
||||
# password file. Supplied by the operator's environment at `docker compose up` —
|
||||
# never defaulted here, so a missing value fails loudly instead of silently
|
||||
# provisioning a known-password broker.
|
||||
MQTT_FIXTURE_USERNAME: ${MQTT_FIXTURE_USERNAME:?set MQTT_FIXTURE_USERNAME (must match gen-fixture-material.sh)}
|
||||
MQTT_FIXTURE_PASSWORD: ${MQTT_FIXTURE_PASSWORD:?set MQTT_FIXTURE_PASSWORD (must match gen-fixture-material.sh)}
|
||||
|
||||
publisher:
|
||||
image: eclipse-mosquitto:2.0.22
|
||||
container_name: otopcua-mqtt-publisher
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
project: lmxopcua
|
||||
depends_on:
|
||||
mosquitto:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./publisher/publish.sh:/publish.sh:ro
|
||||
environment:
|
||||
BROKER_HOST: mosquitto
|
||||
BROKER_PORT: "1883"
|
||||
TOPIC_PREFIX: ${MQTT_FIXTURE_TOPIC_PREFIX:-otopcua/fixture}
|
||||
PUBLISH_INTERVAL: ${MQTT_FIXTURE_PUBLISH_INTERVAL:-2}
|
||||
MQTT_FIXTURE_USERNAME: ${MQTT_FIXTURE_USERNAME:?set MQTT_FIXTURE_USERNAME}
|
||||
MQTT_FIXTURE_PASSWORD: ${MQTT_FIXTURE_PASSWORD:?set MQTT_FIXTURE_PASSWORD}
|
||||
entrypoint: ["/bin/sh", "/publish.sh"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sparkplug B edge-node simulator — the SAME `SparkplugEdgeNode` engine the live
|
||||
# tests drive, run standalone (see SparkplugSimulator/Program.cs).
|
||||
#
|
||||
# WHAT IT IS FOR, AND WHAT IT IS NOT FOR. It exists so an operator (and the Task-26
|
||||
# AdminUI `/run` verification) has a live Sparkplug plant to point the address picker
|
||||
# at: NBIRTH/DBIRTH then N/DDATA every couple of seconds, honouring rebirth NCMDs.
|
||||
# `SparkplugLiveTests` does NOT use it and must not — the §3.6 matrix needs an edge
|
||||
# node it can command mid-test ("now reuse that alias", "now skip a seq", "now die"),
|
||||
# which a container cannot be. Hence the profile: the default `docker compose up`
|
||||
# leaves it out, and nothing in the test suite depends on it.
|
||||
#
|
||||
# PREREQUISITE: `./publish-simulator.sh` — the app directory below is build output,
|
||||
# gitignored and rsynced like the rest of this folder. Without it the container has
|
||||
# nothing to run.
|
||||
# ---------------------------------------------------------------------------
|
||||
sparkplug-sim:
|
||||
image: mcr.microsoft.com/dotnet/runtime:10.0
|
||||
container_name: otopcua-sparkplug-sim
|
||||
profiles: ["sparkplug"]
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
project: lmxopcua
|
||||
depends_on:
|
||||
mosquitto:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# Framework-dependent publish output: portable IL, so it runs under the stock
|
||||
# runtime image whatever this file was rsynced from.
|
||||
- ./simulator/app:/app:ro
|
||||
# The fixture CA, so the simulator PINS the broker exactly as the driver does
|
||||
# rather than trusting anything. The cert's SAN list includes DNS:mosquitto (see
|
||||
# gen-fixture-material.sh), which is the host name used below.
|
||||
- ./secrets/ca.crt:/secrets/ca.crt:ro
|
||||
environment:
|
||||
MQTT_SIM_HOST: mosquitto
|
||||
MQTT_SIM_PORT: "8883"
|
||||
MQTT_SIM_USE_TLS: "true"
|
||||
MQTT_SIM_CA_CERT: /secrets/ca.crt
|
||||
MQTT_SIM_GROUP: ${MQTT_SIM_GROUP:-OtOpcUaSim}
|
||||
MQTT_SIM_NODES: ${MQTT_SIM_NODES:-EdgeA,EdgeB}
|
||||
MQTT_SIM_INTERVAL_SECONDS: ${MQTT_SIM_INTERVAL_SECONDS:-2}
|
||||
MQTT_FIXTURE_USERNAME: ${MQTT_FIXTURE_USERNAME:?set MQTT_FIXTURE_USERNAME}
|
||||
MQTT_FIXTURE_PASSWORD: ${MQTT_FIXTURE_PASSWORD:?set MQTT_FIXTURE_PASSWORD}
|
||||
command: ["dotnet", "/app/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator.dll"]
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env sh
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generates the MQTT fixture's password file + TLS material into ./secrets/.
|
||||
#
|
||||
# NOTHING this script writes is committed: ./secrets/ is excluded by the sibling
|
||||
# .gitignore. The SCRIPT is the artifact in git, the OUTPUT never is. Re-run it on any
|
||||
# machine that needs the fixture (or on the Docker host); the material is throwaway by
|
||||
# construction — a self-issued CA, a 2048-bit server key, and whatever password the
|
||||
# operator supplies in the environment.
|
||||
#
|
||||
# Usage:
|
||||
# MQTT_FIXTURE_PASSWORD='<pick-one>' ./gen-fixture-material.sh
|
||||
#
|
||||
# Environment:
|
||||
# MQTT_FIXTURE_PASSWORD (required) broker password. No default — a committed or
|
||||
# defaulted password is exactly what the plan's
|
||||
# "secrets from env, never committed" rule forbids.
|
||||
# MQTT_FIXTURE_USERNAME broker username (default: otopcua)
|
||||
# MQTT_FIXTURE_SAN_IPS comma-separated IP SANs for the server certificate
|
||||
# (default: 10.100.0.35,127.0.0.1)
|
||||
# MQTT_FIXTURE_SAN_DNS comma-separated DNS SANs
|
||||
# (default: localhost,mosquitto)
|
||||
# MQTT_FIXTURE_CERT_CN server certificate CN (default: first IP SAN)
|
||||
# MQTT_FIXTURE_CERT_DAYS certificate lifetime in days (default: 825)
|
||||
# MQTT_FIXTURE_SECRETS_DIR output directory (default: <script dir>/secrets)
|
||||
# MOSQUITTO_IMAGE image used to run mosquitto_passwd
|
||||
# (default: eclipse-mosquitto:2.0.22)
|
||||
#
|
||||
# The SANs matter: MqttConnection pins the TLS target host with
|
||||
# `.WithTargetHost(options.Host)`, so hostname verification is enforced and the
|
||||
# certificate MUST carry a SAN matching whatever host the tests dial — the shared
|
||||
# Docker host's IP literal (10.100.0.35) in the normal case. A cert without the right
|
||||
# SAN fails the handshake even with a correct CA pin, and the failure looks like a pin
|
||||
# bug.
|
||||
#
|
||||
# Requires: openssl, docker (only for hashing the password — mosquitto 2.0 will not
|
||||
# read a plaintext password file, and mosquitto_passwd is the only supported way to
|
||||
# produce its PBKDF2-SHA512 format).
|
||||
# ---------------------------------------------------------------------------
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
OUT=${MQTT_FIXTURE_SECRETS_DIR:-"$SCRIPT_DIR/secrets"}
|
||||
USERNAME=${MQTT_FIXTURE_USERNAME:-otopcua}
|
||||
SAN_IPS=${MQTT_FIXTURE_SAN_IPS:-10.100.0.35,127.0.0.1}
|
||||
SAN_DNS=${MQTT_FIXTURE_SAN_DNS:-localhost,mosquitto}
|
||||
DAYS=${MQTT_FIXTURE_CERT_DAYS:-825}
|
||||
MOSQUITTO_IMAGE=${MOSQUITTO_IMAGE:-eclipse-mosquitto:2.0.22}
|
||||
|
||||
if [ -z "${MQTT_FIXTURE_PASSWORD:-}" ]; then
|
||||
echo "error: MQTT_FIXTURE_PASSWORD is not set." >&2
|
||||
echo " Supply the broker password in the environment; this script never" >&2
|
||||
echo " defaults or commits one. Example:" >&2
|
||||
echo " MQTT_FIXTURE_PASSWORD='\$(openssl rand -base64 18)' $0" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
command -v openssl >/dev/null 2>&1 || { echo "error: openssl not found on PATH." >&2; exit 2; }
|
||||
command -v docker >/dev/null 2>&1 || {
|
||||
echo "error: docker not found on PATH — needed to run mosquitto_passwd." >&2
|
||||
echo " Run this script on the Docker host (10.100.0.35) instead, or install docker." >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
CN=${MQTT_FIXTURE_CERT_CN:-$(printf '%s' "$SAN_IPS" | cut -d, -f1)}
|
||||
|
||||
mkdir -p "$OUT"
|
||||
chmod 0755 "$OUT"
|
||||
|
||||
echo "==> generating fixture material into $OUT"
|
||||
echo " username : $USERNAME"
|
||||
echo " cert CN : $CN"
|
||||
echo " IP SANs : $SAN_IPS"
|
||||
echo " DNS SANs : $SAN_DNS"
|
||||
|
||||
# --- CA -------------------------------------------------------------------
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout "$OUT/ca.key" -out "$OUT/ca.crt" -days "$DAYS" \
|
||||
-subj "/CN=OtOpcUa MQTT fixture CA/O=OtOpcUa test fixture" \
|
||||
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign" \
|
||||
>/dev/null 2>&1
|
||||
|
||||
# --- server leaf ----------------------------------------------------------
|
||||
# The SAN list is assembled into a temp extfile rather than a process substitution,
|
||||
# which is a bashism this /bin/sh script cannot use.
|
||||
EXT=$(mktemp)
|
||||
trap 'rm -f "$EXT" "$OUT/server.csr" "$OUT/ca.srl"' EXIT
|
||||
|
||||
SAN=""
|
||||
OLD_IFS=$IFS
|
||||
IFS=,
|
||||
for ip in $SAN_IPS; do [ -n "$ip" ] && SAN="$SAN,IP:$ip"; done
|
||||
for dn in $SAN_DNS; do [ -n "$dn" ] && SAN="$SAN,DNS:$dn"; done
|
||||
IFS=$OLD_IFS
|
||||
SAN=${SAN#,}
|
||||
|
||||
cat >"$EXT" <<EOF
|
||||
subjectAltName = $SAN
|
||||
basicConstraints = critical,CA:FALSE
|
||||
keyUsage = critical,digitalSignature,keyEncipherment
|
||||
extendedKeyUsage = serverAuth
|
||||
EOF
|
||||
|
||||
openssl req -newkey rsa:2048 -nodes \
|
||||
-keyout "$OUT/server.key" -out "$OUT/server.csr" \
|
||||
-subj "/CN=$CN/O=OtOpcUa test fixture" \
|
||||
>/dev/null 2>&1
|
||||
|
||||
openssl x509 -req -in "$OUT/server.csr" \
|
||||
-CA "$OUT/ca.crt" -CAkey "$OUT/ca.key" -CAcreateserial \
|
||||
-out "$OUT/server.crt" -days "$DAYS" -sha256 -extfile "$EXT" \
|
||||
>/dev/null 2>&1
|
||||
|
||||
# --- password file --------------------------------------------------------
|
||||
# `--user` so the file lands owned by the invoking user rather than by root: the host
|
||||
# user must be able to chmod it below, and (unlike a root-owned 0600 file) the broker
|
||||
# container's `mosquitto` user must be able to READ it.
|
||||
rm -f "$OUT/passwd"
|
||||
docker run --rm --user "$(id -u):$(id -g)" -v "$OUT":/out "$MOSQUITTO_IMAGE" \
|
||||
mosquitto_passwd -b -c /out/passwd "$USERNAME" "$MQTT_FIXTURE_PASSWORD" >/dev/null
|
||||
|
||||
# mosquitto runs as uid 1883 inside the container and reads these through a read-only
|
||||
# bind mount, so they must be world-readable. This is a throwaway fixture key by
|
||||
# construction — see the banner.
|
||||
chmod 0644 "$OUT/ca.crt" "$OUT/server.crt" "$OUT/server.key" "$OUT/passwd"
|
||||
chmod 0600 "$OUT/ca.key"
|
||||
|
||||
echo "==> done. Files written:"
|
||||
ls -l "$OUT"
|
||||
echo
|
||||
echo "Next:"
|
||||
echo " 1. Export the same creds for the test suite:"
|
||||
echo " export MQTT_FIXTURE_USERNAME='$USERNAME'"
|
||||
echo " export MQTT_FIXTURE_PASSWORD='<the same password>'"
|
||||
echo " export MQTT_FIXTURE_CA_CERT='$OUT/ca.crt'"
|
||||
echo " 2. Push this directory to the Docker host and bring the stack up."
|
||||
@@ -0,0 +1,65 @@
|
||||
# Eclipse Mosquitto configuration for the OtOpcUa MQTT driver integration fixture.
|
||||
#
|
||||
# SECURITY POSTURE — deliberate, and the whole reason this fixture exists.
|
||||
# `allow_anonymous false` is set GLOBALLY (with per_listener_settings false), so it
|
||||
# governs BOTH listeners. An anonymous fixture would let the driver's auth and TLS
|
||||
# code paths — including the CA-pin logic in MqttConnection.ConfigureTls /
|
||||
# ValidateAgainstPinnedCa — go completely untested, which is exactly backwards: those
|
||||
# are the paths whose failure mode is silent (a pin that never rejects anything looks
|
||||
# identical to a pin that works).
|
||||
#
|
||||
# The password file and the TLS material are NOT in git. Generate them first with
|
||||
# `./gen-fixture-material.sh` (see that script's banner); it writes ./secrets/, which
|
||||
# .gitignore excludes.
|
||||
|
||||
# One global security policy rather than per-listener ones: with
|
||||
# per_listener_settings true, `allow_anonymous`/`password_file` would have to be
|
||||
# repeated under EVERY listener block, and a listener that forgot them would silently
|
||||
# fall back to allowing anonymous connections. Global + fail-closed is the safer shape.
|
||||
per_listener_settings false
|
||||
|
||||
allow_anonymous false
|
||||
password_file /mosquitto/secrets/passwd
|
||||
|
||||
# No persistence: the fixture is throwaway, and a persisted retained-message store
|
||||
# surviving a `docker compose down` would make the retained-seed test's outcome depend
|
||||
# on a previous run rather than on this one's publisher.
|
||||
persistence false
|
||||
|
||||
log_dest stdout
|
||||
log_type error
|
||||
log_type warning
|
||||
log_type notice
|
||||
log_type information
|
||||
connection_messages true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plain (non-TLS) listener — smoke only.
|
||||
#
|
||||
# Still authenticated (allow_anonymous false above applies here too); it is the
|
||||
# TRANSPORT that is plaintext, not the access policy. It exists so the subscribe /
|
||||
# retained-seed / unauthored-topic legs can be exercised without dragging the TLS
|
||||
# handshake into every assertion, and so an operator can `mosquitto_sub` the fixture
|
||||
# by hand. Never a model for a deployment.
|
||||
# ---------------------------------------------------------------------------
|
||||
listener 1883 0.0.0.0
|
||||
protocol mqtt
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLS listener — the real one.
|
||||
#
|
||||
# `require_certificate false`: the driver authenticates with username/password over
|
||||
# TLS (the shape MqttDriverOptions models today); mutual TLS is not a driver feature
|
||||
# yet, so demanding a client certificate here would make the fixture untestable rather
|
||||
# than more secure.
|
||||
#
|
||||
# tls_version is deliberately NOT pinned: mosquitto's `tls_version` selects exactly
|
||||
# one version, so pinning 1.2 would refuse a .NET client that negotiated 1.3. Leaving
|
||||
# it unset lets OpenSSL negotiate the best mutually-supported version.
|
||||
# ---------------------------------------------------------------------------
|
||||
listener 8883 0.0.0.0
|
||||
protocol mqtt
|
||||
cafile /mosquitto/secrets/ca.crt
|
||||
certfile /mosquitto/secrets/server.crt
|
||||
keyfile /mosquitto/secrets/server.key
|
||||
require_certificate false
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env sh
|
||||
# ---------------------------------------------------------------------------
|
||||
# Publishes the Sparkplug edge-node simulator into ./simulator/app/, which the
|
||||
# compose file's `sparkplug-sim` service bind-mounts and runs.
|
||||
#
|
||||
# WHY A PUBLISHED DIRECTORY RATHER THAN AN IMAGE BUILD. This fixture is deployed by
|
||||
# rsyncing THIS directory to /opt/otopcua-mqtt on the Docker host (see the compose
|
||||
# banner and infra/README.md §1) — the host never sees the repo, so a Dockerfile whose
|
||||
# build context is the solution root could not run there. A framework-dependent publish
|
||||
# is portable IL: it runs unchanged under the stock dotnet/runtime image on the host,
|
||||
# whatever this machine's architecture is.
|
||||
#
|
||||
# Usage, from a checkout (needs the .NET 10 SDK):
|
||||
#
|
||||
# ./publish-simulator.sh
|
||||
# rsync -av --exclude 'secrets/' ./ dohertj2@10.100.0.35:/opt/otopcua-mqtt/
|
||||
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt \
|
||||
# && MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD=<same> \
|
||||
# docker compose --profile sparkplug up -d'
|
||||
#
|
||||
# ./simulator/app/ is gitignored: it is build output, regenerated per machine, exactly
|
||||
# like ./secrets/.
|
||||
# ---------------------------------------------------------------------------
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
PROJECT="$SCRIPT_DIR/../SparkplugSimulator/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator.csproj"
|
||||
OUT="$SCRIPT_DIR/simulator/app"
|
||||
|
||||
command -v dotnet >/dev/null 2>&1 || { echo "error: dotnet SDK not found on PATH." >&2; exit 2; }
|
||||
[ -f "$PROJECT" ] || { echo "error: simulator project not found at $PROJECT" >&2; exit 2; }
|
||||
|
||||
echo "==> publishing $(basename "$PROJECT") -> $OUT"
|
||||
rm -rf "$OUT"
|
||||
dotnet publish "$PROJECT" -c Release -o "$OUT" --nologo
|
||||
|
||||
echo "==> done:"
|
||||
ls -1 "$OUT" | head -20
|
||||
echo
|
||||
echo "Next: rsync this directory to the Docker host and bring the stack up with"
|
||||
echo " docker compose --profile sparkplug up -d"
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
#!/bin/sh
|
||||
# ---------------------------------------------------------------------------
|
||||
# The fixture's JSON publisher. Runs inside a second eclipse-mosquitto container (the
|
||||
# image already ships mosquitto_pub, so no bespoke image / paho dependency is needed)
|
||||
# and feeds the broker over the PLAIN listener from inside the compose network —
|
||||
# authenticated like every other client, since the broker is allow_anonymous false.
|
||||
#
|
||||
# Topics (all under $TOPIC_PREFIX, default otopcua/fixture):
|
||||
#
|
||||
# retained/seed published EXACTLY ONCE, retain=true, never republished.
|
||||
# This is what makes the retained-seed test meaningful: a
|
||||
# subscriber that connects minutes later and still receives a
|
||||
# value on this topic can only have received the BROKER'S
|
||||
# RETAINED COPY. On a topic that is also republished every few
|
||||
# seconds, a retained seed is indistinguishable from a live
|
||||
# publish that happened to arrive quickly.
|
||||
# line1/temperature JSON object, retain=true, republished every $PUBLISH_INTERVAL
|
||||
# line1/counter JSON object, retain=true, monotonically increasing
|
||||
# line1/state bare scalar text (no JSON), retain=true
|
||||
# noise/chatter JSON, retain=FALSE — deliberately never authored as a tag.
|
||||
# A broker carrying traffic the deployment did not author is
|
||||
# the normal case, and the driver must stay silent about it.
|
||||
# ---------------------------------------------------------------------------
|
||||
set -eu
|
||||
|
||||
HOST=${BROKER_HOST:-mosquitto}
|
||||
PORT=${BROKER_PORT:-1883}
|
||||
PREFIX=${TOPIC_PREFIX:-otopcua/fixture}
|
||||
INTERVAL=${PUBLISH_INTERVAL:-2}
|
||||
|
||||
: "${MQTT_FIXTURE_USERNAME:?publisher needs MQTT_FIXTURE_USERNAME}"
|
||||
: "${MQTT_FIXTURE_PASSWORD:?publisher needs MQTT_FIXTURE_PASSWORD}"
|
||||
|
||||
pub_retained() {
|
||||
mosquitto_pub -h "$HOST" -p "$PORT" \
|
||||
-u "$MQTT_FIXTURE_USERNAME" -P "$MQTT_FIXTURE_PASSWORD" \
|
||||
-q 1 -r -t "$1" -m "$2"
|
||||
}
|
||||
|
||||
pub_volatile() {
|
||||
mosquitto_pub -h "$HOST" -p "$PORT" \
|
||||
-u "$MQTT_FIXTURE_USERNAME" -P "$MQTT_FIXTURE_PASSWORD" \
|
||||
-q 0 -t "$1" -m "$2"
|
||||
}
|
||||
|
||||
echo "publisher: broker=$HOST:$PORT prefix=$PREFIX interval=${INTERVAL}s"
|
||||
|
||||
# The one-shot retained seed. Published before the loop starts and never again.
|
||||
pub_retained "$PREFIX/retained/seed" \
|
||||
'{"value":42.5,"unit":"C","note":"published once at fixture start; never republished"}'
|
||||
echo "publisher: retained seed published on $PREFIX/retained/seed"
|
||||
|
||||
n=0
|
||||
while true; do
|
||||
n=$((n + 1))
|
||||
temperature=$(awk "BEGIN { printf \"%.2f\", 20 + ($n % 50) / 10 }")
|
||||
|
||||
pub_retained "$PREFIX/line1/temperature" \
|
||||
"{\"value\":$temperature,\"unit\":\"C\",\"seq\":$n}"
|
||||
pub_retained "$PREFIX/line1/counter" "{\"value\":$n}"
|
||||
pub_retained "$PREFIX/line1/state" "RUNNING"
|
||||
|
||||
# Unauthored chatter — the driver must never materialise a tag for this.
|
||||
pub_volatile "$PREFIX/noise/chatter" "{\"value\":$n,\"source\":\"chatter\"}"
|
||||
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Environment gate + reachability probe for the Mosquitto fixture defined in
|
||||
/// <c>Docker/docker-compose.yml</c> (auth + TLS on <c>:8883</c>, authenticated plaintext on
|
||||
/// <c>:1883</c>, plus a JSON publisher emitting retained messages).
|
||||
/// <para>
|
||||
/// <b>Unset env ⇒ skip, never fail.</b> Unlike the Modbus / S7 fixtures — which default to
|
||||
/// the shared Docker host and probe it unconditionally — this one has <b>no default
|
||||
/// endpoint</b>: without <c>MQTT_FIXTURE_ENDPOINT</c> the whole suite reports
|
||||
/// <see cref="NotConfigured"/> and every test calls <c>Assert.Skip</c>. That is deliberate.
|
||||
/// The fixture needs credentials that are generated per machine and never committed, so
|
||||
/// "probe a well-known host and hope" would produce auth failures rather than a clean skip
|
||||
/// on a dev box that never provisioned it. `dotnet test` on macOS with no Docker therefore
|
||||
/// stays green.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Env vars, all read once at fixture construction:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <c>MQTT_FIXTURE_ENDPOINT</c> — <c>host:port</c> of the <b>TLS</b> listener, e.g.
|
||||
/// <c>10.100.0.35:8883</c>. <b>The gate</b>: absent ⇒ the whole suite skips.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <c>MQTT_FIXTURE_PLAIN_ENDPOINT</c> — <c>host:port</c> of the plaintext listener.
|
||||
/// Defaults to the TLS host on port 1883.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <c>MQTT_FIXTURE_USERNAME</c> / <c>MQTT_FIXTURE_PASSWORD</c> — the broker credentials
|
||||
/// <c>gen-fixture-material.sh</c> was run with. The password has no default and is
|
||||
/// never committed; without it the suite skips.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <c>MQTT_FIXTURE_CA_CERT</c> — path to the fixture CA (<c>secrets/ca.crt</c>).
|
||||
/// Optional: supplied, the TLS legs pin to it and the CA-pin test runs; absent, the
|
||||
/// connect legs fall back to <c>AllowUntrustedServerCertificate</c> and the pin test
|
||||
/// skips.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <c>MQTT_FIXTURE_TOPIC_PREFIX</c> — must match the publisher's. Default
|
||||
/// <c>otopcua/fixture</c>.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// The probe is one-shot and its socket is closed immediately: tests open their own real
|
||||
/// <see cref="MqttConnection"/> / <see cref="MqttDriver"/> sessions, and sharing a socket
|
||||
/// would serialise them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MqttFixture
|
||||
{
|
||||
/// <summary>The gate: absent ⇒ the whole suite skips.</summary>
|
||||
public const string EndpointEnvVar = "MQTT_FIXTURE_ENDPOINT";
|
||||
|
||||
/// <summary>Optional override for the plaintext listener; defaults to the TLS host on 1883.</summary>
|
||||
public const string PlainEndpointEnvVar = "MQTT_FIXTURE_PLAIN_ENDPOINT";
|
||||
|
||||
/// <summary>Broker username; defaults to the generator script's own default.</summary>
|
||||
public const string UsernameEnvVar = "MQTT_FIXTURE_USERNAME";
|
||||
|
||||
/// <summary>Broker password. No default — a defaulted fixture password is a committed secret.</summary>
|
||||
public const string PasswordEnvVar = "MQTT_FIXTURE_PASSWORD";
|
||||
|
||||
/// <summary>Path to the fixture CA PEM, for the CA-pin legs.</summary>
|
||||
public const string CaCertEnvVar = "MQTT_FIXTURE_CA_CERT";
|
||||
|
||||
/// <summary>Topic prefix the fixture publisher emits under.</summary>
|
||||
public const string TopicPrefixEnvVar = "MQTT_FIXTURE_TOPIC_PREFIX";
|
||||
|
||||
private const string DefaultUsername = "otopcua";
|
||||
private const string DefaultTopicPrefix = "otopcua/fixture";
|
||||
private const int DefaultPlainPort = 1883;
|
||||
|
||||
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="MqttFixture"/> class.</summary>
|
||||
public MqttFixture()
|
||||
{
|
||||
Username = Environment.GetEnvironmentVariable(UsernameEnvVar) is { Length: > 0 } u ? u : DefaultUsername;
|
||||
Password = Environment.GetEnvironmentVariable(PasswordEnvVar);
|
||||
TopicPrefix = Environment.GetEnvironmentVariable(TopicPrefixEnvVar) is { Length: > 0 } p
|
||||
? p.TrimEnd('/')
|
||||
: DefaultTopicPrefix;
|
||||
|
||||
var caPath = Environment.GetEnvironmentVariable(CaCertEnvVar);
|
||||
CaCertificatePath = string.IsNullOrWhiteSpace(caPath) ? null : caPath;
|
||||
|
||||
var tlsRaw = Environment.GetEnvironmentVariable(EndpointEnvVar);
|
||||
if (string.IsNullOrWhiteSpace(tlsRaw))
|
||||
{
|
||||
SkipReason =
|
||||
$"Skipped: set {EndpointEnvVar} (e.g. 10.100.0.35:8883) plus {UsernameEnvVar}/{PasswordEnvVar} to run "
|
||||
+ "the MQTT fixture live suite. Bring the fixture up first — see "
|
||||
+ "tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml.";
|
||||
return;
|
||||
}
|
||||
|
||||
(TlsHost, TlsPort) = ParseEndpoint(tlsRaw, defaultPort: 8883);
|
||||
|
||||
var plainRaw = Environment.GetEnvironmentVariable(PlainEndpointEnvVar);
|
||||
(PlainHost, PlainPort) = string.IsNullOrWhiteSpace(plainRaw)
|
||||
? (TlsHost, DefaultPlainPort)
|
||||
: ParseEndpoint(plainRaw, DefaultPlainPort);
|
||||
|
||||
if (string.IsNullOrEmpty(Password))
|
||||
{
|
||||
// A blank password is NOT a valid "anonymous" configuration for this fixture — the
|
||||
// broker runs allow_anonymous false — so treat it as unconfigured rather than letting
|
||||
// every test fail with an indistinguishable "not authorized".
|
||||
SkipReason =
|
||||
$"Skipped: {EndpointEnvVar} is set but {PasswordEnvVar} is not. The fixture broker runs "
|
||||
+ "allow_anonymous=false; supply the password gen-fixture-material.sh was run with.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe the TLS listener: it is the endpoint the gate names, and a broker that is up serves
|
||||
// both listeners from one process.
|
||||
SkipReason = Probe(TlsHost, TlsPort);
|
||||
}
|
||||
|
||||
/// <summary>Gets the TLS listener host.</summary>
|
||||
public string TlsHost { get; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the TLS listener port.</summary>
|
||||
public int TlsPort { get; }
|
||||
|
||||
/// <summary>Gets the plaintext listener host.</summary>
|
||||
public string PlainHost { get; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the plaintext listener port.</summary>
|
||||
public int PlainPort { get; }
|
||||
|
||||
/// <summary>Gets the broker username.</summary>
|
||||
public string Username { get; }
|
||||
|
||||
/// <summary>Gets the broker password, or <see langword="null"/> when unconfigured.</summary>
|
||||
public string? Password { get; }
|
||||
|
||||
/// <summary>Gets the fixture CA PEM path, or <see langword="null"/> when not supplied.</summary>
|
||||
public string? CaCertificatePath { get; }
|
||||
|
||||
/// <summary>Gets the topic prefix the fixture publisher emits under (no trailing slash).</summary>
|
||||
public string TopicPrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason every test in the suite must skip, or <see langword="null"/> when the
|
||||
/// fixture is configured and reachable.
|
||||
/// </summary>
|
||||
public string? SkipReason { get; }
|
||||
|
||||
/// <summary>Gets a value indicating whether the suite must skip.</summary>
|
||||
public bool NotConfigured => SkipReason is not null;
|
||||
|
||||
/// <summary>Composes a fixture topic under <see cref="TopicPrefix"/>.</summary>
|
||||
/// <param name="suffix">The topic suffix, e.g. <c>line1/temperature</c>.</param>
|
||||
/// <returns>The full topic.</returns>
|
||||
public string Topic(string suffix) => $"{TopicPrefix}/{suffix.TrimStart('/')}";
|
||||
|
||||
private static (string Host, int Port) ParseEndpoint(string raw, int defaultPort)
|
||||
{
|
||||
var parts = raw.Trim().Split(':', 2);
|
||||
var port = parts.Length == 2 && int.TryParse(parts[1], out var parsed) ? parsed : defaultPort;
|
||||
return (parts[0], port);
|
||||
}
|
||||
|
||||
private static string? Probe(string host, int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var connect = client.ConnectAsync(host, port);
|
||||
if (!connect.Wait(ProbeTimeout) || !client.Connected)
|
||||
{
|
||||
return Unreachable(host, port, $"no TCP connection within {ProbeTimeout.TotalSeconds:0}s");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Unreachable(host, port, $"{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Unreachable(string host, int port, string detail) =>
|
||||
$"Skipped: the MQTT fixture broker at {host}:{port} is configured but unreachable ({detail}). "
|
||||
+ "Bring the stack up on the Docker host: "
|
||||
+ "`ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt && MQTT_FIXTURE_USERNAME=... MQTT_FIXTURE_PASSWORD=... docker compose up -d'`.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition so the reachability probe runs once per test session rather than once
|
||||
/// per test — probing a firewalled endpoint per test would burn the probe timeout each time.
|
||||
/// </summary>
|
||||
[Xunit.CollectionDefinition(Name)]
|
||||
public sealed class MqttFixtureCollection : Xunit.ICollectionFixture<MqttFixture>
|
||||
{
|
||||
/// <summary>The collection name.</summary>
|
||||
public const string Name = "MqttFixture";
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using MQTTnet;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Live validation of the plain-MQTT (P1) driver against the real Mosquitto fixture in
|
||||
/// <c>Docker/docker-compose.yml</c> — auth + TLS on <c>:8883</c>, authenticated plaintext on
|
||||
/// <c>:1883</c>, and a publisher emitting retained JSON. Every component exercised here is the
|
||||
/// production one: <see cref="MqttConnection"/> (broker session, TLS, CA pin),
|
||||
/// <see cref="MqttSubscriptionManager"/> (SUBSCRIBE, routing, extraction) and
|
||||
/// <see cref="MqttDriver"/> (the <c>ISubscribable</c>/<c>IReadable</c> surface).
|
||||
/// <para>
|
||||
/// <b>Env-gated + skip-clean.</b> Without <c>MQTT_FIXTURE_ENDPOINT</c> (and the credentials)
|
||||
/// every test calls <c>Assert.Skip</c>, so <c>dotnet test</c> is green on a laptop with no
|
||||
/// Docker. See <see cref="MqttFixture"/> for the full env-var list.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why these four things.</b> The TLS/auth legs are the first time the Task-3 handshake
|
||||
/// code meets a real broker — until now the CA-pin path had only ever seen synthetic certs,
|
||||
/// and its failure mode (a pin that accepts everything) is invisible without a negative
|
||||
/// control, which is why <see cref="Tls_connect_pinned_to_a_foreign_ca_is_rejected"/> exists
|
||||
/// alongside the positive. The subscribe leg asserts the published <b>RawPath</b>, the single
|
||||
/// most defect-prone identity in this plan. And the unauthored-topic leg is written so it
|
||||
/// cannot pass vacuously — it proves an authored tag was flowing throughout the window in
|
||||
/// which the unauthored topic produced nothing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection(MqttFixtureCollection.Name)]
|
||||
[Trait("Category", "LiveIntegration")]
|
||||
public sealed class PlainMqttLiveTests(MqttFixture fixture)
|
||||
{
|
||||
/// <summary>OPC UA <c>BadWaitingForInitialData</c> — "no value has ever been observed".</summary>
|
||||
private const uint BadWaitingForInitialData = 0x80320000u;
|
||||
|
||||
/// <summary>OPC UA <c>BadNodeIdUnknown</c> — "that reference is not an authored MQTT tag".</summary>
|
||||
private const uint BadNodeIdUnknown = 0x80340000u;
|
||||
|
||||
/// <summary>OPC UA <c>Good</c>.</summary>
|
||||
private const uint Good = 0x00000000u;
|
||||
|
||||
/// <summary>
|
||||
/// Bound on every wait for broker traffic. The fixture publisher emits every 2s by default,
|
||||
/// so 30s is ~15 publish cycles — generous enough to absorb a slow container start, short
|
||||
/// enough that a genuinely dark subscription fails rather than hangs (the plan's per-op
|
||||
/// bounded-deadline rule).
|
||||
/// </summary>
|
||||
private static readonly TimeSpan MessageTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly MqttFixture _fx = fixture;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// TLS + auth
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Connects over the TLS listener with real credentials and asserts the session establishes.
|
||||
/// Pins to the fixture CA when <c>MQTT_FIXTURE_CA_CERT</c> is supplied — so on a fully
|
||||
/// provisioned rig this is simultaneously the CA-pin happy path — and falls back to
|
||||
/// <c>AllowUntrustedServerCertificate</c> otherwise so the auth leg still runs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Tls_auth_connect_succeeds()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var connection = new MqttConnection(TlsOptions(), "mqtt-live-tls");
|
||||
|
||||
await connection.ConnectAsync(ct);
|
||||
|
||||
connection.IsConnected.ShouldBeTrue("a TLS+auth connect against the fixture broker must establish a session");
|
||||
connection.State.ShouldBe(MqttConnectionState.Connected);
|
||||
|
||||
TestContext.Current.SendDiagnosticMessage(
|
||||
$"TLS+auth connect to {_fx.TlsHost}:{_fx.TlsPort} succeeded "
|
||||
+ $"(ca pin: {_fx.CaCertificatePath ?? "<none — AllowUntrustedServerCertificate>"}).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The auth negative, and the assertion that gives every other test in this file its meaning:
|
||||
/// a wrong password must never yield a usable session. The broker runs
|
||||
/// <c>allow_anonymous false</c>, so if bad credentials produced a session the fixture would be
|
||||
/// misconfigured and "connected" would prove nothing about auth.
|
||||
/// <para>
|
||||
/// <b>LIVE-GATE FINDING (2026-07-24), now FIXED — this test was tightened accordingly.</b>
|
||||
/// Against real Mosquitto, <see cref="MqttConnection.ConnectAsync"/> originally
|
||||
/// <b>returned normally</b> for a CONNECT the broker rejected as not-authorized: MQTTnet 5
|
||||
/// does not throw on an unsuccessful CONNACK, and <c>ConnectCoreAsync</c> then
|
||||
/// unconditionally published <see cref="MqttConnectionState.Connected"/> and started the
|
||||
/// reconnect supervisor. Measured at +300 ms with a wrong password:
|
||||
/// <c>thrown=<none></c>, <c>IsConnected=False</c>, <c>State=Reconnecting</c>. Broker
|
||||
/// side: <c>Client … disconnected, not authorised.</c> The operator-visible consequence was
|
||||
/// that <c>InitializeAsync</c> reported <c>DriverState.Healthy</c> / <c>HostState.Running</c>
|
||||
/// off a connect that never authenticated, so a deployment carrying the wrong broker
|
||||
/// password sealed green.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>ConnectCoreAsync</c> now inspects the CONNACK and raises
|
||||
/// <see cref="MqttConnectRejectedException"/>, so this asserts the strong form: the
|
||||
/// rejection is <b>surfaced</b>, classified as unrecoverable, and lands the connection in
|
||||
/// <see cref="MqttConnectionState.Faulted"/>. The original weak invariant — polled over 5 s
|
||||
/// so the optimistic <c>Connected</c> window cannot pass it for the wrong reason — is kept
|
||||
/// underneath it, because "no session is ever established" is the property that actually
|
||||
/// matters to a consumer and it must hold regardless of how the failure is reported.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Tls_connect_with_wrong_password_is_rejected_and_surfaced()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var options = TlsOptions() with { Password = _fx.Password + "-definitely-wrong" };
|
||||
await using var connection = new MqttConnection(options, "mqtt-live-badpass");
|
||||
|
||||
var rejection = await Should.ThrowAsync<MqttConnectRejectedException>(
|
||||
async () => await connection.ConnectAsync(ct));
|
||||
|
||||
// Mosquitto answers bad credentials with NotAuthorized (MQTT 5) / 0x05 (3.1.1); either maps to
|
||||
// the same unrecoverable classification, which is what stops the reconnect supervisor.
|
||||
rejection.IsUnrecoverable.ShouldBeTrue(
|
||||
$"a broker that refused the credentials returned {rejection.ResultCode}, classified as retryable");
|
||||
rejection.Message.ShouldNotContain(options.Password ?? "<unset>", Case.Sensitive);
|
||||
connection.State.ShouldBe(MqttConnectionState.Faulted);
|
||||
|
||||
// Poll rather than sample once: a regression that went back to publishing an optimistic
|
||||
// Connected would still read "not connected" in the first millisecond. Every sample must show
|
||||
// no session — a correct password would make IsConnected true within the first sample.
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
connection.IsConnected.ShouldBeFalse(
|
||||
"a CONNECT the broker rejected as not-authorized must never yield a usable session");
|
||||
connection.State.ShouldNotBe(
|
||||
MqttConnectionState.Connected,
|
||||
"a rejected CONNECT must never advertise a healthy session");
|
||||
await Task.Delay(100, ct);
|
||||
}
|
||||
|
||||
TestContext.Current.SendDiagnosticMessage(
|
||||
$"wrong-password connect: {rejection.ResultCode} (unrecoverable={rejection.IsUnrecoverable}), "
|
||||
+ $"IsConnected={connection.IsConnected}, State={connection.State}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CA-pin happy path: <c>CaCertificatePath</c> set to the fixture's own CA, with the
|
||||
/// untrusted-certificate escape hatch explicitly OFF, so the handshake can only succeed by
|
||||
/// chaining the broker's leaf up to the pinned root through
|
||||
/// <c>MqttConnection.ValidateAgainstPinnedCa</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Tls_connect_pinned_to_fixture_ca_succeeds()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
if (_fx.CaCertificatePath is null)
|
||||
{
|
||||
Assert.Skip(
|
||||
$"Skipped: set {MqttFixture.CaCertEnvVar} to the fixture CA (Docker/secrets/ca.crt) to run the CA-pin leg.");
|
||||
}
|
||||
|
||||
File.Exists(_fx.CaCertificatePath).ShouldBeTrue(
|
||||
$"{MqttFixture.CaCertEnvVar}='{_fx.CaCertificatePath}' must point at the fixture CA PEM");
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var options = TlsOptions() with
|
||||
{
|
||||
CaCertificatePath = _fx.CaCertificatePath,
|
||||
AllowUntrustedServerCertificate = false,
|
||||
};
|
||||
await using var connection = new MqttConnection(options, "mqtt-live-capin");
|
||||
|
||||
await connection.ConnectAsync(ct);
|
||||
|
||||
connection.IsConnected.ShouldBeTrue("the broker leaf must chain up to the pinned fixture CA");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pin's falsifiability control, and the reason the positive above means anything: pin to
|
||||
/// a CA the broker's certificate was <b>not</b> issued by and assert the handshake fails. A
|
||||
/// validator that returned <c>true</c> unconditionally — the silent failure mode for this
|
||||
/// kind of code — would pass the positive test and fail here.
|
||||
/// <para>
|
||||
/// The foreign CA is generated in-process rather than taken from the fixture, so this leg
|
||||
/// needs no extra rig provisioning and cannot accidentally be handed the real CA.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Tls_connect_pinned_to_a_foreign_ca_is_rejected()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var foreignCaPath = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-foreign-ca-{Guid.NewGuid():N}.pem");
|
||||
await File.WriteAllTextAsync(foreignCaPath, CreateForeignCaPem(), ct);
|
||||
|
||||
try
|
||||
{
|
||||
var options = TlsOptions() with
|
||||
{
|
||||
CaCertificatePath = foreignCaPath,
|
||||
AllowUntrustedServerCertificate = false,
|
||||
// The handshake fails fast; no need to sit on the default 15s budget.
|
||||
ConnectTimeoutSeconds = 10,
|
||||
};
|
||||
await using var connection = new MqttConnection(options, "mqtt-live-foreignca");
|
||||
|
||||
await Should.ThrowAsync<Exception>(async () => await connection.ConnectAsync(ct));
|
||||
|
||||
connection.IsConnected.ShouldBeFalse(
|
||||
"a broker certificate that does not chain to the pinned CA must be refused — fail closed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(foreignCaPath);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Plain subscribe / read
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The identity assertion. Subscribes an authored tag whose RawPath is deliberately unrelated
|
||||
/// to its MQTT topic, and asserts the driver publishes the change under the <b>RawPath</b> —
|
||||
/// the v3 driver wire reference <c>DriverHostActor</c> fans out to the raw + UNS NodeIds. A
|
||||
/// driver that keyed notifications by topic (or by the TagConfig blob, the retired pre-v3
|
||||
/// shape) would deliver a value that no node in the address space is listening for, and every
|
||||
/// "did a value arrive" assertion would still pass.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Plain_subscribe_delivers_value_under_rawpath()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
const string rawPath = "Fixture/Mqtt/Line1/Temperature";
|
||||
var tag = JsonTag(rawPath, _fx.Topic("line1/temperature"), "$.value", DriverDataType.Float64);
|
||||
|
||||
await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-subscribe");
|
||||
var recorder = new DataChangeRecorder(driver);
|
||||
|
||||
await driver.InitializeAsync(string.Empty, ct);
|
||||
await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
|
||||
|
||||
var change = await recorder.WaitForAsync(rawPath, MessageTimeout, ct);
|
||||
|
||||
change.FullReference.ShouldBe(
|
||||
rawPath,
|
||||
"the driver must publish under the tag's RawPath, never its topic or its TagConfig blob");
|
||||
change.Snapshot.StatusCode.ShouldBe(Good);
|
||||
change.Snapshot.Value.ShouldBeOfType<double>().ShouldBeInRange(-100d, 1000d);
|
||||
|
||||
// ...and the read path serves the same last value under the same key.
|
||||
var read = await driver.ReadAsync([rawPath], ct);
|
||||
read.Count.ShouldBe(1);
|
||||
read[0].StatusCode.ShouldBe(Good);
|
||||
read[0].Value.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retained-message seeding. The fixture publisher emits <c>retained/seed</c> exactly once,
|
||||
/// at container start, and never republishes it — so a subscriber connecting later can only
|
||||
/// obtain a value from the broker's retained copy. Asserting the value on a topic that is
|
||||
/// <i>also</i> republished every couple of seconds would prove nothing: a live publish
|
||||
/// arriving quickly looks identical to a retained seed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Retained_message_seeds_value_on_subscribe()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
const string rawPath = "Fixture/Mqtt/Retained/Seed";
|
||||
var tag = JsonTag(rawPath, _fx.Topic("retained/seed"), "$.value", DriverDataType.Float64);
|
||||
|
||||
await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-retained");
|
||||
var recorder = new DataChangeRecorder(driver);
|
||||
|
||||
await driver.InitializeAsync(string.Empty, ct);
|
||||
await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
|
||||
|
||||
var change = await recorder.WaitForAsync(rawPath, MessageTimeout, ct);
|
||||
|
||||
change.FullReference.ShouldBe(rawPath);
|
||||
change.Snapshot.StatusCode.ShouldBe(Good);
|
||||
change.Snapshot.Value.ShouldBeOfType<double>().ShouldBe(
|
||||
42.5d,
|
||||
"the retained seed carries the publisher's one-shot value; anything else means the "
|
||||
+ "subscription was seeded from somewhere other than the broker's retained store");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A chatty broker must not auto-provision. Publishes on a topic no tag was authored for and
|
||||
/// asserts the driver never raises a notification for it.
|
||||
/// <para>
|
||||
/// <b>Deliberately falsifiable.</b> A version of this test that authored nothing would
|
||||
/// pass vacuously — silence proves nothing if the pipeline is dead. So a real tag is
|
||||
/// authored and subscribed first, and the test asserts that authored tag kept delivering
|
||||
/// <i>throughout</i> the window in which the unauthored topic was being published. The
|
||||
/// only way both assertions hold is if the driver was live and chose to ignore the
|
||||
/// unauthored traffic.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Unauthored_topic_is_silent_while_an_authored_tag_keeps_flowing()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
const string authoredRawPath = "Fixture/Mqtt/Line1/Counter";
|
||||
const string unauthoredRawPath = "Fixture/Mqtt/Line1/NotAuthored";
|
||||
|
||||
// Unique per run so a previous run's retained message can never seed it.
|
||||
var unauthoredTopic = _fx.Topic($"unauthored/{Guid.NewGuid():N}");
|
||||
|
||||
var tag = JsonTag(authoredRawPath, _fx.Topic("line1/counter"), "$.value", DriverDataType.Int64);
|
||||
|
||||
await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-unauthored");
|
||||
var recorder = new DataChangeRecorder(driver);
|
||||
|
||||
await driver.InitializeAsync(string.Empty, ct);
|
||||
|
||||
// Subscribing an unauthored reference alongside the authored one: the driver must say
|
||||
// BadNodeIdUnknown rather than leaving it forever "waiting for initial data" — and must
|
||||
// never later turn it Good off broker traffic.
|
||||
await driver.SubscribeAsync([authoredRawPath, unauthoredRawPath], TimeSpan.FromSeconds(1), ct);
|
||||
|
||||
// Proof the pipeline is live BEFORE the unauthored traffic starts.
|
||||
await recorder.WaitForAsync(authoredRawPath, MessageTimeout, ct);
|
||||
var beforeCount = recorder.CountFor(authoredRawPath);
|
||||
|
||||
// Emit unauthored traffic with our own client — the fixture publisher's own noise topic is
|
||||
// not enough, because a test must be able to point at the exact messages it caused.
|
||||
await PublishUnauthoredAsync(unauthoredTopic, messages: 5, ct);
|
||||
|
||||
// Let the authored tag tick at least once more while the unauthored messages sit delivered.
|
||||
await recorder.WaitForCountAsync(authoredRawPath, beforeCount + 1, MessageTimeout, ct);
|
||||
|
||||
var seen = recorder.Snapshot();
|
||||
seen.ShouldAllBe(
|
||||
e => e.FullReference == authoredRawPath,
|
||||
"the driver must raise notifications ONLY for authored tags; a broker's other topics are none of its business. "
|
||||
+ $"Saw: {string.Join(", ", seen.Select(e => e.FullReference).Distinct())}");
|
||||
|
||||
var read = await driver.ReadAsync([authoredRawPath, unauthoredRawPath], ct);
|
||||
read[0].StatusCode.ShouldBe(Good, "the authored tag was flowing throughout — this is the falsifiability control");
|
||||
read[1].StatusCode.ShouldBeOneOf(
|
||||
BadNodeIdUnknown,
|
||||
BadWaitingForInitialData);
|
||||
read[1].Value.ShouldBeNull("an unauthored reference must never acquire a value from broker traffic");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void SkipIfUnconfigured()
|
||||
{
|
||||
if (_fx.NotConfigured)
|
||||
{
|
||||
Assert.Skip(_fx.SkipReason!);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Driver options for the TLS listener, pinning the fixture CA when one was supplied.</summary>
|
||||
private MqttDriverOptions TlsOptions(params RawTagEntry[] rawTags) => new()
|
||||
{
|
||||
Host = _fx.TlsHost,
|
||||
Port = _fx.TlsPort,
|
||||
UseTls = true,
|
||||
// With no CA to pin, the fixture's self-issued chain cannot validate against the OS trust
|
||||
// store, so the documented dev escape hatch is the only way the auth leg can run at all.
|
||||
CaCertificatePath = _fx.CaCertificatePath,
|
||||
AllowUntrustedServerCertificate = _fx.CaCertificatePath is null,
|
||||
Username = _fx.Username,
|
||||
Password = _fx.Password,
|
||||
ClientId = $"otopcua-live-{Guid.NewGuid():N}",
|
||||
ConnectTimeoutSeconds = 15,
|
||||
Mode = MqttMode.Plain,
|
||||
Plain = new MqttPlainOptions { DefaultQos = 1 },
|
||||
RawTags = rawTags,
|
||||
};
|
||||
|
||||
/// <summary>Driver options for the plaintext listener — still authenticated.</summary>
|
||||
private MqttDriverOptions PlainOptions(params RawTagEntry[] rawTags) => new()
|
||||
{
|
||||
Host = _fx.PlainHost,
|
||||
Port = _fx.PlainPort,
|
||||
UseTls = false,
|
||||
Username = _fx.Username,
|
||||
Password = _fx.Password,
|
||||
ClientId = $"otopcua-live-{Guid.NewGuid():N}",
|
||||
ConnectTimeoutSeconds = 15,
|
||||
Mode = MqttMode.Plain,
|
||||
Plain = new MqttPlainOptions { DefaultQos = 1 },
|
||||
RawTags = rawTags,
|
||||
};
|
||||
|
||||
private static RawTagEntry JsonTag(string rawPath, string topic, string jsonPath, DriverDataType dataType) =>
|
||||
new(
|
||||
rawPath,
|
||||
$$"""
|
||||
{"topic":"{{topic}}","payloadFormat":"Json","jsonPath":"{{jsonPath}}","dataType":"{{dataType}}","qos":1,"retainSeed":true}
|
||||
""",
|
||||
WriteIdempotent: false);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes on a topic no tag is authored for, with a raw MQTTnet client rather than through
|
||||
/// the driver (the driver has no write path in P1, and the point is traffic the driver did
|
||||
/// not originate).
|
||||
/// </summary>
|
||||
private async Task PublishUnauthoredAsync(string topic, int messages, CancellationToken ct)
|
||||
{
|
||||
using var client = new MqttClientFactory().CreateMqttClient();
|
||||
var options = new MqttClientOptionsBuilder()
|
||||
.WithTcpServer(_fx.PlainHost, _fx.PlainPort)
|
||||
.WithCredentials(_fx.Username, _fx.Password)
|
||||
.WithClientId($"otopcua-live-noise-{Guid.NewGuid():N}")
|
||||
.WithTimeout(TimeSpan.FromSeconds(10))
|
||||
.Build();
|
||||
|
||||
await client.ConnectAsync(options, ct);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < messages; i++)
|
||||
{
|
||||
await client.PublishStringAsync(
|
||||
topic,
|
||||
$$"""{"value":{{i}},"source":"unauthored-live-test"}""",
|
||||
MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce,
|
||||
retain: false,
|
||||
ct);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await client.DisconnectAsync(cancellationToken: ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A self-signed CA the fixture broker's certificate was definitively NOT issued by, as PEM.
|
||||
/// Generated per call so nothing about it can be shared with — or mistaken for — the real
|
||||
/// fixture CA.
|
||||
/// </summary>
|
||||
private static string CreateForeignCaPem()
|
||||
{
|
||||
using var key = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
"CN=OtOpcUa MQTT foreign CA (negative control)",
|
||||
key,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
request.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
|
||||
|
||||
using var certificate = request.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
DateTimeOffset.UtcNow.AddDays(1));
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("-----BEGIN CERTIFICATE-----");
|
||||
builder.AppendLine(Convert.ToBase64String(certificate.RawData, Base64FormattingOptions.InsertLineBreaks));
|
||||
builder.AppendLine("-----END CERTIFICATE-----");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records every <c>OnDataChange</c> the driver raises and lets a test await a specific
|
||||
/// RawPath. Attached in the constructor — <b>before</b> the MQTT subscribe — because the
|
||||
/// retained seed can arrive inside the SUBSCRIBE round-trip, and a handler attached
|
||||
/// afterwards would miss exactly the message the retained-seed test is about.
|
||||
/// </summary>
|
||||
private sealed class DataChangeRecorder
|
||||
{
|
||||
private readonly List<DataChangeEventArgs> _events = [];
|
||||
private readonly Lock _gate = new();
|
||||
private readonly List<Waiter> _waiters = [];
|
||||
|
||||
public DataChangeRecorder(MqttDriver driver) => driver.OnDataChange += OnDataChange;
|
||||
|
||||
/// <summary>A point-in-time copy of everything recorded so far.</summary>
|
||||
/// <returns>The recorded events.</returns>
|
||||
public IReadOnlyList<DataChangeEventArgs> Snapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return [.. _events];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How many notifications have been recorded for one RawPath.</summary>
|
||||
/// <param name="rawPath">The RawPath to count.</param>
|
||||
/// <returns>The count.</returns>
|
||||
public int CountFor(string rawPath)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _events.Count(e => e.FullReference == rawPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Awaits the first notification for <paramref name="rawPath"/>.</summary>
|
||||
/// <param name="rawPath">The RawPath to await.</param>
|
||||
/// <param name="timeout">Bound on the wait.</param>
|
||||
/// <param name="cancellationToken">Caller cancellation.</param>
|
||||
/// <returns>The first matching notification.</returns>
|
||||
public Task<DataChangeEventArgs> WaitForAsync(
|
||||
string rawPath,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
=> WaitForCountAsync(rawPath, count: 1, timeout, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Awaits the <paramref name="count"/>-th notification for <paramref name="rawPath"/>.
|
||||
/// Pre-scans what has already been recorded, so a caller that registers the wait after
|
||||
/// the message arrived is served immediately rather than timing out.
|
||||
/// </summary>
|
||||
/// <param name="rawPath">The RawPath to await.</param>
|
||||
/// <param name="count">How many notifications must have been seen.</param>
|
||||
/// <param name="timeout">Bound on the wait.</param>
|
||||
/// <param name="cancellationToken">Caller cancellation.</param>
|
||||
/// <returns>The <paramref name="count"/>-th matching notification.</returns>
|
||||
public async Task<DataChangeEventArgs> WaitForCountAsync(
|
||||
string rawPath,
|
||||
int count,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Waiter waiter;
|
||||
lock (_gate)
|
||||
{
|
||||
var matches = _events.Where(e => e.FullReference == rawPath).ToList();
|
||||
if (matches.Count >= count)
|
||||
{
|
||||
return matches[count - 1];
|
||||
}
|
||||
|
||||
waiter = new Waiter(rawPath, count);
|
||||
_waiters.Add(waiter);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await waiter.Completion.Task.WaitAsync(timeout, cancellationToken);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"No notification #{count} for RawPath '{rawPath}' within {timeout.TotalSeconds:0}s. "
|
||||
+ $"Recorded so far: {DescribeRecorded()}. Is the fixture publisher container running "
|
||||
+ "(`docker logs otopcua-mqtt-publisher`), and does MQTT_FIXTURE_TOPIC_PREFIX match it?");
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_waiters.Remove(waiter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string DescribeRecorded()
|
||||
{
|
||||
var snapshot = Snapshot();
|
||||
return snapshot.Count == 0
|
||||
? "(nothing)"
|
||||
: string.Join(", ", snapshot.GroupBy(e => e.FullReference).Select(g => $"{g.Key}×{g.Count()}"));
|
||||
}
|
||||
|
||||
private void OnDataChange(object? sender, DataChangeEventArgs args)
|
||||
{
|
||||
List<Waiter>? satisfied = null;
|
||||
lock (_gate)
|
||||
{
|
||||
_events.Add(args);
|
||||
foreach (var waiter in _waiters)
|
||||
{
|
||||
if (waiter.RawPath != args.FullReference) continue;
|
||||
if (_events.Count(e => e.FullReference == waiter.RawPath) < waiter.Count) continue;
|
||||
|
||||
(satisfied ??= []).Add(waiter);
|
||||
}
|
||||
}
|
||||
|
||||
// Completed OUTSIDE the lock, and with RunContinuationsAsynchronously below, so a test
|
||||
// continuation never runs on MQTTnet's dispatcher thread while holding this lock —
|
||||
// that would stall delivery for every other subscription.
|
||||
if (satisfied is null) return;
|
||||
foreach (var waiter in satisfied)
|
||||
{
|
||||
waiter.Completion.TrySetResult(args);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Waiter(string RawPath, int Count)
|
||||
{
|
||||
public TaskCompletionSource<DataChangeEventArgs> Completion { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,979 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using MQTTnet;
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// The Sparkplug B live gate: the §3.6 state-machine matrix driven end-to-end over the real
|
||||
/// Mosquitto fixture, against a real edge node (<see cref="SparkplugEdgeNode"/>) that encodes with
|
||||
/// the same generated Tahu schema the driver decodes with.
|
||||
/// <para>
|
||||
/// <b>What the live gate adds over the offline suite.</b> Every case here already has an
|
||||
/// offline pin driven through <c>SparkplugIngestor.Dispatch</c> with hand-built decoded
|
||||
/// payloads. Those prove the <i>logic</i>. They cannot prove the <b>wire</b>: that a negative
|
||||
/// Int32 really does arrive as an unsigned two's-complement <c>int_value</c>, that a DATA
|
||||
/// metric really does carry an alias and nothing else, that an NDEATH really is a broker-issued
|
||||
/// Will with no <c>seq</c>, or that the NCMD this driver encodes is one an independent decoder
|
||||
/// accepts as a rebirth request. A hand-built <c>SparkplugPayload</c> shaped by the same
|
||||
/// assumptions as the decoder cannot falsify any of that; a byte stream through a broker can.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Env-gated + skip-clean</b>, exactly as <see cref="PlainMqttLiveTests"/>: without
|
||||
/// <c>MQTT_FIXTURE_ENDPOINT</c> (and credentials) every test calls <c>Assert.Skip</c>. See
|
||||
/// <see cref="MqttFixture"/> for the env-var list.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every test uses its own Sparkplug group id.</b> The fixture broker is shared and the
|
||||
/// driver subscribes <c>spBv1.0/{group}/#</c>; a fixed group would let one test's edge node
|
||||
/// feed another's driver, and — worse — let a previous run's traffic satisfy an assertion.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection(MqttFixtureCollection.Name)]
|
||||
[Trait("Category", "LiveIntegration")]
|
||||
public sealed class SparkplugLiveTests(MqttFixture fixture)
|
||||
{
|
||||
/// <summary>OPC UA <c>Good</c>.</summary>
|
||||
private const uint Good = 0x00000000u;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA <c>BadCommunicationError</c> — the quality the ingest state machine stamps on a tag
|
||||
/// whose source announced it is offline (NDEATH/DDEATH).
|
||||
/// </summary>
|
||||
private const uint BadCommunicationError = 0x80050000u;
|
||||
|
||||
/// <summary>
|
||||
/// Bound on every wait for broker traffic. Generous enough to absorb a TLS handshake plus a
|
||||
/// rebirth round-trip; short enough that a genuinely dark path fails rather than hangs.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// How long a "this must NOT happen" assertion watches for. Long enough that the message would
|
||||
/// have arrived (a broker round-trip on this rig is single-digit milliseconds), short enough not
|
||||
/// to dominate the suite.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan NegativeWindow = TimeSpan.FromSeconds(4);
|
||||
|
||||
private readonly MqttFixture _fx = fixture;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// birth → data → alias resolve → OnDataChange (+ §3.6 case iv: negative Int32)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The spine of the driver: an NBIRTH/DBIRTH builds the alias table, an alias-only NDATA/DDATA
|
||||
/// resolves through it, and the value is published under the tag's <b>RawPath</b> — the v3
|
||||
/// driver wire reference, not the Sparkplug tuple and not the topic.
|
||||
/// <para>
|
||||
/// It simultaneously carries <b>§3.6 matrix case (iv), the negative Int32</b>. The metric's
|
||||
/// value of −1234 travels as <c>int_value = 4294966062</c> because that is what Sparkplug
|
||||
/// says; a driver that skipped <c>ReinterpretSigned</c> would publish 4294966062 at Good
|
||||
/// quality — a plausible number, a wrong one, and invisible to any assertion that only
|
||||
/// checks "a value arrived".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Birth_then_alias_only_data_publishes_under_rawpath_with_signed_ints_intact()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var group = NewGroup("birth");
|
||||
const string node = "EdgeA";
|
||||
const string device = "Filler1";
|
||||
|
||||
const string tempPath = "Fixture/Sparkplug/EdgeA/Temperature";
|
||||
const string countPath = "Fixture/Sparkplug/EdgeA/Count";
|
||||
const string levelPath = "Fixture/Sparkplug/EdgeA/Filler1/Level";
|
||||
|
||||
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
|
||||
sim.SetNodeMetrics(
|
||||
new SparkplugMetricSeed("Temperature", 1, DataType.Float, 21.5f),
|
||||
new SparkplugMetricSeed("Count", 2, DataType.Int32, -42));
|
||||
sim.SetDeviceMetrics(device, new SparkplugMetricSeed("Level", 10, DataType.Double, 3.5d));
|
||||
|
||||
await sim.ConnectAsync(ct);
|
||||
await sim.BirthAsync(ct);
|
||||
|
||||
await using var driver = new MqttDriver(
|
||||
SparkplugOptions(
|
||||
group,
|
||||
Tag(tempPath, group, node, null, "Temperature"),
|
||||
Tag(countPath, group, node, null, "Count"),
|
||||
Tag(levelPath, group, node, device, "Level")),
|
||||
"mqtt-live-sp-birth");
|
||||
var recorder = new SparkplugRecorder(driver);
|
||||
|
||||
await driver.InitializeAsync(string.Empty, ct);
|
||||
await driver.SubscribeAsync([tempPath, countPath, levelPath], TimeSpan.FromSeconds(1), ct);
|
||||
|
||||
// The birth is a full snapshot, so these are the BIRTH's own values.
|
||||
var birthTemp = await recorder.WaitAsync(tempPath, v => Equals(v.Value, 21.5f), Timeout, ct);
|
||||
birthTemp.FullReference.ShouldBe(
|
||||
tempPath,
|
||||
"the driver must publish under the tag's RawPath, never the Sparkplug tuple or the topic");
|
||||
birthTemp.Snapshot.StatusCode.ShouldBe(Good);
|
||||
|
||||
await recorder.WaitAsync(countPath, v => Equals(v.Value, -42), Timeout, ct);
|
||||
await recorder.WaitAsync(levelPath, v => Equals(v.Value, 3.5d), Timeout, ct);
|
||||
|
||||
// …and now DATA: alias only, no name, no datatype — the shape a bind-by-alias driver survives
|
||||
// and a bind-by-name driver must too.
|
||||
await sim.PublishNodeDataAsync([("Temperature", 33.25f), ("Count", -1234)], ct);
|
||||
await sim.PublishDeviceDataAsync(device, [("Level", 9.75d)], ct);
|
||||
|
||||
var temp = await recorder.WaitAsync(tempPath, v => Equals(v.Value, 33.25f), Timeout, ct);
|
||||
temp.Snapshot.StatusCode.ShouldBe(Good);
|
||||
|
||||
// The predicate names the DATA value, not just "an int arrived": the birth already delivered a
|
||||
// negative Int32 (−42), so a laxer wait would be satisfied by the earlier message and the
|
||||
// assertion below would compare the wrong notification.
|
||||
var count = await recorder.WaitAsync(countPath, v => Equals(v.Value, -1234), Timeout, ct);
|
||||
count.Snapshot.StatusCode.ShouldBe(Good);
|
||||
count.Snapshot.Value.ShouldBe(
|
||||
-1234,
|
||||
"a Sparkplug Int32 rides as two's complement in the UNSIGNED int_value field (−1234 goes on "
|
||||
+ "the wire as 4294966062); without ReinterpretSigned that is what would be published, at "
|
||||
+ "Good quality");
|
||||
|
||||
await recorder.WaitAsync(levelPath, v => Equals(v.Value, 9.75d), Timeout, ct);
|
||||
|
||||
// The read path serves the same last values under the same keys.
|
||||
var read = await driver.ReadAsync([tempPath, countPath, levelPath], ct);
|
||||
read.Select(r => r.StatusCode).ShouldAllBe(s => s == Good);
|
||||
read[1].Value.ShouldBe(-1234);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// rebirth recovery (§3.6 #5)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Late join: the driver connects to a node that has already birthed and gone quiet, so it holds
|
||||
/// no alias table at all. It must request a rebirth, and the request must be one a real edge
|
||||
/// node accepts.
|
||||
/// <para>
|
||||
/// <b>This is the NCMD encode/decode symmetry check.</b> The driver's
|
||||
/// <c>RebirthRequester.Build</c> output is decoded here by the simulator — an independent
|
||||
/// reader that looks for a Boolean metric literally named <c>Node Control/Rebirth</c> set to
|
||||
/// true. Offline tests assert the driver's own bytes against the driver's own expectations;
|
||||
/// only this one proves the command means what the spec says to a party that did not write it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Late_join_requests_a_rebirth_the_edge_node_honours_and_metadata_recovers()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var group = NewGroup("rebirth");
|
||||
const string node = "EdgeA";
|
||||
const string rawPath = "Fixture/Sparkplug/Rebirth/Temperature";
|
||||
|
||||
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
|
||||
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 7, DataType.Double, 64.5d));
|
||||
|
||||
// Connected and listening for NCMD, but DELIBERATELY not birthed: the driver joins a stream it
|
||||
// has no metadata for, which is exactly the state a restart or a long outage leaves it in.
|
||||
await sim.ConnectAsync(ct);
|
||||
|
||||
await using var driver = new MqttDriver(
|
||||
SparkplugOptions(group, Tag(rawPath, group, node, null, "Temperature")),
|
||||
"mqtt-live-sp-rebirth");
|
||||
var recorder = new SparkplugRecorder(driver);
|
||||
|
||||
await driver.InitializeAsync(string.Empty, ct);
|
||||
await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
|
||||
|
||||
var honoured = await sim.WaitForRebirthAsync(1, Timeout, ct);
|
||||
honoured.ShouldBeGreaterThanOrEqualTo(
|
||||
1,
|
||||
"the driver must issue a late-join rebirth NCMD on connect (§3.6 #5), and it must be one a "
|
||||
+ "spec-reading edge node recognises");
|
||||
|
||||
var change = await recorder.WaitAsync(rawPath, v => Equals(v.Value, 64.5d), Timeout, ct);
|
||||
change.Snapshot.StatusCode.ShouldBe(
|
||||
Good,
|
||||
"the rebirth's NBIRTH is a full snapshot — its values are what restores the tag");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// §3.6 case (i) — alias reuse across a rebirth
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// <b>The single most dangerous Sparkplug defect, on the wire.</b> An edge node may reassign an
|
||||
/// alias to a <i>different</i> metric across a rebirth — legal, and something real edge nodes do
|
||||
/// whenever their metric set changes. A driver that bound its tags to aliases keeps routing by
|
||||
/// the old mapping and publishes Pressure's value into the Temperature tag: Good quality,
|
||||
/// entirely plausible values, wrong tag, and nothing anywhere reports an error.
|
||||
/// <para>
|
||||
/// The test swaps aliases 5 and 6 between Temperature and Pressure at the rebirth, then
|
||||
/// publishes a DATA message carrying <b>alias 5 only</b>. Bind-by-name puts it in Pressure;
|
||||
/// bind-by-alias puts it in Temperature. The assertion checks both tags, because "Pressure
|
||||
/// got the value" alone would still pass if the driver had fed both.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Alias_reused_across_a_rebirth_routes_by_metric_name_not_by_alias()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var group = NewGroup("alias");
|
||||
const string node = "EdgeA";
|
||||
const string tempPath = "Fixture/Sparkplug/Alias/Temperature";
|
||||
const string pressPath = "Fixture/Sparkplug/Alias/Pressure";
|
||||
|
||||
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
|
||||
sim.SetNodeMetrics(
|
||||
new SparkplugMetricSeed("Temperature", 5, DataType.Double, 1d),
|
||||
new SparkplugMetricSeed("Pressure", 6, DataType.Double, 2d));
|
||||
|
||||
await sim.ConnectAsync(ct);
|
||||
await sim.BirthAsync(ct);
|
||||
|
||||
await using var driver = new MqttDriver(
|
||||
SparkplugOptions(
|
||||
group,
|
||||
Tag(tempPath, group, node, null, "Temperature"),
|
||||
Tag(pressPath, group, node, null, "Pressure")),
|
||||
"mqtt-live-sp-alias");
|
||||
var recorder = new SparkplugRecorder(driver);
|
||||
|
||||
await driver.InitializeAsync(string.Empty, ct);
|
||||
await driver.SubscribeAsync([tempPath, pressPath], TimeSpan.FromSeconds(1), ct);
|
||||
|
||||
await recorder.WaitAsync(tempPath, v => Equals(v.Value, 1d), Timeout, ct);
|
||||
await recorder.WaitAsync(pressPath, v => Equals(v.Value, 2d), Timeout, ct);
|
||||
|
||||
// Sanity, pre-swap: alias 5 IS Temperature right now.
|
||||
await sim.PublishNodeDataAsync([("Temperature", 11d)], ct);
|
||||
await recorder.WaitAsync(tempPath, v => Equals(v.Value, 11d), Timeout, ct);
|
||||
|
||||
// The rebirth: aliases 5 and 6 change hands, and the birth republishes both values.
|
||||
sim.SetNodeMetrics(
|
||||
new SparkplugMetricSeed("Temperature", 6, DataType.Double, 100d),
|
||||
new SparkplugMetricSeed("Pressure", 5, DataType.Double, 200d));
|
||||
await sim.BirthAsync(ct);
|
||||
|
||||
await recorder.WaitAsync(tempPath, v => Equals(v.Value, 100d), Timeout, ct);
|
||||
await recorder.WaitAsync(pressPath, v => Equals(v.Value, 200d), Timeout, ct);
|
||||
|
||||
// …and now the message that discriminates: alias 5, which is Pressure NOW and was Temperature
|
||||
// a moment ago.
|
||||
await sim.PublishNodeDataAsync([("Pressure", 777d)], ct);
|
||||
|
||||
var pressure = await recorder.WaitAsync(pressPath, v => Equals(v.Value, 777d), Timeout, ct);
|
||||
pressure.Snapshot.StatusCode.ShouldBe(Good);
|
||||
|
||||
// The other half of the assertion. Without it, a driver that fed BOTH tags would pass.
|
||||
var read = await driver.ReadAsync([tempPath], ct);
|
||||
read[0].Value.ShouldBe(
|
||||
100d,
|
||||
"alias 5 now belongs to Pressure; a driver still routing it to Temperature would show 777 "
|
||||
+ "here — Good quality, plausible value, wrong tag");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// §3.6 case (ii) — a stale-bdSeq NDEATH arriving after the reconnect NBIRTH
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// An edge node's link drops, it reconnects and births under a new <c>bdSeq</c>, and only
|
||||
/// <i>then</i> does the broker get round to delivering the old connection's Will. Without the
|
||||
/// <c>bdSeq</c> tie that stale death marks a live, freshly-born node dead and drives every one
|
||||
/// of its tags Bad, with nothing coming to correct it.
|
||||
/// <para>
|
||||
/// Both arms are asserted, and they must be: a driver that ignored <b>every</b> NDEATH would
|
||||
/// pass the first half. So the matching-token death follows immediately and must land.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ndeath_with_a_stale_bdseq_is_ignored_and_the_current_one_stales()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var group = NewGroup("bdseq");
|
||||
const string node = "EdgeA";
|
||||
const string rawPath = "Fixture/Sparkplug/BdSeq/Temperature";
|
||||
|
||||
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
|
||||
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 3, DataType.Double, 10d));
|
||||
|
||||
await sim.ConnectAsync(ct); // session 0
|
||||
sim.BdSeq.ShouldBe(0UL);
|
||||
await sim.BirthAsync(ct);
|
||||
|
||||
await using var driver = new MqttDriver(
|
||||
SparkplugOptions(group, Tag(rawPath, group, node, null, "Temperature")),
|
||||
"mqtt-live-sp-bdseq");
|
||||
var recorder = new SparkplugRecorder(driver);
|
||||
|
||||
await driver.InitializeAsync(string.Empty, ct);
|
||||
await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
|
||||
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 10d), Timeout, ct);
|
||||
|
||||
// The node "reconnects": a clean DISCONNECT (so no Will fires) followed by a new session, whose
|
||||
// bdSeq is 1, and a fresh birth carrying a distinguishable value.
|
||||
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 3, DataType.Double, 20d));
|
||||
await sim.ConnectAsync(ct); // session 1
|
||||
sim.BdSeq.ShouldBe(1UL);
|
||||
await sim.BirthAsync(ct);
|
||||
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 20d), Timeout, ct);
|
||||
|
||||
// The old session's Will, delivered late. bdSeq 0 ≠ the live session's 1 ⇒ it must be discarded.
|
||||
await sim.PublishNodeDeathAsync(bdSeq: 0UL, ct);
|
||||
|
||||
var deadline = DateTime.UtcNow + NegativeWindow;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var sample = await driver.ReadAsync([rawPath], ct);
|
||||
sample[0].StatusCode.ShouldBe(
|
||||
Good,
|
||||
"an NDEATH carrying a PREVIOUS session's bdSeq is a stale Will; acting on it kills a live node");
|
||||
await Task.Delay(200, ct);
|
||||
}
|
||||
|
||||
// …and the falsifiability control: the SAME message with the CURRENT token must stale the tag.
|
||||
// Without this leg, a driver that ignored every death would pass the assertion above.
|
||||
await sim.PublishNodeDeathAsync(bdSeq: 1UL, ct);
|
||||
|
||||
var stale = await recorder.WaitAsync(
|
||||
rawPath,
|
||||
v => v.StatusCode == BadCommunicationError,
|
||||
Timeout,
|
||||
ct);
|
||||
stale.Snapshot.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// death → STALE, through the real Last-Will path
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The realistic death: the edge node's session ends and the <b>broker</b> publishes the NDEATH
|
||||
/// Will it registered at CONNECT. Nothing in this test publishes the death message — which is
|
||||
/// the point, because a Will is the only death an edge node that crashed can produce, and it is
|
||||
/// also the one that carries no <c>seq</c>.
|
||||
/// <para>
|
||||
/// The recovery half matters as much: §3.6 #4 says the next birth restores Good, so a driver
|
||||
/// that latched Bad on death would be wrong in the direction nobody notices until a plant
|
||||
/// comes back and its tags do not.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Broker_published_ndeath_will_stales_the_tags_and_the_next_birth_restores_them()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var group = NewGroup("death");
|
||||
const string node = "EdgeA";
|
||||
const string rawPath = "Fixture/Sparkplug/Death/Temperature";
|
||||
|
||||
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
|
||||
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 4, DataType.Double, 42d));
|
||||
|
||||
await sim.ConnectAsync(ct);
|
||||
await sim.BirthAsync(ct);
|
||||
|
||||
await using var driver = new MqttDriver(
|
||||
SparkplugOptions(group, Tag(rawPath, group, node, null, "Temperature")),
|
||||
"mqtt-live-sp-death");
|
||||
var recorder = new SparkplugRecorder(driver);
|
||||
|
||||
await driver.InitializeAsync(string.Empty, ct);
|
||||
await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
|
||||
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 42d), Timeout, ct);
|
||||
|
||||
// The broker fires the Will — we publish nothing.
|
||||
await sim.KillAsync(ct);
|
||||
|
||||
var stale = await recorder.WaitAsync(rawPath, v => v.StatusCode == BadCommunicationError, Timeout, ct);
|
||||
stale.FullReference.ShouldBe(rawPath);
|
||||
stale.Snapshot.Value.ShouldBeNull("a staled tag must not keep serving the dead node's last value");
|
||||
|
||||
// §3.6 #4: the next birth restores Good.
|
||||
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 4, DataType.Double, 43d));
|
||||
await sim.ConnectAsync(ct);
|
||||
await sim.BirthAsync(ct);
|
||||
|
||||
var revived = await recorder.WaitAsync(rawPath, v => Equals(v.Value, 43d), Timeout, ct);
|
||||
revived.Snapshot.StatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// §3.6 case (iii) — seq wrap 255 → 0, and its falsifiability control
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// <b>255 → 0 is the sequence continuing, not a gap.</b> Read the wrap as a gap and every edge
|
||||
/// node is commanded to rebirth once per 256 messages — a busy node would spend its life
|
||||
/// republishing metadata. This drives a full lap of the counter over a real broker and asserts
|
||||
/// that <b>no NCMD appears on the wire</b>.
|
||||
/// <para>
|
||||
/// <b>Two things make this test able to fail.</b> First, the ingestor is built with
|
||||
/// <c>rebirthDebounce: TimeSpan.Zero</c>: at the shipped 10 s default a wrap-triggered
|
||||
/// request would be swallowed by the debounce and the test would pass for the wrong reason.
|
||||
/// Second, the negative control — a deliberately skipped <c>seq</c> immediately afterwards
|
||||
/// <b>must</b> produce an NCMD. Silence proves nothing unless noise is also proven.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The assertion is made on the wire, by a third MQTT client watching
|
||||
/// <c>spBv1.0/{group}/NCMD/#</c> — not on a counter inside the driver.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Seq_wrap_255_to_0_requests_no_rebirth_but_a_real_gap_does()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var group = NewGroup("seq");
|
||||
const string node = "EdgeA";
|
||||
const string rawPath = "Fixture/Sparkplug/Seq/Counter";
|
||||
|
||||
await using var watcher = await NcmdWatcher.StartAsync(_fx, group, ct);
|
||||
|
||||
// QoS 1 for the 256-message lap: the seq semantics under test are identical at either QoS, and
|
||||
// a broker-side drop at QoS 0 would surface as a "gap" — a real detection, but a flaky test.
|
||||
await using var sim = new SparkplugEdgeNode(SimOptions(group, node) with { DataQos = 1 });
|
||||
sim.SetNodeMetrics(new SparkplugMetricSeed("Counter", 1, DataType.Double, 0d));
|
||||
|
||||
await sim.ConnectAsync(ct);
|
||||
await sim.BirthAsync(ct);
|
||||
|
||||
var options = SparkplugOptions(group, Tag(rawPath, group, node, null, "Counter"));
|
||||
|
||||
// The production ingest path — AttachTo + EstablishAsync is exactly what MqttDriver does — but
|
||||
// constructed directly so the rebirth debounce can be taken out of the picture.
|
||||
await using var connection = new MqttConnection(options, "mqtt-live-sp-seq");
|
||||
var ingestor = new SparkplugIngestor(
|
||||
options,
|
||||
"mqtt-live-sp-seq",
|
||||
logger: null,
|
||||
maxPayloadBytes: options.MaxPayloadBytes,
|
||||
rebirthDebounce: TimeSpan.Zero);
|
||||
ingestor.Register(options.RawTags);
|
||||
ingestor.AttachTo(connection);
|
||||
|
||||
var recorder = new SparkplugRecorder(ingestor);
|
||||
await connection.ConnectAsync(ct);
|
||||
await ingestor.EstablishAsync(ct);
|
||||
await ingestor.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
|
||||
|
||||
// Establish issues the late-join NCMD; let the node answer it and settle before measuring.
|
||||
await sim.WaitForRebirthAsync(1, Timeout, ct);
|
||||
await recorder.WaitAsync(rawPath, v => v.StatusCode == Good, Timeout, ct);
|
||||
await Task.Delay(500, ct);
|
||||
|
||||
var beforeWrap = watcher.Count;
|
||||
|
||||
// A full lap: the birth left seq at 0, so these carry seq 1…255 and then 0 — the wrap.
|
||||
for (var i = 1; i <= 256; i++)
|
||||
{
|
||||
await sim.PublishNodeDataAsync([("Counter", (double)i)], ct);
|
||||
}
|
||||
|
||||
sim.LastSeq.ShouldBe((byte)0, "256 messages after a birth at seq 0 must land exactly on the wrap");
|
||||
|
||||
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 256d), Timeout, ct);
|
||||
await Task.Delay(500, ct); // any NCMD the wrap provoked would have arrived by now
|
||||
|
||||
watcher.Count.ShouldBe(
|
||||
beforeWrap,
|
||||
$"a seq wrap 255 → 0 is the stream continuing, not a gap; NCMDs seen: "
|
||||
+ $"{string.Join(", ", watcher.Topics)}");
|
||||
|
||||
// ---- the falsifiability control -------------------------------------------------
|
||||
// Burn one sequence number without publishing it. That IS a lost message from the host's point
|
||||
// of view, and it MUST produce a rebirth request — otherwise the silence above proves only that
|
||||
// this driver never asks for anything.
|
||||
sim.SkipSeq();
|
||||
await sim.PublishNodeDataAsync([("Counter", 999d)], ct);
|
||||
|
||||
await watcher.WaitForCountAsync(beforeWrap + 1, Timeout, ct);
|
||||
|
||||
watcher.Topics.ShouldAllBe(t => t == $"spBv1.0/{group}/NCMD/{node}");
|
||||
|
||||
// A gap resynchronizes rather than latching: the message that exposed it still lands.
|
||||
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 999d), Timeout, ct);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// browser: passive window
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// <b>Opening an address picker against a live plant must publish nothing.</b> The browse
|
||||
/// session counts its own publishes, and the offline suite asserts that counter — but a counter
|
||||
/// can only see messages that went through the seam it guards. This asserts the property where
|
||||
/// it actually matters: on the broker, with an independent client subscribed to the group's NCMD
|
||||
/// topic while the picker opens, roots, expands every level and reads every attribute.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browser_open_and_full_tree_walk_publish_no_ncmd()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var group = NewGroup("browse");
|
||||
|
||||
await using var watcher = await NcmdWatcher.StartAsync(_fx, group, ct);
|
||||
await using var edgeA = await StartBrowseNodeAsync(group, "EdgeA", withDevice: true, ct);
|
||||
await using var edgeB = await StartBrowseNodeAsync(group, "EdgeB", withDevice: false, ct);
|
||||
|
||||
var browser = new MqttDriverBrowser();
|
||||
await using var session = await browser.OpenAsync(BrowseConfigJson(group), ct);
|
||||
|
||||
// The window is passive, so it can only see births published AFTER it subscribed — Sparkplug
|
||||
// births are never retained. This is the same round-trip the picker's "Request rebirth" button
|
||||
// exists for; here the test plays the edge node's part directly, so the browser stays untouched.
|
||||
await edgeA.BirthAsync(ct);
|
||||
await edgeB.BirthAsync(ct);
|
||||
|
||||
var roots = await WaitForRootAsync(session, group, ct);
|
||||
roots.ShouldContain(n => n.NodeId == group);
|
||||
|
||||
// Walk everything the picker could walk: every level expanded, every node's attributes read.
|
||||
var visited = await WalkAsync(session, roots, ct);
|
||||
|
||||
visited.ShouldContain($"{group}/EdgeA");
|
||||
visited.ShouldContain($"{group}/EdgeB");
|
||||
visited.ShouldContain($"{group}/EdgeA/Filler1");
|
||||
visited.ShouldContain($"{group}/EdgeA::Temperature");
|
||||
visited.ShouldContain($"{group}/EdgeA/Filler1::Level");
|
||||
|
||||
// Give anything the walk might have kicked off time to reach the broker before concluding.
|
||||
await Task.Delay(NegativeWindow, ct);
|
||||
|
||||
watcher.Count.ShouldBe(
|
||||
0,
|
||||
"no browse path may publish — an operator opening a picker must not be able to command a "
|
||||
+ $"running plant. Published: {string.Join(", ", watcher.Topics)}");
|
||||
edgeA.RebirthCount.ShouldBe(0);
|
||||
edgeB.RebirthCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// browser: explicit rebirth, node vs. group scope
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The one browse-session member that publishes, and the enumeration it performs: an edge-node
|
||||
/// scope addresses exactly that node, a group scope fans out to every edge node observed beneath
|
||||
/// it. Asserted on the wire (which topics were published) and at the far end (which simulated
|
||||
/// nodes actually honoured a rebirth), because "the call returned 2" says nothing about where
|
||||
/// the commands went.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browser_request_rebirth_addresses_one_node_or_every_node_in_the_group()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var group = NewGroup("rbscope");
|
||||
|
||||
await using var watcher = await NcmdWatcher.StartAsync(_fx, group, ct);
|
||||
await using var edgeA = await StartBrowseNodeAsync(group, "EdgeA", withDevice: true, ct);
|
||||
await using var edgeB = await StartBrowseNodeAsync(group, "EdgeB", withDevice: false, ct);
|
||||
|
||||
var browser = new MqttDriverBrowser();
|
||||
await using var session = await browser.OpenAsync(BrowseConfigJson(group), ct);
|
||||
var rebirthable = session.ShouldBeAssignableTo<IRebirthCapableBrowseSession>();
|
||||
|
||||
await edgeA.BirthAsync(ct);
|
||||
await edgeB.BirthAsync(ct);
|
||||
await WaitForRootAsync(session, group, ct);
|
||||
|
||||
// Both edge nodes must be in the tree before the group scope is exercised — it enumerates what
|
||||
// the window OBSERVED, so asserting the fan-out against a half-built tree would prove nothing.
|
||||
await WaitForChildrenAsync(session, group, expected: 2, ct);
|
||||
|
||||
// ---- node scope ----
|
||||
var nodeTargets = await rebirthable.RequestRebirthAsync($"{group}/EdgeA", ct);
|
||||
nodeTargets.ShouldBe(1);
|
||||
|
||||
await watcher.WaitForCountAsync(1, Timeout, ct);
|
||||
watcher.Topics.ShouldBe([$"spBv1.0/{group}/NCMD/EdgeA"]);
|
||||
await edgeA.WaitForRebirthAsync(1, Timeout, ct);
|
||||
edgeB.RebirthCount.ShouldBe(0, "a node-scoped rebirth must not touch the node next to it");
|
||||
|
||||
// ---- group scope ----
|
||||
var groupTargets = await rebirthable.RequestRebirthAsync(group, ct);
|
||||
groupTargets.ShouldBe(2);
|
||||
|
||||
await watcher.WaitForCountAsync(3, Timeout, ct);
|
||||
watcher.Topics.ShouldBe(
|
||||
[
|
||||
$"spBv1.0/{group}/NCMD/EdgeA",
|
||||
$"spBv1.0/{group}/NCMD/EdgeA",
|
||||
$"spBv1.0/{group}/NCMD/EdgeB",
|
||||
],
|
||||
"a group scope addresses every observed edge node, once each, in a deterministic order");
|
||||
|
||||
await edgeA.WaitForRebirthAsync(2, Timeout, ct);
|
||||
await edgeB.WaitForRebirthAsync(1, Timeout, ct);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void SkipIfUnconfigured()
|
||||
{
|
||||
if (_fx.NotConfigured)
|
||||
{
|
||||
Assert.Skip(_fx.SkipReason!);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Sparkplug group id unique to this run. The fixture broker is shared and the driver
|
||||
/// subscribes the whole group, so a fixed id would let tests — and previous runs — feed each
|
||||
/// other.
|
||||
/// </summary>
|
||||
private static string NewGroup(string label) => $"OtOpcUaLive-{label}-{Guid.NewGuid():N}"[..40];
|
||||
|
||||
/// <summary>Driver options for Sparkplug mode over the fixture's TLS listener.</summary>
|
||||
private MqttDriverOptions SparkplugOptions(string groupId, params RawTagEntry[] rawTags) => new()
|
||||
{
|
||||
Host = _fx.TlsHost,
|
||||
Port = _fx.TlsPort,
|
||||
UseTls = true,
|
||||
CaCertificatePath = _fx.CaCertificatePath,
|
||||
AllowUntrustedServerCertificate = _fx.CaCertificatePath is null,
|
||||
Username = _fx.Username,
|
||||
Password = _fx.Password,
|
||||
ClientId = $"otopcua-live-sp-{Guid.NewGuid():N}",
|
||||
ConnectTimeoutSeconds = 15,
|
||||
Mode = MqttMode.SparkplugB,
|
||||
Sparkplug = new MqttSparkplugOptions { GroupId = groupId, RequestRebirthOnGap = true },
|
||||
RawTags = rawTags,
|
||||
};
|
||||
|
||||
/// <summary>Simulator connection settings, pointed at the same listener with the same credentials.</summary>
|
||||
private SparkplugEdgeNodeOptions SimOptions(string groupId, string edgeNodeId) => new()
|
||||
{
|
||||
Host = _fx.TlsHost,
|
||||
Port = _fx.TlsPort,
|
||||
UseTls = true,
|
||||
CaCertificatePath = _fx.CaCertificatePath,
|
||||
AllowUntrustedServerCertificate = _fx.CaCertificatePath is null,
|
||||
Username = _fx.Username,
|
||||
Password = _fx.Password,
|
||||
GroupId = groupId,
|
||||
EdgeNodeId = edgeNodeId,
|
||||
};
|
||||
|
||||
/// <summary>An authored Sparkplug raw tag — the binding tuple, with the datatype left to the birth.</summary>
|
||||
private static RawTagEntry Tag(
|
||||
string rawPath,
|
||||
string groupId,
|
||||
string edgeNodeId,
|
||||
string? deviceId,
|
||||
string metricName)
|
||||
{
|
||||
var device = deviceId is null ? "" : $"""
|
||||
"deviceId":"{deviceId}",
|
||||
""";
|
||||
return new RawTagEntry(
|
||||
rawPath,
|
||||
$$"""
|
||||
{"groupId":"{{groupId}}","edgeNodeId":"{{edgeNodeId}}",{{device}}"metricName":"{{metricName}}"}
|
||||
""",
|
||||
WriteIdempotent: false);
|
||||
}
|
||||
|
||||
/// <summary>The browse configuration blob, parsed through the driver's ONE shared JSON options.</summary>
|
||||
private string BrowseConfigJson(string groupId) =>
|
||||
JsonSerializer.Serialize(SparkplugOptions(groupId), MqttJson.Options);
|
||||
|
||||
/// <summary>Brings up one simulated edge node for the browse legs, connected but not yet birthed.</summary>
|
||||
private async Task<SparkplugEdgeNode> StartBrowseNodeAsync(
|
||||
string group,
|
||||
string node,
|
||||
bool withDevice,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sim = new SparkplugEdgeNode(SimOptions(group, node));
|
||||
sim.SetNodeMetrics(
|
||||
new SparkplugMetricSeed("Temperature", 1, DataType.Float, 21.5f),
|
||||
new SparkplugMetricSeed("Count", 2, DataType.Int32, -42));
|
||||
|
||||
if (withDevice)
|
||||
{
|
||||
sim.SetDeviceMetrics("Filler1", new SparkplugMetricSeed("Level", 10, DataType.Double, 3.5d));
|
||||
}
|
||||
|
||||
await sim.ConnectAsync(ct);
|
||||
return sim;
|
||||
}
|
||||
|
||||
/// <summary>Polls <c>RootAsync</c> until the observation window has recorded the group.</summary>
|
||||
private static async Task<IReadOnlyList<BrowseNode>> WaitForRootAsync(
|
||||
IBrowseSession session,
|
||||
string group,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + Timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var roots = await session.RootAsync(ct);
|
||||
if (roots.Any(n => n.NodeId == group))
|
||||
{
|
||||
return roots;
|
||||
}
|
||||
|
||||
await Task.Delay(200, ct);
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
$"The browse window never observed Sparkplug group '{group}'. A birth must be published "
|
||||
+ "AFTER the window opens — Sparkplug births are not retained.");
|
||||
}
|
||||
|
||||
/// <summary>Polls until a node reports at least <paramref name="expected"/> children.</summary>
|
||||
private static async Task WaitForChildrenAsync(
|
||||
IBrowseSession session,
|
||||
string nodeId,
|
||||
int expected,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + Timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var children = await session.ExpandAsync(nodeId, ct);
|
||||
if (children.Count >= expected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(200, ct);
|
||||
}
|
||||
|
||||
throw new TimeoutException($"'{nodeId}' never reported {expected} children in the browse window.");
|
||||
}
|
||||
|
||||
/// <summary>Expands every node depth-first and reads every node's attributes; returns the node ids seen.</summary>
|
||||
private static async Task<List<string>> WalkAsync(
|
||||
IBrowseSession session,
|
||||
IReadOnlyList<BrowseNode> level,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var seen = new List<string>();
|
||||
foreach (var node in level)
|
||||
{
|
||||
seen.Add(node.NodeId);
|
||||
await session.AttributesAsync(node.NodeId, ct);
|
||||
|
||||
var children = await session.ExpandAsync(node.NodeId, ct);
|
||||
if (children.Count > 0)
|
||||
{
|
||||
seen.AddRange(await WalkAsync(session, children, ct));
|
||||
}
|
||||
}
|
||||
|
||||
return seen;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// fixtures used by the tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Records every <c>OnDataChange</c> raised by a driver or an ingestor and lets a test await a
|
||||
/// specific RawPath reaching a specific state.
|
||||
/// <para>
|
||||
/// <b>Awaits a predicate, not merely an arrival.</b> A Sparkplug tag is fed by births,
|
||||
/// rebirths and data alike, so "a notification arrived for this RawPath" is satisfied by the
|
||||
/// wrong message roughly half the time; every wait here names the value or quality it is
|
||||
/// waiting for. It also pre-scans, so a test that registers after the message landed is
|
||||
/// served rather than timing out.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private sealed class SparkplugRecorder
|
||||
{
|
||||
private readonly List<DataChangeEventArgs> _events = [];
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
public SparkplugRecorder(MqttDriver driver) => driver.OnDataChange += Record;
|
||||
|
||||
public SparkplugRecorder(SparkplugIngestor ingestor) => ingestor.OnDataChange += Record;
|
||||
|
||||
public async Task<DataChangeEventArgs> WaitAsync(
|
||||
string rawPath,
|
||||
Func<DataValueSnapshot, bool> predicate,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (true)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var candidate in _events)
|
||||
{
|
||||
if (candidate.FullReference == rawPath && predicate(candidate.Snapshot))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow >= deadline)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"No notification for RawPath '{rawPath}' satisfied the expected state within "
|
||||
+ $"{timeout.TotalSeconds:0}s. Recorded: {Describe()}");
|
||||
}
|
||||
|
||||
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private string Describe()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _events.Count == 0
|
||||
? "(nothing)"
|
||||
: string.Join(
|
||||
" | ",
|
||||
_events
|
||||
.GroupBy(e => e.FullReference)
|
||||
.Select(g =>
|
||||
$"{g.Key}: [{string.Join(", ", g.Select(e => $"{e.Snapshot.Value ?? "null"}@0x{e.Snapshot.StatusCode:X8}"))}]"));
|
||||
}
|
||||
}
|
||||
|
||||
private void Record(object? sender, DataChangeEventArgs args)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_events.Add(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An independent MQTT client subscribed to <c>spBv1.0/{group}/NCMD/#</c>, recording every
|
||||
/// command published into the group.
|
||||
/// <para>
|
||||
/// <b>This is what makes the passivity assertions mean something.</b> The browse session's
|
||||
/// own <c>PublishCountForTest</c> can only observe messages that pass through the seam it
|
||||
/// guards; this observes the broker. If a future change published an NCMD by some other
|
||||
/// route — a raw client, a Last Will, a stray retained command — the counter would still
|
||||
/// read zero and this would not.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private sealed class NcmdWatcher : IAsyncDisposable
|
||||
{
|
||||
private readonly IMqttClient _client;
|
||||
private readonly ConcurrentQueue<string> _topics = new();
|
||||
|
||||
private NcmdWatcher(IMqttClient client) => _client = client;
|
||||
|
||||
/// <summary>Every NCMD topic observed, in arrival order.</summary>
|
||||
public IReadOnlyList<string> Topics => [.. _topics];
|
||||
|
||||
/// <summary>How many NCMDs have been observed.</summary>
|
||||
public int Count => _topics.Count;
|
||||
|
||||
public static async Task<NcmdWatcher> StartAsync(MqttFixture fixture, string group, CancellationToken ct)
|
||||
{
|
||||
var client = new MqttClientFactory().CreateMqttClient();
|
||||
var watcher = new NcmdWatcher(client);
|
||||
|
||||
client.ApplicationMessageReceivedAsync += args =>
|
||||
{
|
||||
// Only a genuine rebirth command counts. A malformed or unrelated NCMD would be a
|
||||
// different defect, and lumping them together would make the count uninterpretable.
|
||||
if (watcher.IsRebirth(args.ApplicationMessage))
|
||||
{
|
||||
watcher._topics.Enqueue(args.ApplicationMessage.Topic);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
var options = new MqttClientOptionsBuilder()
|
||||
.WithTcpServer(fixture.PlainHost, fixture.PlainPort)
|
||||
.WithCredentials(fixture.Username, fixture.Password)
|
||||
.WithClientId($"otopcua-live-ncmdwatch-{Guid.NewGuid():N}")
|
||||
.WithTimeout(TimeSpan.FromSeconds(10))
|
||||
.Build();
|
||||
|
||||
await client.ConnectAsync(options, ct).ConfigureAwait(false);
|
||||
await client.SubscribeAsync(
|
||||
new MqttClientSubscribeOptionsBuilder()
|
||||
.WithTopicFilter(f => f.WithTopic($"spBv1.0/{group}/NCMD/#"))
|
||||
.Build(),
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return watcher;
|
||||
}
|
||||
|
||||
public async Task WaitForCountAsync(int count, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (Count < count)
|
||||
{
|
||||
if (DateTime.UtcNow >= deadline)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"Only {Count} rebirth NCMD(s) reached the broker within {timeout.TotalSeconds:0}s; "
|
||||
+ $"expected {count}. Seen: {string.Join(", ", Topics)}");
|
||||
}
|
||||
|
||||
await Task.Delay(100, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await _client.DisconnectAsync(new MqttClientDisconnectOptions(), cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Teardown is best-effort; the broker may already have gone.
|
||||
}
|
||||
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
private bool IsRebirth(MqttApplicationMessage message)
|
||||
{
|
||||
var body = message.Payload;
|
||||
var bytes = body.IsSingleSegment ? body.FirstSpan.ToArray() : body.ToArray();
|
||||
|
||||
try
|
||||
{
|
||||
var payload = Payload.Parser.ParseFrom(bytes);
|
||||
return payload.Metrics.Any(m =>
|
||||
m.Name == SparkplugEdgeNode.RebirthMetricName
|
||||
&& m.ValueCase == Payload.Types.Metric.ValueOneofCase.BooleanValue
|
||||
&& m.BooleanValue);
|
||||
}
|
||||
catch (Google.Protobuf.InvalidProtocolBufferException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone entry point for the `sparkplug-sim` fixture container.
|
||||
//
|
||||
// Runs one or more simulated edge nodes against the fixture broker, forever: NBIRTH + DBIRTH once,
|
||||
// then N/DDATA on a cadence, honouring rebirth NCMDs throughout. It exists so an operator (and Task
|
||||
// 26's AdminUI `/run` verification) has a live Sparkplug plant to browse, NOT so the live tests have
|
||||
// one — those drive `SparkplugEdgeNode` in process, because the §3.6 matrix needs an edge node it
|
||||
// can command mid-test.
|
||||
//
|
||||
// Every setting comes from the environment; nothing is defaulted to a credential.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var host = Env("MQTT_SIM_HOST", "mosquitto");
|
||||
var port = int.TryParse(Env("MQTT_SIM_PORT", "8883"), out var p) ? p : 8883;
|
||||
var useTls = !string.Equals(Env("MQTT_SIM_USE_TLS", "true"), "false", StringComparison.OrdinalIgnoreCase);
|
||||
var group = Env("MQTT_SIM_GROUP", "OtOpcUaSim");
|
||||
var nodes = Env("MQTT_SIM_NODES", "EdgeA,EdgeB").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var interval = int.TryParse(Env("MQTT_SIM_INTERVAL_SECONDS", "2"), out var i) && i > 0 ? i : 2;
|
||||
|
||||
var username = Environment.GetEnvironmentVariable("MQTT_FIXTURE_USERNAME");
|
||||
var password = Environment.GetEnvironmentVariable("MQTT_FIXTURE_PASSWORD");
|
||||
var caPath = Environment.GetEnvironmentVariable("MQTT_SIM_CA_CERT");
|
||||
var allowUntrusted = string.Equals(Env("MQTT_SIM_ALLOW_UNTRUSTED", "false"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
// Same fail-loud posture as the compose file's `${VAR:?}` guards: the broker runs
|
||||
// allow_anonymous=false, so a blank password would produce an unexplained connect refusal.
|
||||
Console.Error.WriteLine(
|
||||
"sparkplug-sim: MQTT_FIXTURE_PASSWORD is not set. The fixture broker runs allow_anonymous=false; "
|
||||
+ "supply the password gen-fixture-material.sh was run with.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
using var stopping = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
stopping.Cancel();
|
||||
};
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => stopping.Cancel();
|
||||
|
||||
Console.WriteLine(
|
||||
$"sparkplug-sim: broker={host}:{port} tls={useTls} group={group} nodes=[{string.Join(", ", nodes)}] "
|
||||
+ $"interval={interval}s");
|
||||
|
||||
var running = new List<Task>();
|
||||
var edges = new List<SparkplugEdgeNode>();
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var edge = new SparkplugEdgeNode(
|
||||
new SparkplugEdgeNodeOptions
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
UseTls = useTls,
|
||||
CaCertificatePath = caPath,
|
||||
AllowUntrustedServerCertificate = allowUntrusted,
|
||||
Username = username,
|
||||
Password = password,
|
||||
GroupId = group,
|
||||
EdgeNodeId = node,
|
||||
},
|
||||
Console.WriteLine);
|
||||
|
||||
// A small but genuinely heterogeneous catalog: a float, a double, a NEGATIVE int32 (the two's
|
||||
// complement case), a bool and a string — plus one device, so the picker's
|
||||
// Group → EdgeNode → Device → Metric tree has every level populated.
|
||||
edge.SetNodeMetrics(
|
||||
new SparkplugMetricSeed("Temperature", 1, DataType.Float, 21.5f),
|
||||
new SparkplugMetricSeed("Pressure", 2, DataType.Double, 101.325d),
|
||||
new SparkplugMetricSeed("Count", 3, DataType.Int32, -42),
|
||||
new SparkplugMetricSeed("Running", 4, DataType.Boolean, true),
|
||||
new SparkplugMetricSeed("Serial", 5, DataType.String, $"{node}-001"));
|
||||
|
||||
edge.SetDeviceMetrics(
|
||||
"Filler1",
|
||||
new SparkplugMetricSeed("Temperature", 10, DataType.Float, 55.5f),
|
||||
new SparkplugMetricSeed("FillCount", 11, DataType.Int64, 1000L),
|
||||
new SparkplugMetricSeed("Jammed", 12, DataType.Boolean, false));
|
||||
|
||||
edges.Add(edge);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
await edge.ConnectAsync(stopping.Token);
|
||||
running.Add(edge.RunAsync(TimeSpan.FromSeconds(interval), stopping.Token));
|
||||
}
|
||||
|
||||
await Task.WhenAll(running);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine("sparkplug-sim: stopping.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
await edge.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
static string Env(string name, string fallback) =>
|
||||
Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : fallback;
|
||||
+917
@@ -0,0 +1,917 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Google.Protobuf;
|
||||
using MQTTnet;
|
||||
using MQTTnet.Protocol;
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
|
||||
|
||||
/// <summary>
|
||||
/// A controllable Sparkplug B <b>edge node</b> — the driver's counterparty on a real broker.
|
||||
/// Registers an NDEATH Last Will at CONNECT, publishes NBIRTH/DBIRTH then N/DDATA, honours a
|
||||
/// <c>Node Control/Rebirth</c> NCMD, and exposes the §3.6 pathologies (alias reuse across a
|
||||
/// rebirth, a seq gap, a stale-<c>bdSeq</c> death) as explicit, on-demand operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Encodes with the generated Tahu types, frames topics independently.</b> The payloads are
|
||||
/// built from <see cref="Payload"/> — the same generated schema the driver decodes with, which
|
||||
/// is what makes this fixture a genuine encode/decode symmetry check. The <i>topic</i> strings
|
||||
/// are composed here from literals rather than through the driver's own
|
||||
/// <c>SparkplugTopic.Format</c>: a simulator that borrowed the parser's formatter could not
|
||||
/// detect a bug in it, because both sides of the comparison would be wrong together.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What "the way a real edge node does it" means, concretely, and why each detail matters:</b>
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <b>NDEATH is the broker-published Last Will, registered at CONNECT</b> — not a message
|
||||
/// this class publishes on the way out. It carries a <c>bdSeq</c> metric and <b>no
|
||||
/// <c>seq</c></b>, because it is not a member of the node's sequence.
|
||||
/// <see cref="KillAsync"/> makes the broker fire it.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b><c>bdSeq</c> increments once per CONNECT</b> and appears in both the NBIRTH and the
|
||||
/// NDEATH of that session — that pairing is the only thing that lets a host tell a
|
||||
/// just-delivered stale will from a real death.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>NBIRTH restarts <c>seq</c> at 0</b>; DBIRTH/NDATA/DDATA/DDEATH continue it, wrapping
|
||||
/// 255 → 0.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>DATA metrics carry an alias and nothing else</b> — no name, no datatype. That is what
|
||||
/// every real post-birth DATA message looks like, and a driver that binds tags to aliases
|
||||
/// rather than names passes every test written against a friendlier simulator.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Signed integers ride as two's complement in the UNSIGNED proto field.</b> A
|
||||
/// <c>DataType.Int32</c> metric holding −1234 goes on the wire as <c>int_value =
|
||||
/// 4294966062</c>. Encoding it "helpfully" as a positive number would silently retire the
|
||||
/// driver's <c>ReinterpretSigned</c> path from the live gate.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Not thread-safe for concurrent publishes: <c>seq</c> is a single counter and interleaving
|
||||
/// publishes would manufacture the very gaps the tests are measuring. Drive one node from one
|
||||
/// test at a time; the NCMD-triggered rebirth is serialized against callers by
|
||||
/// <see cref="_publishGate"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SparkplugEdgeNode : IAsyncDisposable
|
||||
{
|
||||
/// <summary>The well-known Sparkplug node-control metric an NCMD sets to trigger a rebirth.</summary>
|
||||
public const string RebirthMetricName = "Node Control/Rebirth";
|
||||
|
||||
/// <summary>The Sparkplug session-token metric name, carried by NBIRTH and NDEATH.</summary>
|
||||
public const string BdSeqMetricName = "bdSeq";
|
||||
|
||||
private const string Namespace = "spBv1.0";
|
||||
|
||||
private readonly SparkplugEdgeNodeOptions _options;
|
||||
private readonly Action<string>? _log;
|
||||
private readonly SemaphoreSlim _publishGate = new(1, 1);
|
||||
|
||||
/// <summary>Device id → its metric catalog. Ordered so a birth's metric order is deterministic.</summary>
|
||||
private readonly ConcurrentDictionary<string, IReadOnlyList<SparkplugMetricSeed>> _devices = new(StringComparer.Ordinal);
|
||||
|
||||
private IMqttClient? _client;
|
||||
private IReadOnlyList<SparkplugMetricSeed> _nodeMetrics = [];
|
||||
private ulong _bdSeq;
|
||||
private int _sessions;
|
||||
private byte _seq;
|
||||
private bool _seqStarted;
|
||||
private int _rebirthCount;
|
||||
private int _disposed;
|
||||
private TaskCompletionSource<int> _rebirthSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Initializes a new simulated edge node. Does no I/O — call <see cref="ConnectAsync"/>.</summary>
|
||||
/// <param name="options">Broker connection + Sparkplug identity.</param>
|
||||
/// <param name="log">Optional line sink for the standalone runner's console output.</param>
|
||||
public SparkplugEdgeNode(SparkplugEdgeNodeOptions options, Action<string>? log = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(options.GroupId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(options.EdgeNodeId);
|
||||
|
||||
_options = options;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>The Sparkplug group id.</summary>
|
||||
public string GroupId => _options.GroupId;
|
||||
|
||||
/// <summary>The Sparkplug edge-node id.</summary>
|
||||
public string EdgeNodeId => _options.EdgeNodeId;
|
||||
|
||||
/// <summary>The current session token — incremented on every <see cref="ConnectAsync"/>.</summary>
|
||||
public ulong BdSeq => Volatile.Read(ref _bdSeq);
|
||||
|
||||
/// <summary>The <c>seq</c> of the most recently published sequenced message.</summary>
|
||||
public byte LastSeq => Volatile.Read(ref _seq);
|
||||
|
||||
/// <summary>How many rebirth NCMDs this node has honoured since construction.</summary>
|
||||
public int RebirthCount => Volatile.Read(ref _rebirthCount);
|
||||
|
||||
/// <summary>Whether the underlying MQTT client currently holds a session.</summary>
|
||||
public bool IsConnected => _client?.IsConnected ?? false;
|
||||
|
||||
/// <summary>The device ids this node currently publishes for.</summary>
|
||||
public IReadOnlyCollection<string> Devices => [.. _devices.Keys];
|
||||
|
||||
/// <summary>
|
||||
/// Replaces this node's own (NBIRTH-level) metric catalog. Takes effect at the next birth —
|
||||
/// which is exactly how an edge node reassigns an alias across a rebirth.
|
||||
/// </summary>
|
||||
/// <param name="metrics">The catalog.</param>
|
||||
public void SetNodeMetrics(params SparkplugMetricSeed[] metrics) =>
|
||||
_nodeMetrics = [.. metrics ?? []];
|
||||
|
||||
/// <summary>Replaces one device's (DBIRTH-level) metric catalog. Takes effect at the next birth.</summary>
|
||||
/// <param name="deviceId">The device id.</param>
|
||||
/// <param name="metrics">The catalog.</param>
|
||||
public void SetDeviceMetrics(string deviceId, params SparkplugMetricSeed[] metrics)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceId);
|
||||
_devices[deviceId] = [.. metrics ?? []];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the broker with an NDEATH Last Will registered for this session, and subscribes
|
||||
/// the node's NCMD topic so a rebirth request can be honoured.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>The Will is registered here or not at all.</b> MQTT fixes a client's will at CONNECT; a
|
||||
/// "death message" published later by the client itself is a different thing entirely and would
|
||||
/// never fire for the case that matters — the node dying without getting to say so.
|
||||
/// </remarks>
|
||||
/// <param name="cancellationToken">Cancellation for the connect.</param>
|
||||
/// <returns>A task that completes when the session is established and NCMD is subscribed.</returns>
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
|
||||
await DropClientAsync(graceful: true).ConfigureAwait(false);
|
||||
|
||||
// A NEW session ⇒ a new session token, starting at 0 and incrementing per CONNECT. Both the
|
||||
// Will registered below and the next NBIRTH carry it, which is the pairing a host uses to
|
||||
// discard a previous session's late-delivered will.
|
||||
Volatile.Write(ref _bdSeq, (ulong)(Interlocked.Increment(ref _sessions) - 1));
|
||||
|
||||
var client = new MqttClientFactory().CreateMqttClient();
|
||||
client.ApplicationMessageReceivedAsync += OnMessageAsync;
|
||||
|
||||
var clientId = _options.ClientId is { Length: > 0 } id ? id : $"sim-{Guid.NewGuid():N}";
|
||||
|
||||
var builder = new MqttClientOptionsBuilder()
|
||||
.WithTcpServer(_options.Host, _options.Port)
|
||||
.WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500)
|
||||
.WithCleanSession(true)
|
||||
.WithClientId(clientId)
|
||||
.WithKeepAlivePeriod(TimeSpan.FromSeconds(30))
|
||||
.WithTimeout(TimeSpan.FromSeconds(_options.TimeoutSeconds))
|
||||
|
||||
// NDEATH: QoS 1, retain false, no seq, carries this session's bdSeq. Sparkplug B v3.0 §6.
|
||||
.WithWillTopic(NodeTopic("NDEATH"))
|
||||
.WithWillPayload(BuildDeathPayload(CurrentBdSeq()).ToByteArray())
|
||||
.WithWillQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
|
||||
.WithWillRetain(false);
|
||||
|
||||
if (!string.IsNullOrEmpty(_options.Username))
|
||||
{
|
||||
builder = builder.WithCredentials(_options.Username, _options.Password ?? string.Empty);
|
||||
}
|
||||
|
||||
builder = builder.WithTlsOptions(ConfigureTls);
|
||||
|
||||
_client = client;
|
||||
await client.ConnectAsync(builder.Build(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var subscribe = new MqttClientSubscribeOptionsBuilder()
|
||||
.WithTopicFilter(f => f
|
||||
.WithTopic(NodeTopic("NCMD"))
|
||||
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce))
|
||||
.Build();
|
||||
await client.SubscribeAsync(subscribe, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Log($"connected as '{clientId}' (bdSeq {CurrentBdSeq()}), listening on {NodeTopic("NCMD")}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the node's birth certificate — NBIRTH at <c>seq = 0</c>, then one DBIRTH per
|
||||
/// device continuing the same sequence.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation for the publishes.</param>
|
||||
/// <returns>A task that completes when every birth message has been published.</returns>
|
||||
public async Task BirthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await BirthCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an NDATA carrying the named node-level metrics, encoded the way a real edge node
|
||||
/// does: <b>alias only</b>, resolved from the current birth catalog.
|
||||
/// </summary>
|
||||
/// <param name="values">Metric name → new value. A name not in the catalog throws.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the publish.</param>
|
||||
/// <returns>A task that completes when the message has been published.</returns>
|
||||
public Task PublishNodeDataAsync(
|
||||
IReadOnlyList<(string Metric, object? Value)> values,
|
||||
CancellationToken cancellationToken) =>
|
||||
PublishDataAsync(device: null, values, cancellationToken);
|
||||
|
||||
/// <summary>Publishes a DDATA for one device — alias only, same sequence as the node's own stream.</summary>
|
||||
/// <param name="deviceId">The device id.</param>
|
||||
/// <param name="values">Metric name → new value.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the publish.</param>
|
||||
/// <returns>A task that completes when the message has been published.</returns>
|
||||
public Task PublishDeviceDataAsync(
|
||||
string deviceId,
|
||||
IReadOnlyList<(string Metric, object? Value)> values,
|
||||
CancellationToken cancellationToken) =>
|
||||
PublishDataAsync(deviceId, values, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an NDATA whose metrics carry an alias the current birth does <b>not</b> declare —
|
||||
/// the "unknown alias" arm of §3.6.
|
||||
/// </summary>
|
||||
/// <param name="alias">The undeclared alias.</param>
|
||||
/// <param name="value">A double value, encoded as <c>double_value</c>.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the publish.</param>
|
||||
/// <returns>A task that completes when the message has been published.</returns>
|
||||
public async Task PublishUnknownAliasDataAsync(ulong alias, double value, CancellationToken cancellationToken)
|
||||
{
|
||||
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var payload = NewSequencedPayload();
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Alias = alias,
|
||||
Timestamp = NowMs(),
|
||||
DoubleValue = value,
|
||||
});
|
||||
|
||||
await PublishAsync(NodeTopic("NDATA"), payload, _options.DataQos, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Burns <paramref name="count"/> sequence numbers without publishing them — the next message
|
||||
/// therefore arrives with a <c>seq</c> the host was not expecting. This is how a lost message
|
||||
/// looks from the receiving end, produced deterministically instead of by hoping for packet loss.
|
||||
/// </summary>
|
||||
/// <param name="count">How many sequence numbers to skip; must be at least 1.</param>
|
||||
public void SkipSeq(int count = 1)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(count, 1);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
NextSeq();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an NDEATH <b>directly</b>, carrying <paramref name="bdSeq"/>. Two uses: a stale
|
||||
/// token (a previous session's will, delivered late) which a correct host must ignore, and the
|
||||
/// current token, which it must act on.
|
||||
/// </summary>
|
||||
/// <param name="bdSeq">The session token to stamp; <see langword="null"/> uses the current one.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the publish.</param>
|
||||
/// <returns>A task that completes when the message has been published.</returns>
|
||||
/// <remarks>
|
||||
/// A real NDEATH is broker-issued (see <see cref="KillAsync"/>); this is the deterministic
|
||||
/// equivalent for the ordering case a test cannot otherwise stage — a will that arrives
|
||||
/// <i>after</i> the reconnect's NBIRTH.
|
||||
/// </remarks>
|
||||
public async Task PublishNodeDeathAsync(ulong? bdSeq, CancellationToken cancellationToken)
|
||||
{
|
||||
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// QoS 1 and NO seq — an NDEATH is not a member of the node's sequence.
|
||||
await PublishAsync(
|
||||
NodeTopic("NDEATH"),
|
||||
BuildDeathPayload(bdSeq ?? CurrentBdSeq()),
|
||||
qos: 1,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Publishes a DDEATH for one device — sequenced, unlike an NDEATH.</summary>
|
||||
/// <param name="deviceId">The device id.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the publish.</param>
|
||||
/// <returns>A task that completes when the message has been published.</returns>
|
||||
public async Task PublishDeviceDeathAsync(string deviceId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceId);
|
||||
|
||||
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await PublishAsync(
|
||||
DeviceTopic("DDEATH", deviceId),
|
||||
NewSequencedPayload(),
|
||||
_options.DataQos,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends the session in a way that makes the <b>broker</b> publish this node's registered
|
||||
/// NDEATH Will — the realistic death path.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation for the disconnect.</param>
|
||||
/// <returns>A task that completes once the client has gone.</returns>
|
||||
/// <remarks>
|
||||
/// Uses the MQTT 5 <c>DISCONNECT</c> reason code <c>0x04 Disconnect with Will Message</c>, which
|
||||
/// is the protocol's own "I am going away, publish my will" signal. Deliberately chosen over
|
||||
/// yanking the socket: both produce the will, but only this one does so <i>deterministically</i>
|
||||
/// and immediately — a killed socket leaves the broker waiting out the keep-alive, which would
|
||||
/// make the death test depend on a 30-second timer.
|
||||
/// </remarks>
|
||||
public async Task KillAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _client;
|
||||
if (client is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new MqttClientDisconnectOptionsBuilder()
|
||||
.WithReason(MqttClientDisconnectOptionsReason.DisconnectWithWillMessage)
|
||||
.Build();
|
||||
|
||||
try
|
||||
{
|
||||
await client.DisconnectAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"kill: disconnect threw ({ex.GetType().Name}); the socket is gone either way");
|
||||
}
|
||||
|
||||
client.ApplicationMessageReceivedAsync -= OnMessageAsync;
|
||||
client.Dispose();
|
||||
_client = null;
|
||||
Log($"killed session (bdSeq {CurrentBdSeq()}); the broker owns the NDEATH now");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Awaits the <paramref name="count"/>-th honoured rebirth NCMD. Pre-checks what has already
|
||||
/// happened, so a caller that registers after the command landed is served immediately.
|
||||
/// </summary>
|
||||
/// <param name="count">The rebirth count to wait for.</param>
|
||||
/// <param name="timeout">Bound on the wait.</param>
|
||||
/// <param name="cancellationToken">Caller cancellation.</param>
|
||||
/// <returns>The rebirth count once it has reached <paramref name="count"/>.</returns>
|
||||
public async Task<int> WaitForRebirthAsync(int count, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (true)
|
||||
{
|
||||
var observed = RebirthCount;
|
||||
if (observed >= count)
|
||||
{
|
||||
return observed;
|
||||
}
|
||||
|
||||
var remaining = deadline - DateTime.UtcNow;
|
||||
if (remaining <= TimeSpan.Zero)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"Simulated edge node '{GroupId}/{EdgeNodeId}' honoured {observed} rebirth request(s) "
|
||||
+ $"within {timeout.TotalSeconds:0}s; expected at least {count}. Is the driver's "
|
||||
+ "requestRebirthOnGap on, and is the node's NCMD subscription live?");
|
||||
}
|
||||
|
||||
var signal = Volatile.Read(ref _rebirthSignal);
|
||||
try
|
||||
{
|
||||
await signal.Task.WaitAsync(remaining, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Loop; the deadline check above produces the diagnostic message.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The standalone (container) loop: birth once, then publish node + device data every
|
||||
/// <paramref name="interval"/> until cancelled. Rebirth NCMDs are honoured throughout.
|
||||
/// </summary>
|
||||
/// <param name="interval">Publish cadence.</param>
|
||||
/// <param name="cancellationToken">Stops the loop.</param>
|
||||
/// <returns>A task that completes when the loop is cancelled.</returns>
|
||||
public async Task RunAsync(TimeSpan interval, CancellationToken cancellationToken)
|
||||
{
|
||||
await BirthAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tick = 0;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
tick++;
|
||||
try
|
||||
{
|
||||
await PublishNodeDataAsync(
|
||||
[.. _nodeMetrics
|
||||
.Where(m => m.Name is not BdSeqMetricName and not RebirthMetricName)
|
||||
.Select(m => (m.Name, Advance(m, tick)))],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var (device, metrics) in _devices)
|
||||
{
|
||||
await PublishDeviceDataAsync(
|
||||
device,
|
||||
[.. metrics.Select(m => (m.Name, Advance(m, tick)))],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Log($"publish failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await DropClientAsync(graceful: true).ConfigureAwait(false);
|
||||
_publishGate.Dispose();
|
||||
}
|
||||
|
||||
// ---- publishing ----
|
||||
|
||||
private async Task BirthCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// An NBIRTH restarts the sequence: seq = 0, by spec, on the birth itself.
|
||||
Volatile.Write(ref _seq, 0);
|
||||
_seqStarted = true;
|
||||
|
||||
var nbirth = new Payload { Timestamp = NowMs(), Seq = 0UL };
|
||||
|
||||
// bdSeq first, as Tahu's own reference edge node emits it: it is the session token the
|
||||
// matching NDEATH will carry.
|
||||
nbirth.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = BdSeqMetricName,
|
||||
Datatype = (uint)DataType.Int64,
|
||||
Timestamp = NowMs(),
|
||||
LongValue = CurrentBdSeq(),
|
||||
});
|
||||
|
||||
// Every conformant edge node declares the rebirth control point in its NBIRTH.
|
||||
nbirth.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = RebirthMetricName,
|
||||
Datatype = (uint)DataType.Boolean,
|
||||
Timestamp = NowMs(),
|
||||
BooleanValue = false,
|
||||
});
|
||||
|
||||
foreach (var metric in _nodeMetrics)
|
||||
{
|
||||
nbirth.Metrics.Add(EncodeBirthMetric(metric));
|
||||
}
|
||||
|
||||
await PublishAsync(NodeTopic("NBIRTH"), nbirth, _options.DataQos, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var (device, metrics) in _devices)
|
||||
{
|
||||
// A DBIRTH is a sequenced member of the edge node's stream — it does NOT restart seq, and
|
||||
// it carries no bdSeq.
|
||||
var dbirth = NewSequencedPayload();
|
||||
foreach (var metric in metrics)
|
||||
{
|
||||
dbirth.Metrics.Add(EncodeBirthMetric(metric));
|
||||
}
|
||||
|
||||
await PublishAsync(DeviceTopic("DBIRTH", device), dbirth, _options.DataQos, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Log($"birthed (bdSeq {CurrentBdSeq()}, {_nodeMetrics.Count} node metric(s), {_devices.Count} device(s))");
|
||||
}
|
||||
|
||||
private async Task PublishDataAsync(
|
||||
string? device,
|
||||
IReadOnlyList<(string Metric, object? Value)> values,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
|
||||
var catalog = device is null
|
||||
? _nodeMetrics
|
||||
: _devices.TryGetValue(device, out var found) ? found : [];
|
||||
|
||||
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var payload = NewSequencedPayload();
|
||||
foreach (var (name, value) in values)
|
||||
{
|
||||
var seed = catalog.FirstOrDefault(m => string.Equals(m.Name, name, StringComparison.Ordinal))
|
||||
?? throw new InvalidOperationException(
|
||||
$"Metric '{name}' is not in the current birth catalog for "
|
||||
+ $"'{GroupId}/{EdgeNodeId}{(device is null ? "" : "/" + device)}'. A DATA message can only "
|
||||
+ "carry metrics a birth declared — that is the whole point of the alias table.");
|
||||
|
||||
payload.Metrics.Add(EncodeDataMetric(seed.WithValue(value)));
|
||||
}
|
||||
|
||||
var topic = device is null ? NodeTopic("NDATA") : DeviceTopic("DDATA", device);
|
||||
await PublishAsync(topic, payload, _options.DataQos, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PublishAsync(string topic, Payload payload, int qos, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _client ?? throw new InvalidOperationException(
|
||||
$"Simulated edge node '{GroupId}/{EdgeNodeId}' is not connected; call ConnectAsync first.");
|
||||
|
||||
var message = new MqttApplicationMessageBuilder()
|
||||
.WithTopic(topic)
|
||||
.WithPayload(payload.ToByteArray())
|
||||
.WithQualityOfServiceLevel((MqttQualityOfServiceLevel)Math.Clamp(qos, 0, 2))
|
||||
.WithRetainFlag(false) // Sparkplug never retains birth or data messages.
|
||||
.Build();
|
||||
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
deadline.CancelAfter(TimeSpan.FromSeconds(_options.TimeoutSeconds));
|
||||
await client.PublishAsync(message, deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ---- inbound NCMD ----
|
||||
|
||||
private async Task OnMessageAsync(MqttApplicationMessageReceivedEventArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!IsRebirthCommand(args.ApplicationMessage))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var honoured = Interlocked.Increment(ref _rebirthCount);
|
||||
Log($"rebirth NCMD #{honoured} received; republishing metadata");
|
||||
|
||||
await _publishGate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await BirthCoreAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishGate.Release();
|
||||
}
|
||||
|
||||
// Swap the signal AFTER the birth is on the wire, so a waiter that wakes on it can
|
||||
// immediately assert against the republished state rather than racing it.
|
||||
var previous = Interlocked.Exchange(
|
||||
ref _rebirthSignal,
|
||||
new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
previous.TrySetResult(honoured);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// This runs on MQTTnet's dispatcher thread; a throw here stalls delivery for the client.
|
||||
Log($"NCMD handling failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsRebirthCommand(MqttApplicationMessage message)
|
||||
{
|
||||
if (!string.Equals(message.Topic, NodeTopic("NCMD"), StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var body = message.Payload;
|
||||
var bytes = body.IsSingleSegment ? body.FirstSpan.ToArray() : body.ToArray();
|
||||
|
||||
Payload decoded;
|
||||
try
|
||||
{
|
||||
decoded = Payload.Parser.ParseFrom(bytes);
|
||||
}
|
||||
catch (InvalidProtocolBufferException)
|
||||
{
|
||||
Log("NCMD payload was not a Sparkplug payload; ignored");
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var metric in decoded.Metrics)
|
||||
{
|
||||
if (string.Equals(metric.Name, RebirthMetricName, StringComparison.Ordinal)
|
||||
&& metric.ValueCase == Payload.Types.Metric.ValueOneofCase.BooleanValue
|
||||
&& metric.BooleanValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- encoding ----
|
||||
|
||||
/// <summary>
|
||||
/// A birth metric: name, alias, datatype and value — everything a host needs to build its alias
|
||||
/// table.
|
||||
/// </summary>
|
||||
private static Payload.Types.Metric EncodeBirthMetric(SparkplugMetricSeed seed)
|
||||
{
|
||||
var metric = new Payload.Types.Metric
|
||||
{
|
||||
Name = seed.Name,
|
||||
Alias = seed.Alias,
|
||||
Datatype = (uint)seed.DataType,
|
||||
Timestamp = NowMs(),
|
||||
};
|
||||
|
||||
ApplyValue(metric, seed.DataType, seed.Value);
|
||||
return metric;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A DATA metric: <b>alias only</b>. No name, no datatype — which is how a real edge node
|
||||
/// publishes after a birth, and the shape that makes bind-by-name load-bearing on the host.
|
||||
/// </summary>
|
||||
private static Payload.Types.Metric EncodeDataMetric(SparkplugMetricSeed seed)
|
||||
{
|
||||
var metric = new Payload.Types.Metric
|
||||
{
|
||||
Alias = seed.Alias,
|
||||
Timestamp = NowMs(),
|
||||
};
|
||||
|
||||
ApplyValue(metric, seed.DataType, seed.Value);
|
||||
return metric;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a value into the metric's <c>oneof</c>, in the field Sparkplug's datatype dictates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Signed integers go on the wire as two's complement in the UNSIGNED field</b> — Int8/16/32
|
||||
/// in <c>int_value</c>, Int64 in <c>long_value</c>. This is the encoding a host has to undo, and
|
||||
/// writing a negative value any other way would quietly retire that code path from the gate.
|
||||
/// </remarks>
|
||||
private static void ApplyValue(Payload.Types.Metric metric, DataType dataType, object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
// An explicit null, not an omitted value: the two mean different things, and only the
|
||||
// explicit form says "this metric exists and currently has no value".
|
||||
metric.IsNull = true;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (dataType)
|
||||
{
|
||||
case DataType.Int8:
|
||||
metric.IntValue = unchecked((uint)(sbyte)Convert.ToSByte(value, CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case DataType.Int16:
|
||||
metric.IntValue = unchecked((uint)Convert.ToInt16(value, CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case DataType.Int32:
|
||||
metric.IntValue = unchecked((uint)Convert.ToInt32(value, CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case DataType.Int64:
|
||||
metric.LongValue = unchecked((ulong)Convert.ToInt64(value, CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case DataType.Uint8:
|
||||
case DataType.Uint16:
|
||||
case DataType.Uint32:
|
||||
metric.IntValue = Convert.ToUInt32(value, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DataType.Uint64:
|
||||
metric.LongValue = Convert.ToUInt64(value, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DataType.Float:
|
||||
metric.FloatValue = Convert.ToSingle(value, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DataType.Double:
|
||||
metric.DoubleValue = Convert.ToDouble(value, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DataType.Boolean:
|
||||
metric.BooleanValue = Convert.ToBoolean(value, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DataType.DateTime:
|
||||
metric.LongValue = value is DateTime dt
|
||||
? (ulong)new DateTimeOffset(dt.ToUniversalTime()).ToUnixTimeMilliseconds()
|
||||
: Convert.ToUInt64(value, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DataType.String:
|
||||
case DataType.Text:
|
||||
case DataType.Uuid:
|
||||
metric.StringValue = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
break;
|
||||
case DataType.Bytes:
|
||||
case DataType.File:
|
||||
metric.BytesValue = ByteString.CopyFrom((byte[])value);
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException(
|
||||
$"The simulator does not encode Sparkplug datatype {dataType}; v1 of the driver does "
|
||||
+ "not consume it either.");
|
||||
}
|
||||
}
|
||||
|
||||
private Payload NewSequencedPayload()
|
||||
{
|
||||
var payload = new Payload { Timestamp = NowMs(), Seq = NextSeq() };
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// <summary>Advances the wrapping 0–255 sequence and returns the value to stamp.</summary>
|
||||
private ulong NextSeq()
|
||||
{
|
||||
if (!_seqStarted)
|
||||
{
|
||||
// Nothing has birthed yet: start the stream at 0 rather than at 1, so a data-before-birth
|
||||
// test still produces a legal-looking stream.
|
||||
_seqStarted = true;
|
||||
Volatile.Write(ref _seq, 0);
|
||||
return 0UL;
|
||||
}
|
||||
|
||||
var next = unchecked((byte)(Volatile.Read(ref _seq) + 1));
|
||||
Volatile.Write(ref _seq, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
private Payload BuildDeathPayload(ulong bdSeq)
|
||||
{
|
||||
// NO seq: an NDEATH is the broker-published Last Will and is not a member of the node's
|
||||
// sequence. Adding one here would be the single most plausible-looking way to make every
|
||||
// death look like a gap on the host.
|
||||
var payload = new Payload { Timestamp = NowMs() };
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = BdSeqMetricName,
|
||||
Datatype = (uint)DataType.Int64,
|
||||
Timestamp = NowMs(),
|
||||
LongValue = bdSeq,
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private ulong CurrentBdSeq() => Volatile.Read(ref _bdSeq);
|
||||
|
||||
private string NodeTopic(string type) => $"{Namespace}/{_options.GroupId}/{type}/{_options.EdgeNodeId}";
|
||||
|
||||
private string DeviceTopic(string type, string device) =>
|
||||
$"{Namespace}/{_options.GroupId}/{type}/{_options.EdgeNodeId}/{device}";
|
||||
|
||||
private static ulong NowMs() => (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
/// <summary>Nudges a seed's value so the standalone loop publishes something that visibly moves.</summary>
|
||||
private static object? Advance(SparkplugMetricSeed seed, int tick) => seed.DataType switch
|
||||
{
|
||||
DataType.Float => 20f + (tick % 50) / 10f,
|
||||
DataType.Double => 100d + (tick % 100) / 10d,
|
||||
DataType.Int32 or DataType.Int16 or DataType.Int8 => -tick,
|
||||
DataType.Int64 => (long)tick,
|
||||
DataType.Uint8 or DataType.Uint16 or DataType.Uint32 or DataType.Uint64 => (uint)tick,
|
||||
DataType.Boolean => tick % 2 == 0,
|
||||
DataType.String or DataType.Text or DataType.Uuid => $"tick-{tick}",
|
||||
_ => seed.Value,
|
||||
};
|
||||
|
||||
private void ConfigureTls(MqttClientTlsOptionsBuilder tls)
|
||||
{
|
||||
if (!_options.UseTls)
|
||||
{
|
||||
tls.UseTls(false);
|
||||
return;
|
||||
}
|
||||
|
||||
tls.UseTls(true).WithTargetHost(_options.Host);
|
||||
|
||||
if (_options.AllowUntrustedServerCertificate)
|
||||
{
|
||||
tls.WithAllowUntrustedCertificates(true)
|
||||
.WithIgnoreCertificateChainErrors(true)
|
||||
.WithCertificateValidationHandler(static _ => true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.CaCertificatePath))
|
||||
{
|
||||
return; // OS trust store.
|
||||
}
|
||||
|
||||
var path = _options.CaCertificatePath;
|
||||
var roots = new Lazy<X509Certificate2Collection?>(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var collection = new X509Certificate2Collection();
|
||||
collection.ImportFromPemFile(path);
|
||||
return collection.Count == 0 ? null : collection;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
},
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
tls.WithCertificateValidationHandler(args =>
|
||||
{
|
||||
if (roots.Value is not { Count: > 0 } trusted || args.Certificate is null)
|
||||
{
|
||||
return false; // Fail closed, exactly as the driver's own pin does.
|
||||
}
|
||||
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.AddRange(trusted);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
|
||||
var presented = args.Certificate as X509Certificate2
|
||||
?? X509CertificateLoader.LoadCertificate(args.Certificate.Export(X509ContentType.Cert));
|
||||
|
||||
return chain.Build(presented);
|
||||
});
|
||||
}
|
||||
|
||||
private async Task DropClientAsync(bool graceful)
|
||||
{
|
||||
var client = _client;
|
||||
_client = null;
|
||||
if (client is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
client.ApplicationMessageReceivedAsync -= OnMessageAsync;
|
||||
|
||||
if (graceful)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await client.DisconnectAsync(new MqttClientDisconnectOptions(), cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// The broker may already be gone; teardown is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
private void Log(string message) => _log?.Invoke($"[{GroupId}/{EdgeNodeId}] {message}");
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
|
||||
|
||||
/// <summary>
|
||||
/// Connection + identity settings for one simulated Sparkplug B edge node.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately its own shape rather than a reuse of the driver's <c>MqttDriverOptions</c>: an edge
|
||||
/// node is the driver's <i>counterparty</i>, and reusing the driver's options record would drag the
|
||||
/// driver assembly into this project and blur which side of the wire a setting belongs to. The TLS
|
||||
/// knobs mirror the driver's by name so a rig configured for one is configured for the other.
|
||||
/// </remarks>
|
||||
public sealed record SparkplugEdgeNodeOptions
|
||||
{
|
||||
/// <summary>Broker hostname or IP.</summary>
|
||||
public string Host { get; init; } = "localhost";
|
||||
|
||||
/// <summary>Broker TCP port.</summary>
|
||||
public int Port { get; init; } = 8883;
|
||||
|
||||
/// <summary>Connect over TLS.</summary>
|
||||
public bool UseTls { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// PEM CA file pinning the broker chain. <see langword="null"/>/empty falls back to the OS
|
||||
/// trust store (or to <see cref="AllowUntrustedServerCertificate"/> when that is set).
|
||||
/// </summary>
|
||||
public string? CaCertificatePath { get; init; }
|
||||
|
||||
/// <summary>Accept any broker certificate. Fixture escape hatch; never a deployment posture.</summary>
|
||||
public bool AllowUntrustedServerCertificate { get; init; }
|
||||
|
||||
/// <summary>Broker username. The fixture broker runs <c>allow_anonymous false</c>.</summary>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>Broker password.</summary>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>The Sparkplug group id this node publishes under.</summary>
|
||||
public string GroupId { get; init; } = "Plant1";
|
||||
|
||||
/// <summary>The Sparkplug edge-node id.</summary>
|
||||
public string EdgeNodeId { get; init; } = "EdgeA";
|
||||
|
||||
/// <summary>
|
||||
/// MQTT client id. Left unset a unique one is generated — two simulated nodes sharing a client
|
||||
/// id would knock each other off the broker.
|
||||
/// </summary>
|
||||
public string? ClientId { get; init; }
|
||||
|
||||
/// <summary>Bounded connect/publish deadline, in seconds.</summary>
|
||||
public int TimeoutSeconds { get; init; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// QoS for NBIRTH/DBIRTH/NDATA/DDATA/DDEATH. Sparkplug B publishes all of these at QoS 0
|
||||
/// (only NDEATH — the broker-issued Will — and STATE are QoS 1), which is the default here.
|
||||
/// </summary>
|
||||
public int DataQos { get; init; }
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
|
||||
|
||||
/// <summary>
|
||||
/// One metric in a simulated edge node's (or device's) catalog: the name a birth certificate
|
||||
/// declares, the alias it binds that name to for the life of the birth, the Sparkplug datatype, and
|
||||
/// the value the birth publishes.
|
||||
/// </summary>
|
||||
/// <param name="Name">The stable metric name — what an authored tag binds to.</param>
|
||||
/// <param name="Alias">
|
||||
/// The per-birth alias. <b>Mutable across rebirths by design</b>: reassigning an alias to a
|
||||
/// different metric on a rebirth is legal Sparkplug and is §3.6 matrix case (i), the one that
|
||||
/// silently mis-routes data in a driver that binds by alias.
|
||||
/// </param>
|
||||
/// <param name="DataType">The Sparkplug datatype the birth declares for this metric.</param>
|
||||
/// <param name="Value">
|
||||
/// The value the birth publishes. <see langword="null"/> is encoded as an explicit
|
||||
/// <c>is_null</c> metric rather than as an omitted value — those mean different things on the wire.
|
||||
/// </param>
|
||||
public sealed record SparkplugMetricSeed(string Name, ulong Alias, DataType DataType, object? Value)
|
||||
{
|
||||
/// <summary>Returns this seed with a different value, leaving name/alias/type alone.</summary>
|
||||
/// <param name="value">The new value.</param>
|
||||
/// <returns>The updated seed.</returns>
|
||||
public SparkplugMetricSeed WithValue(object? value) => this with { Value = value };
|
||||
|
||||
/// <summary>Returns this seed with a different alias — the alias-reuse case.</summary>
|
||||
/// <param name="alias">The new alias.</param>
|
||||
/// <returns>The updated seed.</returns>
|
||||
public SparkplugMetricSeed WithAlias(ulong alias) => this with { Alias = alias };
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The Sparkplug B edge-node simulator: one engine, two consumers.
|
||||
|
||||
1. `SparkplugLiveTests` (the sibling IntegrationTests project) references this project and
|
||||
drives `SparkplugEdgeNode` IN PROCESS, because the §3.6 matrix needs an edge node that can
|
||||
be TOLD to reuse an alias, skip a seq, or die — a container cannot be commanded mid-test.
|
||||
2. `Docker/docker-compose.yml`'s `sparkplug-sim` service runs the very same assembly as a
|
||||
standalone always-on edge node (see `Program`), so an operator can drive the AdminUI
|
||||
Sparkplug picker against live birth/data traffic.
|
||||
|
||||
It is therefore an Exe (a container entrypoint) that is also referenced as a library. That is
|
||||
the reason it is a nested project rather than a folder of files in the test project, and the
|
||||
reason the test project's csproj removes this directory from its default globs.
|
||||
|
||||
DEPENDENCIES ARE DELIBERATELY NARROW: `.Contracts` (for the generated Tahu `Payload` types —
|
||||
the ENCODE side of the encode/decode symmetry this fixture exists to prove) plus MQTTnet. It
|
||||
does NOT reference the runtime `.Driver` project, on purpose: a simulator that formatted its
|
||||
topics with the driver's own `SparkplugTopic.Format` could not detect a bug in it, because
|
||||
both sides of the comparison would be wrong together. The schema is shared (it IS the spec);
|
||||
the framing logic is written independently here.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MQTTnet"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- OTOPCUA0001 (arch-review 07/C-1): this suite invokes driver capability methods DIRECTLY to
|
||||
exercise wire-level behavior against a real broker — the analyzer's documented intentional
|
||||
case ("move the suppression into a NoWarn for the test project"). Not a resilience-dispatch
|
||||
gap. Same suppression as the S7 / AB CIP integration suites. -->
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<!--
|
||||
Direct reference (not merely transitive through the driver) because the tests
|
||||
publish into the fixture broker with their own raw MQTTnet client — the
|
||||
unauthored-topic test has to emit traffic the DRIVER never emits.
|
||||
-->
|
||||
<PackageReference Include="MQTTnet"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
SparkplugSimulator/ is its OWN project (an Exe: it is also the `sparkplug-sim` fixture
|
||||
container's entrypoint — see its csproj banner), nested here because the live tests are its
|
||||
other consumer. The SDK's default globs would otherwise compile its sources into this
|
||||
assembly as well, so they are removed and the project is referenced instead.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<Compile Remove="SparkplugSimulator\**"/>
|
||||
<None Remove="SparkplugSimulator\**"/>
|
||||
<EmbeddedResource Remove="SparkplugSimulator\**"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
||||
<!--
|
||||
The address-picker browser, for the Sparkplug browse legs: the passive-window guarantee
|
||||
("opening a picker against a live plant publishes nothing") is only worth asserting
|
||||
against a real broker, where the assertion can be made on the WIRE rather than on the
|
||||
session's own publish counter.
|
||||
-->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj"/>
|
||||
<ProjectReference Include="SparkplugSimulator\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Docker/ is DELIBERATELY not copied to the output directory (the sibling Modbus /
|
||||
S7 suites copy theirs because their tests read simulator profile JSONs at
|
||||
runtime; nothing here does). Copying it would also drag Docker/secrets/ — the
|
||||
generated broker password hash and the CA private key — into bin/, i.e. out of
|
||||
the one directory .gitignore protects.
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,393 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||||
|
||||
// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
|
||||
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
|
||||
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
|
||||
// scope does not cross a ProjectReference.
|
||||
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="AliasTable"/> + <see cref="BirthCache"/> (Task 18) — design doc §3.6
|
||||
/// invariants #1 and #2.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>What is actually being pinned here.</b> Sparkplug's alias mechanism exists to shrink DATA
|
||||
/// payloads: a BIRTH declares <c>name ↔ alias</c> and every subsequent DATA metric carries only
|
||||
/// the alias. The alias is scoped to <i>one birth</i> — after a rebirth an edge node is free to
|
||||
/// assign alias 5 to an entirely different metric. If this driver merged alias tables across
|
||||
/// births, or bound authored tags to aliases rather than to the stable metric name, a rebirth
|
||||
/// would silently start routing Pressure readings into a Temperature tag: <b>good quality,
|
||||
/// plausible values, completely wrong</b>. It is invisible without a test that specifically
|
||||
/// reuses an alias across a rebirth, which is what
|
||||
/// <see cref="Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias"/> and
|
||||
/// <see cref="DeviceRebirth_ReusingAnAliasForADifferentMetric_ResolvesByName"/> do.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The cross-scope half of the same hazard is that a device's alias table can outlive the birth
|
||||
/// it came from when its <i>edge node</i> re-births — pinned by
|
||||
/// <see cref="NodeBirth_InvalidatesEveryDeviceUnderThatNode"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AliasBindingTests
|
||||
{
|
||||
// ---- AliasTable: the two load-bearing invariants ----
|
||||
|
||||
/// <summary>
|
||||
/// §3.6 invariant #1/#2, the headline case: a rebirth may point the same alias at a different
|
||||
/// metric, and the table must report the <b>new</b> metric — i.e. the birth replaced the map
|
||||
/// rather than merging into it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias()
|
||||
{
|
||||
var table = new AliasTable();
|
||||
|
||||
table.RebuildFromBirth([Birth("Temperature", 5, SparkplugDataType.Float)]);
|
||||
table.Resolve(5)!.Name.ShouldBe("Temperature");
|
||||
|
||||
// Rebirth: alias 5 now means a DIFFERENT metric.
|
||||
table.RebuildFromBirth([Birth("Pressure", 5, SparkplugDataType.Float)]);
|
||||
|
||||
table.Resolve(5)!.Name.ShouldBe("Pressure"); // wholesale replace, not merge
|
||||
table.TryResolveByName("Temperature", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §3.6 invariant #2: an alias the new birth did not declare must be <b>gone</b>, not carried
|
||||
/// forward. A merge would leave alias 1 resolving to a metric the edge node no longer publishes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RebuildFromBirth_DoesNotMergeStaleAliases()
|
||||
{
|
||||
var table = new AliasTable();
|
||||
|
||||
table.RebuildFromBirth([Birth("A", 1, SparkplugDataType.Int32)]);
|
||||
table.RebuildFromBirth([Birth("B", 2, SparkplugDataType.Int32)]);
|
||||
|
||||
table.TryResolve(1, out _).ShouldBeFalse(); // alias 1 gone after rebirth
|
||||
table.TryResolve(2, out _).ShouldBeTrue();
|
||||
table.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The degenerate replace path: a birth carrying no usable metric still <b>clears</b> the table.
|
||||
/// An implementation that short-circuits on an empty input (or merges) keeps every stale alias
|
||||
/// alive behind a birth that declared none of them.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RebuildFromBirth_EmptyBirth_ClearsEveryAlias()
|
||||
{
|
||||
var table = new AliasTable();
|
||||
table.RebuildFromBirth([Birth("A", 1, SparkplugDataType.Int32), Birth("B", 2, SparkplugDataType.Int32)]);
|
||||
|
||||
table.RebuildFromBirth([]);
|
||||
|
||||
table.Count.ShouldBe(0);
|
||||
table.TryResolve(1, out _).ShouldBeFalse();
|
||||
table.TryResolve(2, out _).ShouldBeFalse();
|
||||
table.Metrics.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The mirror of the headline case: the <i>metric</i> stays put and its alias moves. Binding is
|
||||
/// by name, so the tag keeps resolving; only the per-birth alias cache changed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rebirth_MovesAMetricToADifferentAlias_NameBindingFollowsIt()
|
||||
{
|
||||
var table = new AliasTable();
|
||||
table.RebuildFromBirth([Birth("Temperature", 5, SparkplugDataType.Float)]);
|
||||
|
||||
table.RebuildFromBirth([Birth("Temperature", 9, SparkplugDataType.Float)]);
|
||||
|
||||
table.TryResolveByName("Temperature", out var byName).ShouldBeTrue();
|
||||
byName!.Alias.ShouldBe(9ul);
|
||||
table.Resolve(9)!.Name.ShouldBe("Temperature");
|
||||
table.TryResolve(5, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- AliasTable: malformed births ----
|
||||
|
||||
/// <summary>
|
||||
/// A BIRTH metric carrying an alias but no name is malformed and is <b>rejected</b>, never
|
||||
/// stored alias-only: the name is the binding key, so an alias-only entry could only ever
|
||||
/// resolve to a binding no authored tag can match — or, worse, match a tag whose authored
|
||||
/// metric name happens to be blank.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BirthMetricWithAnAliasButNoName_IsRejected_NotStoredAliasOnly()
|
||||
{
|
||||
var table = new AliasTable();
|
||||
|
||||
var result = table.RebuildFromBirth([Birth(null, 5, SparkplugDataType.Float), Birth(" ", 6, SparkplugDataType.Float)]);
|
||||
|
||||
result.Accepted.ShouldBe(0);
|
||||
result.RejectedUnnamed.ShouldBe(2);
|
||||
result.HasAnomaly.ShouldBeTrue();
|
||||
table.TryResolve(5, out _).ShouldBeFalse();
|
||||
table.TryResolve(6, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aliases are optional in Sparkplug — a publisher may declare a metric by name alone and then
|
||||
/// send DATA carrying the name. Such a metric is a legitimate binding; it simply never appears
|
||||
/// in the alias index.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BirthMetricWithNoAlias_IsBindableByName_AndAbsentFromTheAliasIndex()
|
||||
{
|
||||
var table = new AliasTable();
|
||||
|
||||
table.RebuildFromBirth([Birth("Temperature", null, SparkplugDataType.Float)]);
|
||||
|
||||
table.TryResolveByName("Temperature", out var binding).ShouldBeTrue();
|
||||
binding!.Alias.ShouldBeNull();
|
||||
table.Metrics.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two metrics claiming the same alias in one birth is malformed, and any resolution is a coin
|
||||
/// flip between two different metrics — precisely the mis-route this whole type exists to
|
||||
/// prevent. The colliding alias is dropped from the alias index (the consumer sees an unknown
|
||||
/// alias and can request a rebirth) while both metrics stay bindable by their names.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DuplicateAliasInOneBirth_IsUnresolvable_RatherThanGuessed()
|
||||
{
|
||||
var table = new AliasTable();
|
||||
|
||||
var result = table.RebuildFromBirth(
|
||||
[Birth("Temperature", 5, SparkplugDataType.Float), Birth("Pressure", 5, SparkplugDataType.Float)]);
|
||||
|
||||
result.AliasCollisions.ShouldBe(1);
|
||||
table.TryResolve(5, out _).ShouldBeFalse();
|
||||
table.TryResolveByName("Temperature", out _).ShouldBeTrue();
|
||||
table.TryResolveByName("Pressure", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two metrics sharing a name in one birth: last-wins (the name is the binding key, so evicting
|
||||
/// it would take the tag dark entirely), reported so the consumer can warn.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DuplicateNameInOneBirth_IsLastWins_AndReported()
|
||||
{
|
||||
var table = new AliasTable();
|
||||
|
||||
var result = table.RebuildFromBirth(
|
||||
[Birth("Temperature", 5, SparkplugDataType.Float), Birth("Temperature", 6, SparkplugDataType.Double)]);
|
||||
|
||||
result.DuplicateNames.ShouldBe(1);
|
||||
result.Accepted.ShouldBe(1);
|
||||
table.TryResolveByName("Temperature", out var binding).ShouldBeTrue();
|
||||
binding!.Alias.ShouldBe(6ul);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A BIRTH metric with no datatype is malformed, but it is <b>kept</b> as
|
||||
/// <see cref="SparkplugDataType.Unknown"/> rather than rejected: rejecting it makes every DATA
|
||||
/// message for its alias an unknown-alias event, which the ingest state machine answers with a
|
||||
/// rebirth request — and the edge node would answer with the same malformed birth, forever. Kept
|
||||
/// as Unknown, the typed layer refuses the value once (<c>ToDriverDataType()</c> returns null)
|
||||
/// and nothing storms.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BirthMetricWithNoDatatype_IsKeptAsUnknown_NotRejected()
|
||||
{
|
||||
var table = new AliasTable();
|
||||
|
||||
var result = table.RebuildFromBirth([Untyped("Temperature", 5)]);
|
||||
|
||||
result.Accepted.ShouldBe(1);
|
||||
result.RejectedUnnamed.ShouldBe(0);
|
||||
table.Resolve(5)!.DataType.ShouldBe(SparkplugDataType.Unknown);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The birth datatype is what makes Sparkplug's two's-complement-in-an-unsigned-field encoding
|
||||
/// undoable: a DATA metric carries no datatype of its own, so the binding is the only place the
|
||||
/// signedness is known. Without this, an Int32 metric holding -42 publishes as 4294967254 —
|
||||
/// plausible, Good quality, invisible.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Binding_Reinterpret_UndoesTwosComplement_UsingTheBirthDatatype()
|
||||
{
|
||||
var table = new AliasTable();
|
||||
table.RebuildFromBirth([Birth("Setpoint", 5, SparkplugDataType.Int32)]);
|
||||
|
||||
// What a DDATA metric for alias 5 carries: the raw unsigned wire field, no datatype.
|
||||
var raw = unchecked((uint)-42);
|
||||
|
||||
table.Resolve(5)!.Reinterpret(raw).ShouldBe(-42);
|
||||
}
|
||||
|
||||
/// <summary>A null birth sequence is a caller bug, not a birth — it must not silently clear the table.</summary>
|
||||
[Fact]
|
||||
public void RebuildFromBirth_Null_Throws() =>
|
||||
Should.Throw<ArgumentNullException>(() => new AliasTable().RebuildFromBirth(null!));
|
||||
|
||||
// ---- BirthCache: scoping ----
|
||||
|
||||
/// <summary>
|
||||
/// Sparkplug spec: an NBIRTH means the edge node (re)started, so <b>every</b> DBIRTH previously
|
||||
/// published under it is invalid until re-published. Keeping a device's alias table across its
|
||||
/// node's rebirth is the same stale-alias mis-route as invariant #2, one scope up.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NodeBirth_InvalidatesEveryDeviceUnderThatNode()
|
||||
{
|
||||
var cache = new BirthCache();
|
||||
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
|
||||
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler2", [Birth("Speed", 7, SparkplugDataType.Float)]);
|
||||
|
||||
var outcome = cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Node Control/Rebirth", 1, SparkplugDataType.Boolean)]);
|
||||
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler2")).ShouldBeNull();
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldNotBeNull();
|
||||
|
||||
// The invalidated devices come back with their metric sets so the consumer can fan STALE out
|
||||
// without a lookup against a table that has already been evicted.
|
||||
outcome.InvalidatedDevices.Count.ShouldBe(2);
|
||||
outcome.InvalidatedDevices.SelectMany(d => d.Metrics).Select(m => m.Name)
|
||||
.ShouldBe(["Temperature", "Speed"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
/// <summary>An NBIRTH is scoped to its own edge node — another node's devices are untouched.</summary>
|
||||
[Fact]
|
||||
public void NodeBirth_DoesNotTouchAnotherNodesDevices()
|
||||
{
|
||||
var cache = new BirthCache();
|
||||
cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
|
||||
|
||||
var outcome = cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
|
||||
|
||||
outcome.InvalidatedDevices.ShouldBeEmpty();
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1")).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A DBIRTH replaces exactly one device's table; its siblings and its node keep theirs.</summary>
|
||||
[Fact]
|
||||
public void DeviceBirth_ReplacesOnlyThatDevicesTable()
|
||||
{
|
||||
var cache = new BirthCache();
|
||||
cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
|
||||
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
|
||||
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler2", [Birth("Speed", 5, SparkplugDataType.Float)]);
|
||||
|
||||
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Humidity", 5, SparkplugDataType.Float)]);
|
||||
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Humidity");
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler2"))!.Resolve(5)!.Name.ShouldBe("Speed");
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeA", null))!.Resolve(1)!.Name.ShouldBe("Uptime");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key completeness. A cache keyed on the device id alone — or on <c>node/device</c> without the
|
||||
/// group — would let one plant's <c>Filler1</c> answer for another's, which is the same
|
||||
/// wrong-but-plausible mis-route as an alias collision.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Scopes_AreDistinctAcrossGroupsAndNodesWithTheSameDeviceId()
|
||||
{
|
||||
var cache = new BirthCache();
|
||||
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
|
||||
cache.ApplyDeviceBirth("Plant2", "EdgeA", "Filler1", [Birth("Pressure", 5, SparkplugDataType.Float)]);
|
||||
cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Speed", 5, SparkplugDataType.Float)]);
|
||||
|
||||
cache.Count.ShouldBe(3);
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Temperature");
|
||||
cache.Find(new SparkplugScope("Plant2", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Pressure");
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Speed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §3.6 invariant #4's data half: an NDEATH takes the node and everything under it out of the
|
||||
/// cache and hands back the metric sets, which is what the consumer fans STALE out over.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ForgetNode_RemovesNodeAndDevices_AndReturnsTheirMetricsForStaleFanOut()
|
||||
{
|
||||
var cache = new BirthCache();
|
||||
cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
|
||||
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
|
||||
cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Speed", 5, SparkplugDataType.Float)]);
|
||||
|
||||
var removed = cache.ForgetNode("Plant1", "EdgeA");
|
||||
|
||||
removed.SelectMany(r => r.Metrics).Select(m => m.Name).ShouldBe(["Uptime", "Temperature"], ignoreOrder: true);
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldBeNull();
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1")).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A DDEATH takes out exactly one device and leaves its node's own metrics live.</summary>
|
||||
[Fact]
|
||||
public void TryForgetDevice_RemovesOnlyThatDevice_AndReturnsItsMetrics()
|
||||
{
|
||||
var cache = new BirthCache();
|
||||
cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
|
||||
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
|
||||
|
||||
cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out var removed).ShouldBeTrue();
|
||||
|
||||
removed.Metrics.Select(m => m.Name).ShouldBe(["Temperature"]);
|
||||
removed.Scope.DeviceId.ShouldBe("Filler1");
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldNotBeNull();
|
||||
cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After a death the scope is <b>absent</b>, not merely empty — that is what makes DATA arriving
|
||||
/// before the next birth a data-before-birth event (⇒ rebirth request) rather than a silent
|
||||
/// unknown-alias drop against a table that still looks alive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Find_AfterDeath_IsNull_SoDataBeforeRebirthIsNotResolvable()
|
||||
{
|
||||
var cache = new BirthCache();
|
||||
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
|
||||
|
||||
cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out _).ShouldBeTrue();
|
||||
|
||||
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
|
||||
cache.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invariant #1 end-to-end through the cache: the alias-reuse mis-route must not sneak back in
|
||||
/// through the scoped path the ingest state machine actually calls.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DeviceRebirth_ReusingAnAliasForADifferentMetric_ResolvesByName()
|
||||
{
|
||||
var cache = new BirthCache();
|
||||
var scope = new SparkplugScope("Plant1", "EdgeA", "Filler1");
|
||||
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
|
||||
|
||||
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Pressure", 5, SparkplugDataType.Float)]);
|
||||
|
||||
cache.Find(scope)!.Resolve(5)!.Name.ShouldBe("Pressure");
|
||||
cache.Find(scope)!.TryResolveByName("Temperature", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Builds a BIRTH metric the way <see cref="SparkplugCodec"/> projects one.</summary>
|
||||
private static SparkplugMetric Birth(string? name, ulong? alias, SparkplugDataType dataType, object? value = null) =>
|
||||
new(
|
||||
Name: name,
|
||||
Alias: alias,
|
||||
DataType: dataType,
|
||||
TimestampMs: null,
|
||||
ValueKind: value is null ? SparkplugValueKind.Absent : SparkplugValueKind.Scalar,
|
||||
Value: value);
|
||||
|
||||
/// <summary>Builds a malformed BIRTH metric that declares no datatype at all.</summary>
|
||||
private static SparkplugMetric Untyped(string name, ulong alias) =>
|
||||
new(Name: name, Alias: alias, DataType: null, TimestampMs: null, ValueKind: SparkplugValueKind.Absent, Value: null);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,88 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
public sealed class LastValueCacheTests
|
||||
{
|
||||
// OPC UA BadWaitingForInitialData (0x80320000) — the repo-wide convention for "no value
|
||||
// observed yet" (mirrored by CalculationDriver, VirtualTagEngine, FOCAS, and
|
||||
// OtOpcUaNodeManager for freshly-materialised / not-yet-evaluated nodes). MQTT is
|
||||
// subscribe-first, so an unseen RawPath is in exactly that state until its first publish.
|
||||
private const uint BadWaitingForInitialData = 0x80320000u;
|
||||
|
||||
[Fact]
|
||||
public void Read_UnseenRef_ReturnsPerRefWaitingForInitialData_DoesNotThrow()
|
||||
{
|
||||
var cache = new LastValueCache();
|
||||
|
||||
var snap = cache.Read("factory/oven/temp");
|
||||
|
||||
snap.StatusCode.ShouldBe(BadWaitingForInitialData);
|
||||
snap.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ThenRead_ReturnsLastValue()
|
||||
{
|
||||
var cache = new LastValueCache();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
cache.Update("factory/oven/temp", new DataValueSnapshot(42.0, 0u, now, now));
|
||||
|
||||
var snap = cache.Read("factory/oven/temp");
|
||||
snap.Value.ShouldBe(42.0);
|
||||
snap.StatusCode.ShouldBe(0u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_OverwritesPreviousValue_ForSameRawPath()
|
||||
{
|
||||
var cache = new LastValueCache();
|
||||
var t1 = DateTime.UtcNow;
|
||||
var t2 = t1.AddSeconds(1);
|
||||
|
||||
cache.Update("factory/oven/temp", new DataValueSnapshot(1.0, 0u, t1, t1));
|
||||
cache.Update("factory/oven/temp", new DataValueSnapshot(2.0, 0u, t2, t2));
|
||||
|
||||
cache.Read("factory/oven/temp").Value.ShouldBe(2.0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void Read_NullOrEmptyRawPath_DoesNotThrow_ReturnsWaitingForInitialData(string? rawPath)
|
||||
{
|
||||
var cache = new LastValueCache();
|
||||
|
||||
var snap = cache.Read(rawPath!);
|
||||
|
||||
snap.StatusCode.ShouldBe(BadWaitingForInitialData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NullRawPath_DoesNotThrow()
|
||||
{
|
||||
var cache = new LastValueCache();
|
||||
|
||||
Should.NotThrow(() => cache.Update(null!, new DataValueSnapshot(1.0, 0u, DateTime.UtcNow, DateTime.UtcNow)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_DistinguishesUnseenFromGenuinelyBadObservedValue()
|
||||
{
|
||||
var cache = new LastValueCache();
|
||||
var now = DateTime.UtcNow;
|
||||
const uint badDeviceFailure = 0x808B0000u;
|
||||
|
||||
cache.Update("factory/oven/temp", new DataValueSnapshot(null, badDeviceFailure, null, now));
|
||||
|
||||
var observedBad = cache.Read("factory/oven/temp");
|
||||
var neverObserved = cache.Read("factory/oven/other");
|
||||
|
||||
observedBad.StatusCode.ShouldBe(badDeviceFailure);
|
||||
neverObserved.StatusCode.ShouldBe(BadWaitingForInitialData);
|
||||
observedBad.StatusCode.ShouldNotBe(neverObserved.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The smallest broker that can complete an MQTT 3.1.1 handshake: accept TCP, answer CONNECT with
|
||||
/// a CONNACK, answer PINGREQ with PINGRESP, and nothing else. That is enough for MQTTnet to report
|
||||
/// a real connection — which is what makes a real drop (<see cref="DropAll"/>) drive a real
|
||||
/// <c>DisconnectedAsync</c> and a real reconnect, rather than a test seam standing in for one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shared across suites rather than nested in one, because both the connection-level and the
|
||||
/// driver-level rejection tests need it: the driver-level one is the pin for the actual
|
||||
/// operator-visible symptom (a rejected CONNECT must not report <c>Healthy</c>).
|
||||
/// </remarks>
|
||||
internal sealed class MiniBroker : IDisposable
|
||||
{
|
||||
/// <summary>CONNACK "Connection Accepted" (MQTT 3.1.1 return code 0).</summary>
|
||||
internal const byte ReturnCodeAccepted = 0x00;
|
||||
|
||||
/// <summary>CONNACK "Identifier rejected" — MQTTnet maps it to <c>ClientIdentifierNotValid</c>.</summary>
|
||||
internal const byte ReturnCodeIdentifierRejected = 0x02;
|
||||
|
||||
/// <summary>CONNACK "Server unavailable" — MQTTnet maps it to <c>ServerUnavailable</c> (transient).</summary>
|
||||
internal const byte ReturnCodeServerUnavailable = 0x03;
|
||||
|
||||
/// <summary>CONNACK "Bad user name or password" — MQTTnet maps it to <c>BadUserNameOrPassword</c>.</summary>
|
||||
internal const byte ReturnCodeBadUserNameOrPassword = 0x04;
|
||||
|
||||
/// <summary>CONNACK "Not authorized" — MQTTnet maps it to <c>NotAuthorized</c> (unrecoverable).</summary>
|
||||
internal const byte ReturnCodeNotAuthorized = 0x05;
|
||||
|
||||
private static readonly byte[] PingResp = [0xD0, 0x00];
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly HashSet<TcpClient> _live = [];
|
||||
private readonly TcpListener _listener;
|
||||
private int _acceptedCount;
|
||||
|
||||
public MiniBroker()
|
||||
{
|
||||
_listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
_listener.Start();
|
||||
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
_ = Task.Run(AcceptLoopAsync);
|
||||
}
|
||||
|
||||
public int Port { get; }
|
||||
|
||||
/// <summary>When set, TCP is still accepted but CONNECT is never answered (frozen peer).</summary>
|
||||
public bool Blackhole { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The MQTT 3.1.1 return code answered to every CONNECT. Anything but
|
||||
/// <see cref="ReturnCodeAccepted"/> is a broker that completes the handshake and then
|
||||
/// <i>refuses</i> — the shape MQTTnet reports as a result rather than an exception, and the one
|
||||
/// a real Mosquitto with the wrong password produces.
|
||||
/// </summary>
|
||||
public byte ConnAckReturnCode { get; set; } = ReturnCodeAccepted;
|
||||
|
||||
/// <summary>Total TCP connections accepted since construction — counts reconnect attempts.</summary>
|
||||
public int AcceptedCount => Volatile.Read(ref _acceptedCount);
|
||||
|
||||
/// <summary>
|
||||
/// Sockets the broker still has open. Drops to zero only once the peer actually closes, so a
|
||||
/// leaked-but-live client keeps this above zero.
|
||||
/// </summary>
|
||||
public int LiveConnections
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_live)
|
||||
{
|
||||
return _live.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Kills every established session — the "broker went away" event.</summary>
|
||||
public void DropAll()
|
||||
{
|
||||
lock (_live)
|
||||
{
|
||||
foreach (var client in _live)
|
||||
{
|
||||
try
|
||||
{
|
||||
client.Close();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
|
||||
_live.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_listener.Stop();
|
||||
DropAll();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
var client = await _listener.AcceptTcpClientAsync(_cts.Token);
|
||||
Interlocked.Increment(ref _acceptedCount);
|
||||
lock (_live)
|
||||
{
|
||||
_live.Add(client);
|
||||
}
|
||||
|
||||
_ = Task.Run(() => ServeAsync(client));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Listener stopped / token cancelled — the fixture is going away.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeAsync(TcpClient client)
|
||||
{
|
||||
var buffer = new byte[1024];
|
||||
try
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, _cts.Token);
|
||||
if (read == 0)
|
||||
{
|
||||
break; // peer closed — this is how a disposed client stops counting as live
|
||||
}
|
||||
|
||||
if (Blackhole)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var packetType = buffer[0] & 0xF0;
|
||||
if (packetType == 0x10)
|
||||
{
|
||||
await stream.WriteAsync(new byte[] { 0x20, 0x02, 0x00, ConnAckReturnCode }, _cts.Token);
|
||||
|
||||
// A real broker closes the socket straight after refusing, which is what drives
|
||||
// MQTTnet's DisconnectedAsync. Mirroring it keeps the fake honest.
|
||||
if (ConnAckReturnCode != ReturnCodeAccepted)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (packetType == 0xC0)
|
||||
{
|
||||
await stream.WriteAsync(PingResp, _cts.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Socket torn down by either side.
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_live)
|
||||
{
|
||||
_live.Remove(client);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
client.Dispose();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
|
||||
|
||||
// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
|
||||
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
|
||||
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
|
||||
// scope does not cross a ProjectReference.
|
||||
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the passive <c>#</c>-observation browse session and the browser that opens it. The
|
||||
/// session is exercised through its test seam (<c>ObserveTopicForTest</c>) rather than a live
|
||||
/// broker: the accumulating tree is the whole of the P1 logic, and the MQTTnet plumbing that
|
||||
/// feeds it is a one-line handler.
|
||||
/// </summary>
|
||||
public sealed class MqttBrowseSessionTests
|
||||
{
|
||||
// ---------------------------------------------------------------- tree shape
|
||||
|
||||
[Fact]
|
||||
public async Task ExpandAsync_BuildsTopicSegmentTree_FromObservedTopics()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("factory/line3/oven/temp");
|
||||
|
||||
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
||||
root.ShouldContain(n => n.DisplayName == "factory");
|
||||
|
||||
var lvl2 = await s.ExpandAsync("factory", TestContext.Current.CancellationToken);
|
||||
lvl2.ShouldContain(n => n.DisplayName == "line3");
|
||||
|
||||
var lvl3 = await s.ExpandAsync("factory/line3", TestContext.Current.CancellationToken);
|
||||
lvl3.ShouldContain(n => n.DisplayName == "oven");
|
||||
|
||||
var lvl4 = await s.ExpandAsync("factory/line3/oven", TestContext.Current.CancellationToken);
|
||||
var leaf = lvl4.ShouldHaveSingleItem();
|
||||
leaf.DisplayName.ShouldBe("temp");
|
||||
leaf.NodeId.ShouldBe("factory/line3/oven/temp");
|
||||
leaf.Kind.ShouldBe(BrowseNodeKind.Leaf);
|
||||
leaf.HasChildrenHint.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ObservedTopics_AccumulateAcrossMessages_AndDoNotDuplicate()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("factory/line3/oven/temp");
|
||||
s.ObserveTopicForTest("factory/line3/oven/temp"); // repeat — must not duplicate
|
||||
s.ObserveTopicForTest("factory/line3/oven/pressure");
|
||||
s.ObserveTopicForTest("factory/line4/mixer/rpm");
|
||||
s.ObserveTopicForTest("plant/power");
|
||||
|
||||
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
||||
root.Select(n => n.DisplayName).ShouldBe(["factory", "plant"]); // sorted, deduped
|
||||
|
||||
var lines = await s.ExpandAsync("factory", TestContext.Current.CancellationToken);
|
||||
lines.Select(n => n.DisplayName).ShouldBe(["line3", "line4"]);
|
||||
|
||||
var oven = await s.ExpandAsync("factory/line3/oven", TestContext.Current.CancellationToken);
|
||||
oven.Select(n => n.DisplayName).ShouldBe(["pressure", "temp"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IntermediateNode_IsFolder_WithChildrenHint()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("factory/line3/oven/temp");
|
||||
|
||||
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
||||
var factory = root.ShouldHaveSingleItem();
|
||||
factory.Kind.ShouldBe(BrowseNodeKind.Folder);
|
||||
factory.HasChildrenHint.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpandAsync_UnknownNode_ReturnsEmpty()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("factory/line3");
|
||||
|
||||
var children = await s.ExpandAsync("nope/not/here", TestContext.Current.CancellationToken);
|
||||
children.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RootAsync_BeforeAnyTraffic_IsEmpty()
|
||||
{
|
||||
// MQTT has no discovery protocol: an observation window that has seen nothing shows nothing.
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- read-only guarantee
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseCalls_PublishNothing() // browse is read-only
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("factory/line3/oven/temp", "23.5");
|
||||
|
||||
await s.RootAsync(TestContext.Current.CancellationToken);
|
||||
await s.ExpandAsync("factory", TestContext.Current.CancellationToken);
|
||||
await s.AttributesAsync("factory/line3/oven/temp", TestContext.Current.CancellationToken);
|
||||
|
||||
s.PublishCountForTest.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- attributes
|
||||
|
||||
[Theory]
|
||||
[InlineData("23.5", nameof(Core.Abstractions.DriverDataType.Float64))]
|
||||
[InlineData("42", nameof(Core.Abstractions.DriverDataType.Int64))]
|
||||
[InlineData("true", nameof(Core.Abstractions.DriverDataType.Boolean))]
|
||||
[InlineData("{\"v\":1}", nameof(Core.Abstractions.DriverDataType.String))]
|
||||
public async Task AttributesAsync_InfersTypeFromLastPayload(string payload, string expectedType)
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("factory/oven/temp", payload);
|
||||
|
||||
var attrs = await s.AttributesAsync("factory/oven/temp", TestContext.Current.CancellationToken);
|
||||
var a = attrs.ShouldHaveSingleItem();
|
||||
a.Name.ShouldBe("temp");
|
||||
a.DriverDataType.ShouldBe(expectedType);
|
||||
a.IsArray.ShouldBeFalse();
|
||||
a.IsAlarm.ShouldBeFalse();
|
||||
a.SecurityClass.ShouldContain(payload); // the last-payload snippet
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttributesAsync_LongPayload_IsTruncatedToASnippet()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("t/long", new string('x', 500));
|
||||
|
||||
var a = (await s.AttributesAsync("t/long", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
a.SecurityClass.Length.ShouldBeLessThanOrEqualTo(MqttBrowseSession.PayloadSnippetMaxChars + 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttributesAsync_UnobservedFolder_ReturnsEmpty()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("factory/oven/temp", "1");
|
||||
|
||||
// "factory" is a synthesised path segment — nothing was ever published to it.
|
||||
(await s.AttributesAsync("factory", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
(await s.AttributesAsync("no/such/node", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- concurrency
|
||||
|
||||
[Fact]
|
||||
public async Task ObserveDuringBrowse_IsThreadSafe()
|
||||
{
|
||||
// Messages arrive on MQTTnet's dispatcher thread while the picker calls ExpandAsync.
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
using var stop = new CancellationTokenSource();
|
||||
|
||||
var observed = 0;
|
||||
var writer = Task.Run(() =>
|
||||
{
|
||||
var i = 0;
|
||||
while (!stop.Token.IsCancellationRequested)
|
||||
{
|
||||
s.ObserveTopicForTest($"factory/line{i % 8}/dev{i % 32}/m{i}", i.ToString());
|
||||
Interlocked.Increment(ref observed);
|
||||
i++;
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
// The read loop is pure in-memory work and would otherwise finish before the writer thread
|
||||
// is even scheduled, leaving the two never actually overlapping.
|
||||
while (Volatile.Read(ref observed) == 0) await Task.Yield();
|
||||
|
||||
for (var i = 0; i < 400; i++)
|
||||
{
|
||||
await s.RootAsync(TestContext.Current.CancellationToken);
|
||||
await s.ExpandAsync("factory", TestContext.Current.CancellationToken);
|
||||
await s.ExpandAsync("factory/line3", TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
await stop.CancelAsync();
|
||||
await writer; // rethrows anything the observer thread hit
|
||||
|
||||
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- lifetime
|
||||
|
||||
[Fact]
|
||||
public async Task LastUsedUtc_AdvancesOnEveryCall()
|
||||
{
|
||||
var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
var before = s.LastUsedUtc;
|
||||
await Task.Delay(15, TestContext.Current.CancellationToken);
|
||||
|
||||
await s.RootAsync(TestContext.Current.CancellationToken);
|
||||
s.LastUsedUtc.ShouldBeGreaterThan(before);
|
||||
|
||||
var afterRoot = s.LastUsedUtc;
|
||||
await Task.Delay(15, TestContext.Current.CancellationToken);
|
||||
await s.ExpandAsync("x", TestContext.Current.CancellationToken);
|
||||
s.LastUsedUtc.ShouldBeGreaterThan(afterRoot);
|
||||
|
||||
await s.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_OnNeverOpenedSession_IsIdempotentAndDoesNotThrow()
|
||||
{
|
||||
var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
await Should.NotThrowAsync(async () => await s.DisposeAsync());
|
||||
await Should.NotThrowAsync(async () => await s.DisposeAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AfterDispose_BrowseCallsThrowObjectDisposed()
|
||||
{
|
||||
var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("a/b");
|
||||
await s.DisposeAsync();
|
||||
|
||||
await Should.ThrowAsync<ObjectDisposedException>(
|
||||
async () => await s.RootAsync(TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<ObjectDisposedException>(
|
||||
async () => await s.ExpandAsync("a", TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<ObjectDisposedException>(
|
||||
async () => await s.AttributesAsync("a/b", TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ObserveAfterDispose_IsIgnored()
|
||||
{
|
||||
// A message already in MQTTnet's dispatcher when the reaper disposes the session.
|
||||
var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
await s.DisposeAsync();
|
||||
Should.NotThrow(() => s.ObserveTopicForTest("late/arrival", "1"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- node cap
|
||||
|
||||
[Fact]
|
||||
public async Task NodeCap_StopsRecording_AndSurfacesATruncationMarker()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain, nodeCap: 4);
|
||||
s.ObserveTopicForTest("a/b/c"); // 3 nodes
|
||||
s.ObserveTopicForTest("d/e/f"); // would take it past 4
|
||||
|
||||
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
||||
root.ShouldContain(n => n.NodeId == MqttBrowseSession.TruncatedNodeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OversizedTopic_IsRejectedWhole_AndSurfacesAMarker()
|
||||
{
|
||||
// A truncated topic is a DIFFERENT topic — the picker would commit it as a tag address.
|
||||
// Every segment here is 4 chars, well under MaxSegmentChars, so ONLY the whole-topic bound
|
||||
// can reject it — the segment guard must not be what makes this test pass.
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("good/topic", "1");
|
||||
|
||||
var manyShortSegments = "huge/" + string.Join('/', Enumerable.Repeat("abc", MqttBrowseSession.MaxTopicChars / 4 + 8));
|
||||
manyShortSegments.Length.ShouldBeGreaterThan(MqttBrowseSession.MaxTopicChars);
|
||||
manyShortSegments.Split('/').ShouldAllBe(seg => seg.Length <= MqttBrowseSession.MaxSegmentChars);
|
||||
s.ObserveTopicForTest(manyShortSegments, "1");
|
||||
|
||||
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
||||
root.ShouldContain(n => n.NodeId == MqttBrowseSession.OversizedNodeId);
|
||||
root.ShouldContain(n => n.DisplayName == "good");
|
||||
root.ShouldNotContain(n => n.DisplayName == "huge"); // no half-built path left behind
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OversizedSegment_IsRejectedWhole_EvenWhenTheTopicFits()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("a/" + new string('y', MqttBrowseSession.MaxSegmentChars + 1) + "/b", "1");
|
||||
|
||||
s.OversizedRejected.ShouldBeTrue();
|
||||
(await s.ExpandAsync("a", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
||||
root.ShouldNotContain(n => n.DisplayName == "a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TopicAtExactlyTheCap_IsAccepted()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest(new string('z', MqttBrowseSession.MaxSegmentChars), "1");
|
||||
|
||||
s.OversizedRejected.ShouldBeFalse();
|
||||
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- payload bounds
|
||||
|
||||
[Fact]
|
||||
public async Task PayloadBeyondTheInspectionWindow_IsNeverExamined()
|
||||
{
|
||||
// The discriminating test for the byte cap: content past PayloadInspectMaxBytes cannot
|
||||
// influence the result at all. With an unbounded decode the trailing scalar would be found
|
||||
// and typed Int64, and would show up in the snippet.
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest(
|
||||
"t/tail",
|
||||
new string(' ', MqttBrowseSession.PayloadInspectMaxBytes + 64) + "42");
|
||||
|
||||
var a = (await s.AttributesAsync("t/tail", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.String));
|
||||
a.SecurityClass.ShouldNotContain("42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HugePayload_YieldsABoundedSnippetMarkedAsClipped()
|
||||
{
|
||||
// A '#' subscription receives multi-MB retained blobs; the node cap never engages for a
|
||||
// large payload on an already-known topic, so the decode itself must be bounded.
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("t/blob", new string('a', 5_000_000));
|
||||
|
||||
var a = (await s.AttributesAsync("t/blob", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
a.SecurityClass.Length.ShouldBeLessThanOrEqualTo(MqttBrowseSession.PayloadSnippetMaxChars + 1);
|
||||
a.SecurityClass.ShouldEndWith("…");
|
||||
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.String));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClippedMultiByteUtf8_IsNotMislabelledAsBinary()
|
||||
{
|
||||
// The inspection window can land mid-sequence; backing off to a whole sequence keeps a
|
||||
// perfectly good UTF-8 payload from being reported as binary.
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
|
||||
// 3 bytes per char, so 512 / 3 leaves a partial sequence at the window edge.
|
||||
s.ObserveTopicForTest("t/utf8", new string('☃', 4000));
|
||||
|
||||
var a = (await s.AttributesAsync("t/utf8", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
a.SecurityClass.ShouldStartWith("☃");
|
||||
a.SecurityClass.ShouldNotContain("binary");
|
||||
a.SecurityClass.ShouldNotContain("�"); // no replacement char from a split sequence
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BinaryPayload_IsReportedAsOpaque_WithItsTrueLength()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
var binary = new byte[2048];
|
||||
Array.Fill(binary, (byte)0xC0); // never legal UTF-8
|
||||
s.Observe("t/bin", binary);
|
||||
|
||||
var a = (await s.AttributesAsync("t/bin", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
a.SecurityClass.ShouldBe("(2048 bytes, binary)"); // full length, not the clipped window
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmbeddedControlCharacters_AreScrubbedToSpaces()
|
||||
{
|
||||
// Trim() only strips the ends; an embedded newline would break the picker's single-line row.
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("t/multiline", "line one\nline two\ttabbed[31m");
|
||||
|
||||
var a = (await s.AttributesAsync("t/multiline", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
a.SecurityClass.ShouldNotContain("\n");
|
||||
a.SecurityClass.ShouldNotContain("\t");
|
||||
a.SecurityClass.ShouldNotContain("");
|
||||
a.SecurityClass.ShouldStartWith("line one line two tabbed");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the browser
|
||||
|
||||
[Fact]
|
||||
public void Ctor_IsConnectionFree_AndReportsTheMqttDriverType()
|
||||
{
|
||||
// The universal-browser CanBrowse pattern constructs a throwaway instance purely to ask
|
||||
// what driver type it handles — the ctor must never touch the network.
|
||||
var browser = new MqttDriverBrowser();
|
||||
browser.DriverType.ShouldBe("Mqtt");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "#")]
|
||||
[InlineData("", "#")]
|
||||
[InlineData(" ", "#")]
|
||||
[InlineData("factory", "factory/#")]
|
||||
[InlineData("factory/", "factory/#")]
|
||||
[InlineData("factory/line3", "factory/line3/#")]
|
||||
public void BuildRootFilter_ScopesTheWildcardToTheConfiguredPrefix(string? prefix, string expected)
|
||||
{
|
||||
var opts = new MqttDriverOptions
|
||||
{
|
||||
Mode = MqttMode.Plain,
|
||||
Plain = prefix is null ? null : new MqttPlainOptions { TopicPrefix = prefix },
|
||||
};
|
||||
MqttDriverBrowser.BuildRootFilter(opts).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BrowseClientIdSuffix_IsUnique_AndIsAppliedToTheConnectClientId()
|
||||
{
|
||||
// A broker disconnects an existing client when a new one CONNECTs with the same id —
|
||||
// browsing must never knock the running driver offline.
|
||||
var a = MqttDriverBrowser.BuildBrowseClientIdSuffix();
|
||||
var b = MqttDriverBrowser.BuildBrowseClientIdSuffix();
|
||||
a.ShouldNotBe(b);
|
||||
a.ShouldStartWith("-browse-");
|
||||
a.Length.ShouldBe("-browse-".Length + 8);
|
||||
|
||||
var opts = new MqttDriverOptions { ClientId = "otopcua-node1" };
|
||||
MqttConnection.BuildClientOptions(opts, a).ClientId.ShouldBe("otopcua-node1" + a);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BrowseConnect_ForcesACleanSession()
|
||||
{
|
||||
// A transient browse identity must not leave queued-message state behind on the broker.
|
||||
var opts = new MqttDriverOptions { CleanSession = false };
|
||||
MqttDriverBrowser.ToBrowseOptions(opts).CleanSession.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 5)] // clamped up
|
||||
[InlineData(15, 15)]
|
||||
[InlineData(600, 30)] // clamped down
|
||||
public void OpenBudget_IsClampedTo5To30Seconds(int configured, int expected)
|
||||
{
|
||||
var opts = new MqttDriverOptions { ConnectTimeoutSeconds = configured };
|
||||
MqttDriverBrowser.OpenBudget(opts).ShouldBe(TimeSpan.FromSeconds(expected));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- Sparkplug: the Last-Will landmine
|
||||
|
||||
[Fact]
|
||||
public void MqttDriverOptions_CarryNothingWillShaped_SoABrowseSessionCanNeverFireAnNdeath()
|
||||
{
|
||||
// THE landmine (see the marked block on MqttDriverBrowser.ToBrowseOptions). A Sparkplug
|
||||
// NDEATH is a Last Will: it is published by the BROKER, not by us, so it is invisible to
|
||||
// PublishCountForTest — and any ungraceful end to a browse session would fire an NDEATH
|
||||
// under the plant's real edge-node identity. ToBrowseOptions is where a will must be
|
||||
// stripped; today there is nothing to strip, and this test is what makes that a checked
|
||||
// fact rather than a claim: adding a will-shaped property anywhere in the options graph
|
||||
// reddens it and forces the author to handle it in ToBrowseOptions.
|
||||
static IEnumerable<string> Names(Type t) => t.GetProperties().Select(p => p.Name);
|
||||
|
||||
var suspects = Names(typeof(MqttDriverOptions))
|
||||
.Concat(Names(typeof(MqttSparkplugOptions)))
|
||||
.Concat(Names(typeof(MqttPlainOptions)))
|
||||
.Where(n => n.Contains("will", StringComparison.OrdinalIgnoreCase)
|
||||
|| n.Contains("testament", StringComparison.OrdinalIgnoreCase)
|
||||
|| n.Contains("death", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
suspects.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- Sparkplug: birth-driven tree
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugTree_FillsFromObservedBirths_BrowseNeverPublishes()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
|
||||
|
||||
var groups = await s.RootAsync(TestContext.Current.CancellationToken);
|
||||
groups.ShouldContain(n => n.DisplayName == "Plant1");
|
||||
|
||||
var nodes = await s.ExpandAsync("Plant1", TestContext.Current.CancellationToken);
|
||||
nodes.ShouldContain(n => n.DisplayName == "EdgeA");
|
||||
|
||||
var devices = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
|
||||
var device = devices.ShouldHaveSingleItem();
|
||||
device.DisplayName.ShouldBe("Filler1");
|
||||
device.Kind.ShouldBe(BrowseNodeKind.Folder);
|
||||
|
||||
var metrics = await s.ExpandAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken);
|
||||
var metric = metrics.ShouldHaveSingleItem();
|
||||
metric.DisplayName.ShouldBe("Temperature");
|
||||
metric.Kind.ShouldBe(BrowseNodeKind.Leaf);
|
||||
metric.NodeId.ShouldBe("Plant1/EdgeA/Filler1::Temperature");
|
||||
|
||||
await s.AttributesAsync(metric.NodeId, TestContext.Current.CancellationToken);
|
||||
|
||||
s.PublishCountForTest.ShouldBe(0); // passive — browsing a live plant broker publishes nothing
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugTree_NodeLevelMetrics_HangDirectlyUnderTheEdgeNode()
|
||||
{
|
||||
// An NBIRTH metric has no device. It is NOT parked under a synthesised device folder —
|
||||
// a fake segment would be indistinguishable from a real device of the same name, and the
|
||||
// authored address tuple carries deviceId = null for exactly these.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", device: null, "Node Control/Rebirth", SparkplugDataType.Boolean);
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
|
||||
|
||||
var underEdge = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
|
||||
underEdge.Select(n => n.DisplayName).ShouldBe(["Filler1", "Node Control/Rebirth"]);
|
||||
|
||||
var device = underEdge.Single(n => n.DisplayName == "Filler1");
|
||||
device.Kind.ShouldBe(BrowseNodeKind.Folder);
|
||||
|
||||
// A metric name may itself contain '/'; it stays ONE leaf (the metric name is the tag's
|
||||
// stable binding key, so a split would invite committing a partial name).
|
||||
var nodeMetric = underEdge.Single(n => n.DisplayName == "Node Control/Rebirth");
|
||||
nodeMetric.Kind.ShouldBe(BrowseNodeKind.Leaf);
|
||||
nodeMetric.NodeId.ShouldBe("Plant1/EdgeA::Node Control/Rebirth");
|
||||
(await s.ExpandAsync(nodeMetric.NodeId, TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugTree_ADeviceAndANodeMetricSharingALabel_AreBothKept()
|
||||
{
|
||||
// The two are siblings under the edge node and CAN share a label. They are distinct nodes with
|
||||
// distinct ids, so neither may displace the other — a tree keyed by label would silently drop
|
||||
// whichever arrived second, and the picker would offer a folder where a metric should be.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", "Temp", "Value", SparkplugDataType.Float); // device "Temp"
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Temp", SparkplugDataType.Float); // metric "Temp"
|
||||
|
||||
var children = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
|
||||
children.Count.ShouldBe(2);
|
||||
children.ShouldContain(n => n.NodeId == "Plant1/EdgeA/Temp" && n.Kind == BrowseNodeKind.Folder);
|
||||
children.ShouldContain(n => n.NodeId == "Plant1/EdgeA::Temp" && n.Kind == BrowseNodeKind.Leaf);
|
||||
|
||||
(await s.AttributesAsync("Plant1/EdgeA::Temp", TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem().Name.ShouldBe("Temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugTree_EmptyBirth_StillRendersItsDeviceAsAFolder()
|
||||
{
|
||||
// A DBIRTH carrying no metrics leaves a childless device node; it must not masquerade as a
|
||||
// committable metric leaf.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveEmptyBirthForTest("Plant1", "EdgeA", "Filler1");
|
||||
|
||||
var device = (await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem();
|
||||
device.DisplayName.ShouldBe("Filler1");
|
||||
device.Kind.ShouldBe(BrowseNodeKind.Folder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugObserve_NonBirthMessages_BuildNoTree()
|
||||
{
|
||||
// Only NBIRTH/DBIRTH are self-describing. A DDATA metric carries an alias and no name at
|
||||
// all, so anything built from one would be a tree of unnamed nodes.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveRawForTest("spBv1.0/Plant1/DDATA/EdgeA/Filler1", [0x08, 0x01]);
|
||||
s.ObserveRawForTest("spBv1.0/Plant1/NCMD/EdgeA", [0x08, 0x01]);
|
||||
s.ObserveRawForTest("spBv1.0/Plant1/NDEATH/EdgeA", [0x08, 0x01]);
|
||||
s.ObserveRawForTest("spBv1.0/STATE/host1", "ONLINE"u8.ToArray());
|
||||
s.ObserveRawForTest("some/plain/topic", "23.5"u8.ToArray()); // not Sparkplug at all
|
||||
|
||||
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
s.PublishCountForTest.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SparkplugObserve_GarbageBirthPayload_IsIgnoredAndNeverThrows()
|
||||
{
|
||||
// Runs on MQTTnet's dispatcher thread behind an unauthenticated firehose.
|
||||
var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
Should.NotThrow(() => s.ObserveRawForTest("spBv1.0/Plant1/NBIRTH/EdgeA", [0xFF, 0xFF, 0xFF, 0xFF]));
|
||||
Should.NotThrow(() => s.ObserveRawForTest("spBv1.0/Plant1/NBIRTH/EdgeA", []));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- Sparkplug: AttributesAsync
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugAttributes_AreBirthDerived_NotPayloadSniffed()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
|
||||
|
||||
var a = (await s.AttributesAsync(
|
||||
"Plant1/EdgeA/Filler1::Temperature", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
a.Name.ShouldBe("Temperature");
|
||||
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.Float32)); // from the birth
|
||||
a.IsArray.ShouldBeFalse();
|
||||
a.SecurityClass.ShouldContain("DBIRTH");
|
||||
a.SecurityClass.ShouldContain("Float");
|
||||
// The plain-mode payload-snippet machinery must not run: a Sparkplug body is protobuf, and
|
||||
// sniffing it would report "(N bytes, binary)" for every metric in the plant.
|
||||
a.SecurityClass.ShouldNotContain("binary");
|
||||
a.SecurityClass.ShouldNotContain("bytes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugAttributes_ArrayMetric_ReportsElementTypePlusIsArray()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Profile", SparkplugDataType.Int32Array);
|
||||
|
||||
var a = (await s.AttributesAsync(
|
||||
"Plant1/EdgeA::Profile", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.Int32)); // element type
|
||||
a.IsArray.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugAttributes_UnsupportedDataType_IsReportedNotDefaulted()
|
||||
{
|
||||
// ToDriverDataType() returns null for DataSet/Template/PropertySet/PropertySetList/Unknown.
|
||||
// A null must never be coerced into a guessed DriverDataType — the operator has to see that
|
||||
// the metric is not bindable.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Curve", SparkplugDataType.DataSet);
|
||||
|
||||
var a = (await s.AttributesAsync(
|
||||
"Plant1/EdgeA::Curve", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
Enum.TryParse<Core.Abstractions.DriverDataType>(a.DriverDataType, ignoreCase: true, out _)
|
||||
.ShouldBeFalse(); // not a guessed type — unmappable, and visibly so
|
||||
a.SecurityClass.ShouldContain("unsupported");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugAttributes_StateTheBindingTuple_SoTheCommitNeverParsesTheNodeId()
|
||||
{
|
||||
// The address of a Sparkplug tag is (group, edgeNode, device?, metric) — NOT the node id. The
|
||||
// session already holds the decomposition (it parsed the birth topic), so it states it; the
|
||||
// AdminUI's browse-commit mapper reads these keys and never splits the id.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
|
||||
|
||||
var a = (await s.AttributesAsync(
|
||||
"OtOpcUaSim/EdgeA/Filler1::Temperature", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
a.AddressFields.ShouldNotBeNull();
|
||||
a.AddressFields![MqttTagConfigKeys.GroupId].ShouldBe("OtOpcUaSim");
|
||||
a.AddressFields[MqttTagConfigKeys.EdgeNodeId].ShouldBe("EdgeA");
|
||||
a.AddressFields[MqttTagConfigKeys.DeviceId].ShouldBe("Filler1");
|
||||
a.AddressFields[MqttTagConfigKeys.MetricName].ShouldBe("Temperature");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugAttributes_NodeLevelMetric_StatesNoDeviceId()
|
||||
{
|
||||
// An NBIRTH metric belongs to the edge node itself. An invented/blank device id would be a
|
||||
// device that does not exist — the same reason the tree hangs it directly under the edge node.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", null, "Uptime", SparkplugDataType.Int64);
|
||||
|
||||
var a = (await s.AttributesAsync(
|
||||
"OtOpcUaSim/EdgeA::Uptime", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
a.AddressFields.ShouldNotBeNull();
|
||||
a.AddressFields!.ContainsKey(MqttTagConfigKeys.DeviceId).ShouldBeFalse();
|
||||
a.AddressFields[MqttTagConfigKeys.EdgeNodeId].ShouldBe("EdgeA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugAttributes_MetricNameContainingASlash_IsStatedWhole()
|
||||
{
|
||||
// 'Node Control/Rebirth' is a canonical Sparkplug metric name. Its node id is
|
||||
// 'OtOpcUaSim/EdgeA::Node Control/Rebirth' — un-splittable back into the tuple, which is why the
|
||||
// tuple travels rather than the id.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", null, "Node Control/Rebirth", SparkplugDataType.Boolean);
|
||||
|
||||
var a = (await s.AttributesAsync(
|
||||
"OtOpcUaSim/EdgeA::Node Control/Rebirth", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
a.AddressFields.ShouldNotBeNull();
|
||||
a.AddressFields![MqttTagConfigKeys.MetricName].ShouldBe("Node Control/Rebirth");
|
||||
a.AddressFields[MqttTagConfigKeys.EdgeNodeId].ShouldBe("EdgeA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugAttributes_DeviceAndNodeLevelMetricsOfTheSameName_StateDifferentAddresses()
|
||||
{
|
||||
// The two are siblings sharing a label ('EdgeA/Temp' the device folder vs 'EdgeA::Temp' the
|
||||
// node-level metric). Anything that re-derived the address from the label would collapse them.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", null, "Temp", SparkplugDataType.Float);
|
||||
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", "Filler1", "Temp", SparkplugDataType.Float);
|
||||
|
||||
var nodeLevel = (await s.AttributesAsync(
|
||||
"OtOpcUaSim/EdgeA::Temp", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
var deviceLevel = (await s.AttributesAsync(
|
||||
"OtOpcUaSim/EdgeA/Filler1::Temp", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
nodeLevel.AddressFields!.ContainsKey(MqttTagConfigKeys.DeviceId).ShouldBeFalse();
|
||||
deviceLevel.AddressFields![MqttTagConfigKeys.DeviceId].ShouldBe("Filler1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlainAttributes_StateTheTopic_AsTheAddress()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("otopcua/fixture/oven/temp", "21.5");
|
||||
|
||||
var a = (await s.AttributesAsync(
|
||||
"otopcua/fixture/oven/temp", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
a.AddressFields.ShouldNotBeNull();
|
||||
a.AddressFields![MqttTagConfigKeys.Topic].ShouldBe("otopcua/fixture/oven/temp");
|
||||
a.AddressFields.ContainsKey(MqttTagConfigKeys.MetricName).ShouldBeFalse(); // Plain has no metric
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MqttMode.SparkplugB, true)]
|
||||
[InlineData(MqttMode.Plain, false)]
|
||||
public void RebirthAvailable_IsSparkplugOnly(MqttMode mode, bool expected)
|
||||
{
|
||||
// The picker draws its Request-rebirth affordance off this. A plain window has no re-announce
|
||||
// action at all — RequestRebirthAsync refuses one — so offering the button would be a lie.
|
||||
new MqttBrowseSession(mode).RebirthAvailable.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugAttributes_ForAFolderNode_AreEmpty()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
|
||||
|
||||
(await s.AttributesAsync("Plant1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
(await s.AttributesAsync("Plant1/EdgeA", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
(await s.AttributesAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
(await s.AttributesAsync("no/such/node", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- Sparkplug: the one sanctioned publish
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_ScopedToNode_PublishesOneNcmd()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
|
||||
|
||||
var published = await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
|
||||
|
||||
published.ShouldBe(1);
|
||||
s.PublishCountForTest.ShouldBe(1);
|
||||
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_ForANeverSeenEdgeNode_StillPublishes()
|
||||
{
|
||||
// A node that has never birthed is the PRIME rebirth target — refusing to address one
|
||||
// because the observation window never saw it would defeat the whole feature.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
|
||||
(await s.RequestRebirthAsync("Plant1/EdgeGhost", TestContext.Current.CancellationToken)).ShouldBe(1);
|
||||
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeGhost"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_FromADeviceOrMetricNodeId_ResolvesUpToItsEdgeNode()
|
||||
{
|
||||
// NCMD is node-addressed; there is no device-scoped rebirth. Selecting a metric and
|
||||
// clicking Request-rebirth must address the metric's OWNING edge node — resolved from the
|
||||
// tuple stored on the node, never re-parsed out of the id string.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
|
||||
|
||||
(await s.RequestRebirthAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken)).ShouldBe(1);
|
||||
(await s.RequestRebirthAsync("Plant1/EdgeA/Filler1::T", TestContext.Current.CancellationToken)).ShouldBe(1);
|
||||
|
||||
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA", "spBv1.0/Plant1/NCMD/EdgeA"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_GroupScope_PublishesExactlyOneNcmdPerObservedEdgeNode()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("Plant1", "EdgeB", null, "T", SparkplugDataType.Float);
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler2", "T", SparkplugDataType.Float); // same node
|
||||
s.ObserveBirthForTest("Plant2", "EdgeZ", null, "T", SparkplugDataType.Float); // other group
|
||||
|
||||
var published = await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken);
|
||||
|
||||
published.ShouldBe(2);
|
||||
s.PublishCountForTest.ShouldBe(2);
|
||||
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA", "spBv1.0/Plant1/NCMD/EdgeB"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_GroupScope_BeyondTheCap_PublishesNothingAtAll()
|
||||
{
|
||||
// A 200-node group must not emit 200 NCMDs. The refusal is all-or-nothing: a partial fan-out
|
||||
// would leave the operator unable to say which half of the plant was asked to rebirth.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
for (var i = 0; i <= MqttBrowseSession.MaxGroupRebirthNodes; i++)
|
||||
s.ObserveBirthForTest("Plant1", $"Edge{i:D3}", null, "T", SparkplugDataType.Float);
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken));
|
||||
|
||||
ex.Message.ShouldContain(MqttBrowseSession.MaxGroupRebirthNodes.ToString());
|
||||
s.PublishCountForTest.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_GroupScope_TruncatedBeforeAnyEdgeNode_PublishesNothing()
|
||||
{
|
||||
// The node cap can leave a group node with no edge node recorded beneath it. A group that
|
||||
// resolves to zero targets is refused rather than quietly succeeding with a count of 0 — the
|
||||
// operator clicked Request-rebirth and is entitled to know nothing was sent.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB, nodeCap: 1);
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", null, "T", SparkplugDataType.Float);
|
||||
|
||||
s.Truncated.ShouldBeTrue();
|
||||
(await s.ExpandAsync("Plant1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken));
|
||||
s.PublishCountForTest.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("Plant1")] // a group this window has never observed
|
||||
[InlineData("Plant1/EdgeA/Filler1/Extra")] // unknown, and not resolvable to an edge node
|
||||
public async Task RequestRebirthAsync_UnusableScope_PublishesNothing(string scope)
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
|
||||
await Should.ThrowAsync<ArgumentException>(
|
||||
async () => await s.RequestRebirthAsync(scope, TestContext.Current.CancellationToken));
|
||||
s.PublishCountForTest.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_InPlainMode_IsRefused()
|
||||
{
|
||||
// P1's guarantee is unconditional: a plain-topic browse window publishes nothing, ever.
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("factory/oven/temp", "1");
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(
|
||||
async () => await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken));
|
||||
s.PublishCountForTest.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_AfterDispose_Throws()
|
||||
{
|
||||
var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
await s.DisposeAsync();
|
||||
|
||||
await Should.ThrowAsync<ObjectDisposedException>(
|
||||
async () => await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken));
|
||||
s.PublishCountForTest.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- Sparkplug: the browser
|
||||
|
||||
[Fact]
|
||||
public async Task OpenAsync_SparkplugMode_NoLongerRefuses_ButStillReachesTheBroker()
|
||||
{
|
||||
// Task 23 unseals the Sparkplug branch. The open still has to CONNECT, so an unreachable
|
||||
// broker fails the way any other open does — what must NOT come back is the Task-10
|
||||
// NotSupportedException seal.
|
||||
var browser = new MqttDriverBrowser();
|
||||
const string cfg = """
|
||||
{"host":"broker.invalid","port":8883,"mode":"SparkplugB","connectTimeoutSeconds":5,
|
||||
"sparkplug":{"groupId":"Plant1"}}
|
||||
""";
|
||||
|
||||
var ex = await Should.ThrowAsync<Exception>(
|
||||
async () => await browser.OpenAsync(cfg, TestContext.Current.CancellationToken));
|
||||
ex.ShouldNotBeOfType<NotSupportedException>();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Plant1", "spBv1.0/Plant1/#")]
|
||||
[InlineData("", "spBv1.0/#")] // group not authored yet — discover every group
|
||||
[InlineData(" ", "spBv1.0/#")]
|
||||
public void BuildRootFilter_SparkplugMode_ScopesToTheSparkplugNamespace(string groupId, string expected)
|
||||
{
|
||||
var opts = new MqttDriverOptions
|
||||
{
|
||||
Mode = MqttMode.SparkplugB,
|
||||
Sparkplug = new MqttSparkplugOptions { GroupId = groupId },
|
||||
Plain = new MqttPlainOptions { TopicPrefix = "ignored/in/sparkplug/mode" },
|
||||
};
|
||||
MqttDriverBrowser.BuildRootFilter(opts).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRootFilter_SparkplugMode_WithNoSparkplugSection_DiscoversEveryGroup()
|
||||
{
|
||||
var opts = new MqttDriverOptions { Mode = MqttMode.SparkplugB, Sparkplug = null };
|
||||
MqttDriverBrowser.BuildRootFilter(opts).ShouldBe("spBv1.0/#");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
using System.Text.Json;
|
||||
using MQTTnet;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The operator-visible half of the refused-CONNACK defect. <see cref="MqttConnectionTests"/>
|
||||
/// pins that <c>MqttConnection.ConnectAsync</c> raises the rejection; this pins the thing an
|
||||
/// operator actually sees — that a driver whose broker refused the CONNECT does <b>not</b> seal
|
||||
/// green.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Worth its own suite because the two failures are independent. The connection could classify a
|
||||
/// rejection perfectly and the driver could still report <c>Healthy</c>, simply by not letting the
|
||||
/// failure reach <c>InitializeCoreAsync</c>'s catch — which is exactly the shape the Task-13 live
|
||||
/// fixture found (a wrong broker password produced <c>DriverState.Healthy</c> /
|
||||
/// <c>HostState.Running</c> on a session that never authenticated).
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MqttDriverConnectRejectionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// THE regression pin. A configured-wrong password must fail the deploy loudly, not produce a
|
||||
/// healthy driver that will never receive a single value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_BrokerRefusesTheCredentials_IsNotHealthy()
|
||||
{
|
||||
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized };
|
||||
var options = BrokerOptions(broker.Port);
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
|
||||
async () => await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
|
||||
|
||||
ex.ResultCode.ShouldBe(MqttClientConnectResultCode.NotAuthorized);
|
||||
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldNotBe(DriverState.Healthy, "a driver the broker refused must never seal green");
|
||||
health.State.ShouldBe(DriverState.Faulted);
|
||||
health.LastError.ShouldNotBeNullOrWhiteSpace();
|
||||
health.LastError!.ShouldContain("rejected the credentials");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transient counterpart: still a failed initialize (the driver has no session), but it must
|
||||
/// not be reported as the same unrecoverable shape — a broker restarting is not a misconfigured
|
||||
/// deployment.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_BrokerTemporarilyUnavailable_FailsAsTransient()
|
||||
{
|
||||
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeServerUnavailable };
|
||||
var options = BrokerOptions(broker.Port);
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
|
||||
async () => await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
|
||||
|
||||
ex.IsUnrecoverable.ShouldBeFalse();
|
||||
driver.GetHealth().State.ShouldNotBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The control. Without it, every assertion above would still hold if the driver simply never
|
||||
/// went healthy against this fake broker — the suite would be green and prove nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_BrokerAcceptsTheConnect_IsHealthy()
|
||||
{
|
||||
using var broker = new MiniBroker();
|
||||
var options = BrokerOptions(broker.Port);
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
private static MqttDriverOptions BrokerOptions(int port) => new()
|
||||
{
|
||||
Mode = MqttMode.Plain,
|
||||
Host = "127.0.0.1",
|
||||
Port = port,
|
||||
UseTls = false,
|
||||
ProtocolVersion = MqttProtocolVersion.V311,
|
||||
Username = "svc",
|
||||
Password = "pw",
|
||||
KeepAliveSeconds = 300,
|
||||
ConnectTimeoutSeconds = 5,
|
||||
ReconnectMinBackoffSeconds = 1,
|
||||
ReconnectMaxBackoffSeconds = 2,
|
||||
};
|
||||
|
||||
private static string Json(MqttDriverOptions options) => JsonSerializer.Serialize(options, MqttJson.Options);
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
using System.Text;
|
||||
using Google.Protobuf;
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||||
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shell-level tests for <see cref="MqttDriver"/>: authored-only discovery, the lifecycle
|
||||
/// surface (<c>Reinitialize</c> / <c>Shutdown</c> / health / footprint / cache flush), and the
|
||||
/// <c>IReadable</c> batch contract. Nothing here dials a broker — every test exercises the
|
||||
/// connection-free half of the driver, which is exactly the half a chatty broker must not be
|
||||
/// able to influence.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MqttDriverDiscoveryTests
|
||||
{
|
||||
/// <summary>
|
||||
/// An authored TagConfig blob. <c>dataType</c> uses the real <see cref="DriverDataType"/>
|
||||
/// member names — there is no <c>Double</c>; the 64-bit float is <c>Float64</c>.
|
||||
/// </summary>
|
||||
private static string TagJson(string topic, string dataType = "String", string payloadFormat = "Raw")
|
||||
=> $$"""{"topic":"{{topic}}","payloadFormat":"{{payloadFormat}}","dataType":"{{dataType}}"}""";
|
||||
|
||||
private static RawTagEntry Tag(string rawPath, string topic, string dataType = "String")
|
||||
=> new(rawPath, TagJson(topic, dataType), WriteIdempotent: false);
|
||||
|
||||
private static MqttDriver PlainDriver(params RawTagEntry[] tags)
|
||||
=> new(new MqttDriverOptions { Mode = MqttMode.Plain, RawTags = tags }, "d", null);
|
||||
|
||||
/// <summary>
|
||||
/// Options aimed at a definitely-closed port so a connect is <b>refused immediately</b> rather
|
||||
/// than hanging — the tests that must prove a rebuild actually dials need the dial to fail
|
||||
/// fast, not to burn a connect deadline.
|
||||
/// </summary>
|
||||
private static MqttDriverOptions ClosedPortOptions(params RawTagEntry[] tags) => new()
|
||||
{
|
||||
Mode = MqttMode.Plain,
|
||||
Host = "127.0.0.1",
|
||||
Port = 1,
|
||||
UseTls = false,
|
||||
ConnectTimeoutSeconds = 2,
|
||||
RawTags = tags,
|
||||
};
|
||||
|
||||
private static MqttDriver ClosedPortDriver(params RawTagEntry[] tags)
|
||||
=> new(ClosedPortOptions(tags), "d", null);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a full options record as the reinitialize blob. Round-tripping the whole record
|
||||
/// (rather than hand-writing a partial JSON object) guarantees that only the field the caller
|
||||
/// changed differs — a hand-written delta silently omitting a session field would take the
|
||||
/// rebuild branch for the wrong reason and the test would pass vacuously.
|
||||
/// </summary>
|
||||
private static string DeltaJson(MqttDriverOptions options)
|
||||
=> System.Text.Json.JsonSerializer.Serialize(options, MqttJson.Options);
|
||||
|
||||
/// <summary>
|
||||
/// Plain mode replays ONLY the authored tag set: one variable per authored raw tag, a
|
||||
/// single discovery pass (<see cref="DiscoveryRediscoverPolicy.Once"/>), and no online
|
||||
/// discovery — the universal browser must never treat a broker's topic traffic as a
|
||||
/// browsable address space.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_Plain_StreamsAuthoredTagsOnly_PolicyOnce()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
|
||||
await driver.DiscoverAsync(b, CancellationToken.None);
|
||||
|
||||
b.Variables.Count.ShouldBe(1);
|
||||
b.Variables[0].Info.FullName.ShouldBe("Plant/Mqtt/dev1/Temp");
|
||||
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
|
||||
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability pin for authored-only discovery: a message that arrives on a topic no
|
||||
/// authored tag names must not add anything to the discovered set. A driver that
|
||||
/// auto-provisioned from broker traffic would grow the address space on every deploy against
|
||||
/// a chatty broker.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_UnauthoredBrokerTraffic_DoesNotAppear()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
|
||||
// Traffic this driver never authored — a neighbouring publisher on the same broker.
|
||||
driver.Subscriptions.HandleMessage("some/other/topic", Encoding.UTF8.GetBytes("42"), retained: false);
|
||||
driver.Subscriptions.HandleMessage("f/t/deeper", Encoding.UTF8.GetBytes("42"), retained: false);
|
||||
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
await driver.DiscoverAsync(b, CancellationToken.None);
|
||||
|
||||
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Temp"]);
|
||||
}
|
||||
|
||||
/// <summary>A raw tag whose TagConfig does not map is skipped, never thrown, and never discovered.</summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_SkipsUnmappableTagConfig()
|
||||
{
|
||||
var driver = PlainDriver(
|
||||
Tag("Plant/Mqtt/dev1/Good", "f/t"),
|
||||
new RawTagEntry("Plant/Mqtt/dev1/Bad", "not-json", WriteIdempotent: false));
|
||||
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
await driver.DiscoverAsync(b, CancellationToken.None);
|
||||
|
||||
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Good"]);
|
||||
}
|
||||
|
||||
/// <summary>Discovered MQTT variables are read-only — this driver has no <c>IWritable</c> leg.</summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_MarksVariablesViewOnly()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t", "Float64"));
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
|
||||
await driver.DiscoverAsync(b, CancellationToken.None);
|
||||
|
||||
b.Variables[0].Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Float64);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability pin for the batch-read contract: a reference that is not an authored
|
||||
/// tag degrades to <c>BadWaitingForInitialData</c> in its own slot rather than throwing and
|
||||
/// failing every other reference in the same batch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_UnknownReference_DoesNotThrow_AndDegradesPerRef()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
|
||||
|
||||
var results = await driver.ReadAsync(
|
||||
["Plant/Mqtt/dev1/Temp", "Plant/Mqtt/dev1/NeverAuthored"],
|
||||
CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe("hot");
|
||||
results[1].StatusCode.ShouldBe(0x80320000u); // BadWaitingForInitialData
|
||||
}
|
||||
|
||||
/// <summary>An empty batch is legal and returns an empty result, not an exception.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_EmptyBatch_ReturnsEmpty()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
|
||||
var results = await driver.ReadAsync([], CancellationToken.None);
|
||||
|
||||
results.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability pin for the cache-flush contract: the last-value cache backs
|
||||
/// <c>IReadable</c>, so flushing it would silently turn every subscribed node Bad under
|
||||
/// memory pressure. <c>FlushOptionalCachesAsync</c> must leave it untouched.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task FlushOptionalCachesAsync_LeavesLastValueCacheIntact()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
|
||||
|
||||
await driver.FlushOptionalCachesAsync(CancellationToken.None);
|
||||
|
||||
var results = await driver.ReadAsync(["Plant/Mqtt/dev1/Temp"], CancellationToken.None);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe("hot");
|
||||
}
|
||||
|
||||
/// <summary>A malformed reinitialize delta must never fault the driver, and must not lose the running config.</summary>
|
||||
[Fact]
|
||||
public async Task ReinitializeAsync_MalformedDelta_KeepsRunningConfig_AndDoesNotFault()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
|
||||
await Should.NotThrowAsync(() => driver.ReinitializeAsync("{ this is not json", CancellationToken.None));
|
||||
|
||||
driver.GetHealth().State.ShouldNotBe(DriverState.Faulted);
|
||||
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
await driver.DiscoverAsync(b, CancellationToken.None);
|
||||
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Temp"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A delta that changes only the authored tag set is applied in place — no teardown, no
|
||||
/// reconnect — and is applied WHOLESALE: a tag the redeploy dropped stops being discovered.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReinitializeAsync_TagOnlyDelta_AppliedInPlace_Wholesale()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
|
||||
var pressure = System.Text.Json.JsonSerializer.Serialize(TagJson("f/p"));
|
||||
var flow = System.Text.Json.JsonSerializer.Serialize(TagJson("f/q"));
|
||||
var delta = $$"""
|
||||
{
|
||||
"mode": "Plain",
|
||||
"rawTags": [
|
||||
{ "rawPath": "Plant/Mqtt/dev1/Pressure", "tagConfig": {{pressure}}, "writeIdempotent": false },
|
||||
{ "rawPath": "Plant/Mqtt/dev1/Flow", "tagConfig": {{flow}}, "writeIdempotent": false }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
await driver.ReinitializeAsync(delta, CancellationToken.None);
|
||||
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
await driver.DiscoverAsync(b, CancellationToken.None);
|
||||
|
||||
b.Variables.Select(v => v.Info.FullName)
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ShouldBe(["Plant/Mqtt/dev1/Flow", "Plant/Mqtt/dev1/Pressure"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A delta that changes the broker endpoint takes the <b>rebuild</b> branch: it must actually
|
||||
/// dial the new endpoint (proven here by the refused connect), and a rebuild whose connect
|
||||
/// fails must land <see cref="DriverState.Faulted"/> rather than letting the failure escape
|
||||
/// with the health surface still claiming Healthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReinitializeAsync_SessionChangingDelta_Rebuilds_AndFaultsOnUnreachableBroker()
|
||||
{
|
||||
var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
|
||||
// Only Host differs from the ctor options — everything else round-trips identically.
|
||||
var delta = DeltaJson(ClosedPortOptions() with { Host = "127.0.0.2" });
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => driver.ReinitializeAsync(delta, CancellationToken.None));
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins that <c>IngestIdentity</c> is nested inside <c>SessionIdentity</c>: a delta touching
|
||||
/// ONLY an ingest setting still rebuilds. Without the nesting it would be judged "same
|
||||
/// session", applied in place, and the subscription manager that actually reads
|
||||
/// <c>MaxPayloadBytes</c> would never be rebuilt — a config change that silently does nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReinitializeAsync_IngestOnlyDelta_AlsoRebuilds()
|
||||
{
|
||||
var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
|
||||
var delta = DeltaJson(ClosedPortOptions() with { MaxPayloadBytes = 4096 });
|
||||
|
||||
// Reaching the (refused) connect at all is the proof the rebuild branch was taken; the
|
||||
// tag-only test above is the control that shows an in-place delta never dials.
|
||||
await Should.ThrowAsync<Exception>(() => driver.ReinitializeAsync(delta, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability pin for the orphaned-connection class: <see cref="MqttDriver.DisposeAsync"/>
|
||||
/// must serialize behind an in-flight lifecycle operation, not race past it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An ungated dispose running while a rebuild holds the gate observes <c>_connection == null</c>
|
||||
/// (the rebuild has torn the old session down but not yet assigned the new one), does nothing,
|
||||
/// and sets the disposed flag — after which the rebuild assigns a live connection plus a
|
||||
/// host-probe loop that no later dispose can reach. Here the driver is parked inside
|
||||
/// <c>InitializeCoreAsync</c> at the pre-connect hook; the assertion is that dispose does not
|
||||
/// complete while it is parked, and does complete once it is released.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_SerializesBehindAnInFlightLifecycleOperation()
|
||||
{
|
||||
var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
driver.BeforeConnectHookForTests = async _ =>
|
||||
{
|
||||
entered.TrySetResult();
|
||||
await release.Task;
|
||||
};
|
||||
|
||||
var initialize = Task.Run(() => driver.InitializeAsync("", CancellationToken.None));
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
var dispose = driver.DisposeAsync().AsTask();
|
||||
|
||||
// The gate is held by the parked initialize. Dispose must still be waiting. (The gate wait is
|
||||
// bounded by ConnectTimeoutSeconds = 2 s, so this 300 ms window is comfortably inside it —
|
||||
// an ungated dispose returns essentially instantly.)
|
||||
var raced = await Task.WhenAny(dispose, Task.Delay(TimeSpan.FromMilliseconds(300)));
|
||||
raced.ShouldNotBe(dispose, "DisposeAsync returned while a lifecycle operation held the gate");
|
||||
|
||||
release.SetResult();
|
||||
await Should.ThrowAsync<Exception>(() => initialize); // connect refused on the closed port
|
||||
|
||||
await dispose.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
dispose.IsCompletedSuccessfully.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Identity is fixed at construction and must match the persisted <c>DriverInstance.DriverType</c>.</summary>
|
||||
[Fact]
|
||||
public void Identity_IsMqtt()
|
||||
{
|
||||
var driver = PlainDriver();
|
||||
|
||||
driver.DriverType.ShouldBe("Mqtt");
|
||||
driver.DriverInstanceId.ShouldBe("d");
|
||||
}
|
||||
|
||||
/// <summary>A driver that has never been initialized reports Unknown, not Healthy.</summary>
|
||||
[Fact]
|
||||
public void GetHealth_BeforeInitialize_IsUnknown()
|
||||
{
|
||||
PlainDriver().GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
/// <summary>Plain mode never signals rediscovery — the authored set only changes by redeploy.</summary>
|
||||
[Fact]
|
||||
public async Task Plain_NeverRaisesRediscoveryNeeded()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
var fired = 0;
|
||||
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
|
||||
|
||||
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
|
||||
await driver.DiscoverAsync(new CapturingAddressSpaceBuilder(), CancellationToken.None);
|
||||
|
||||
fired.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>The footprint tracks the authored table; an empty driver reports no driver-attributable bytes.</summary>
|
||||
[Fact]
|
||||
public void GetMemoryFootprint_TracksAuthoredTable()
|
||||
{
|
||||
PlainDriver().GetMemoryFootprint().ShouldBe(0);
|
||||
PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).GetMemoryFootprint().ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>Shutdown on a never-initialized driver is a no-op, not a null-reference.</summary>
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_BeforeInitialize_DoesNotThrow()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
|
||||
await Should.NotThrowAsync(() => driver.ShutdownAsync(CancellationToken.None));
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
/// <summary>The single-broker connectivity probe names the configured endpoint.</summary>
|
||||
[Fact]
|
||||
public void GetHostStatuses_ReportsTheConfiguredBroker()
|
||||
{
|
||||
var driver = new MqttDriver(
|
||||
new MqttDriverOptions { Mode = MqttMode.Plain, Host = "broker.example", Port = 1883 },
|
||||
"d",
|
||||
null);
|
||||
|
||||
var statuses = ((IHostConnectivityProbe)driver).GetHostStatuses();
|
||||
|
||||
statuses.Count.ShouldBe(1);
|
||||
statuses[0].HostName.ShouldBe("broker.example:1883");
|
||||
statuses[0].State.ShouldBe(HostState.Unknown);
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// Task 22 — Sparkplug discovery policy, birth-filled datatypes, rediscovery trigger
|
||||
// =================================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Sparkplug's discovered shape is not fully known at connect: a tag authored without a
|
||||
/// <c>dataType</c> takes its type from the birth certificate, which arrives asynchronously.
|
||||
/// That is precisely the <see cref="DiscoveryRediscoverPolicy.UntilStable"/> contract — the
|
||||
/// host re-runs discovery until the captured set settles.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SparkplugMode_RediscoverPolicy_IsUntilStable()
|
||||
=> SparkplugDriver().RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable);
|
||||
|
||||
/// <summary>
|
||||
/// The mode gate, asserted from the other side: plain MQTT's authored set is fully known
|
||||
/// synchronously, so it must stay <see cref="DiscoveryRediscoverPolicy.Once"/>. A blanket flip
|
||||
/// to <c>UntilStable</c> would make every plain deployment re-run discovery on a timer for a
|
||||
/// tree that can never change.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PlainMode_RediscoverPolicy_StaysOnce()
|
||||
=> PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
|
||||
|
||||
/// <summary>
|
||||
/// A DBIRTH for an authored device the driver has never held a birth for introduces the
|
||||
/// metrics' datatypes, so the discovered tree genuinely changed: rediscovery fires.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NewDbirth_FiresOnRediscoveryNeeded()
|
||||
{
|
||||
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
|
||||
var fired = 0;
|
||||
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
|
||||
|
||||
FeedNodeBirth(driver);
|
||||
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
|
||||
|
||||
fired.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The primary anti-storm pin.</b> An edge node that re-births on a timer (or a flapping
|
||||
/// connection driving the late-join rebirth) republishes the same metric set over and over. If
|
||||
/// every birth fired rediscovery, each one would trigger an address-space rebuild — expensive
|
||||
/// and disruptive on a running server, forever, for a tree that never changed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IdenticalRebirth_DoesNotFireRediscovery()
|
||||
{
|
||||
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
|
||||
FeedNodeBirth(driver);
|
||||
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
|
||||
|
||||
// Subscribe only AFTER the first birth so the count below is purely the rebirths'.
|
||||
var fired = 0;
|
||||
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
FeedNodeBirth(driver, seq: 0, bdSeq: (ulong)(i + 2));
|
||||
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
|
||||
}
|
||||
|
||||
fired.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The other half of the gate: a rebirth that actually changes the metric set (a metric added,
|
||||
/// removed or renamed) DOES change what discovery can resolve, and must fire.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RebirthWithChangedMetricSet_FiresRediscovery()
|
||||
{
|
||||
var driver = SparkplugDriver(
|
||||
SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")),
|
||||
SpTag(SpPressPath, SpBlob(SpDevice, "Pressure")));
|
||||
FeedNodeBirth(driver);
|
||||
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
|
||||
|
||||
var fired = 0;
|
||||
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
|
||||
|
||||
FeedDeviceBirth(driver, seq: 2, ("Temperature", 5UL, TahuDataType.Float), ("Pressure", 6UL, TahuDataType.Float));
|
||||
|
||||
fired.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metric ORDER is not the metric SET. A birth that lists the same metrics in a different order
|
||||
/// describes the same address space; firing on it would reintroduce the rebuild storm through
|
||||
/// the back door, because nothing obliges an edge node to keep its birth order stable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RebirthReorderingTheSameMetrics_DoesNotFireRediscovery()
|
||||
{
|
||||
var driver = SparkplugDriver(
|
||||
SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")),
|
||||
SpTag(SpPressPath, SpBlob(SpDevice, "Pressure")));
|
||||
FeedNodeBirth(driver);
|
||||
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float), ("Pressure", 6UL, TahuDataType.Float));
|
||||
|
||||
var fired = 0;
|
||||
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
|
||||
|
||||
FeedDeviceBirth(driver, seq: 2, ("Pressure", 6UL, TahuDataType.Float), ("Temperature", 5UL, TahuDataType.Float));
|
||||
|
||||
fired.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A birth for a device NO authored tag names cannot change <see cref="MqttDriver.DiscoverAsync"/>'s
|
||||
/// output — discovery replays the authored set. A large plant DBIRTHs devices this deployment
|
||||
/// never reads on their own schedules; firing for those is a rebuild storm sourced from traffic
|
||||
/// the configuration has no opinion about.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BirthForAnUnauthoredDevice_DoesNotFireRediscovery()
|
||||
{
|
||||
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
|
||||
FeedNodeBirth(driver);
|
||||
|
||||
var fired = 0;
|
||||
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
|
||||
|
||||
// Same (authored) edge node, a device nothing binds — the ingestor still applies the birth.
|
||||
FeedDeviceBirth(driver, "Filler99", seq: 1, ("Temperature", 5UL, TahuDataType.Float));
|
||||
|
||||
fired.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rediscovery event names the subtree this driver's discovery actually emits (the
|
||||
/// <c>Mqtt</c> folder) so a consumer scoping a rebuild on it scopes to exactly the tree that
|
||||
/// changed, and carries the Sparkplug scope in the diagnostic reason.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RediscoveryEvent_ScopeHintIsTheDiscoveryFolder_ReasonNamesTheScope()
|
||||
{
|
||||
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
|
||||
RediscoveryEventArgs? args = null;
|
||||
((IRediscoverable)driver).OnRediscoveryNeeded += (_, e) => args = e;
|
||||
|
||||
FeedNodeBirth(driver);
|
||||
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
|
||||
|
||||
args.ShouldNotBeNull();
|
||||
args.ScopeHint.ShouldBe("Mqtt");
|
||||
args.Reason.ShouldContain($"{SpGroup}/{SpNode}/{SpDevice}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The datatype fill-in.</b> <c>dataType</c> is optional in the Sparkplug tag shape — the
|
||||
/// birth certificate declares it. Before any birth the tag can only report the record default;
|
||||
/// once the DBIRTH lands, discovery must re-stream it with the type the birth declared.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_Sparkplug_UnauthoredDataType_FillsInFromTheBirth()
|
||||
{
|
||||
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
|
||||
|
||||
var before = new CapturingAddressSpaceBuilder();
|
||||
await driver.DiscoverAsync(before, TestContext.Current.CancellationToken);
|
||||
|
||||
// The node exists from the first pass — an authored tag is part of the declared configuration,
|
||||
// not something the plant grants by publishing.
|
||||
before.Variables.Select(v => v.Info.FullName).ShouldBe([SpTempPath]);
|
||||
before.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.String); // the record default
|
||||
|
||||
FeedNodeBirth(driver);
|
||||
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
|
||||
|
||||
var after = new CapturingAddressSpaceBuilder();
|
||||
await driver.DiscoverAsync(after, TestContext.Current.CancellationToken);
|
||||
|
||||
after.Variables.Select(v => v.Info.FullName).ShouldBe([SpTempPath]);
|
||||
after.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The authored <c>dataType</c> wins over the birth's — the same precedence the ingest path
|
||||
/// applies when it coerces a value (<c>DataTypeAuthored ? authored : birth</c>). A discovery
|
||||
/// surface that disagreed with the publish surface would report a type no value ever arrives as.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_Sparkplug_AuthoredDataType_WinsOverTheBirth()
|
||||
{
|
||||
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Int32")));
|
||||
FeedNodeBirth(driver);
|
||||
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
|
||||
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
await driver.DiscoverAsync(b, TestContext.Current.CancellationToken);
|
||||
|
||||
b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A birth declaring a Sparkplug type this driver cannot map (DataSet / Template / PropertySet
|
||||
/// / Unknown) must not blank the tag's type — <c>ToDriverDataType()</c> returns null for those,
|
||||
/// and discovery falls back to the authored/default type rather than to nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_Sparkplug_UnsupportedBirthType_FallsBackToTheDefault()
|
||||
{
|
||||
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
|
||||
FeedNodeBirth(driver);
|
||||
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.DataSet));
|
||||
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
await driver.DiscoverAsync(b, TestContext.Current.CancellationToken);
|
||||
|
||||
b.Variables.Count.ShouldBe(1);
|
||||
b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The composition-root line.</b> Task 21 wires the ingestor's <c>OnDataChange</c> to the
|
||||
/// driver's own in <c>CreateSparkplugIngestor</c>; every other Sparkplug test in the repo drives
|
||||
/// <see cref="SparkplugIngestor"/> directly and would pass with that lambda deleted. This repo
|
||||
/// has twice shipped a component correct in isolation and inert in production for exactly that
|
||||
/// reason (<c>DeferredAddressSpaceSink</c>, <c>GatewayTagProvisioner</c>) — so the re-raise is
|
||||
/// asserted through <see cref="MqttDriver"/>'s own event, under the RawPath.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SparkplugMode_DriverRepublishesIngestDataChanges_UnderTheRawPath()
|
||||
{
|
||||
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
|
||||
await driver.SubscribeAsync([SpTempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
||||
|
||||
object? sender = null;
|
||||
string? firedRef = null;
|
||||
object? firedValue = null;
|
||||
((ISubscribable)driver).OnDataChange += (s, e) =>
|
||||
{
|
||||
sender = s;
|
||||
firedRef = e.FullReference;
|
||||
firedValue = e.Snapshot.Value;
|
||||
};
|
||||
|
||||
FeedNodeBirth(driver);
|
||||
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
|
||||
FeedDeviceData(driver, seq: 2, (5UL, 21.5f));
|
||||
|
||||
firedRef.ShouldBe(SpTempPath);
|
||||
firedValue.ShouldBe(21.5f);
|
||||
|
||||
// The driver re-raises with ITSELF as sender, not the ingestor — DriverHostActor attributes
|
||||
// publishes to the driver instance.
|
||||
sender.ShouldBeSameAs(driver);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>ReadAsync</c> serves from the driver-owned <c>LastValueCache</c>, which the Sparkplug
|
||||
/// ingestor is handed at construction. If the two ever stopped sharing one instance, every
|
||||
/// Sparkplug read would answer <c>BadWaitingForInitialData</c> forever while subscriptions
|
||||
/// looked perfectly healthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SparkplugMode_ReadAsyncServesTheIngestedValue()
|
||||
{
|
||||
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
|
||||
|
||||
FeedNodeBirth(driver);
|
||||
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
|
||||
FeedDeviceData(driver, seq: 2, (5UL, 33.5f));
|
||||
|
||||
var results = await driver.ReadAsync([SpTempPath], TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].Value.ShouldBe(33.5f);
|
||||
results[0].StatusCode.ShouldBe(0x00000000u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe/unsubscribe dispatch by mode. After an unsubscribe the reference is no longer
|
||||
/// attributed to a handle, so ingest stops raising for it — while the cache keeps answering, so
|
||||
/// a read still works. Both halves matter: a driver that kept publishing after unsubscribe
|
||||
/// would leak notifications into a torn-down monitored item.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SparkplugMode_UnsubscribeStopsNotifications_ButNotReads()
|
||||
{
|
||||
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
|
||||
var handle = await driver.SubscribeAsync([SpTempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
||||
|
||||
FeedNodeBirth(driver);
|
||||
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
|
||||
|
||||
var fired = 0;
|
||||
((ISubscribable)driver).OnDataChange += (_, _) => Interlocked.Increment(ref fired);
|
||||
|
||||
await driver.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
FeedDeviceData(driver, seq: 2, (5UL, 7.5f));
|
||||
|
||||
fired.ShouldBe(0);
|
||||
(await driver.ReadAsync([SpTempPath], TestContext.Current.CancellationToken))[0].Value.ShouldBe(7.5f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The mode gate from the other side — the reviewer's reading of the branching, turned into a
|
||||
/// test. A Plain-mode driver builds NO Sparkplug ingestor at all, so no Sparkplug traffic can
|
||||
/// reach it and a Sparkplug-shaped blob (which carries no <c>topic</c>) resolves to nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PlainMode_BuildsNoSparkplugIngestor()
|
||||
{
|
||||
var driver = new MqttDriver(
|
||||
new MqttDriverOptions
|
||||
{
|
||||
Mode = MqttMode.Plain,
|
||||
RawTags = [SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32"))],
|
||||
},
|
||||
"d",
|
||||
null);
|
||||
|
||||
driver.Sparkplug.ShouldBeNull();
|
||||
driver.Subscriptions.TryResolve(SpTempPath, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// …and the converse: a Sparkplug-mode driver registers into the Sparkplug path, not the plain
|
||||
/// manager. Registering into both would double-warn on every deploy and, worse, let a plain
|
||||
/// topic route a Sparkplug tag.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SparkplugMode_RegistersIntoTheSparkplugPathOnly()
|
||||
{
|
||||
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
|
||||
|
||||
driver.Sparkplug!.TryResolve(SpTempPath, out var def).ShouldBeTrue();
|
||||
def.MetricName.ShouldBe("Temperature");
|
||||
driver.Subscriptions.TryResolve(SpTempPath, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- Sparkplug fixtures ----
|
||||
|
||||
private const string SpGroup = "Plant1";
|
||||
private const string SpNode = "EdgeA";
|
||||
private const string SpDevice = "Filler1";
|
||||
private const string SpTempPath = "Plant/Mqtt/spb/Filler1Temp";
|
||||
private const string SpPressPath = "Plant/Mqtt/spb/Filler1Press";
|
||||
|
||||
/// <summary>A Sparkplug tag blob. <c>dataType</c> is omitted unless supplied — it is optional.</summary>
|
||||
private static string SpBlob(string? device, string metric, string? dataType = null)
|
||||
{
|
||||
var deviceKey = device is null ? "" : $"""{'"'}deviceId{'"'}:"{device}",""";
|
||||
var typeKey = dataType is null ? "" : $""","dataType":"{dataType}" """;
|
||||
return $$"""{"groupId":"{{SpGroup}}","edgeNodeId":"{{SpNode}}",{{deviceKey}}"metricName":"{{metric}}"{{typeKey}}}""";
|
||||
}
|
||||
|
||||
private static RawTagEntry SpTag(string rawPath, string blob) => new(rawPath, blob, WriteIdempotent: false);
|
||||
|
||||
private static MqttDriver SparkplugDriver(params RawTagEntry[] tags)
|
||||
=> new(
|
||||
new MqttDriverOptions
|
||||
{
|
||||
Mode = MqttMode.SparkplugB,
|
||||
Sparkplug = new MqttSparkplugOptions { GroupId = SpGroup },
|
||||
RawTags = tags,
|
||||
},
|
||||
"d",
|
||||
null);
|
||||
|
||||
/// <summary>
|
||||
/// Feeds a real NBIRTH through the real codec + ingest state machine — no broker, and no
|
||||
/// test-only shortcut on the driver. <c>Dispatch</c> is the method MQTTnet's dispatcher thread
|
||||
/// reaches, so this exercises the production path end to end.
|
||||
/// </summary>
|
||||
private static void FeedNodeBirth(MqttDriver driver, ulong seq = 0, ulong bdSeq = 1)
|
||||
{
|
||||
var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL };
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = "bdSeq",
|
||||
Datatype = (uint)TahuDataType.Uint64,
|
||||
LongValue = bdSeq,
|
||||
});
|
||||
payload.Metrics.Add(SpBirthMetric("Uptime", 7UL, TahuDataType.Int64));
|
||||
|
||||
driver.Sparkplug!.Dispatch(
|
||||
new SparkplugTopic(SparkplugMessageType.NBIRTH, SpGroup, SpNode, null, null),
|
||||
SparkplugCodec.Decode(payload.ToByteArray()));
|
||||
}
|
||||
|
||||
private static void FeedDeviceBirth(
|
||||
MqttDriver driver,
|
||||
ulong seq,
|
||||
params (string Name, ulong Alias, TahuDataType Type)[] metrics)
|
||||
=> FeedDeviceBirth(driver, SpDevice, seq, metrics);
|
||||
|
||||
private static void FeedDeviceBirth(
|
||||
MqttDriver driver,
|
||||
string device,
|
||||
ulong seq,
|
||||
params (string Name, ulong Alias, TahuDataType Type)[] metrics)
|
||||
{
|
||||
var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL };
|
||||
foreach (var (name, alias, type) in metrics)
|
||||
{
|
||||
payload.Metrics.Add(SpBirthMetric(name, alias, type));
|
||||
}
|
||||
|
||||
driver.Sparkplug!.Dispatch(
|
||||
new SparkplugTopic(SparkplugMessageType.DBIRTH, SpGroup, SpNode, device, null),
|
||||
SparkplugCodec.Decode(payload.ToByteArray()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feeds a real DDATA — alias-only metrics, exactly as a Sparkplug node publishes after a birth.
|
||||
/// </summary>
|
||||
private static void FeedDeviceData(MqttDriver driver, ulong seq, params (ulong Alias, float Value)[] metrics)
|
||||
{
|
||||
var payload = new Payload { Seq = seq, Timestamp = 1721822405000UL };
|
||||
foreach (var (alias, value) in metrics)
|
||||
{
|
||||
payload.Metrics.Add(new Payload.Types.Metric { Alias = alias, FloatValue = value });
|
||||
}
|
||||
|
||||
driver.Sparkplug!.Dispatch(
|
||||
new SparkplugTopic(SparkplugMessageType.DDATA, SpGroup, SpNode, SpDevice, null),
|
||||
SparkplugCodec.Decode(payload.ToByteArray()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A birth metric declaring name/alias/datatype and <b>no value</b> — a birth's job here is to
|
||||
/// declare the catalog, and these tests assert on the catalog, never on a published value.
|
||||
/// </summary>
|
||||
private static Payload.Types.Metric SpBirthMetric(string name, ulong alias, TahuDataType type)
|
||||
=> new() { Name = name, Alias = alias, Datatype = (uint)type };
|
||||
|
||||
// ---- test doubles ----
|
||||
|
||||
/// <summary>
|
||||
/// Records the streamed discovery tree instead of materializing OPC UA nodes. Local to this
|
||||
/// suite: the driver test project does not reference <c>Commons</c>, where the runtime's own
|
||||
/// capturing builder lives. Mirrors the per-driver <c>RecordingBuilder</c> the AB CIP / FOCAS
|
||||
/// suites use.
|
||||
/// </summary>
|
||||
private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
/// <summary>Every folder streamed, in order, across the whole tree.</summary>
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
|
||||
|
||||
/// <summary>Every variable streamed, in order, across the whole tree.</summary>
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add((browseName, displayName));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add((browseName, attributeInfo));
|
||||
return new Handle(attributeInfo.FullName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="MqttDriverFactoryExtensions"/> — the seam that turns a persisted
|
||||
/// <c>DriverInstance.DriverConfig</c> blob into a live <see cref="MqttDriver"/>. Mirrors
|
||||
/// <c>OpcUaClientDriverFactoryExtensions</c>' shape (direct deserialization into the options
|
||||
/// record, no intermediate DTO).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MqttDriverFactoryExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Register_ThenCreate_BuildsMqttDriver()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
MqttDriverFactoryExtensions.Register(registry);
|
||||
|
||||
var factory = registry.TryGet(MqttDriverFactoryExtensions.DriverTypeName);
|
||||
factory.ShouldNotBeNull();
|
||||
|
||||
var driver = factory("d1", """{"host":"h","port":1883,"mode":"Plain"}""");
|
||||
|
||||
driver.ShouldBeOfType<MqttDriver>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The factory's registration key and the canonical constant must be the same string. A
|
||||
/// drift here is invisible at compile time and silently breaks dispatch — the repo's
|
||||
/// <c>ModbusTcp</c>/<c>Modbus</c> incident.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DriverTypeName_MatchesConstant()
|
||||
=> MqttDriverFactoryExtensions.DriverTypeName.ShouldBe(DriverTypeNames.Mqtt);
|
||||
|
||||
/// <summary>
|
||||
/// Third independent pin on the literal itself: both the constant and the factory name could
|
||||
/// be renamed together and still agree with each other while disagreeing with every persisted
|
||||
/// <c>DriverInstance.DriverType</c> row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DriverTypeName_IsExactlyMqtt()
|
||||
{
|
||||
MqttDriverFactoryExtensions.DriverTypeName.ShouldBe("Mqtt");
|
||||
DriverTypeNames.Mqtt.ShouldBe("Mqtt");
|
||||
DriverTypeNames.All.ShouldContain("Mqtt");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>rawTags</c> is the ONLY source of the driver's discoverable node set (plain MQTT has no
|
||||
/// browsable address space), so a factory that parsed everything else correctly but dropped
|
||||
/// this array would produce a driver that connects, subscribes to nothing and materializes
|
||||
/// nothing — green everywhere, dark in production.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateInstance_CarriesRawTagsThroughToDiscovery()
|
||||
{
|
||||
const string tagConfig = """{"topic":"f/t","payloadFormat":"Raw","dataType":"String"}""";
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
host = "h",
|
||||
port = 1883,
|
||||
mode = "Plain",
|
||||
rawTags = new[]
|
||||
{
|
||||
new { rawPath = "Plant/Mqtt/dev1/Temp", tagConfig, writeIdempotent = false },
|
||||
},
|
||||
});
|
||||
|
||||
var driver = MqttDriverFactoryExtensions.CreateInstance("d1", json);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await driver.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.VariableFullNames.ShouldBe(["Plant/Mqtt/dev1/Temp"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enum-valued knobs must bind from their string NAMES — the repo's documented systemic
|
||||
/// enum-serialization bug is an AdminUI-authored config whose enum field one seam can read
|
||||
/// and another cannot. Removing <see cref="JsonStringEnumConverter"/> from
|
||||
/// <see cref="MqttJson.Options"/> makes this throw.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateInstance_EnumsAuthoredAsNames_Bind()
|
||||
{
|
||||
const string json = """
|
||||
{"host":"h","port":8883,"mode":"SparkplugB","protocolVersion":"V311",
|
||||
"sparkplug":{"groupId":"Plant1","hostId":"h1"}}
|
||||
""";
|
||||
|
||||
var driver = MqttDriverFactoryExtensions.CreateInstance("d1", json);
|
||||
|
||||
driver.ShouldBeOfType<MqttDriver>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direct pin on the single shared instance: the enum converter is what the test above
|
||||
/// exercises behaviourally, and this asserts it structurally so a reader can see the one
|
||||
/// instance every MQTT config seam parses through.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SharedJsonOptions_CarryTheStringEnumConverter()
|
||||
=> MqttJson.Options.Converters.ShouldContain(c => c is JsonStringEnumConverter);
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_BlankConfig_Throws()
|
||||
=> Should.Throw<ArgumentException>(() => MqttDriverFactoryExtensions.CreateInstance("d1", " "));
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_JsonNullLiteral_ThrowsInvalidOperation()
|
||||
=> Should.Throw<InvalidOperationException>(() => MqttDriverFactoryExtensions.CreateInstance("d1", "null"));
|
||||
|
||||
/// <summary>
|
||||
/// Minimal discovery recorder — local to this suite because the driver test project does not
|
||||
/// reference <c>Commons</c>, where the runtime's capturing builder lives (same reason
|
||||
/// <c>MqttDriverDiscoveryTests</c> carries its own).
|
||||
/// </summary>
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<string> VariableFullNames { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
VariableFullNames.Add(attributeInfo.FullName);
|
||||
return new Handle(attributeInfo.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
public sealed class MqttDriverOptionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The production instance, not a look-alike copy. A local clone would keep passing after
|
||||
/// <see cref="MqttJson.Options"/> lost its <c>JsonStringEnumConverter</c> — testing the copy
|
||||
/// instead of the thing every seam actually parses through.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions J = MqttJson.Options;
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_SparkplugConfig_ReadsModeAndSubObject()
|
||||
{
|
||||
const string json = """
|
||||
{ "host":"10.100.0.35","port":8883,"useTls":true,"mode":"SparkplugB",
|
||||
"sparkplug":{"groupId":"Plant1","hostId":"h1","requestRebirthOnGap":true} }
|
||||
""";
|
||||
var o = JsonSerializer.Deserialize<MqttDriverOptions>(json, J)!;
|
||||
o.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
o.UseTls.ShouldBeTrue();
|
||||
o.Sparkplug!.GroupId.ShouldBe("Plant1");
|
||||
o.Plain.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_WritesEnumsAsNames_NotOrdinals()
|
||||
{
|
||||
var s = JsonSerializer.Serialize(new MqttDriverOptions { Mode = MqttMode.Plain }, J);
|
||||
s.ShouldContain("\"Plain\"");
|
||||
s.ShouldNotContain("\"mode\":0");
|
||||
}
|
||||
|
||||
// Pins the plan's cross-cutting rule ("never ship anonymous/public-broker defaults") as an
|
||||
// executable assertion — nothing else in this suite fails if UseTls / AllowUntrustedServerCertificate
|
||||
// (or the §5.1 numeric defaults) get flipped by an unrelated edit.
|
||||
[Fact]
|
||||
public void Defaults_SecurityCriticalKnobs_MatchPlan()
|
||||
{
|
||||
var o = new MqttDriverOptions();
|
||||
o.UseTls.ShouldBeTrue();
|
||||
o.AllowUntrustedServerCertificate.ShouldBeFalse();
|
||||
o.ConnectTimeoutSeconds.ShouldBe(15);
|
||||
o.ReconnectMinBackoffSeconds.ShouldBe(1);
|
||||
o.ReconnectMaxBackoffSeconds.ShouldBe(30);
|
||||
}
|
||||
|
||||
// The production case: an operator-authored config blob that simply doesn't mention TLS must
|
||||
// never silently deserialize into an insecure instance.
|
||||
[Fact]
|
||||
public void Deserialize_PartialJsonOmittingTlsKnobs_KeepsSecureDefaults()
|
||||
{
|
||||
const string json = """{ "host":"10.100.0.35","port":8883 }""";
|
||||
var o = JsonSerializer.Deserialize<MqttDriverOptions>(json, J)!;
|
||||
o.UseTls.ShouldBeTrue();
|
||||
o.AllowUntrustedServerCertificate.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_DoesNotLeakPassword()
|
||||
{
|
||||
var o = new MqttDriverOptions { Password = "hunter2-supersecret" };
|
||||
var s = o.ToString();
|
||||
s.ShouldNotContain("hunter2-supersecret");
|
||||
s.ShouldContain("***");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="MqttDriverProbe"/>. Mirrors <c>ModbusDriverProbeTests</c>'
|
||||
/// shape: a real loopback listener drives server-side behaviour so the probe's CONNECT
|
||||
/// handshake is exercised against something real rather than mocked.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MqttDriverProbeTests
|
||||
{
|
||||
private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>A loopback listener that accepts the TCP connection and then never answers CONNACK —
|
||||
/// the shape that actually wedges a client. A refused port fails instantly with ECONNREFUSED and
|
||||
/// would pass whether or not the probe's deadline logic works at all, so the timeout test below
|
||||
/// needs this rather than a closed port.</summary>
|
||||
private sealed class BlackholeBroker : IDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly List<TcpClient> _accepted = [];
|
||||
|
||||
public BlackholeBroker()
|
||||
{
|
||||
_listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
_listener.Start();
|
||||
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
_ = Task.Run(AcceptLoopAsync);
|
||||
}
|
||||
|
||||
public int Port { get; }
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
var client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false);
|
||||
lock (_accepted)
|
||||
{
|
||||
_accepted.Add(client);
|
||||
}
|
||||
// Accept the TCP handshake and then say nothing — never a CONNACK.
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Listener stopped / cancelled — expected at teardown.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_listener.Stop();
|
||||
lock (_accepted)
|
||||
{
|
||||
foreach (var client in _accepted)
|
||||
{
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static TcpListener StartListener()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
return l;
|
||||
}
|
||||
|
||||
private static int ListenerPort(TcpListener l) => ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
|
||||
[Fact]
|
||||
public void DriverType_IsCanonicalMqtt() => new MqttDriverProbe().DriverType.ShouldBe("Mqtt");
|
||||
|
||||
/// <summary>The plan's own vacuous-pass trap: a closed port fails instantly (ECONNREFUSED)
|
||||
/// regardless of whether the deadline logic works. Kept as the "refused" leg, not the
|
||||
/// "deadline" leg — see <see cref="ProbeAsync_BlackholeBroker_FailsWithinDeadline_NoHang"/>
|
||||
/// for the real timeout-path exercise.</summary>
|
||||
[Fact]
|
||||
public async Task ProbeAsync_DeadBroker_ReturnsFailedResult_WithinDeadline()
|
||||
{
|
||||
var listener = StartListener();
|
||||
var port = ListenerPort(listener);
|
||||
listener.Stop();
|
||||
|
||||
var r = await new MqttDriverProbe().ProbeAsync(
|
||||
$$"""{"host":"127.0.0.1","port":{{port}},"useTls":false,"connectTimeoutSeconds":2}""",
|
||||
QuickTimeout,
|
||||
CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
/// <summary>The real timeout-path exercise: a broker that completes the TCP handshake and then
|
||||
/// never answers CONNACK must still fail at the configured deadline rather than hang.</summary>
|
||||
[Fact]
|
||||
public async Task ProbeAsync_BlackholeBroker_FailsWithinDeadline_NoHang()
|
||||
{
|
||||
using var blackhole = new BlackholeBroker();
|
||||
var config = $$"""{"host":"127.0.0.1","port":{{blackhole.Port}},"useTls":false,"connectTimeoutSeconds":1}""";
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);
|
||||
sw.Stop();
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNullOrEmpty();
|
||||
r.Message.ShouldContain("timed out", Case.Insensitive);
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeAsync_InvalidJson_ReturnsFailedResult_NotThrown()
|
||||
{
|
||||
var r = await new MqttDriverProbe().ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeAsync_NoHost_ReturnsFailedResult()
|
||||
{
|
||||
var r = await new MqttDriverProbe().ProbeAsync(
|
||||
"""{"host":"","port":0}""", QuickTimeout, CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Never leak a configured password into the probe's result message — it surfaces
|
||||
/// directly in the AdminUI Test-connect UI.</summary>
|
||||
[Fact]
|
||||
public async Task ProbeAsync_NeverLeaksPasswordIntoMessage()
|
||||
{
|
||||
var listener = StartListener();
|
||||
var port = ListenerPort(listener);
|
||||
listener.Stop();
|
||||
|
||||
const string secret = "S3cr3tSquirrel!!";
|
||||
var config = $$"""
|
||||
{"host":"127.0.0.1","port":{{port}},"useTls":false,"username":"probe-user","password":"{{secret}}","connectTimeoutSeconds":2}
|
||||
""";
|
||||
|
||||
var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNullOrEmpty();
|
||||
r.Message.ShouldNotContain(secret);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enum fields (protocolVersion) must round-trip by name, per the repo's documented
|
||||
/// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs.
|
||||
/// <para>
|
||||
/// <b>The assertions are the whole point.</b> "Ok is false with a non-empty message" is
|
||||
/// ALSO what a JSON-parse failure produces, so asserting only that would stay green if
|
||||
/// <c>JsonStringEnumConverter</c> were ever dropped from <see cref="MqttJson.Options"/>
|
||||
/// — i.e. the exact regression this test exists to catch. So it asserts the probe got
|
||||
/// PAST the parse (message is not the <c>"Config JSON is invalid"</c> shape) and reached
|
||||
/// the network (the message names the target endpoint).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ProbeAsync_EnumAsName_ParsesAndReachesTheNetwork()
|
||||
{
|
||||
var listener = StartListener();
|
||||
var port = ListenerPort(listener);
|
||||
listener.Stop();
|
||||
|
||||
var config = $$"""
|
||||
{"host":"127.0.0.1","port":{{port}},"useTls":false,"protocolVersion":"V500","connectTimeoutSeconds":2}
|
||||
""";
|
||||
|
||||
var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNullOrEmpty();
|
||||
r.Message.ShouldNotStartWith(
|
||||
"Config JSON is invalid",
|
||||
Case.Insensitive,
|
||||
"the probe never parsed the config — 'V500' was rejected, so the shared JSON options " +
|
||||
"lost their JsonStringEnumConverter");
|
||||
r.Message.ShouldNotStartWith("Config JSON deserialized to null");
|
||||
r.Message.ShouldNotStartWith("Config has no host/port");
|
||||
// A network-level marker: the probe got as far as dialling the (closed) loopback port.
|
||||
r.Message.ShouldContain($"127.0.0.1:{port}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,851 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="MqttSubscriptionManager"/> — the Task-6 plain-MQTT ingest path: authored-tag
|
||||
/// registration keyed by RawPath, topic dedupe + fan-out, payload extraction, the retained seed,
|
||||
/// and the subscribe/SUBACK contract. Every test runs without a broker: messages are fed straight
|
||||
/// through <see cref="MqttSubscriptionManager.HandleMessage"/> (the method MQTTnet's dispatcher
|
||||
/// calls) and the subscribe leg runs against a fake <see cref="IMqttSubscribeTransport"/>.
|
||||
/// </summary>
|
||||
public sealed class MqttSubscriptionManagerTests
|
||||
{
|
||||
// OPC UA status codes, verified against Opc.Ua.StatusCodes rather than recalled.
|
||||
private const uint Good = 0x00000000u;
|
||||
private const uint BadDecodingError = 0x80070000u;
|
||||
private const uint BadTypeMismatch = 0x80740000u;
|
||||
private const uint BadNodeIdUnknown = 0x80340000u;
|
||||
private const uint BadCommunicationError = 0x80050000u;
|
||||
|
||||
private const string OvenTempPath = "Plant/Mqtt/broker1/OvenTemp";
|
||||
private const string OvenPressPath = "Plant/Mqtt/broker1/OvenPressure";
|
||||
private const string OvenTopic = "f/oven/temp";
|
||||
|
||||
private static RawTagEntry Tag(string rawPath, string tagConfig) => new(rawPath, tagConfig, WriteIdempotent: false);
|
||||
|
||||
private static MqttSubscriptionManager NewManager(IMqttSubscribeTransport? transport = null) =>
|
||||
new(new MqttDriverOptions { Mode = MqttMode.Plain }, "mqtt-1", transport);
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Routing + the RawPath identity of the published reference
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The plan's first snippet, corrected twice: <c>Double</c> is not a
|
||||
/// <see cref="DriverDataType"/> member (<c>Float64</c> is), and the published reference is
|
||||
/// asserted to BE the RawPath rather than merely to <i>contain</i> the topic — a topic-keyed
|
||||
/// publish would satisfy the plan's assertion and be silently dead against
|
||||
/// <c>DriverHostActor</c>'s RawPath-keyed dual-namespace fan-out.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HandleMessage_MatchingTopic_RaisesDataChangeAtJsonPath_UnderRawPath()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
string? gotRef = null;
|
||||
object? gotVal = null;
|
||||
mgr.OnDataChange += (_, e) => { gotRef = e.FullReference; gotVal = e.Snapshot.Value; };
|
||||
|
||||
mgr.HandleMessage(OvenTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
|
||||
|
||||
gotVal.ShouldBe(21.5);
|
||||
gotRef.ShouldBe(OvenTempPath);
|
||||
gotRef.ShouldNotBe(OvenTopic);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A chatty broker must never auto-provision a tag the operator did not author. Deliberately
|
||||
/// run with a live, subscribed tag present rather than an empty table: an empty table makes
|
||||
/// the assertion unfalsifiable — a "deliver to every authored tag" implementation would pass it
|
||||
/// for want of anything to deliver to.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HandleMessage_UnauthoredTopic_RaisesNothing()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
var fired = false;
|
||||
mgr.OnDataChange += (_, _) => fired = true;
|
||||
|
||||
mgr.HandleMessage("random/topic", "1"u8.ToArray(), retained: false);
|
||||
|
||||
fired.ShouldBeFalse();
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldNotBe(Good); // and nothing was cached for it either
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two tags on ONE topic (different JSONPaths) — the message must reach BOTH. A
|
||||
/// <c>TryGetValue → first match → return</c> implementation passes every single-tag test and
|
||||
/// silently starves every tag after the first.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HandleMessage_TopicSharedByTwoTags_FansOutToEveryMatchingTag()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register(
|
||||
[
|
||||
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.temp","dataType":"Float64"}"""),
|
||||
Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.press","dataType":"Float64"}"""),
|
||||
]);
|
||||
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
var seen = new Dictionary<string, object?>();
|
||||
mgr.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, """{"temp":21.5,"press":1.2}"""u8.ToArray(), retained: false);
|
||||
|
||||
seen.Count.ShouldBe(2);
|
||||
seen[OvenTempPath].ShouldBe(21.5);
|
||||
seen[OvenPressPath].ShouldBe(1.2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An authored tag nobody subscribed to still caches its value (so a later
|
||||
/// <c>IReadable.ReadAsync</c> answers) but must not raise <c>OnDataChange</c> — there is no
|
||||
/// subscription handle to attribute it to.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HandleMessage_AuthoredButUnsubscribedTag_CachesValue_RaisesNothing()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
|
||||
var fired = false;
|
||||
mgr.OnDataChange += (_, _) => fired = true;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, "7.5"u8.ToArray(), retained: false);
|
||||
|
||||
fired.ShouldBeFalse();
|
||||
mgr.Values.Read(OvenTempPath).Value.ShouldBe(7.5);
|
||||
}
|
||||
|
||||
/// <summary>After <c>UnsubscribeAsync</c> the reference stops publishing.</summary>
|
||||
[Fact]
|
||||
public async Task UnsubscribeAsync_StopsRaisingForThoseReferences()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
var count = 0;
|
||||
mgr.OnDataChange += (_, _) => count++;
|
||||
mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false);
|
||||
|
||||
await mgr.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
mgr.HandleMessage(OvenTopic, "2"u8.ToArray(), retained: false);
|
||||
|
||||
count.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Wildcard-authored topics (accepted by the parser, warned at deploy)
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("f/+/temp", "f/oven/temp", true)]
|
||||
[InlineData("f/+/temp", "f/oven/deep/temp", false)] // '+' is single-level
|
||||
[InlineData("f/#", "f/oven/deep/temp", true)] // '#' is multi-level
|
||||
[InlineData("f/#", "g/oven/temp", false)]
|
||||
public async Task HandleMessage_WildcardAuthoredTopic_UsesRealMqttFilterSemantics(
|
||||
string authoredTopic, string incomingTopic, bool expectMatch)
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{authoredTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
var fired = false;
|
||||
mgr.OnDataChange += (_, _) => fired = true;
|
||||
|
||||
mgr.HandleMessage(incomingTopic, "1"u8.ToArray(), retained: false);
|
||||
|
||||
fired.ShouldBe(expectMatch);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Retained seed
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task HandleMessage_RetainedMessage_SeedsWhenRetainSeedIsTrue()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
// retainSeed absent ⇒ true (the factory default).
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
object? gotVal = null;
|
||||
mgr.OnDataChange += (_, e) => gotVal = e.Snapshot.Value;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: true);
|
||||
|
||||
gotVal.ShouldBe(3.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleMessage_RetainedMessage_IgnoredWhenRetainSeedIsFalse()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64","retainSeed":false}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
var fired = false;
|
||||
mgr.OnDataChange += (_, _) => fired = true;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: true);
|
||||
fired.ShouldBeFalse();
|
||||
|
||||
// A live (non-retained) publish on the same tag still lands — retainSeed gates the seed only.
|
||||
mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: false);
|
||||
fired.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Value extraction — Json / Scalar / Raw, and per-tag degradation
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("$", """21.5""", 21.5)]
|
||||
[InlineData("$.value", """{"value":21.5}""", 21.5)]
|
||||
[InlineData("$.a.b", """{"a":{"b":21.5}}""", 21.5)]
|
||||
[InlineData("$.arr[1]", """{"arr":[1.5,21.5]}""", 21.5)]
|
||||
[InlineData("$['value']", """{"value":21.5}""", 21.5)]
|
||||
public async Task HandleMessage_Json_ExtractsSupportedJsonPathShapes(string jsonPath, string payload, double expected)
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"{{jsonPath.Replace("\\", "\\\\").Replace("\"", "\\\"")}}","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleMessage_MalformedJsonPayload_PublishesBadDecodingError_NeverThrows()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
Should.NotThrow(() => mgr.HandleMessage(OvenTopic, "{not json"u8.ToArray(), retained: false));
|
||||
|
||||
snap!.StatusCode.ShouldBe(BadDecodingError);
|
||||
snap.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("$.missing")]
|
||||
[InlineData("$..value")] // recursive descent — outside the supported subset
|
||||
[InlineData("$.arr[9]")]
|
||||
public async Task HandleMessage_UnresolvableOrUnsupportedJsonPath_PublishesBadDecodingError(string jsonPath)
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"{{jsonPath}}","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, """{"value":21.5,"arr":[1]}"""u8.ToArray(), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(BadDecodingError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleMessage_ValueNotCoercibleToDeclaredDataType_PublishesBadTypeMismatch()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, """{"value":"not-a-number"}"""u8.ToArray(), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(BadTypeMismatch);
|
||||
snap.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Boolean", "true", true)]
|
||||
[InlineData("Int32", "42", 42)]
|
||||
[InlineData("Int64", "42", 42L)]
|
||||
[InlineData("Float32", "1.5", 1.5f)]
|
||||
[InlineData("Float64", "1.5", 1.5)]
|
||||
[InlineData("String", "hello", "hello")]
|
||||
public async Task HandleMessage_Scalar_ParsesByDeclaredDataType(string dataType, string payload, object expected)
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"{{dataType}}"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>Raw</c> means "bytes as-is": the payload is published as its UTF-8 text and the declared
|
||||
/// <c>dataType</c> is deliberately NOT applied (<c>Scalar</c> is the member that coerces).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HandleMessage_Raw_PublishesUtf8TextAndIgnoresDeclaredDataType()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Raw","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, "21.5"u8.ToArray(), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBe("21.5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleMessage_RawNonUtf8Payload_PublishesBadDecodingError_NeverThrows()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Raw","dataType":"String"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
Should.NotThrow(() => mgr.HandleMessage(OvenTopic, new byte[] { 0xC3, 0x28 }, retained: false));
|
||||
|
||||
snap!.StatusCode.ShouldBe(BadDecodingError);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>HandleMessage</c> runs on MQTTnet's dispatcher thread, so nothing it calls — including a
|
||||
/// misbehaving downstream subscriber — may escape and stall the client's pump.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HandleMessage_ThrowingSubscriber_DoesNotEscapeToTheDispatcher()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register(
|
||||
[
|
||||
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
]);
|
||||
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
var delivered = new List<string>();
|
||||
mgr.OnDataChange += (_, e) =>
|
||||
{
|
||||
delivered.Add(e.FullReference);
|
||||
throw new InvalidOperationException("downstream blew up");
|
||||
};
|
||||
|
||||
Should.NotThrow(() => mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false));
|
||||
|
||||
// And a throwing subscriber must not starve the tags behind it in the fan-out.
|
||||
delivered.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Registration
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Register_UnmappableTagConfig_IsSkipped_NeverThrows()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
|
||||
var mapped = 0;
|
||||
Should.NotThrow(() => mapped = mgr.Register(
|
||||
[
|
||||
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
Tag(OvenPressPath, """{"payloadFormat":"Scalar"}"""), // no topic ⇒ rejected by the factory
|
||||
Tag("Plant/Mqtt/broker1/Junk", "not json at all"),
|
||||
]));
|
||||
|
||||
mapped.ShouldBe(1);
|
||||
mgr.TryResolve(OvenTempPath, out _).ShouldBeTrue();
|
||||
mgr.TryResolve(OvenPressPath, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Re-registering replaces the authored table wholesale — a redeploy is not additive.</summary>
|
||||
[Fact]
|
||||
public void Register_Twice_ReplacesTheAuthoredTable()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
|
||||
mgr.Register([Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
|
||||
mgr.TryResolve(OvenTempPath, out _).ShouldBeFalse();
|
||||
mgr.TryResolve(OvenPressPath, out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Filter building — one SUBSCRIBE per distinct topic, strongest QoS, retain handling
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildFilters_DedupesTopics_TakesStrongestQos_AndSeedsWhenAnyTagWants()
|
||||
{
|
||||
var a = new MqttTagDefinition("p/a", OvenTopic, MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: 0, RetainSeed: false);
|
||||
var b = new MqttTagDefinition("p/b", OvenTopic, MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: 2, RetainSeed: true);
|
||||
var c = new MqttTagDefinition("p/c", "f/oven/press", MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: null, RetainSeed: false);
|
||||
|
||||
var filters = MqttSubscriptionManager.BuildFilters([a, b, c], defaultQos: 1);
|
||||
|
||||
filters.Count.ShouldBe(2);
|
||||
var oven = filters.Single(f => f.Topic == OvenTopic);
|
||||
oven.Qos.ShouldBe(2); // strongest wins — nobody gets a weaker guarantee than authored
|
||||
oven.SeedRetained.ShouldBeTrue();
|
||||
var press = filters.Single(f => f.Topic == "f/oven/press");
|
||||
press.Qos.ShouldBe(1); // null ⇒ the driver-level default
|
||||
press.SeedRetained.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Subscribe / SUBACK contract
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_IssuesTheFirstSubscribeItself_OneFilterPerDistinctTopic()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register(
|
||||
[
|
||||
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
]);
|
||||
|
||||
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls.Count.ShouldBe(1);
|
||||
transport.Calls[0].Select(f => f.Topic).ShouldBe([OvenTopic]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_HandleDiagnosticId_NamesModeAndFilters()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
|
||||
var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
handle.DiagnosticId.ShouldContain("Plain");
|
||||
handle.DiagnosticId.ShouldContain(OvenTopic);
|
||||
}
|
||||
|
||||
/// <summary>An already-subscribed topic is not re-SUBSCRIBEd by a second overlapping subscription.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_AlreadySubscribedTopic_IsNotResubscribed()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register(
|
||||
[
|
||||
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
]);
|
||||
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
await mgr.SubscribeAsync([OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_UnknownReference_MarksItBadNodeIdUnknown_AndDoesNotThrow()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([]);
|
||||
|
||||
await Should.NotThrowAsync(() =>
|
||||
mgr.SubscribeAsync(["Plant/Mqtt/broker1/Nope"], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
|
||||
mgr.Values.Read("Plant/Mqtt/broker1/Nope").StatusCode.ShouldBe(BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A SUBACK failure on the <c>ISubscribable</c> path degrades the affected references to Bad
|
||||
/// and returns a handle — it must never hang, and must never throw out of the OPC UA server's
|
||||
/// subscribe call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_SubackFailure_DegradesRefsToBad_ReturnsHandle_DoesNotThrow()
|
||||
{
|
||||
var transport = new FakeTransport { Reject = _ => true };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
|
||||
// Not wrapped in Should.NotThrowAsync: an escaping exception fails this test on its own, and
|
||||
// the handle must still come back.
|
||||
var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
handle.ShouldNotBeNull();
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_TransportThrows_DegradesRefsToBad_DoesNotThrow()
|
||||
{
|
||||
var transport = new FakeTransport { Throw = new InvalidOperationException("broker gone") };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
|
||||
await Should.NotThrowAsync(() =>
|
||||
mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Reconnect re-subscribe
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_ReSubscribesEveryLiveTopic_Idempotently()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
||||
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls.Count.ShouldBe(3); // initial + two reconnects
|
||||
transport.Calls[1].Select(f => f.Topic).ShouldBe([OvenTopic]);
|
||||
transport.Calls[2].Select(f => f.Topic).ShouldBe([OvenTopic]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A total re-subscribe failure THROWS: <see cref="MqttConnection"/>'s documented contract is
|
||||
/// that a throwing <c>Reconnected</c> subscriber tears the session down and retries under
|
||||
/// backoff, and a connected-but-deaf client is the one outcome this driver must never serve.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_TotalFailure_Throws_SoTheSessionIsTornDownAndRetried()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Throw = new InvalidOperationException("broker gone");
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A PARTIAL rejection (e.g. one ACL-denied topic) must NOT throw — tearing the session down
|
||||
/// forever because one of many topics is denied would take the whole driver dark. Only the
|
||||
/// denied references degrade.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_PartialRejection_DoesNotThrow_OnlyDeniedRefsDegrade()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register(
|
||||
[
|
||||
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
]);
|
||||
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false);
|
||||
|
||||
transport.Reject = t => t == "f/oven/press";
|
||||
|
||||
await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
|
||||
|
||||
mgr.Values.Read(OvenPressPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// C1 — wildcard-authored tags on the paths that reconstruct state from a FILTER string.
|
||||
// Delivery matches an incoming topic (concrete); reconnect + degrade key off the subscribed
|
||||
// filter, which for a wildcard tag is its PATTERN. Reading the concrete-only index there
|
||||
// silently drops the tag: no exception, no log, last Good value frozen forever.
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_WildcardAuthoredTopic_IsReSubscribed()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
transport.Calls.Count.ShouldBe(1);
|
||||
|
||||
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls.Count.ShouldBe(2);
|
||||
transport.Calls[1].Select(f => f.Topic).ShouldBe(["f/+/temp"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The end-to-end shape of the defect: after a reconnect the wildcard tag must still receive
|
||||
/// data. A dropped re-subscribe is invisible in every other assertion — the cache keeps its last
|
||||
/// Good value, so nothing turns Bad.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_WildcardAuthoredTopic_KeepsDeliveringAfterwards()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// The broker only forwards what it was re-subscribed to; model that by asserting the filter is
|
||||
// live, then that a matching publish still lands.
|
||||
transport.Calls[^1].Select(f => f.Topic).ShouldContain("f/+/temp");
|
||||
|
||||
object? got = null;
|
||||
mgr.OnDataChange += (_, e) => got = e.Snapshot.Value;
|
||||
mgr.HandleMessage("f/oven/temp", "9.5"u8.ToArray(), retained: false);
|
||||
got.ShouldBe(9.5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A total re-subscribe failure must throw for a wildcard-only driver too. Before the fix the
|
||||
/// wildcard tag never reached the filter set, so <c>filters.Count == 0</c> took a silent early
|
||||
/// return and the caller was told everything was fine.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_WildcardOnly_TotalFailure_StillThrows()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Throw = new InvalidOperationException("broker gone");
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_WildcardAuthoredTopic_SubackRejection_DegradesItsRefs()
|
||||
{
|
||||
var transport = new FakeTransport { Reject = _ => true };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
// Before the fix this stayed at BadWaitingForInitialData — the broker refused the subscription
|
||||
// and the tag never learned it was unreachable.
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_WildcardAuthoredTopic_PartialRejection_DegradesTheWildcardRefs()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register(
|
||||
[
|
||||
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
Tag(OvenPressPath, """{"topic":"f/+/press","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
]);
|
||||
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false);
|
||||
|
||||
transport.Reject = t => t == "f/+/press";
|
||||
|
||||
await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
|
||||
|
||||
mgr.Values.Read(OvenPressPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// I2 — bounded decode on the dispatcher thread
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task HandleMessage_PayloadOverTheCeiling_IsRefusedBeforeDecode()
|
||||
{
|
||||
var mgr = new MqttSubscriptionManager(
|
||||
new MqttDriverOptions { Mode = MqttMode.Plain }, "mqtt-1", maxPayloadBytes: 16);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"String"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, new byte[17], retained: false);
|
||||
snap!.StatusCode.ShouldBe(BadDecodingError);
|
||||
|
||||
// …and a body at the ceiling still flows.
|
||||
mgr.HandleMessage(OvenTopic, "0123456789ABCDEF"u8.ToArray(), retained: false);
|
||||
snap.StatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxPayloadBytes_NonPositive_FallsBackToTheDefault() =>
|
||||
new MqttSubscriptionManager(new MqttDriverOptions(), "d", maxPayloadBytes: 0)
|
||||
.MaxPayloadBytes.ShouldBe(MqttSubscriptionManager.DefaultMaxPayloadBytes);
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// I3 — integral-valued reals against integer tags
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("Int32", """{"value":5.0}""", 5)]
|
||||
[InlineData("Int32", """{"value":5}""", 5)]
|
||||
[InlineData("Int64", """{"value":5.0}""", 5L)]
|
||||
[InlineData("Int16", """{"value":5.0}""", (short)5)]
|
||||
[InlineData("UInt32", """{"value":5.0}""", 5u)]
|
||||
public async Task HandleMessage_Json_IntegralValuedReal_CoercesToIntegerTag(
|
||||
string dataType, string payload, object expected)
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"{{dataType}}"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>A genuinely fractional value is still refused — never rounded into a wrong value.</summary>
|
||||
[Fact]
|
||||
public async Task HandleMessage_Json_FractionalReal_StillRefusedByIntegerTag()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Int32"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, """{"value":5.5}"""u8.ToArray(), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(BadTypeMismatch);
|
||||
snap.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleMessage_Json_OutOfRangeIntegralReal_IsRefused()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Int16"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, """{"value":99999.0}"""u8.ToArray(), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(BadTypeMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleMessage_Scalar_IntegralValuedReal_CoercesToIntegerTag()
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Int32"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, "5.0"u8.ToArray(), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBe(5);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// I5 — a JSON string holding a number, against a numeric tag (documented as supported)
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("Float64", """{"value":"21.5"}""", 21.5)]
|
||||
[InlineData("Int32", """{"value":"5"}""", 5)]
|
||||
[InlineData("Boolean", """{"value":"true"}""", true)]
|
||||
public async Task HandleMessage_Json_StringHoldingANumber_CoercesToTheNumericTag(
|
||||
string dataType, string payload, object expected)
|
||||
{
|
||||
var mgr = NewManager();
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"{{dataType}}"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
DataValueSnapshot? snap = null;
|
||||
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
||||
|
||||
mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false);
|
||||
|
||||
snap!.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Stale-state pruning on redeploy
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Register_DroppingATag_StopsItsTopicBeingReSubscribedForever()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
mgr.Register([]); // redeploy drops the tag
|
||||
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls.Count.ShouldBe(1); // the initial subscribe only — no zombie re-subscribe
|
||||
}
|
||||
|
||||
/// <summary>Records every subscribe call so dedupe / re-subscribe can be asserted without a broker.</summary>
|
||||
private sealed class FakeTransport : IMqttSubscribeTransport
|
||||
{
|
||||
public List<IReadOnlyList<MqttTopicSubscription>> Calls { get; } = [];
|
||||
|
||||
/// <summary>Topics for which the fake SUBACK reports a rejection.</summary>
|
||||
public Func<string, bool> Reject { get; set; } = _ => false;
|
||||
|
||||
/// <summary>When set, the whole subscribe call throws instead of answering.</summary>
|
||||
public Exception? Throw { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<MqttSubscribeOutcome>> SubscribeAsync(
|
||||
IReadOnlyList<MqttTopicSubscription> filters, CancellationToken cancellationToken)
|
||||
{
|
||||
Calls.Add(filters);
|
||||
if (Throw is not null) throw Throw;
|
||||
IReadOnlyList<MqttSubscribeOutcome> outcomes =
|
||||
[.. filters.Select(f => new MqttSubscribeOutcome(f.Topic, !Reject(f.Topic), Reject(f.Topic) ? "NotAuthorized" : "GrantedQoS1"))];
|
||||
return Task.FromResult(outcomes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the two entry points of <see cref="MqttTagDefinitionFactory"/> and their deliberately
|
||||
/// different strictness contracts: <c>FromTagConfig</c> is the runtime path (never throws, a
|
||||
/// malformed blob is a hard reject that upstream turns into <c>BadNodeIdUnknown</c>) and
|
||||
/// <c>Inspect</c> is the deploy-time path (human-readable warnings so a bad tag surfaces at
|
||||
/// deploy instead of going dark at runtime).
|
||||
/// </summary>
|
||||
public sealed class MqttTagDefinitionFactoryTests
|
||||
{
|
||||
private const string RawPath = "Plant/Mqtt/oven/Temp";
|
||||
|
||||
[Fact]
|
||||
public void FromTagConfig_PlainJsonBlob_PopulatesTopicAndPath()
|
||||
{
|
||||
// NOTE: the plan's sample blob wrote "dataType":"Double"; the authoritative type is
|
||||
// DriverDataType (design §6.1), whose 64-bit float member is Float64. "Double" is therefore a
|
||||
// typo the strict read rejects — pinned by FromTagConfig_TypoedDataType_RejectsStrict.
|
||||
const string blob = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1}""";
|
||||
MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, out var def).ShouldBeTrue();
|
||||
def.Topic.ShouldBe("factory/oven/temp");
|
||||
def.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
|
||||
def.JsonPath.ShouldBe("$.value");
|
||||
def.DataType.ShouldBe(DriverDataType.Float64);
|
||||
def.Qos.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The v3 identity contract, stated explicitly so a regression back to blob-keying goes red.
|
||||
/// <see cref="MqttTagDefinition.Name"/> MUST be the RawPath the driver was handed — it is the key
|
||||
/// <c>EquipmentTagRefResolver</c> looks up, the key <c>OnDataChange</c> publishes under, and the
|
||||
/// key <c>DriverHostActor</c> fans out to the raw + UNS NodeIds. Keying it by the TagConfig blob
|
||||
/// (the pre-v3, now-retired shape) would leave the driver silently dead in production while every
|
||||
/// other test here still passed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromTagConfig_DefinitionIdentity_IsTheRawPath_NotTheBlob()
|
||||
{
|
||||
const string blob = """{"topic":"factory/oven/temp","payloadFormat":"Raw"}""";
|
||||
MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, out var def).ShouldBeTrue();
|
||||
def.Name.ShouldBe(RawPath);
|
||||
def.Name.ShouldNotBe(blob);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The identity is whatever RawPath the caller supplies — the factory must never re-derive it
|
||||
/// from the blob's contents (e.g. from <c>topic</c>), or two tags sharing a topic would collide.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Plant/Mqtt/oven/Temp")]
|
||||
[InlineData("SiteA/Mqtt/line3/Oven/Setpoint")]
|
||||
public void FromTagConfig_UsesSuppliedRawPathVerbatim(string rawPath)
|
||||
{
|
||||
MqttTagDefinitionFactory.FromTagConfig(
|
||||
"""{"topic":"shared/topic","payloadFormat":"Raw"}""", rawPath, out var def).ShouldBeTrue();
|
||||
def.Name.ShouldBe(rawPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromTagConfig_TypoedPayloadFormat_RejectsStrict()
|
||||
=> MqttTagDefinitionFactory.FromTagConfig(
|
||||
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Float64"}""", RawPath, out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void FromTagConfig_TypoedDataType_RejectsStrict()
|
||||
=> MqttTagDefinitionFactory.FromTagConfig(
|
||||
"""{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""", RawPath, out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Inspect_WildcardTopic_ReturnsWarning()
|
||||
{
|
||||
// The blob is otherwise clean (payloadFormat parses, dataType + qos absent), so the wildcard
|
||||
// check is the ONLY thing that can produce a warning — this cannot pass vacuously.
|
||||
var warnings = MqttTagDefinitionFactory.Inspect("""{"topic":"a/+/c","payloadFormat":"Raw"}""");
|
||||
warnings.ShouldNotBeEmpty();
|
||||
warnings.ShouldHaveSingleItem().ShouldContain("wildcard", Case.Insensitive);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("a/#")]
|
||||
[InlineData("+/b/c")]
|
||||
[InlineData("a/+/c")]
|
||||
public void Inspect_EachWildcardForm_Warns(string topic)
|
||||
=> MqttTagDefinitionFactory.Inspect($$"""{"topic":"{{topic}}","payloadFormat":"Raw"}""").ShouldNotBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void FromTagConfig_WildcardTopic_StillParses_WarningIsDeployTimeOnly()
|
||||
{
|
||||
// A wildcard tag topic is ambiguous, not unparseable: the deploy-time Inspect pass is the
|
||||
// designed surface for it. Rejecting it at runtime would turn an authoring mistake into a
|
||||
// silent BadNodeIdUnknown with no operator-visible cause.
|
||||
MqttTagDefinitionFactory.FromTagConfig(
|
||||
"""{"topic":"a/+/c","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue();
|
||||
def.Topic.ShouldBe("a/+/c");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromTagConfig_RawFormat_AppliesDefaults()
|
||||
{
|
||||
MqttTagDefinitionFactory.FromTagConfig(
|
||||
"""{"topic":"factory/oven/blob","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue();
|
||||
def.PayloadFormat.ShouldBe(MqttPayloadFormat.Raw);
|
||||
def.JsonPath.ShouldBe("$"); // absent ⇒ the root default
|
||||
def.Qos.ShouldBeNull(); // absent ⇒ the driver-level DefaultQos wins
|
||||
def.RetainSeed.ShouldBeTrue(); // absent ⇒ seed from the retained message
|
||||
def.DataType.ShouldBe(DriverDataType.String); // absent ⇒ the documented fallback
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromTagConfig_ExplicitRetainSeedFalse_IsHonoured()
|
||||
{
|
||||
MqttTagDefinitionFactory.FromTagConfig(
|
||||
"""{"topic":"a/b","payloadFormat":"Raw","retainSeed":false}""", RawPath, out var def).ShouldBeTrue();
|
||||
def.RetainSeed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void FromTagConfig_EachLegalQos_IsAccepted(int qos)
|
||||
{
|
||||
MqttTagDefinitionFactory.FromTagConfig(
|
||||
$$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qos}}}""", RawPath, out var def).ShouldBeTrue();
|
||||
def.Qos.ShouldBe(qos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromTagConfig_PlainBlob_LeavesSparkplugDescriptorFieldsNull()
|
||||
{
|
||||
// P2 (Tasks 15–26) fills these; a plain-mode blob must leave them unset so a future
|
||||
// mode discriminator cannot mistake a plain tag for a Sparkplug one.
|
||||
MqttTagDefinitionFactory.FromTagConfig(
|
||||
"""{"topic":"a/b","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue();
|
||||
def.GroupId.ShouldBeNull();
|
||||
def.EdgeNodeId.ShouldBeNull();
|
||||
def.DeviceId.ShouldBeNull();
|
||||
def.MetricName.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("factory/oven/temp")] // a bare reference, not a TagConfig blob (no leading '{')
|
||||
[InlineData("[1,2,3]")] // valid JSON, wrong root kind
|
||||
[InlineData("{ not json at all")] // unparseable — must not throw
|
||||
[InlineData("""{"payloadFormat":"Raw"}""")] // no topic ⇒ nothing to subscribe to
|
||||
[InlineData("""{"topic":"","payloadFormat":"Raw"}""")] // blank topic
|
||||
[InlineData("""{"topic":" ","payloadFormat":"Raw"}""")] // whitespace-only topic
|
||||
public void FromTagConfig_MalformedBlob_ReturnsFalseAndNeverThrows(string? tagConfig)
|
||||
=> MqttTagDefinitionFactory.FromTagConfig(tagConfig!, RawPath, out _).ShouldBeFalse();
|
||||
|
||||
/// <summary>
|
||||
/// A present-but-invalid <c>qos</c> is rejected in every malformed shape, not just the
|
||||
/// out-of-range numeric one. Silently absorbing <c>"high"</c> / <c>1.5</c> into the driver-level
|
||||
/// default would hand the operator a WEAKER delivery guarantee than the one they authored.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("5")] // out of range
|
||||
[InlineData("-1")] // out of range
|
||||
[InlineData("\"high\"")] // wrong JSON type
|
||||
[InlineData("\"1\"")] // stringly-typed number
|
||||
[InlineData("1.5")] // non-integer
|
||||
[InlineData("true")] // wrong JSON type
|
||||
[InlineData("null")] // an explicit null is not "absent"
|
||||
public void FromTagConfig_MalformedQos_RejectsStrict(string qosToken)
|
||||
=> MqttTagDefinitionFactory.FromTagConfig(
|
||||
$$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}""", RawPath, out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Inspect_CleanBlob_ReturnsEmpty()
|
||||
=> MqttTagDefinitionFactory.Inspect(
|
||||
"""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$.v","dataType":"Float64","qos":2}""").ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Inspect_TypoedEnums_WarnsPerField()
|
||||
{
|
||||
var warnings = MqttTagDefinitionFactory.Inspect(
|
||||
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Double"}""");
|
||||
warnings.Count.ShouldBe(2);
|
||||
warnings.ShouldContain(w => w.Contains("payloadFormat", StringComparison.Ordinal));
|
||||
warnings.ShouldContain(w => w.Contains("dataType", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("5")]
|
||||
[InlineData("\"high\"")]
|
||||
[InlineData("1.5")]
|
||||
public void Inspect_MalformedQos_Warns(string qosToken)
|
||||
{
|
||||
var warnings = MqttTagDefinitionFactory.Inspect(
|
||||
$$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}""");
|
||||
warnings.ShouldHaveSingleItem().ShouldContain("qos", Case.Sensitive);
|
||||
}
|
||||
|
||||
/// <summary>Every warning source fires together — the pass reports all of them, not just the first.</summary>
|
||||
[Fact]
|
||||
public void Inspect_MultipleProblems_ReportsAll()
|
||||
{
|
||||
var warnings = MqttTagDefinitionFactory.Inspect(
|
||||
"""{"topic":"a/+/c","payloadFormat":"Jason","dataType":"Double","qos":9}""");
|
||||
warnings.Count.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_UnparseableBlob_Warns()
|
||||
=> MqttTagDefinitionFactory.Inspect("{ not json at all").ShouldNotBeEmpty();
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("factory/oven/temp")]
|
||||
[InlineData("[1,2,3]")] // no leading '{' ⇒ not a TagConfig blob at all (mirrors Modbus)
|
||||
public void Inspect_NotATagConfigBlob_ReturnsEmpty(string? reference)
|
||||
=> MqttTagDefinitionFactory.Inspect(reference!).ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void TagConfigKeys_AreTheWireNames_AndAreNotFreeToRename()
|
||||
{
|
||||
// MqttTagConfigKeys single-sources the address key names across the producer (the AdminUI
|
||||
// browse-commit mapper) and the consumer (this factory) — which means a SYMMETRIC rename would
|
||||
// keep every round-trip test green while silently unbinding every ALREADY-PERSISTED TagConfig
|
||||
// blob in the config DB. These are wire names; pin them to the literals.
|
||||
MqttTagConfigKeys.Topic.ShouldBe("topic");
|
||||
MqttTagConfigKeys.GroupId.ShouldBe("groupId");
|
||||
MqttTagConfigKeys.EdgeNodeId.ShouldBe("edgeNodeId");
|
||||
MqttTagConfigKeys.DeviceId.ShouldBe("deviceId");
|
||||
MqttTagConfigKeys.MetricName.ShouldBe("metricName");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagConfigKeys_AreTheKeysTheFactoryActuallyReads()
|
||||
{
|
||||
// The pin above is only worth having if the factory really reads THESE keys — a blob written
|
||||
// entirely from the constants must parse.
|
||||
var blob = $$"""
|
||||
{"{{MqttTagConfigKeys.GroupId}}":"G",
|
||||
"{{MqttTagConfigKeys.EdgeNodeId}}":"E",
|
||||
"{{MqttTagConfigKeys.DeviceId}}":"D",
|
||||
"{{MqttTagConfigKeys.MetricName}}":"M"}
|
||||
""";
|
||||
|
||||
MqttTagDefinitionFactory.FromSparkplugTagConfig(blob, RawPath, out var def).ShouldBeTrue();
|
||||
def.GroupId.ShouldBe("G");
|
||||
def.EdgeNodeId.ShouldBe("E");
|
||||
def.DeviceId.ShouldBe("D");
|
||||
def.MetricName.ShouldBe("M");
|
||||
|
||||
MqttTagDefinitionFactory
|
||||
.FromTagConfig($$"""{"{{MqttTagConfigKeys.Topic}}":"a/b"}""", RawPath, out var plain)
|
||||
.ShouldBeTrue();
|
||||
plain.Topic.ShouldBe("a/b");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||||
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="RebirthRequester"/> (Task 20) — the NCMD encode + bounded publish seam that
|
||||
/// Task 21 (ingest gap/unknown-alias/data-before-birth detection) and Task 23 (the browser's
|
||||
/// operator-triggered scoped rebirth) both call into.
|
||||
/// </summary>
|
||||
public sealed class RebirthRequesterTests
|
||||
{
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Build — pure encode. The plan's own test, verbatim.
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildRebirthNcmd_EncodesControlMetric_AndTopic()
|
||||
{
|
||||
var (topic, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
|
||||
|
||||
topic.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
|
||||
var p = SparkplugCodec.Decode(bytes);
|
||||
p.Metrics.ShouldContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, true));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falsifiability control for the test above: a metric named anything else must NOT satisfy the
|
||||
/// assertion — proves the <c>ShouldContain</c> predicate is actually discriminating on the name,
|
||||
/// not vacuously true because <see cref="SparkplugCodec.Decode"/> always returns something.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildRebirthNcmd_DoesNotContainAWrongMetricName()
|
||||
{
|
||||
var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
|
||||
var p = SparkplugCodec.Decode(bytes);
|
||||
|
||||
p.Metrics.ShouldNotContain(m => m.Name == "Node Control/NotRebirth" && Equals(m.Value, true));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falsifiability control: a boolean <c>false</c> must not satisfy the plan's assertion either —
|
||||
/// proves the test is checking the metric's <i>value</i>, not merely its presence.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildRebirthNcmd_ValueIsTrue_NotFalse()
|
||||
{
|
||||
var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
|
||||
var p = SparkplugCodec.Decode(bytes);
|
||||
|
||||
p.Metrics.ShouldNotContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DifferentGroupAndNode_ProducesMatchingTopic()
|
||||
{
|
||||
var (topic, _) = RebirthRequester.Build("Line7", "Filler42");
|
||||
topic.ShouldBe("spBv1.0/Line7/NCMD/Filler42");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "EdgeA")]
|
||||
[InlineData("", "EdgeA")]
|
||||
[InlineData("Plant1", null)]
|
||||
[InlineData("Plant1", "")]
|
||||
public void Build_MissingGroupOrNode_Throws(string? groupId, string? edgeNodeId)
|
||||
=> Should.Throw<ArgumentException>(() => RebirthRequester.Build(groupId!, edgeNodeId!));
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Encoded metric shape — datatype + no bogus seq
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Build_MetricCarriesBooleanDatatype()
|
||||
{
|
||||
var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
|
||||
var p = SparkplugCodec.Decode(bytes);
|
||||
|
||||
var metric = p.Metrics.Single(m => m.Name == RebirthRequester.RebirthMetricName);
|
||||
metric.DataType.ShouldBe(TahuDataType.Boolean);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A host-issued NCMD is not part of the edge node's own outbound sequence stream (Sparkplug
|
||||
/// §6.4.1) — stamping a <c>seq</c> here would be meaningless at best. See the design rationale
|
||||
/// on <see cref="RebirthRequester"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_PayloadCarriesNoSeq()
|
||||
{
|
||||
var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
|
||||
var p = SparkplugCodec.Decode(bytes);
|
||||
|
||||
p.Seq.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_PayloadAndMetricCarryATimestamp()
|
||||
{
|
||||
var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
|
||||
var p = SparkplugCodec.Decode(bytes);
|
||||
|
||||
p.TimestampMs.ShouldNotBeNull();
|
||||
var metric = p.Metrics.Single(m => m.Name == RebirthRequester.RebirthMetricName);
|
||||
metric.TimestampMs.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// RequestAsync — the bounded publish seam
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
private sealed class RecordingTransport : IMqttPublishTransport
|
||||
{
|
||||
public int PublishCount { get; private set; }
|
||||
public string? LastTopic { get; private set; }
|
||||
public byte[]? LastPayload { get; private set; }
|
||||
public int LastQos { get; private set; } = -1;
|
||||
public bool LastRetain { get; private set; }
|
||||
|
||||
public Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken)
|
||||
{
|
||||
PublishCount++;
|
||||
LastTopic = topic;
|
||||
LastPayload = payload;
|
||||
LastQos = qos;
|
||||
LastRetain = retain;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestAsync_PublishesExactlyOnce_WithBuiltTopicAndBytes()
|
||||
{
|
||||
var transport = new RecordingTransport();
|
||||
|
||||
await RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", TestContext.Current.CancellationToken);
|
||||
|
||||
transport.PublishCount.ShouldBe(1);
|
||||
transport.LastTopic.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
|
||||
var decoded = SparkplugCodec.Decode(transport.LastPayload!);
|
||||
decoded.Metrics.ShouldContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, true));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retain MUST be false — a retained NCMD replays to the edge node on every future
|
||||
/// subscribe/reconnect, driving a permanent rebirth loop from one stale command (see the
|
||||
/// carry-forward landmine remarks on <see cref="RebirthRequester"/>).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RequestAsync_NeverRetains()
|
||||
{
|
||||
var transport = new RecordingTransport();
|
||||
await RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", TestContext.Current.CancellationToken);
|
||||
|
||||
transport.LastRetain.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestAsync_UsesQos0()
|
||||
{
|
||||
var transport = new RecordingTransport();
|
||||
await RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", TestContext.Current.CancellationToken);
|
||||
|
||||
transport.LastQos.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestAsync_NullTransport_Throws()
|
||||
=> await Should.ThrowAsync<ArgumentNullException>(
|
||||
() => RebirthRequester.RequestAsync(null!, "Plant1", "EdgeA", TestContext.Current.CancellationToken));
|
||||
|
||||
/// <summary>
|
||||
/// A transport that hangs forever must not be able to park the caller — the cross-cutting rule
|
||||
/// every network call in this driver follows (mirrors <c>MqttConnection.SubscribeAsync</c>).
|
||||
/// </summary>
|
||||
private sealed class HangingTransport : IMqttPublishTransport
|
||||
{
|
||||
public Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken)
|
||||
=> Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestAsync_BoundedByDeadline_ThrowsRatherThanHangs()
|
||||
{
|
||||
var transport = new HangingTransport();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => RebirthRequester.RequestAsync(
|
||||
transport,
|
||||
"Plant1",
|
||||
"EdgeA",
|
||||
TestContext.Current.CancellationToken,
|
||||
timeout: TimeSpan.FromMilliseconds(50)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestAsync_CallerCancellation_Propagates()
|
||||
{
|
||||
var transport = new HangingTransport();
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", cts.Token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="SequenceTracker"/> — design doc §3.6 invariant #3. Two halves, each with a
|
||||
/// distinct failure mode the tests below pin from both directions:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <b><c>seq</c> gap detection across the 0–255 wrap.</b> Reading 255→0 as a gap
|
||||
/// rebirth-storms every edge node once per 256 messages; missing a real gap serves stale
|
||||
/// data at Good quality forever. Both directions are asserted.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b><c>bdSeq</c> birth↔death tie.</b> A late Last-Will NDEATH from a previous session
|
||||
/// arriving after the new NBIRTH must not kill the freshly-born node.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class SequenceTrackerTests
|
||||
{
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// seq — the wrap boundary
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The plan's headline case: 255 → 0 is the sequence continuing, not a gap. A tracker that
|
||||
/// reads the wrap as a gap requests a rebirth every 256 messages, and a busy edge node then
|
||||
/// spends its life republishing births instead of data.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Sequence_WrapAt255IsContiguous_NotAGap()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
|
||||
s.Accept(254).ShouldBeTrue();
|
||||
s.Accept(255).ShouldBeTrue();
|
||||
s.Accept(0).ShouldBeTrue(); // wrap
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The plan's mirror case: a skipped value is a genuine gap. Missing it means the driver
|
||||
/// silently keeps serving values it knows are behind the edge node's real state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Sequence_SkippedValue_IsGap()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
|
||||
s.Accept(10).ShouldBeTrue();
|
||||
s.Accept(12).ShouldBeFalse(); // gap → caller requests rebirth
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two-and-a-bit full cycles of the 0–255 range report no gap at any point. The pair of
|
||||
/// single-step tests above can both pass on an off-by-one wrap that only misfires at one
|
||||
/// specific value; walking every value in the range leaves nowhere for that to hide.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Sequence_FullWrapCycles_NeverReportAGap()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.AcceptNodeBirth(0).ShouldBeTrue();
|
||||
|
||||
for (var i = 1; i <= 600; i++)
|
||||
{
|
||||
var seq = (ulong)(i % 256);
|
||||
s.Accept(seq).ShouldBeTrue($"seq {seq} (message {i}) was reported as a gap");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A gap is reported <b>once</b> and the tracker then resynchronizes onto the observed
|
||||
/// sequence. Leaving the baseline pinned at the pre-gap value would make every subsequent
|
||||
/// message report a gap too, turning one lost message into a permanent rebirth storm — the
|
||||
/// same pathology as a wrong wrap boundary, reached by a different route.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Sequence_GapReportedOnce_ThenResynchronizes()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
|
||||
s.Accept(10).ShouldBeTrue();
|
||||
s.Accept(13).ShouldBeFalse(); // three messages lost
|
||||
s.Accept(14).ShouldBeTrue(); // resynchronized onto 13
|
||||
s.Accept(15).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A repeated sequence number is not contiguous. Sparkplug publishes DATA at QoS 0, so a
|
||||
/// duplicate is not a broker redelivery — it is a publisher whose stream no longer matches
|
||||
/// what the driver can reason about, and a rebirth is the cheap correct answer.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Sequence_RepeatedValue_IsGap()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
|
||||
s.Accept(10).ShouldBeTrue();
|
||||
s.Accept(10).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A sequence number going backwards is a gap, not a wrap.</summary>
|
||||
[Fact]
|
||||
public void Sequence_BackwardJump_IsGap()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
|
||||
s.Accept(10).ShouldBeTrue();
|
||||
s.Accept(9).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The first message cannot be a gap — there is no baseline to measure it against — but the
|
||||
/// tracker must still report that it is <b>not</b> birth-synchronized, because a first
|
||||
/// message that was not a birth means the driver joined mid-stream and has no alias table.
|
||||
/// Conflating the two would let a late join look like a healthy synchronized node.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Sequence_FirstMessage_IsNotAGap_ButIsNotBirthSynchronized()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
|
||||
s.IsBirthSynchronized.ShouldBeFalse();
|
||||
s.LastSeq.ShouldBeNull();
|
||||
|
||||
s.Accept(137).ShouldBeTrue();
|
||||
|
||||
s.LastSeq.ShouldBe((byte)137);
|
||||
s.IsBirthSynchronized.ShouldBeFalse("a mid-stream first message is a late join, not a birth");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The aliasing test.</b> The codec hands over <c>seq</c> as a <see cref="ulong"/> and
|
||||
/// deliberately declines to truncate it, so an out-of-range value reaches this type intact. A
|
||||
/// tracker that masks it into range turns 256 into a perfectly plausible 0 — and with the
|
||||
/// baseline sitting at 255, that is exactly the value the wrap expects next, so the bogus
|
||||
/// message would be accepted as contiguous.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Sequence_OutOfRangeSeq_IsRejected_AndDoesNotAliasOntoTheExpectedValue()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.Accept(255).ShouldBeTrue();
|
||||
|
||||
// 256 & 0xFF == 0, and 0 is precisely what follows 255. Masking would return true here.
|
||||
s.Accept(256).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A rejected out-of-range value does not become the baseline either: the tracker keeps the
|
||||
/// last credible sequence number, so the following genuine message is still measured against
|
||||
/// something real.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Sequence_OutOfRangeSeq_DoesNotBecomeTheBaseline()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.Accept(10).ShouldBeTrue();
|
||||
|
||||
s.Accept(ulong.MaxValue).ShouldBeFalse();
|
||||
|
||||
s.LastSeq.ShouldBe((byte)10);
|
||||
s.Accept(11).ShouldBeTrue("the baseline must still be the last credible seq, 10");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A message carrying no sequence number at all is a spec violation the tracker cannot place
|
||||
/// in the stream. It reports a gap (the safe direction — a rebirth resynchronizes everything)
|
||||
/// and leaves the baseline alone.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Sequence_AbsentSeq_IsGap_AndDoesNotAdvanceTheBaseline()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.Accept(10).ShouldBeTrue();
|
||||
|
||||
s.Accept(null).ShouldBeFalse();
|
||||
|
||||
s.LastSeq.ShouldBe((byte)10);
|
||||
s.Accept(11).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// seq — births restart the sequence
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A birth restarts the sequence at 0, so it is never itself a gap however far the previous
|
||||
/// session had counted. A tracker that ran the birth through the ordinary contiguity check
|
||||
/// would flag a gap on the very message that just fixed everything, and request a rebirth in
|
||||
/// response to a birth.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Birth_RestartsTheSequence_SoABirthIsNeverAGap()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.Accept(200).ShouldBeTrue();
|
||||
|
||||
s.AcceptNodeBirth(0).ShouldBeTrue();
|
||||
|
||||
s.LastSeq.ShouldBe((byte)0);
|
||||
s.IsBirthSynchronized.ShouldBeTrue();
|
||||
s.Accept(1).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A gap detected <i>after</i> a birth is genuine — the birth resets the baseline, it does not
|
||||
/// suppress detection. Without this the previous test could be satisfied by a tracker that
|
||||
/// simply stopped checking once a birth arrived.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Birth_ThenSkippedValue_IsStillAGap()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.AcceptNodeBirth(0).ShouldBeTrue();
|
||||
|
||||
s.Accept(1).ShouldBeTrue();
|
||||
s.Accept(3).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A birth whose <c>seq</c> is in range but not the spec-required 0 is adopted as the
|
||||
/// baseline rather than rejected. Refusing it would leave the tracker unsynchronized against
|
||||
/// a publisher whose stream is otherwise perfectly followable, and every following message
|
||||
/// would demand another rebirth.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Birth_WithNonZeroInRangeSeq_IsAdoptedAsTheBaseline()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
|
||||
s.AcceptNodeBirth(7).ShouldBeTrue();
|
||||
|
||||
s.LastSeq.ShouldBe((byte)7);
|
||||
s.IsBirthSynchronized.ShouldBeTrue();
|
||||
s.Accept(8).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A birth whose <c>seq</c> is out of range establishes no baseline — the same
|
||||
/// no-masking rule as the data path, applied where it matters most.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Birth_WithOutOfRangeSeq_EstablishesNoBaseline()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
|
||||
s.AcceptNodeBirth(256).ShouldBeFalse();
|
||||
|
||||
s.LastSeq.ShouldBeNull();
|
||||
s.IsBirthSynchronized.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>A DBIRTH continues the edge node's sequence — it does not restart it, and it must not be
|
||||
/// routed through <see cref="SequenceTracker.AcceptNodeBirth"/>.</b> Only the NBIRTH resets
|
||||
/// <c>seq</c>, and only the NBIRTH/NDEATH carry <c>bdSeq</c>. Sending a DBIRTH to the node-birth
|
||||
/// entry point would rebase the sequence mid-stream <i>and</i> — having no <c>bdSeq</c> to pass
|
||||
/// — wipe the session token, at which point a stale Last Will finds nothing to be compared
|
||||
/// against and kills the live node. This test pins the shape at the tracker's own boundary so
|
||||
/// the ingest state machine has something to fail against if it wires the call sites the other
|
||||
/// way round.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DeviceBirth_ContinuesTheNodeSequence_AndLeavesTheSessionTokenAlone()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.AcceptNodeBirth(0, bdSeq: 7).ShouldBeTrue();
|
||||
|
||||
s.Accept(1).ShouldBeTrue(); // the DBIRTH — next in the node's stream, not a restart
|
||||
s.Accept(2).ShouldBeTrue(); // first DDATA
|
||||
|
||||
s.BirthBdSeq.ShouldBe(7UL, "a device birth must not disturb the node's session token");
|
||||
s.IsDeathForCurrentSession(6).ShouldBeFalse("the stale-will guard must still have a token to compare");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SequenceTracker.Reset"/> returns the tracker to its virgin state. This is the
|
||||
/// reconnect seam: after a dropped connection the driver has missed an unknown number of
|
||||
/// messages, so the baseline it held is no longer evidence of anything.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Reset_ClearsTheBaselineAndTheBirthSynchronizedFlag()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.AcceptNodeBirth(0, bdSeq: 4).ShouldBeTrue();
|
||||
s.Accept(1).ShouldBeTrue();
|
||||
|
||||
s.Reset();
|
||||
|
||||
s.LastSeq.ShouldBeNull();
|
||||
s.IsBirthSynchronized.ShouldBeFalse();
|
||||
s.BirthBdSeq.ShouldBeNull();
|
||||
s.Accept(99).ShouldBeTrue("a virgin tracker cannot detect a gap on its first message");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// bdSeq — the birth↔death tie
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// <b>The case the tie exists for.</b> A node's connection drops, it reconnects and publishes
|
||||
/// a fresh NBIRTH — and only then does the broker notice the old connection died and deliver
|
||||
/// its Last Will NDEATH, carrying the <i>previous</i> session's bdSeq. Without the compare,
|
||||
/// that stale will marks a live, freshly-born node dead and every one of its tags goes Bad.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Death_WithStaleBdSeqArrivingAfterARebirth_DoesNotApply()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.AcceptNodeBirth(0, bdSeq: 7); // session 7 — the one that is about to die
|
||||
s.AcceptNodeBirth(0, bdSeq: 8); // session 8 — the node is back, and alive
|
||||
|
||||
s.IsDeathForCurrentSession(7).ShouldBeFalse("session 7's will must not kill session 8");
|
||||
}
|
||||
|
||||
/// <summary>A death whose bdSeq matches the current birth is the real thing and applies.</summary>
|
||||
[Fact]
|
||||
public void Death_WithMatchingBdSeq_Applies()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.AcceptNodeBirth(0, bdSeq: 8);
|
||||
|
||||
s.IsDeathForCurrentSession(8).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A death arriving before any birth was ever seen applies. The tracker has nothing to
|
||||
/// disprove it with, and the two failure directions are not symmetric: acting on a death
|
||||
/// wrongly marks tags stale until the next birth restores them (§3.6 invariant #4), while
|
||||
/// ignoring a real death leaves a dead node's last values flowing at Good quality forever.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Death_BeforeAnyBirth_Applies()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
|
||||
s.IsDeathForCurrentSession(7).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A death carrying no bdSeq metric cannot be tied to anything, so it applies — same
|
||||
/// fail-toward-stale reasoning as above.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Death_WithNoBdSeq_Applies()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.AcceptNodeBirth(0, bdSeq: 8);
|
||||
|
||||
s.IsDeathForCurrentSession(null).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>A death applies when the <i>birth</i> was the one that carried no bdSeq.</summary>
|
||||
[Fact]
|
||||
public void Death_WhenTheBirthCarriedNoBdSeq_Applies()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.AcceptNodeBirth(0, bdSeq: null);
|
||||
|
||||
s.IsDeathForCurrentSession(7).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A birth records its bdSeq even when its <c>seq</c> was unusable. Session identity and
|
||||
/// sequence position are independent facts; discarding the tie because the sequence number
|
||||
/// was garbled would leave the next death unmatchable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Birth_WithUnusableSeq_StillRecordsItsBdSeq()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
|
||||
s.AcceptNodeBirth(256, bdSeq: 8).ShouldBeFalse();
|
||||
|
||||
s.BirthBdSeq.ShouldBe(8UL);
|
||||
s.IsDeathForCurrentSession(7).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// bdSeq — reading it out of a payload
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// <c>bdSeq</c> is not a top-level payload field: it rides as a metric named <c>bdSeq</c> in
|
||||
/// the NBIRTH and the NDEATH.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryReadBdSeq_ReadsTheBdSeqMetric()
|
||||
{
|
||||
var payload = PayloadWith(Metric("bdSeq", DataType.Int64, 8UL));
|
||||
|
||||
SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeTrue();
|
||||
bdSeq.ShouldBe(8UL);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sparkplug carries <c>Int64</c> as two's complement in an unsigned field, so a negative
|
||||
/// bdSeq reaches the projection as an enormous <see cref="ulong"/>. Reading it raw would
|
||||
/// mint a plausible-looking session token out of nonsense; the reinterpretation is what
|
||||
/// exposes it as negative so it can be refused.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryReadBdSeq_NegativeSignedValue_IsRefused_NotReadAsAHugeToken()
|
||||
{
|
||||
var payload = PayloadWith(Metric("bdSeq", DataType.Int64, unchecked((ulong)-1L)));
|
||||
|
||||
SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeFalse();
|
||||
bdSeq.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unsigned bdSeq is read as-is — no reinterpretation applies. (The generated enum member
|
||||
/// is <c>Uint64</c>, not <c>UInt64</c>: protobuf's C# codegen lowercases the interior of the
|
||||
/// proto's <c>UInt64</c>.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryReadBdSeq_UnsignedValue_IsReadAsIs()
|
||||
{
|
||||
var payload = PayloadWith(Metric("bdSeq", DataType.Uint64, 9UL));
|
||||
|
||||
SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeTrue();
|
||||
bdSeq.ShouldBe(9UL);
|
||||
}
|
||||
|
||||
/// <summary>A payload that carries no bdSeq metric reports absence rather than a default.</summary>
|
||||
[Fact]
|
||||
public void TryReadBdSeq_PayloadWithoutTheMetric_ReportsAbsence()
|
||||
{
|
||||
var payload = PayloadWith(Metric("Temperature", DataType.Float, 21.5f));
|
||||
|
||||
SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeFalse();
|
||||
bdSeq.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An undecodable payload yields no bdSeq. It presents as metric-less exactly like a valid
|
||||
/// but empty one, and only <see cref="SparkplugPayload.IsValid"/> tells them apart.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryReadBdSeq_InvalidPayload_ReportsAbsence()
|
||||
{
|
||||
SequenceTracker.TryReadBdSeq(SparkplugPayload.Invalid, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A null payload is a verdict, not an exception — this runs on the dispatcher thread.</summary>
|
||||
[Fact]
|
||||
public void TryReadBdSeq_NullPayload_ReportsAbsence_DoesNotThrow()
|
||||
{
|
||||
Should.NotThrow(() => SequenceTracker.TryReadBdSeq(null, out _).ShouldBeFalse());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bdSeq metric with no scalar value (explicitly null, or a kind v1 does not support) is
|
||||
/// not a session token.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryReadBdSeq_NonScalarMetric_ReportsAbsence()
|
||||
{
|
||||
var payload = PayloadWith(new SparkplugMetric("bdSeq", null, DataType.Int64, null, SparkplugValueKind.Null, null));
|
||||
|
||||
SequenceTracker.TryReadBdSeq(payload, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static SparkplugPayload PayloadWith(params SparkplugMetric[] metrics) =>
|
||||
new(IsValid: true, Seq: 0UL, TimestampMs: 1UL, Metrics: metrics);
|
||||
|
||||
private static SparkplugMetric Metric(string name, DataType dataType, object value) =>
|
||||
new(name, Alias: null, dataType, TimestampMs: null, SparkplugValueKind.Scalar, value);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
using Google.Protobuf;
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="SparkplugCodec"/>: the golden NBIRTH/NDATA vectors decode into the driver-side
|
||||
/// projection with explicit presence preserved, and no input — malformed, truncated, empty, or
|
||||
/// simply not Sparkplug — makes the decoder throw.
|
||||
/// </summary>
|
||||
public sealed class SparkplugCodecTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The headline case from the plan: a golden NBIRTH yields its sequence number and its metric
|
||||
/// catalog, with name, alias, datatype and value all surviving.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Decode_GoldenNbirth_ExtractsMetrics()
|
||||
{
|
||||
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
|
||||
|
||||
payload.IsValid.ShouldBeTrue();
|
||||
payload.Seq.ShouldBe(0UL);
|
||||
payload.Metrics.ShouldContain(m =>
|
||||
m.Name == "Temperature"
|
||||
&& m.Alias == 5UL
|
||||
&& m.DataType == DataType.Float
|
||||
&& Equals(m.Value, 21.5f));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Explicit presence, case 1.</b> A Sparkplug NBIRTH is REQUIRED to carry <c>seq = 0</c>, so a
|
||||
/// decoder that reads absence off a zero value reports "no sequence" for every birth ever
|
||||
/// published — and the sequence tracker then has no birth to anchor on.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Decode_GoldenNbirth_SeqZero_IsPresentNotAbsent()
|
||||
{
|
||||
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
|
||||
|
||||
payload.Seq.HasValue.ShouldBeTrue();
|
||||
payload.Seq!.Value.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Explicit presence, case 2.</b> A payload that genuinely omits <c>seq</c> must report
|
||||
/// absence, not zero — the mirror image of the test above, and the pair is what makes the
|
||||
/// distinction falsifiable rather than incidental.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Decode_PayloadWithoutSeq_ReportsAbsentSeq()
|
||||
{
|
||||
var wire = new Payload { Timestamp = 1UL }.ToByteArray();
|
||||
|
||||
SparkplugCodec.Decode(wire).Seq.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Explicit presence, case 3.</b> Every real Sparkplug DATA metric omits <c>name</c> and
|
||||
/// <c>datatype</c> and carries only its alias. Reporting an absent name as the empty string
|
||||
/// would make it indistinguishable from a (malformed) metric genuinely named "".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Decode_GoldenNdata_MetricsCarryAliasOnly_NameAndDataTypeAbsent()
|
||||
{
|
||||
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NdataFile));
|
||||
|
||||
payload.IsValid.ShouldBeTrue();
|
||||
payload.Seq.ShouldBe(1UL);
|
||||
payload.Metrics.Count.ShouldBe(3);
|
||||
|
||||
foreach (var metric in payload.Metrics)
|
||||
{
|
||||
metric.Name.ShouldBeNull();
|
||||
metric.DataType.ShouldBeNull();
|
||||
metric.Alias.HasValue.ShouldBeTrue();
|
||||
}
|
||||
|
||||
payload.Metrics.ShouldContain(m => m.Alias == SparkplugGoldenPayloads.TemperatureAlias
|
||||
&& Equals(m.Value, 22.25f));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Explicit presence, case 4.</b> "The metric's value is null" (<c>is_null</c>) is a different
|
||||
/// fact from "the metric carried no value field", and both differ from "the value is zero".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Decode_GoldenNbirth_ExplicitNullMetric_IsNullKind()
|
||||
{
|
||||
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
|
||||
|
||||
var missing = payload.Metrics.Single(m => m.Name == "Missing");
|
||||
missing.ValueKind.ShouldBe(SparkplugValueKind.Null);
|
||||
missing.Value.ShouldBeNull();
|
||||
missing.DataType.ShouldBe(DataType.Float);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Explicit presence, case 5 — the one that makes the other four falsifiable.</b> A publisher
|
||||
/// may legitimately set a metric's name to the empty string, its datatype to <c>Unknown</c> (0)
|
||||
/// and its timestamp to 0 (the Unix epoch). Those are present-and-default, and the golden
|
||||
/// vectors alone cannot tell a presence check from a default check because nothing in them sets
|
||||
/// a field to its own default. This pins the distinction directly: set-to-default must decode as
|
||||
/// PRESENT, unset must decode as ABSENT, on every field that carries presence.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Decode_FieldsSetToTheirOwnDefault_ArePresent_WhileUnsetFieldsAreAbsent()
|
||||
{
|
||||
var payload = new Payload();
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = string.Empty,
|
||||
Alias = 0UL,
|
||||
Datatype = (uint)DataType.Unknown,
|
||||
Timestamp = 0UL,
|
||||
IntValue = 0U,
|
||||
});
|
||||
payload.Metrics.Add(new Payload.Types.Metric { StringValue = "unset-everything-else" });
|
||||
|
||||
var decoded = SparkplugCodec.Decode(payload.ToByteArray());
|
||||
|
||||
var present = decoded.Metrics[0];
|
||||
present.Name.ShouldBe(string.Empty);
|
||||
present.Alias.ShouldBe(0UL);
|
||||
present.DataType.ShouldBe(DataType.Unknown);
|
||||
present.TimestampMs.ShouldBe(0UL);
|
||||
|
||||
var absent = decoded.Metrics[1];
|
||||
absent.Name.ShouldBeNull();
|
||||
absent.Alias.ShouldBeNull();
|
||||
absent.DataType.ShouldBeNull();
|
||||
absent.TimestampMs.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A metric carrying neither a value nor <c>is_null</c> decodes as absent, not null.</summary>
|
||||
[Fact]
|
||||
public void Decode_MetricWithNoValueAndNoIsNull_IsAbsentKind()
|
||||
{
|
||||
var payload = new Payload { Seq = 4UL };
|
||||
payload.Metrics.Add(new Payload.Types.Metric { Name = "Bare" });
|
||||
|
||||
var decoded = SparkplugCodec.Decode(payload.ToByteArray());
|
||||
|
||||
decoded.Metrics.Single().ValueKind.ShouldBe(SparkplugValueKind.Absent);
|
||||
decoded.Metrics.Single().Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every scalar arm of the metric value <c>oneof</c> projects to a CLR value of the wire's own
|
||||
/// type — nothing is coerced here; coercion against the authored tag type happens downstream.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Decode_GoldenNbirth_ProjectsEveryScalarValueArm()
|
||||
{
|
||||
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
|
||||
|
||||
var byName = payload.Metrics.Where(m => m.Name is not null).ToDictionary(m => m.Name!, m => m);
|
||||
|
||||
byName["bdSeq"].Value.ShouldBe(7UL); // long_value → ulong
|
||||
byName["Node Control/Rebirth"].Value.ShouldBe(false); // boolean_value → bool
|
||||
byName["Temperature"].Value.ShouldBe(21.5f); // float_value → float
|
||||
byName["Pressure"].Value.ShouldBe(101.325d); // double_value → double
|
||||
byName["Running"].Value.ShouldBe(true);
|
||||
byName["Serial"].Value.ShouldBe("EDGE-A-001"); // string_value → string
|
||||
byName["Count"].Value.ShouldBe(unchecked((uint)-42)); // int_value → uint, RAW
|
||||
}
|
||||
|
||||
/// <summary>A <c>bytes</c> metric projects to a byte array rather than a protobuf <c>ByteString</c>.</summary>
|
||||
[Fact]
|
||||
public void Decode_BytesMetric_ProjectsToByteArray()
|
||||
{
|
||||
var payload = new Payload();
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = "Blob",
|
||||
Datatype = (uint)DataType.Bytes,
|
||||
BytesValue = ByteString.CopyFrom(1, 2, 3),
|
||||
});
|
||||
|
||||
var decoded = SparkplugCodec.Decode(payload.ToByteArray());
|
||||
|
||||
decoded.Metrics.Single().Value.ShouldBeOfType<byte[]>().ShouldBe(new byte[] { 1, 2, 3 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>DataSet</c> and <c>Template</c> metrics are out of scope for v1. They must decode to an
|
||||
/// explicit "unsupported" kind — never an exception on the MQTT dispatcher thread, and never a
|
||||
/// silently-dropped metric that looks identical to a metric that was never published.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Decode_GoldenNbirth_DataSetMetric_IsUnsupported_NotAThrow()
|
||||
{
|
||||
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
|
||||
|
||||
var config = payload.Metrics.Single(m => m.Name == "Config");
|
||||
config.ValueKind.ShouldBe(SparkplugValueKind.Unsupported);
|
||||
config.Value.ShouldBeNull();
|
||||
config.DataType.ShouldBe(DataType.DataSet);
|
||||
}
|
||||
|
||||
/// <summary>A <c>Template</c> metric takes the same unsupported path as <c>DataSet</c>.</summary>
|
||||
[Fact]
|
||||
public void Decode_TemplateMetric_IsUnsupported()
|
||||
{
|
||||
var payload = new Payload();
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = "Motor",
|
||||
Datatype = (uint)DataType.Template,
|
||||
TemplateValue = new Payload.Types.Template { Version = "1", IsDefinition = false },
|
||||
});
|
||||
|
||||
SparkplugCodec.Decode(payload.ToByteArray()).Metrics.Single().ValueKind
|
||||
.ShouldBe(SparkplugValueKind.Unsupported);
|
||||
}
|
||||
|
||||
/// <summary>Payload and per-metric timestamps survive as raw Sparkplug epoch milliseconds.</summary>
|
||||
[Fact]
|
||||
public void Decode_GoldenVectors_PreserveTimestamps()
|
||||
{
|
||||
var nbirth = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
|
||||
|
||||
nbirth.TimestampMs.ShouldBe(SparkplugGoldenPayloads.NbirthTimestampMs);
|
||||
nbirth.Metrics.Single(m => m.Name == "Temperature").TimestampMs
|
||||
.ShouldBe(SparkplugGoldenPayloads.NbirthTimestampMs);
|
||||
|
||||
// A metric that carried no timestamp of its own reports absence, not the payload's.
|
||||
nbirth.Metrics.Single(m => m.Name == "Pressure").TimestampMs.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sparkplug carries signed integers up to 32 bits as two's complement in the UNSIGNED
|
||||
/// <c>int_value</c> field. The codec hands over the raw wire value; this is the helper that
|
||||
/// turns it back into the number the edge node meant.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(DataType.Int8, -5)]
|
||||
[InlineData(DataType.Int16, -1234)]
|
||||
[InlineData(DataType.Int32, -42)]
|
||||
public void ReinterpretSigned_RecoversNegativeIntegersFromTheUnsignedWireField(DataType datatype, int expected)
|
||||
{
|
||||
var raw = unchecked((uint)expected);
|
||||
|
||||
var result = SparkplugCodec.ReinterpretSigned(raw, datatype);
|
||||
|
||||
Convert.ToInt64(result).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>The 64-bit arm reinterprets <c>long_value</c>; the golden NBIRTH's Count is the 32-bit arm.</summary>
|
||||
[Fact]
|
||||
public void ReinterpretSigned_HandlesInt64_AndTheGoldenCountMetric()
|
||||
{
|
||||
SparkplugCodec.ReinterpretSigned(unchecked((ulong)-9_000_000_000L), DataType.Int64).ShouldBe(-9_000_000_000L);
|
||||
|
||||
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
|
||||
var count = payload.Metrics.Single(m => m.Name == "Count");
|
||||
SparkplugCodec.ReinterpretSigned(count.Value, count.DataType!.Value)
|
||||
.ShouldBe(SparkplugGoldenPayloads.CountBirthValue);
|
||||
}
|
||||
|
||||
/// <summary>Unsigned and non-integer datatypes pass through untouched.</summary>
|
||||
[Fact]
|
||||
public void ReinterpretSigned_LeavesUnsignedAndNonIntegerValuesAlone()
|
||||
{
|
||||
SparkplugCodec.ReinterpretSigned(7U, DataType.Uint32).ShouldBe(7U);
|
||||
SparkplugCodec.ReinterpretSigned(21.5f, DataType.Float).ShouldBe(21.5f);
|
||||
SparkplugCodec.ReinterpretSigned("text", DataType.String).ShouldBe("text");
|
||||
SparkplugCodec.ReinterpretSigned(null, DataType.Int32).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A datatype index this build's enum does not define (a future Sparkplug revision, or a
|
||||
/// misbehaving publisher) is preserved verbatim rather than dropped or coerced to Unknown —
|
||||
/// the mapping layer decides what to do with it, and it cannot decide about data it never saw.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Decode_UnrecognisedDataTypeIndex_IsPreservedVerbatim()
|
||||
{
|
||||
var payload = new Payload();
|
||||
payload.Metrics.Add(new Payload.Types.Metric { Name = "Future", Datatype = 250U, IntValue = 1U });
|
||||
|
||||
var decoded = SparkplugCodec.Decode(payload.ToByteArray());
|
||||
|
||||
((int)decoded.Metrics.Single().DataType!.Value).ShouldBe(250);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Never throws, case 1.</b> Zero-length input is not "a payload with no metrics" — it is no
|
||||
/// answer at all, and is reported as invalid so a truncated-to-nothing body cannot pass as a
|
||||
/// well-formed message with an empty metric list.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryDecode_EmptyInput_IsInvalid_AndDoesNotThrow()
|
||||
{
|
||||
SparkplugCodec.TryDecode([], out var payload).ShouldBeFalse();
|
||||
payload.IsValid.ShouldBeFalse();
|
||||
payload.Metrics.ShouldBeEmpty();
|
||||
payload.Seq.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Never throws, case 2.</b> Garbage bytes: a decoder that let an exception escape here would
|
||||
/// take down the MQTT dispatcher thread — every subscription on the connection, not one tag.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryDecode_GarbageBytes_NeverThrows()
|
||||
{
|
||||
var random = new Random(20260724);
|
||||
|
||||
for (var i = 0; i < 2000; i++)
|
||||
{
|
||||
var bytes = new byte[random.Next(1, 64)];
|
||||
random.NextBytes(bytes);
|
||||
|
||||
// The verdict is irrelevant — some random byte strings ARE valid protobuf. Not throwing is
|
||||
// the whole assertion.
|
||||
Should.NotThrow(() => SparkplugCodec.TryDecode(bytes, out _));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Never throws, case 3.</b> Every truncation of a real payload — the shape a dropped
|
||||
/// connection or a size-capped broker actually produces.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryDecode_EveryTruncationOfAGoldenVector_NeverThrows()
|
||||
{
|
||||
foreach (var file in new[] { SparkplugGoldenPayloads.NbirthFile, SparkplugGoldenPayloads.NdataFile })
|
||||
{
|
||||
var full = SparkplugGoldenPayloads.Read(file);
|
||||
for (var length = 0; length <= full.Length; length++)
|
||||
{
|
||||
var prefix = full.AsSpan(0, length).ToArray();
|
||||
Should.NotThrow(() => SparkplugCodec.TryDecode(prefix, out _));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Never throws, case 4.</b> A valid protobuf message that is not a Sparkplug payload. Protobuf
|
||||
/// is permissive, so this may well parse (as unknown fields) — what matters is that it neither
|
||||
/// throws nor invents metrics.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryDecode_ValidProtobufThatIsNotASparkplugPayload_NeverThrows()
|
||||
{
|
||||
// A DataSet message, serialized standalone — legal protobuf, wrong schema.
|
||||
var alien = new Payload.Types.DataSet { NumOfColumns = 3UL };
|
||||
alien.Columns.Add("a");
|
||||
|
||||
Should.NotThrow(() => SparkplugCodec.TryDecode(alien.ToByteArray(), out _));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Never throws, case 5.</b> A deeply nested Template — protobuf's recursion limit rejects it,
|
||||
/// and the rejection must arrive as a verdict rather than as an exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryDecode_PathologicallyNestedTemplate_NeverThrows()
|
||||
{
|
||||
var template = new Payload.Types.Template { Version = "1" };
|
||||
for (var depth = 0; depth < 200; depth++)
|
||||
{
|
||||
var outer = new Payload.Types.Template { Version = "1" };
|
||||
outer.Metrics.Add(new Payload.Types.Metric { Name = "n", TemplateValue = template });
|
||||
template = outer;
|
||||
}
|
||||
|
||||
var payload = new Payload();
|
||||
payload.Metrics.Add(new Payload.Types.Metric { Name = "Deep", TemplateValue = template });
|
||||
|
||||
Should.NotThrow(() => SparkplugCodec.TryDecode(payload.ToByteArray(), out _));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SparkplugCodec.Decode"/> is the convenience shape the plan's call sites use: it
|
||||
/// never returns <see langword="null"/> and never throws, reporting failure through
|
||||
/// <see cref="SparkplugPayload.IsValid"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Decode_OnUndecodableInput_ReturnsTheInvalidPayload_RatherThanThrowing()
|
||||
{
|
||||
var payload = SparkplugCodec.Decode([]);
|
||||
|
||||
payload.ShouldNotBeNull();
|
||||
payload.IsValid.ShouldBeFalse();
|
||||
payload.Metrics.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
|
||||
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
|
||||
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
|
||||
// scope does not cross a ProjectReference.
|
||||
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SparkplugDataTypeExtensions.ToDriverDataType"/> / <see cref="SparkplugDataTypeExtensions.IsSparkplugArray"/>
|
||||
/// coverage (Task 17), per design doc §3.5. <see cref="ToDriverDataType_HandlesEveryGeneratedDataTypeMember_MappedOrExplicitlyUnsupported"/>
|
||||
/// is the drift guard: because <c>SparkplugDataType</c> is an alias for the vendored proto's
|
||||
/// generated enum (not a hand-duplicated copy — see the remarks on
|
||||
/// <see cref="SparkplugDataTypeExtensions"/>), enumerating it enumerates the live generated member
|
||||
/// set, so a future Eclipse Tahu proto change that adds/removes a <c>DataType</c> member is caught
|
||||
/// here automatically rather than needing a second enum kept manually in sync.
|
||||
/// </summary>
|
||||
public sealed class SparkplugDataTypeTests
|
||||
{
|
||||
/// <summary>Scalar (non-array) datatypes, per the design §3.5 table.</summary>
|
||||
[Theory]
|
||||
[InlineData(SparkplugDataType.Int8, DriverDataType.Int16)] // widened: no 8-bit DriverDataType member
|
||||
[InlineData(SparkplugDataType.Int16, DriverDataType.Int16)]
|
||||
[InlineData(SparkplugDataType.Int32, DriverDataType.Int32)]
|
||||
[InlineData(SparkplugDataType.Int64, DriverDataType.Int64)]
|
||||
[InlineData(SparkplugDataType.Uint8, DriverDataType.UInt16)] // widened
|
||||
[InlineData(SparkplugDataType.Uint16, DriverDataType.UInt16)]
|
||||
[InlineData(SparkplugDataType.Uint32, DriverDataType.UInt32)]
|
||||
[InlineData(SparkplugDataType.Uint64, DriverDataType.UInt64)]
|
||||
[InlineData(SparkplugDataType.Float, DriverDataType.Float32)]
|
||||
[InlineData(SparkplugDataType.Double, DriverDataType.Float64)] // NOT DriverDataType.Double — no such member
|
||||
[InlineData(SparkplugDataType.Boolean, DriverDataType.Boolean)]
|
||||
[InlineData(SparkplugDataType.String, DriverDataType.String)]
|
||||
[InlineData(SparkplugDataType.DateTime, DriverDataType.DateTime)]
|
||||
[InlineData(SparkplugDataType.Text, DriverDataType.String)]
|
||||
[InlineData(SparkplugDataType.Uuid, DriverDataType.String)] // generated name for the proto's `UUID`
|
||||
[InlineData(SparkplugDataType.Bytes, DriverDataType.String)] // v1 base64/raw fallback
|
||||
[InlineData(SparkplugDataType.File, DriverDataType.String)] // v1 base64/raw fallback
|
||||
public void ToDriverDataType_MapsScalarTypes(SparkplugDataType s, DriverDataType d)
|
||||
=> s.ToDriverDataType().ShouldBe(d);
|
||||
|
||||
/// <summary>
|
||||
/// <c>*Array</c> variants map to their scalar element type; <see cref="SparkplugDataTypeExtensions.IsSparkplugArray"/>
|
||||
/// carries the "and it's an array" bit, since <see cref="DriverDataType"/> has no array concept
|
||||
/// of its own (that's an OPC UA ValueRank/ArrayDimensions concern owned further up the stack).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(SparkplugDataType.Int8Array, DriverDataType.Int16)]
|
||||
[InlineData(SparkplugDataType.Int16Array, DriverDataType.Int16)]
|
||||
[InlineData(SparkplugDataType.Int32Array, DriverDataType.Int32)]
|
||||
[InlineData(SparkplugDataType.Int64Array, DriverDataType.Int64)]
|
||||
[InlineData(SparkplugDataType.Uint8Array, DriverDataType.UInt16)]
|
||||
[InlineData(SparkplugDataType.Uint16Array, DriverDataType.UInt16)]
|
||||
[InlineData(SparkplugDataType.Uint32Array, DriverDataType.UInt32)]
|
||||
[InlineData(SparkplugDataType.Uint64Array, DriverDataType.UInt64)]
|
||||
[InlineData(SparkplugDataType.FloatArray, DriverDataType.Float32)]
|
||||
[InlineData(SparkplugDataType.DoubleArray, DriverDataType.Float64)]
|
||||
[InlineData(SparkplugDataType.BooleanArray, DriverDataType.Boolean)]
|
||||
[InlineData(SparkplugDataType.StringArray, DriverDataType.String)]
|
||||
[InlineData(SparkplugDataType.DateTimeArray, DriverDataType.DateTime)]
|
||||
public void ToDriverDataType_ArrayVariants_MapToElementType_AndFlagIsArray(SparkplugDataType s, DriverDataType d)
|
||||
{
|
||||
s.ToDriverDataType().ShouldBe(d);
|
||||
s.IsSparkplugArray().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SparkplugDataType.Int32)]
|
||||
[InlineData(SparkplugDataType.Boolean)]
|
||||
[InlineData(SparkplugDataType.String)]
|
||||
[InlineData(SparkplugDataType.Bytes)]
|
||||
public void IsSparkplugArray_ScalarTypes_ReturnsFalse(SparkplugDataType s)
|
||||
=> s.IsSparkplugArray().ShouldBeFalse();
|
||||
|
||||
/// <summary>
|
||||
/// Design §3.5: "DataSet, Template → unsupported v1". Extended here to the two PropertyValue-only
|
||||
/// variants and the proto's explicit placeholder — all five must come back <see langword="null"/>,
|
||||
/// never a guessed <see cref="DriverDataType.String"/>.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(SparkplugDataType.Unknown)]
|
||||
[InlineData(SparkplugDataType.DataSet)]
|
||||
[InlineData(SparkplugDataType.Template)]
|
||||
[InlineData(SparkplugDataType.PropertySet)]
|
||||
[InlineData(SparkplugDataType.PropertySetList)]
|
||||
public void ToDriverDataType_UnsupportedTypes_ReturnsNull_NotAGuess(SparkplugDataType s)
|
||||
=> s.ToDriverDataType().ShouldBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// The drift guard (see class remarks). Enumerates the live generated <c>DataType</c> member
|
||||
/// set (via the <c>SparkplugDataType</c> alias) and asserts every member is either mapped to a
|
||||
/// <see cref="DriverDataType"/> or is one of the five documented-unsupported members — nothing
|
||||
/// silently falls through the map's <c>_ => null</c> default undetected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToDriverDataType_HandlesEveryGeneratedDataTypeMember_MappedOrExplicitlyUnsupported()
|
||||
{
|
||||
var unsupported = new HashSet<SparkplugDataType>
|
||||
{
|
||||
SparkplugDataType.Unknown,
|
||||
SparkplugDataType.DataSet,
|
||||
SparkplugDataType.Template,
|
||||
SparkplugDataType.PropertySet,
|
||||
SparkplugDataType.PropertySetList,
|
||||
};
|
||||
|
||||
var allMembers = Enum.GetValues<SparkplugDataType>();
|
||||
allMembers.Length.ShouldBe(35, "this pins the generated member count Task 15 vendored; a changed count means the proto changed and every assertion below needs re-auditing.");
|
||||
|
||||
foreach (var value in allMembers)
|
||||
{
|
||||
var mapped = value.ToDriverDataType();
|
||||
if (unsupported.Contains(value))
|
||||
{
|
||||
mapped.ShouldBeNull($"{value} is documented unsupported (design §3.5) and must map to null, not a guessed DriverDataType.");
|
||||
}
|
||||
else
|
||||
{
|
||||
mapped.ShouldNotBeNull($"{value} has no ToDriverDataType() mapping — every generated DataType member must be mapped or explicitly listed as unsupported.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using Google.Protobuf;
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the two Sparkplug-B wire vectors committed under <c>Golden/</c> — an NBIRTH and the
|
||||
/// NDATA that follows it — and names the files they live in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why a generator instead of bytes captured off a real edge node.</b> A capture would pin
|
||||
/// one vendor's encoder rather than the schema, could not be regenerated when the vendored
|
||||
/// proto is re-vendored, and would drag an unaudited third-party payload into the repo. These
|
||||
/// payloads are hand-built from the generated types, serialized once, and committed; the
|
||||
/// <c>SparkplugGoldenVectorTests</c> drift guard re-runs this builder on every test run and
|
||||
/// fails if the committed bytes no longer match, which is what makes the files an actual
|
||||
/// regression pin rather than decoration.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every field in here is load-bearing.</b> The NBIRTH carries <c>seq = 0</c> (present and
|
||||
/// zero — the case a <c>Seq != 0</c> presence check gets wrong), a negative Int32 encoded the
|
||||
/// Sparkplug way (two's complement in an unsigned <c>int_value</c>), an explicit
|
||||
/// <c>is_null</c> metric, and a <c>DataSet</c> metric that v1 does not support. The NDATA
|
||||
/// carries metrics with <b>no name and no datatype</b> — alias only — which is how every real
|
||||
/// Sparkplug DATA message after a birth looks, and the case an "absent means empty string"
|
||||
/// decoder gets wrong. Do not "tidy" these payloads.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class SparkplugGoldenPayloads
|
||||
{
|
||||
/// <summary>Repo-relative directory the committed vectors live in, inside the test project.</summary>
|
||||
public const string Directory = "Golden";
|
||||
|
||||
/// <summary>File name of the NBIRTH vector.</summary>
|
||||
public const string NbirthFile = "nbirth.bin";
|
||||
|
||||
/// <summary>File name of the NDATA vector.</summary>
|
||||
public const string NdataFile = "ndata.bin";
|
||||
|
||||
/// <summary>NBIRTH payload timestamp — 2024-07-24T12:00:00Z, in Sparkplug's epoch milliseconds.</summary>
|
||||
public const ulong NbirthTimestampMs = 1721822400000UL;
|
||||
|
||||
/// <summary>NDATA payload timestamp — five seconds after the birth.</summary>
|
||||
public const ulong NdataTimestampMs = 1721822405000UL;
|
||||
|
||||
/// <summary>The alias the NBIRTH binds to <c>Temperature</c>, reused by the NDATA.</summary>
|
||||
public const ulong TemperatureAlias = 5UL;
|
||||
|
||||
/// <summary>The alias the NBIRTH binds to <c>Count</c> — the negative-Int32 case.</summary>
|
||||
public const ulong CountAlias = 9UL;
|
||||
|
||||
/// <summary>The alias the NBIRTH binds to <c>Missing</c> — the explicit-null case.</summary>
|
||||
public const ulong MissingAlias = 11UL;
|
||||
|
||||
/// <summary>The <c>Count</c> metric's value in the NBIRTH — negative, to pin the wire encoding.</summary>
|
||||
public const int CountBirthValue = -42;
|
||||
|
||||
/// <summary>The <c>Count</c> metric's value in the NDATA.</summary>
|
||||
public const int CountDataValue = -43;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the NBIRTH: <c>seq = 0</c>, a full metric catalog with names, aliases and datatypes.
|
||||
/// </summary>
|
||||
/// <returns>The payload; serialize with <see cref="MessageExtensions.ToByteArray"/>.</returns>
|
||||
public static Payload BuildNbirth()
|
||||
{
|
||||
var payload = new Payload
|
||||
{
|
||||
Timestamp = NbirthTimestampMs,
|
||||
|
||||
// Present AND zero. Sparkplug REQUIRES an NBIRTH to carry seq = 0, so any decoder that
|
||||
// infers absence from a zero value reports "no sequence" for every birth it ever sees.
|
||||
Seq = 0UL,
|
||||
};
|
||||
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = "bdSeq",
|
||||
Datatype = (uint)DataType.Uint64,
|
||||
LongValue = 7UL,
|
||||
});
|
||||
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = "Node Control/Rebirth",
|
||||
Datatype = (uint)DataType.Boolean,
|
||||
BooleanValue = false,
|
||||
});
|
||||
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = "Temperature",
|
||||
Alias = TemperatureAlias,
|
||||
Timestamp = NbirthTimestampMs,
|
||||
Datatype = (uint)DataType.Float,
|
||||
FloatValue = 21.5f,
|
||||
});
|
||||
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = "Pressure",
|
||||
Alias = 6UL,
|
||||
Datatype = (uint)DataType.Double,
|
||||
DoubleValue = 101.325d,
|
||||
});
|
||||
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = "Running",
|
||||
Alias = 7UL,
|
||||
Datatype = (uint)DataType.Boolean,
|
||||
BooleanValue = true,
|
||||
});
|
||||
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = "Serial",
|
||||
Alias = 8UL,
|
||||
Datatype = (uint)DataType.String,
|
||||
StringValue = "EDGE-A-001",
|
||||
});
|
||||
|
||||
// Sparkplug carries every signed integer up to 32 bits in the UNSIGNED int_value field as
|
||||
// two's complement, so -42 goes on the wire as 4294967254. A decoder that hands the raw
|
||||
// uint32 straight to a consumer publishes 4294967254 for a tag whose value is -42.
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = "Count",
|
||||
Alias = CountAlias,
|
||||
Datatype = (uint)DataType.Int32,
|
||||
IntValue = unchecked((uint)CountBirthValue),
|
||||
});
|
||||
|
||||
// DataSet is explicitly out of scope for v1 — present here so "unsupported" is a decoded,
|
||||
// testable outcome rather than an exception on the MQTT dispatcher thread.
|
||||
var dataSet = new Payload.Types.DataSet { NumOfColumns = 1UL };
|
||||
dataSet.Columns.Add("col0");
|
||||
dataSet.Types_.Add((uint)DataType.Int32);
|
||||
var row = new Payload.Types.DataSet.Types.Row();
|
||||
row.Elements.Add(new Payload.Types.DataSet.Types.DataSetValue { IntValue = 1U });
|
||||
dataSet.Rows.Add(row);
|
||||
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = "Config",
|
||||
Alias = 10UL,
|
||||
Datatype = (uint)DataType.DataSet,
|
||||
DatasetValue = dataSet,
|
||||
});
|
||||
|
||||
// is_null = true with NO value in the oneof: "this metric exists and its value is null",
|
||||
// which is a different fact from "this metric carried no value field".
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = "Missing",
|
||||
Alias = MissingAlias,
|
||||
Datatype = (uint)DataType.Float,
|
||||
IsNull = true,
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the NDATA that follows <see cref="BuildNbirth"/>: <c>seq = 1</c> and metrics carrying
|
||||
/// <b>alias only</b> — no name, no datatype — exactly as a real edge node publishes them.
|
||||
/// </summary>
|
||||
/// <returns>The payload; serialize with <see cref="MessageExtensions.ToByteArray"/>.</returns>
|
||||
public static Payload BuildNdata()
|
||||
{
|
||||
var payload = new Payload
|
||||
{
|
||||
Timestamp = NdataTimestampMs,
|
||||
Seq = 1UL,
|
||||
};
|
||||
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Alias = TemperatureAlias,
|
||||
Timestamp = NdataTimestampMs,
|
||||
FloatValue = 22.25f,
|
||||
});
|
||||
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Alias = CountAlias,
|
||||
IntValue = unchecked((uint)CountDataValue),
|
||||
});
|
||||
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Alias = MissingAlias,
|
||||
IsNull = true,
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// <summary>Reads a committed vector from the test binary's output directory.</summary>
|
||||
/// <param name="fileName"><see cref="NbirthFile"/> or <see cref="NdataFile"/>.</param>
|
||||
/// <returns>The committed wire bytes.</returns>
|
||||
public static byte[] Read(string fileName) => File.ReadAllBytes(PathTo(fileName));
|
||||
|
||||
/// <summary>Resolves a vector's path under the test binary's output directory.</summary>
|
||||
/// <param name="fileName"><see cref="NbirthFile"/> or <see cref="NdataFile"/>.</param>
|
||||
/// <returns>The absolute path.</returns>
|
||||
/// <remarks>
|
||||
/// Rooted at <see cref="AppContext.BaseDirectory"/> rather than the process working directory:
|
||||
/// the runner's cwd is not guaranteed to be the output folder, and a relative read that
|
||||
/// happens to work under one runner is exactly how a fixture stops being copied without
|
||||
/// anyone noticing.
|
||||
/// </remarks>
|
||||
public static string PathTo(string fileName) =>
|
||||
Path.Combine(AppContext.BaseDirectory, Directory, fileName);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Google.Protobuf;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the committed <c>Golden/*.bin</c> Sparkplug-B wire vectors: they must still be exactly
|
||||
/// what <see cref="SparkplugGoldenPayloads"/> produces, and they must actually reach the test
|
||||
/// binary's output directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Without the drift guard the vectors would be write-once decoration — a re-vendored proto,
|
||||
/// a protoc upgrade, or an edited builder could change the encoding while every decode test
|
||||
/// kept passing against stale bytes. With it, the committed files are a genuine pin on the
|
||||
/// wire format the driver is expected to read.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>To regenerate</b> (only after a deliberate, reviewed change to the vectors):
|
||||
/// <c>OTOPCUA_SPARKPLUG_GOLDEN_REGEN=1 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests
|
||||
/// --filter "FullyQualifiedName~SparkplugGoldenVectorTests"</c>, then review and commit the
|
||||
/// resulting <c>.bin</c> diff.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SparkplugGoldenVectorTests
|
||||
{
|
||||
private const string RegenEnvVar = "OTOPCUA_SPARKPLUG_GOLDEN_REGEN";
|
||||
|
||||
/// <summary>The committed NBIRTH vector is byte-identical to its builder's output.</summary>
|
||||
[Fact]
|
||||
public void Nbirth_CommittedBytes_MatchTheBuilder() =>
|
||||
AssertMatchesBuilder(SparkplugGoldenPayloads.NbirthFile, SparkplugGoldenPayloads.BuildNbirth().ToByteArray());
|
||||
|
||||
/// <summary>The committed NDATA vector is byte-identical to its builder's output.</summary>
|
||||
[Fact]
|
||||
public void Ndata_CommittedBytes_MatchTheBuilder() =>
|
||||
AssertMatchesBuilder(SparkplugGoldenPayloads.NdataFile, SparkplugGoldenPayloads.BuildNdata().ToByteArray());
|
||||
|
||||
/// <summary>
|
||||
/// Both vectors are present in the test binary's output directory — i.e. the csproj really does
|
||||
/// copy <c>Golden/**</c>. A missing copy item is invisible in source and turns every decode test
|
||||
/// into a <see cref="FileNotFoundException"/> at runtime.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GoldenVectors_AreCopiedToTheOutputDirectory()
|
||||
{
|
||||
File.Exists(SparkplugGoldenPayloads.PathTo(SparkplugGoldenPayloads.NbirthFile)).ShouldBeTrue();
|
||||
File.Exists(SparkplugGoldenPayloads.PathTo(SparkplugGoldenPayloads.NdataFile)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Compares a committed vector to freshly built bytes, regenerating first when asked to.</summary>
|
||||
/// <param name="fileName">The vector's file name.</param>
|
||||
/// <param name="built">The bytes the builder produces now.</param>
|
||||
private static void AssertMatchesBuilder(string fileName, byte[] built)
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable(RegenEnvVar) == "1")
|
||||
{
|
||||
// Write to BOTH the source tree (so the change can be committed) and the output directory
|
||||
// (so the assertion below reads what was just written rather than a stale copied file).
|
||||
var source = Path.Combine(SourceDirectory(), SparkplugGoldenPayloads.Directory, fileName);
|
||||
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(source)!);
|
||||
File.WriteAllBytes(source, built);
|
||||
|
||||
var output = SparkplugGoldenPayloads.PathTo(fileName);
|
||||
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(output)!);
|
||||
File.WriteAllBytes(output, built);
|
||||
}
|
||||
|
||||
var path = SparkplugGoldenPayloads.PathTo(fileName);
|
||||
File.Exists(path).ShouldBeTrue(
|
||||
$"Golden vector '{fileName}' is missing from the output directory. If the csproj copy item is "
|
||||
+ $"intact, regenerate with {RegenEnvVar}=1.");
|
||||
|
||||
File.ReadAllBytes(path).ShouldBe(
|
||||
built,
|
||||
$"Golden vector '{fileName}' no longer matches SparkplugGoldenPayloads. Either the builder or "
|
||||
+ $"the vendored proto changed. If the change is intended, regenerate with {RegenEnvVar}=1 and "
|
||||
+ "commit the .bin diff.");
|
||||
}
|
||||
|
||||
/// <summary>This file's own source directory, resolved at compile time.</summary>
|
||||
/// <param name="path">Supplied by the compiler; never pass it.</param>
|
||||
/// <returns>The directory holding the test sources.</returns>
|
||||
private static string SourceDirectory([CallerFilePath] string path = "") => Path.GetDirectoryName(path)!;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
using Google.Protobuf;
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the vendored Eclipse Tahu <c>sparkplug_b</c> protobuf codegen: the generated types exist,
|
||||
/// land in the namespace the rest of P2 is written against, and carry the Sparkplug-B field numbers
|
||||
/// on the wire. Nothing here decodes a real payload — that is Task 16 (<c>SparkplugCodec</c> +
|
||||
/// golden vectors); this suite exists so a broken/renamed/mangled vendored proto fails at the
|
||||
/// codegen seam rather than as a mysterious decode failure three tasks later.
|
||||
/// </summary>
|
||||
public sealed class SparkplugProtoCodegenTests
|
||||
{
|
||||
[Fact]
|
||||
public void GeneratedPayload_RoundTrips()
|
||||
{
|
||||
var p = new Payload { Seq = 3 };
|
||||
p.Metrics.Add(new Payload.Types.Metric { Name = "Temperature", Alias = 5 });
|
||||
|
||||
var back = Payload.Parser.ParseFrom(p.ToByteArray());
|
||||
|
||||
back.Seq.ShouldBe(3ul);
|
||||
back.Metrics[0].Name.ShouldBe("Temperature");
|
||||
back.Metrics[0].Alias.ShouldBe(5ul);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tasks 16 / 20 / 25 name <c>Org.Eclipse.Tahu.Protobuf</c> explicitly, and the namespace is
|
||||
/// derived (not declared) — protoc PascalCases the proto <c>package</c> because the vendored file
|
||||
/// carries no <c>option csharp_namespace</c>. Editing the package line to "tidy" it would silently
|
||||
/// rehome every generated type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GeneratedTypes_LiveInTheTahuPackageNamespace()
|
||||
{
|
||||
typeof(Payload).Namespace.ShouldBe("Org.Eclipse.Tahu.Protobuf");
|
||||
Payload.Descriptor.File.Package.ShouldBe("org.eclipse.tahu.protobuf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Field numbers are the wire contract with every Sparkplug edge node in the plant; a vendored
|
||||
/// proto that was hand-edited or reconstructed rather than copied from upstream would most likely
|
||||
/// drift here first.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PayloadAndMetric_CarrySparkplugBFieldNumbers()
|
||||
{
|
||||
Payload.TimestampFieldNumber.ShouldBe(1);
|
||||
Payload.MetricsFieldNumber.ShouldBe(2);
|
||||
Payload.SeqFieldNumber.ShouldBe(3);
|
||||
Payload.UuidFieldNumber.ShouldBe(4);
|
||||
Payload.BodyFieldNumber.ShouldBe(5);
|
||||
|
||||
Payload.Types.Metric.NameFieldNumber.ShouldBe(1);
|
||||
Payload.Types.Metric.AliasFieldNumber.ShouldBe(2);
|
||||
Payload.Types.Metric.TimestampFieldNumber.ShouldBe(3);
|
||||
Payload.Types.Metric.DatatypeFieldNumber.ShouldBe(4);
|
||||
Payload.Types.Metric.IsNullFieldNumber.ShouldBe(7);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 17's datatype map keys off these indices. They are Sparkplug-spec constants, not protobuf
|
||||
/// tags, so they are worth pinning independently of the field numbers above.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DataTypeEnum_MatchesSparkplugSpecIndices()
|
||||
{
|
||||
((int)DataType.Int8).ShouldBe(1);
|
||||
((int)DataType.Int32).ShouldBe(3);
|
||||
((int)DataType.Uint64).ShouldBe(8);
|
||||
((int)DataType.Float).ShouldBe(9);
|
||||
((int)DataType.Double).ShouldBe(10);
|
||||
((int)DataType.Boolean).ShouldBe(11);
|
||||
((int)DataType.String).ShouldBe(12);
|
||||
((int)DataType.DateTime).ShouldBe(13);
|
||||
((int)DataType.DataSet).ShouldBe(16);
|
||||
((int)DataType.Template).ShouldBe(19);
|
||||
((int)DataType.Int32Array).ShouldBe(24);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// protoc's C# generator RE-CASES enum member names: the proto's <c>UInt64</c> becomes
|
||||
/// <c>Uint64</c> and <c>UUID</c> becomes <c>Uuid</c> (only the leading character of each
|
||||
/// underscore-separated word survives as a capital). Task 17's datatype map has to spell them
|
||||
/// the mangled way, so pin the mangling rather than letting it be rediscovered as a build error.
|
||||
/// The wire-facing spelling is preserved on <c>OriginalName</c>, which is what
|
||||
/// <c>DataType.Parser</c>-style JSON round-trips use.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DataTypeEnum_CSharpNamesAreProtocMangled_NotTheProtoSpelling()
|
||||
{
|
||||
Enum.GetNames<DataType>().ShouldContain("Uint64");
|
||||
Enum.GetNames<DataType>().ShouldNotContain("UInt64");
|
||||
Enum.GetNames<DataType>().ShouldContain("Uuid");
|
||||
Enum.GetNames<DataType>().ShouldNotContain("UUID");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
// Local alias so this file can spell the mapping table the way design doc §3.5 does
|
||||
// (`SparkplugDataType.Int8`, etc.) — `SparkplugDataType` is a `global using` alias for the generated
|
||||
// `Org.Eclipse.Tahu.Protobuf.DataType` declared in `SparkplugDataType.cs` (Contracts project); that
|
||||
// `global using` is scoped to the project that declares it and does not cross the ProjectReference
|
||||
// into this test project, so it is redeclared here, locally, rather than duplicating the enum itself.
|
||||
// See the remarks on `SparkplugDataTypeExtensions` for the full alias-vs-duplicate rationale.
|
||||
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SparkplugTopic"/> parse/format coverage (Task 17). <see cref="SparkplugTopic.TryParse"/>
|
||||
/// is fed every topic string a live <c>spBv1.0/{group}/#</c> subscription delivers, so a large
|
||||
/// share of this suite is "garbage in, <see langword="false"/> out, never a throw" — see
|
||||
/// <see cref="TryParse_NeverThrows_ForArbitraryInput"/>.
|
||||
/// </summary>
|
||||
public sealed class SparkplugTopicTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_DeviceData_ExtractsAllSegments()
|
||||
{
|
||||
var t = SparkplugTopic.Parse("spBv1.0/Plant1/DDATA/EdgeA/Filler1");
|
||||
|
||||
t.GroupId.ShouldBe("Plant1");
|
||||
t.Type.ShouldBe(SparkplugMessageType.DDATA);
|
||||
t.EdgeNodeId.ShouldBe("EdgeA");
|
||||
t.DeviceId.ShouldBe("Filler1");
|
||||
t.HostId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NodeData_HasNoDeviceSegment()
|
||||
{
|
||||
var t = SparkplugTopic.Parse("spBv1.0/Plant1/NDATA/EdgeA");
|
||||
|
||||
t.GroupId.ShouldBe("Plant1");
|
||||
t.Type.ShouldBe(SparkplugMessageType.NDATA);
|
||||
t.EdgeNodeId.ShouldBe("EdgeA");
|
||||
t.DeviceId.ShouldBeNull();
|
||||
t.HostId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("spBv1.0/Plant1/NBIRTH/EdgeA", SparkplugMessageType.NBIRTH)]
|
||||
[InlineData("spBv1.0/Plant1/NDATA/EdgeA", SparkplugMessageType.NDATA)]
|
||||
[InlineData("spBv1.0/Plant1/NDEATH/EdgeA", SparkplugMessageType.NDEATH)]
|
||||
[InlineData("spBv1.0/Plant1/NCMD/EdgeA", SparkplugMessageType.NCMD)]
|
||||
public void Parse_NodeScopedTypes_Recognised(string topic, SparkplugMessageType expected)
|
||||
=> SparkplugTopic.Parse(topic).Type.ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("spBv1.0/Plant1/DBIRTH/EdgeA/Filler1", SparkplugMessageType.DBIRTH)]
|
||||
[InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1", SparkplugMessageType.DDATA)]
|
||||
[InlineData("spBv1.0/Plant1/DDEATH/EdgeA/Filler1", SparkplugMessageType.DDEATH)]
|
||||
[InlineData("spBv1.0/Plant1/DCMD/EdgeA/Filler1", SparkplugMessageType.DCMD)]
|
||||
public void Parse_DeviceScopedTypes_Recognised(string topic, SparkplugMessageType expected)
|
||||
=> SparkplugTopic.Parse(topic).Type.ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Parse_V3StateForm_ExtractsHostId()
|
||||
{
|
||||
var t = SparkplugTopic.Parse("spBv1.0/STATE/otopcua-host-1");
|
||||
|
||||
t.Type.ShouldBe(SparkplugMessageType.STATE);
|
||||
t.HostId.ShouldBe("otopcua-host-1");
|
||||
t.GroupId.ShouldBeNull();
|
||||
t.EdgeNodeId.ShouldBeNull();
|
||||
t.DeviceId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_LegacyStateForm_ExtractsHostId()
|
||||
{
|
||||
// Pre-3.0 peers publish `STATE/{hostId}` with no `spBv1.0` namespace prefix — tolerated on
|
||||
// receive per design §3.1, even though this driver only ever *emits* the v3.0 form.
|
||||
var t = SparkplugTopic.Parse("STATE/otopcua-host-1");
|
||||
|
||||
t.Type.ShouldBe(SparkplugMessageType.STATE);
|
||||
t.HostId.ShouldBe("otopcua-host-1");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("not/a/sparkplug/topic/at/all/too/many/segments")]
|
||||
[InlineData("spBv1.0")]
|
||||
[InlineData("spBv1.0/")]
|
||||
[InlineData("spBv1.0/Plant1")]
|
||||
[InlineData("spBv1.0/Plant1/BOGUS/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/NDATA")]
|
||||
[InlineData("spBv1.0/Plant1/NDATA/EdgeA/UnexpectedDevice")]
|
||||
[InlineData("spBv1.0/Plant1/DDATA/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1/Extra")]
|
||||
[InlineData("wrong-namespace/Plant1/NDATA/EdgeA")]
|
||||
[InlineData("spBv1.0//NDATA/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/NDATA/")]
|
||||
[InlineData("spBv1.0/Plant1/DDATA/EdgeA/")]
|
||||
[InlineData("spBv1.0/Plant+/NDATA/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/NDATA/Edge#A")]
|
||||
[InlineData("spBv1.0/STATE")]
|
||||
[InlineData("spBv1.0/STATE/")]
|
||||
[InlineData("spBv1.0/STATE/Host/Extra")]
|
||||
[InlineData("STATE")]
|
||||
[InlineData("STATE/")]
|
||||
public void TryParse_NeverThrows_ForArbitraryInput(string? topic)
|
||||
{
|
||||
var ex = Record.Exception(() => SparkplugTopic.TryParse(topic, out var result));
|
||||
ex.ShouldBeNull();
|
||||
SparkplugTopic.TryParse(topic, out var result2).ShouldBeFalse();
|
||||
result2.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_InvalidTopic_ThrowsFormatException()
|
||||
=> Should.Throw<FormatException>(() => SparkplugTopic.Parse("not-a-sparkplug-topic"));
|
||||
|
||||
[Fact]
|
||||
public void Format_NodeScoped_BuildsExpectedTopic()
|
||||
=> SparkplugTopic.Format("Plant1", SparkplugMessageType.NCMD, "EdgeA")
|
||||
.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
|
||||
|
||||
[Fact]
|
||||
public void Format_DeviceScoped_BuildsExpectedTopic()
|
||||
=> SparkplugTopic.Format("Plant1", SparkplugMessageType.DCMD, "EdgeA", "Filler1")
|
||||
.ShouldBe("spBv1.0/Plant1/DCMD/EdgeA/Filler1");
|
||||
|
||||
[Fact]
|
||||
public void Format_State_Throws_UseFormatStateInstead()
|
||||
=> Should.Throw<ArgumentException>(() => SparkplugTopic.Format("Plant1", SparkplugMessageType.STATE, "EdgeA"));
|
||||
|
||||
[Fact]
|
||||
public void FormatState_BuildsV3Topic()
|
||||
=> SparkplugTopic.FormatState("otopcua-host-1").ShouldBe("spBv1.0/STATE/otopcua-host-1");
|
||||
|
||||
[Theory]
|
||||
[InlineData("spBv1.0/Plant1/NBIRTH/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/NDATA/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/NDEATH/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/NCMD/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/DBIRTH/EdgeA/Filler1")]
|
||||
[InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1")]
|
||||
[InlineData("spBv1.0/Plant1/DDEATH/EdgeA/Filler1")]
|
||||
[InlineData("spBv1.0/Plant1/DCMD/EdgeA/Filler1")]
|
||||
[InlineData("spBv1.0/STATE/otopcua-host-1")]
|
||||
public void ToTopicString_RoundTrips(string topic)
|
||||
=> SparkplugTopic.Parse(topic).ToTopicString().ShouldBe(topic);
|
||||
|
||||
[Fact]
|
||||
public void ToTopicString_LegacyStateTopic_NormalisesToV3Form()
|
||||
// Parsed from the legacy no-prefix form, but formatting always emits the v3.0 shape.
|
||||
=> SparkplugTopic.Parse("STATE/otopcua-host-1").ToTopicString().ShouldBe("spBv1.0/STATE/otopcua-host-1");
|
||||
|
||||
// ---- The plan's illustrative datatype-map smoke theory (spelled with the protoc-mangled member
|
||||
// names: the generated enum has `Uint8`, not `UInt8` — see SparkplugDataTypeTests for the
|
||||
// full table). Kept here alongside the topic tests per the Task 17 plan's original grouping;
|
||||
// SparkplugDataTypeTests.cs carries the exhaustive coverage + the completeness/drift guard. ----
|
||||
[Theory]
|
||||
[InlineData(SparkplugDataType.Int8, DriverDataType.Int16)]
|
||||
[InlineData(SparkplugDataType.Uint8, DriverDataType.UInt16)]
|
||||
[InlineData(SparkplugDataType.Float, DriverDataType.Float32)]
|
||||
public void ToDriverDataType_MapsAndWidens(SparkplugDataType s, DriverDataType d)
|
||||
=> s.ToDriverDataType().ShouldBe(d);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- The committed Sparkplug-B golden wire vectors. Without this copy item they stay in the source
|
||||
tree and every SparkplugCodecTests read fails with FileNotFoundException at runtime — the
|
||||
failure mode SparkplugGoldenVectorTests.GoldenVectors_AreCopiedToTheOutputDirectory pins. -->
|
||||
<ItemGroup>
|
||||
<None Update="Golden\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+139
-4
@@ -1,3 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -7,17 +10,23 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
|
||||
|
||||
/// <summary>Unit tests for <see cref="BrowserSessionService"/> — driver-type dispatch
|
||||
/// on open, NotFound semantics on unknown tokens, exception swallowing, and per-call
|
||||
/// timeout enforcement.</summary>
|
||||
/// on open, NotFound semantics on unknown tokens, exception swallowing, per-call
|
||||
/// timeout enforcement, and the DriverOperator gate on the one write-capable member.</summary>
|
||||
public sealed class BrowserSessionServiceTests
|
||||
{
|
||||
private static BrowserSessionService NewService(
|
||||
BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
|
||||
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, new FakeUniversalDriverBrowser());
|
||||
NewService(registry, new FakeUniversalDriverBrowser(), browsers);
|
||||
|
||||
private static BrowserSessionService NewService(
|
||||
BrowseSessionRegistry registry, IUniversalDriverBrowser universal, params IDriverBrowser[] browsers) =>
|
||||
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, universal);
|
||||
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, universal,
|
||||
new FakeAuthorizationService(succeeds: true), new FakeAuthenticationStateProvider());
|
||||
|
||||
private static BrowserSessionService NewServiceWithAuthz(
|
||||
BrowseSessionRegistry registry, bool authorized) =>
|
||||
new([], registry, NullLogger<BrowserSessionService>.Instance, new FakeUniversalDriverBrowser(),
|
||||
new FakeAuthorizationService(authorized), new FakeAuthenticationStateProvider());
|
||||
|
||||
[Fact]
|
||||
public async Task OpenAsync_unknown_driver_type_returns_Ok_false_with_message()
|
||||
@@ -226,4 +235,130 @@ public sealed class BrowserSessionServiceTests
|
||||
// Unknown token is a no-op.
|
||||
await Should.NotThrowAsync(() => service.CloseAsync(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- RequestRebirthAsync: the one write path
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_WithoutDriverOperator_IsRefusedAndNeverReachesTheSession()
|
||||
{
|
||||
// A publishing action that skips authz is a security bug, not a style issue: this one makes a
|
||||
// live plant broker receive a command. The refusal must happen BEFORE the session is touched.
|
||||
var registry = new BrowseSessionRegistry();
|
||||
var session = new FakeRebirthCapableBrowseSession();
|
||||
registry.Register(session);
|
||||
var service = NewServiceWithAuthz(registry, authorized: false);
|
||||
|
||||
await Should.ThrowAsync<UnauthorizedAccessException>(
|
||||
() => service.RequestRebirthAsync(session.Token, "Plant1/EdgeA", CancellationToken.None));
|
||||
|
||||
session.Scopes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_WithDriverOperator_DispatchesTheScopeAndReturnsTheCount()
|
||||
{
|
||||
var registry = new BrowseSessionRegistry();
|
||||
var session = new FakeRebirthCapableBrowseSession { Published = 2 };
|
||||
registry.Register(session);
|
||||
var service = NewServiceWithAuthz(registry, authorized: true);
|
||||
|
||||
var published = await service.RequestRebirthAsync(session.Token, "Plant1", CancellationToken.None);
|
||||
|
||||
published.ShouldBe(2);
|
||||
session.Scopes.ShouldBe(["Plant1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_OnANonRebirthCapableSession_IsNotSupported()
|
||||
{
|
||||
var registry = new BrowseSessionRegistry();
|
||||
var session = new FakeBrowseSession();
|
||||
registry.Register(session);
|
||||
var service = NewServiceWithAuthz(registry, authorized: true);
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(
|
||||
() => service.RequestRebirthAsync(session.Token, "Plant1/EdgeA", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_UnknownToken_ThrowsBrowseSessionNotFound()
|
||||
{
|
||||
var registry = new BrowseSessionRegistry();
|
||||
var service = NewServiceWithAuthz(registry, authorized: true);
|
||||
|
||||
await Should.ThrowAsync<BrowseSessionNotFoundException>(
|
||||
() => service.RequestRebirthAsync(Guid.NewGuid(), "Plant1/EdgeA", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRequestRebirth_IsTrue_OnlyForASessionThatAdvertisesTheAction()
|
||||
{
|
||||
// The picker draws its Request-rebirth affordance off this. A session type CAN re-announce
|
||||
// (MqttBrowseSession implements the interface) while a given INSTANCE cannot — a plain-MQTT
|
||||
// window publishes nothing, ever — so a type test alone would draw a button that only throws.
|
||||
var registry = new BrowseSessionRegistry();
|
||||
var sparkplug = new FakeRebirthCapableBrowseSession();
|
||||
var plain = new FakeRebirthCapableBrowseSession { RebirthAvailable = false };
|
||||
var plainOldSession = new FakeBrowseSession();
|
||||
registry.Register(sparkplug);
|
||||
registry.Register(plain);
|
||||
registry.Register(plainOldSession);
|
||||
var service = NewServiceWithAuthz(registry, authorized: true);
|
||||
|
||||
service.CanRequestRebirth(sparkplug.Token).ShouldBeTrue();
|
||||
service.CanRequestRebirth(plain.Token).ShouldBeFalse();
|
||||
service.CanRequestRebirth(plainOldSession.Token).ShouldBeFalse();
|
||||
service.CanRequestRebirth(Guid.NewGuid()).ShouldBeFalse(); // unknown/reaped token
|
||||
}
|
||||
|
||||
/// <summary>A browse session that records the rebirth scopes it was asked for.</summary>
|
||||
private sealed class FakeRebirthCapableBrowseSession : IRebirthCapableBrowseSession
|
||||
{
|
||||
public Guid Token { get; } = Guid.NewGuid();
|
||||
|
||||
public DateTime LastUsedUtc { get; } = DateTime.UtcNow;
|
||||
|
||||
public int Published { get; init; } = 1;
|
||||
|
||||
/// <summary>Whether this fake session advertises the action (a plain-MQTT window does not).</summary>
|
||||
public bool RebirthAvailable { get; init; } = true;
|
||||
|
||||
public List<string> Scopes { get; } = [];
|
||||
|
||||
public Task<int> RequestRebirthAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
Scopes.Add(scope);
|
||||
return Task.FromResult(Published);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<BrowseNode>>([]);
|
||||
|
||||
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<BrowseNode>>([]);
|
||||
|
||||
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<AttributeInfo>>([]);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IAuthorizationService"/> whose verdict the test dictates.</summary>
|
||||
private sealed class FakeAuthorizationService(bool succeeds) : IAuthorizationService
|
||||
{
|
||||
public Task<AuthorizationResult> AuthorizeAsync(
|
||||
ClaimsPrincipal user, object? resource, IEnumerable<IAuthorizationRequirement> requirements) =>
|
||||
Task.FromResult(succeeds ? AuthorizationResult.Success() : AuthorizationResult.Failed());
|
||||
|
||||
public Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) =>
|
||||
Task.FromResult(succeeds ? AuthorizationResult.Success() : AuthorizationResult.Failed());
|
||||
}
|
||||
|
||||
/// <summary>A fixed authenticated identity for the circuit under test.</summary>
|
||||
private sealed class FakeAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
|
||||
Task.FromResult(new AuthenticationState(
|
||||
new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "tester")], "test"))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
|
||||
@@ -23,15 +24,45 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
/// </summary>
|
||||
public sealed class DriverPageJsonConverterTests
|
||||
{
|
||||
/// <summary>Every concrete <c>*DriverForm</c> in the AdminUI assembly that declares a
|
||||
/// <c>_jsonOpts</c> config serializer.</summary>
|
||||
/// <summary>
|
||||
/// Driver forms whose config serializer does NOT live in a private static <c>_jsonOpts</c> field
|
||||
/// on the form, mapped to the options instance they actually use. Each entry is an explicit,
|
||||
/// reviewed exception — the guard below still asserts the converter on whatever instance is
|
||||
/// named here, so this narrows <em>where</em> the options live, never <em>whether</em> they are
|
||||
/// checked.
|
||||
/// <para><c>MqttDriverForm</c>: the MQTT driver deliberately keeps <b>one</b>
|
||||
/// <see cref="JsonSerializerOptions"/> for all five of its config seams (runtime factory,
|
||||
/// Test-connect probe, driver re-parse, address-picker browser, this form) in
|
||||
/// <c>MqttJson.Options</c> — a per-form copy would be a fifth divergent instance, i.e. exactly
|
||||
/// the bug this whole file guards. The form itself is a shell over the pure
|
||||
/// <c>MqttDriverFormModel</c>, which serialises through that shared instance.</para>
|
||||
/// </summary>
|
||||
private static IReadOnlyDictionary<Type, JsonSerializerOptions> ExternalConfigSerializers { get; } =
|
||||
new Dictionary<Type, JsonSerializerOptions>
|
||||
{
|
||||
[typeof(MqttDriverForm)] = MqttJson.Options,
|
||||
};
|
||||
|
||||
/// <summary>Every concrete <c>*DriverForm</c> in the AdminUI assembly.</summary>
|
||||
private static IReadOnlyList<Type> DriverFormTypes { get; } =
|
||||
typeof(ModbusDriverForm).Assembly.GetTypes()
|
||||
.Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract)
|
||||
.Where(t => t.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is not null)
|
||||
.OrderBy(t => t.Name)
|
||||
.ToList();
|
||||
|
||||
/// <summary>Resolves a form's config serializer — its own <c>_jsonOpts</c> field, else the
|
||||
/// explicitly-registered external instance — or <c>null</c> when it has neither.</summary>
|
||||
/// <param name="formType">A driver form component type.</param>
|
||||
/// <returns>The options the form serialises config through, or <c>null</c>.</returns>
|
||||
private static JsonSerializerOptions? ResolveConfigSerializer(Type formType)
|
||||
{
|
||||
if (formType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is { } field)
|
||||
{
|
||||
return (JsonSerializerOptions?)field.GetValue(null);
|
||||
}
|
||||
return ExternalConfigSerializers.GetValueOrDefault(formType);
|
||||
}
|
||||
|
||||
/// <summary>xUnit theory source over the driver-form types discovered by reflection.</summary>
|
||||
public static TheoryData<Type> DriverFormsWithJsonOpts()
|
||||
{
|
||||
@@ -48,24 +79,28 @@ public sealed class DriverPageJsonConverterTests
|
||||
[MemberData(nameof(DriverFormsWithJsonOpts))]
|
||||
public void Driver_form_json_options_register_string_enum_converter(Type formType)
|
||||
{
|
||||
var field = formType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
var opts = (JsonSerializerOptions)field!.GetValue(null)!;
|
||||
var opts = ResolveConfigSerializer(formType);
|
||||
opts.ShouldNotBeNull(
|
||||
$"{formType.Name} must serialise config through a private static _jsonOpts field, or be listed in " +
|
||||
"ExternalConfigSerializers with the shared options instance it uses — otherwise its enum handling " +
|
||||
"is unguarded.");
|
||||
opts.Converters.OfType<JsonStringEnumConverter>().ShouldNotBeEmpty(
|
||||
$"{formType.Name}._jsonOpts must register a JsonStringEnumConverter; otherwise AdminUI-authored " +
|
||||
"enum config fields serialise as numbers and the string-typed driver factory throws on parse.");
|
||||
$"{formType.Name}'s config serializer must register a JsonStringEnumConverter; otherwise " +
|
||||
"AdminUI-authored enum config fields serialise as numbers and the string-typed driver factory " +
|
||||
"throws on parse.");
|
||||
}
|
||||
|
||||
/// <summary>Enforces that EVERY concrete <c>*DriverForm</c> routes config serialization through a
|
||||
/// <c>_jsonOpts</c> field — otherwise a new form that serialised config a different way would slip
|
||||
/// past the converter guard above. Also a floor check so a rename can't silently shrink the set.</summary>
|
||||
/// serializer the guard above can reach — otherwise a new form that serialised config a different way
|
||||
/// would slip past it. Also a floor check so a rename can't silently shrink the set.</summary>
|
||||
[Fact]
|
||||
public void Every_driver_form_uses_a_guarded_jsonOpts_serializer()
|
||||
{
|
||||
var allDriverForms = typeof(ModbusDriverForm).Assembly.GetTypes()
|
||||
.Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract)
|
||||
.ToList();
|
||||
allDriverForms.Count.ShouldBeGreaterThanOrEqualTo(8, "reflection should discover the full driver-form fleet");
|
||||
DriverFormTypes.Count.ShouldBe(allDriverForms.Count,
|
||||
"every *DriverForm must declare a _jsonOpts config serializer so the string-enum converter guard covers it");
|
||||
DriverFormTypes.Count.ShouldBeGreaterThanOrEqualTo(9, "reflection should discover the full driver-form fleet");
|
||||
|
||||
var unguarded = DriverFormTypes.Where(t => ResolveConfigSerializer(t) is null).Select(t => t.Name).ToList();
|
||||
unguarded.ShouldBeEmpty(
|
||||
"every *DriverForm must expose its config serializer (a _jsonOpts field, or an ExternalConfigSerializers " +
|
||||
"entry) so the string-enum converter guard covers it");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the <c>/raw</c> "New driver" picker against driver-type drift.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="RawDriverTypeDialog"/> is the <b>only</b> surface an operator can author a
|
||||
/// <c>/raw</c> driver from, and its option list is a hand-maintained array — nothing tied it to
|
||||
/// <see cref="DriverTypeNames"/>. The MQTT P1 live gate found the consequence: the driver shipped
|
||||
/// complete (contracts, driver, browser, factory + host registration, typed tag editor, 266 green
|
||||
/// unit tests) and was still <b>unauthorable in production</b>, because nobody added the row to
|
||||
/// this array. Every offline test passed the whole way — the array is not reachable from any of
|
||||
/// them, and AdminUI has no bUnit.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So this asserts parity by reflection rather than re-listing the drivers: a new
|
||||
/// <see cref="DriverTypeNames"/> constant that nobody surfaces in the picker fails here instead of
|
||||
/// at someone's live gate. It reads the private static <c>Types</c> field because that array is
|
||||
/// the component's own source of truth for the rendered <c><option></c> set.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RawDriverTypeDialogParityTests
|
||||
{
|
||||
private static IReadOnlyList<(string Label, string Value)> DialogTypes()
|
||||
{
|
||||
var field = typeof(RawDriverTypeDialog).GetField(
|
||||
"Types",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
field.ShouldNotBeNull(
|
||||
"RawDriverTypeDialog.Types was renamed or removed — this guard reads it by name and " +
|
||||
"silently guards nothing once it cannot find it.");
|
||||
|
||||
var raw = (Array)field!.GetValue(null)!;
|
||||
|
||||
return raw.Cast<object>()
|
||||
.Select(t =>
|
||||
{
|
||||
var type = t.GetType();
|
||||
return ((string)type.GetField("Item1")!.GetValue(t)!,
|
||||
(string)type.GetField("Item2")!.GetValue(t)!);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_declared_driver_type_is_offered_by_the_new_driver_picker()
|
||||
{
|
||||
var offered = DialogTypes().Select(t => t.Value).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = DriverTypeNames.All.Where(n => !offered.Contains(n)).ToList();
|
||||
|
||||
missing.ShouldBeEmpty(
|
||||
$"These driver types are declared in DriverTypeNames but cannot be authored from the " +
|
||||
$"/raw New-driver picker, so they are unreachable in production: {string.Join(", ", missing)}. " +
|
||||
"Add a row to RawDriverTypeDialog.Types.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_picker_offers_no_driver_type_that_is_not_declared()
|
||||
{
|
||||
var declared = DriverTypeNames.All.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var unknown = DialogTypes().Select(t => t.Value).Where(v => !declared.Contains(v)).ToList();
|
||||
|
||||
unknown.ShouldBeEmpty(
|
||||
$"The /raw New-driver picker offers driver types with no DriverTypeNames constant, so " +
|
||||
$"authoring one produces a driver no factory can build: {string.Join(", ", unknown)}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mqtt_is_authorable_from_the_new_driver_picker()
|
||||
{
|
||||
// The specific regression the P1 live gate caught, pinned by name so a future refactor of the
|
||||
// parity facts above cannot quietly drop MQTT again.
|
||||
DialogTypes().Select(t => t.Value).ShouldContain(DriverTypeNames.Mqtt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Guards for the MQTT <b>device</b> model. MQTT holds ONE broker connection per driver, so — like
|
||||
/// Galaxy — the device carries no endpoint of its own and the model round-trips its DeviceConfig
|
||||
/// verbatim. The one behaviour it does add is detecting <i>legacy</i> connection keys left on a
|
||||
/// DeviceConfig by a pre-form (hand-authored / SQL-seeded) deployment, because
|
||||
/// <c>DriverDeviceConfigMerger</c> merges a sole device's keys UP over the driver's and they would
|
||||
/// otherwise silently win over what the driver form shows.
|
||||
/// </summary>
|
||||
public sealed class MqttDeviceModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Round_trips_every_key_verbatim()
|
||||
{
|
||||
const string inbound = """{"Host":"10.100.0.35","Port":8883,"anything":{"nested":true}}""";
|
||||
|
||||
var json = MqttDeviceModel.FromJson(inbound).ToJson();
|
||||
var o = (JsonObject)JsonNode.Parse(json)!;
|
||||
|
||||
o["Host"]!.GetValue<string>().ShouldBe("10.100.0.35");
|
||||
o["Port"]!.GetValue<int>().ShouldBe(8883);
|
||||
o["anything"]!["nested"]!.GetValue<bool>().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_device_serializes_to_an_empty_object()
|
||||
=> MqttDeviceModel.FromJson(null).ToJson().ShouldBe("{}");
|
||||
|
||||
[Fact]
|
||||
public void A_malformed_blob_degrades_to_an_empty_object_rather_than_throwing()
|
||||
=> MqttDeviceModel.FromJson("}{ not json").ToJson().ShouldBe("{}");
|
||||
|
||||
[Fact]
|
||||
public void Validate_always_passes_because_there_is_no_per_device_endpoint()
|
||||
=> MqttDeviceModel.FromJson("""{"Host":"x"}""").Validate().ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void Legacy_connection_keys_are_reported_case_insensitively()
|
||||
{
|
||||
var model = MqttDeviceModel.FromJson("""{"host":"h","PORT":1883,"useTls":false,"unrelated":1}""");
|
||||
|
||||
model.LegacyConnectionKeys.ShouldBe(["host", "PORT", "useTls"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_device_reports_no_legacy_connection_keys()
|
||||
=> MqttDeviceModel.FromJson("{}").LegacyConnectionKeys.ShouldBeEmpty();
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip, enum-serialization and validation guards for the MQTT <b>driver</b> config form model.
|
||||
/// <para>
|
||||
/// The load-bearing assertion is <see cref="Serialized_blob_binds_onto_MqttDriverOptions_through_the_shared_MqttJson_options"/>:
|
||||
/// it deserializes this form's output through <see cref="MqttJson.Options"/> — the exact instance
|
||||
/// <c>MqttDriverFactoryExtensions.CreateInstance</c> and <c>MqttDriverProbe</c> use — so an
|
||||
/// AdminUI-authored blob that Test-connect accepts cannot fault the deployed driver. That is this
|
||||
/// repo's documented systemic driver-enum-serialization bug, reproduced as a guard.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MqttDriverFormModelTests
|
||||
{
|
||||
private static JsonObject Parse(string json) => (JsonObject)JsonNode.Parse(json)!;
|
||||
|
||||
private static bool HasKeyIgnoringCase(JsonObject o, string name)
|
||||
=> o.Any(p => string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// --- defaults -------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Defaults_match_the_driver_options_secure_posture()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
var driverDefaults = new MqttDriverOptions();
|
||||
|
||||
model.Port.ShouldBe(driverDefaults.Port);
|
||||
model.UseTls.ShouldBeTrue();
|
||||
model.UseTls.ShouldBe(driverDefaults.UseTls);
|
||||
model.AllowUntrustedServerCertificate.ShouldBeFalse();
|
||||
model.AllowUntrustedServerCertificate.ShouldBe(driverDefaults.AllowUntrustedServerCertificate);
|
||||
model.ProtocolVersion.ShouldBe(driverDefaults.ProtocolVersion);
|
||||
model.Mode.ShouldBe(driverDefaults.Mode);
|
||||
model.MaxPayloadBytes.ShouldBe(driverDefaults.MaxPayloadBytes);
|
||||
|
||||
// The redundancy-pair eviction guard: an unauthored driver leaves clientId unset so MQTTnet
|
||||
// generates a unique id per connection (P1 live-gate finding).
|
||||
model.ClientId.ShouldBe("");
|
||||
}
|
||||
|
||||
// --- round-trip -----------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_every_authored_field()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
model.Host = "broker.internal";
|
||||
model.Port = 1883;
|
||||
model.ClientId = "otopcua-site-a";
|
||||
model.Username = "otopcua";
|
||||
model.Password = "s3cret";
|
||||
model.UseTls = false;
|
||||
model.AllowUntrustedServerCertificate = true;
|
||||
model.CaCertificatePath = "/etc/ssl/ca.pem";
|
||||
model.ProtocolVersion = MqttProtocolVersion.V311;
|
||||
model.CleanSession = false;
|
||||
model.KeepAliveSeconds = 45;
|
||||
model.ConnectTimeoutSeconds = 9;
|
||||
model.ReconnectMinBackoffSeconds = 2;
|
||||
model.ReconnectMaxBackoffSeconds = 60;
|
||||
model.Mode = MqttMode.Plain;
|
||||
model.MaxPayloadBytes = 4096;
|
||||
model.TopicPrefix = "otopcua/fixture/";
|
||||
model.DefaultQos = 2;
|
||||
|
||||
var back = MqttDriverFormModel.FromJson(model.ToJson());
|
||||
|
||||
back.Host.ShouldBe("broker.internal");
|
||||
back.Port.ShouldBe(1883);
|
||||
back.ClientId.ShouldBe("otopcua-site-a");
|
||||
back.Username.ShouldBe("otopcua");
|
||||
back.Password.ShouldBe("s3cret");
|
||||
back.UseTls.ShouldBeFalse();
|
||||
back.AllowUntrustedServerCertificate.ShouldBeTrue();
|
||||
back.CaCertificatePath.ShouldBe("/etc/ssl/ca.pem");
|
||||
back.ProtocolVersion.ShouldBe(MqttProtocolVersion.V311);
|
||||
back.CleanSession.ShouldBeFalse();
|
||||
back.KeepAliveSeconds.ShouldBe(45);
|
||||
back.ConnectTimeoutSeconds.ShouldBe(9);
|
||||
back.ReconnectMinBackoffSeconds.ShouldBe(2);
|
||||
back.ReconnectMaxBackoffSeconds.ShouldBe(60);
|
||||
back.Mode.ShouldBe(MqttMode.Plain);
|
||||
back.MaxPayloadBytes.ShouldBe(4096);
|
||||
back.TopicPrefix.ShouldBe("otopcua/fixture/");
|
||||
back.DefaultQos.ShouldBe(2);
|
||||
}
|
||||
|
||||
// --- the factory-parity guard (the point of the whole file) ---------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Serialized_blob_binds_onto_MqttDriverOptions_through_the_shared_MqttJson_options()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
model.Host = "10.100.0.35";
|
||||
model.Port = 8883;
|
||||
model.Username = "otopcua";
|
||||
model.Password = "pw";
|
||||
model.ProtocolVersion = MqttProtocolVersion.V311;
|
||||
model.Mode = MqttMode.SparkplugB;
|
||||
model.ConnectTimeoutSeconds = 7;
|
||||
model.DefaultQos = 0;
|
||||
|
||||
// The exact instance the runtime factory + probe + driver + browser parse through.
|
||||
var options = JsonSerializer.Deserialize<MqttDriverOptions>(model.ToJson(), MqttJson.Options);
|
||||
|
||||
options.ShouldNotBeNull();
|
||||
options.Host.ShouldBe("10.100.0.35");
|
||||
options.Port.ShouldBe(8883);
|
||||
options.Username.ShouldBe("otopcua");
|
||||
options.Password.ShouldBe("pw");
|
||||
options.ProtocolVersion.ShouldBe(MqttProtocolVersion.V311);
|
||||
options.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
options.ConnectTimeoutSeconds.ShouldBe(7);
|
||||
options.Plain.ShouldNotBeNull();
|
||||
options.Plain.DefaultQos.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enums_serialize_as_names_never_as_ordinals()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
model.Mode = MqttMode.SparkplugB;
|
||||
model.ProtocolVersion = MqttProtocolVersion.V311;
|
||||
|
||||
var json = model.ToJson();
|
||||
|
||||
json.ShouldContain("\"Mode\":\"SparkplugB\"");
|
||||
json.ShouldContain("\"ProtocolVersion\":\"V311\"");
|
||||
// The historical bug: a numerically-serialized enum that the string-typed runtime binder faults on.
|
||||
json.ShouldNotContain("\"Mode\":1", Case.Sensitive);
|
||||
json.ShouldNotContain("\"ProtocolVersion\":0", Case.Sensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ordinal_only_reader_cannot_bind_the_blob_which_proves_the_enums_are_names()
|
||||
{
|
||||
// The string-match-free half of the enum pin, and a falsifiability control for the test above.
|
||||
// A JsonSerializerOptions WITHOUT a JsonStringEnumConverter can read a NUMERIC enum but not a
|
||||
// named one — so if this form ever regressed to writing ordinals (the historical driver bug),
|
||||
// this deserialize would start SUCCEEDING and the test would fail. Deliberately independent of
|
||||
// MqttJson.Options, because a round-trip through one shared instance is symmetric and would
|
||||
// happily pass on ordinals at both ends.
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
model.Mode = MqttMode.SparkplugB;
|
||||
model.ProtocolVersion = MqttProtocolVersion.V311;
|
||||
|
||||
var ordinalOnlyReader = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
|
||||
Should.Throw<JsonException>(
|
||||
() => JsonSerializer.Deserialize<MqttDriverOptions>(model.ToJson(), ordinalOnlyReader));
|
||||
}
|
||||
|
||||
// --- blob hygiene ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RawTags_are_never_written_into_the_driver_config()
|
||||
{
|
||||
// The deploy artifact injects RawTags via DriverDeviceConfigMerger; a form-authored copy would
|
||||
// be dead weight at best and a stale shadow at worst.
|
||||
const string inbound = """{"Host":"h","rawTags":[{"RawPath":"a/b","TagConfig":"{}"}]}""";
|
||||
|
||||
var json = MqttDriverFormModel.FromJson(inbound).ToJson();
|
||||
|
||||
HasKeyIgnoringCase(Parse(json), "RawTags").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_keys_and_the_sparkplug_subobject_survive_a_load_save()
|
||||
{
|
||||
const string inbound = """
|
||||
{"Host":"h","sparkplug":{"GroupId":"G1","HostId":"H1","ActAsPrimaryHost":true},"aFutureKey":42}
|
||||
""";
|
||||
|
||||
var json = MqttDriverFormModel.FromJson(inbound).ToJson();
|
||||
var o = Parse(json);
|
||||
|
||||
// The blob has no Mode key, so it is Plain — and in Plain mode this form does not author the
|
||||
// Sparkplug sub-object at all, so it must come back byte-identical.
|
||||
o["sparkplug"].ShouldNotBeNull();
|
||||
o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("G1");
|
||||
o["sparkplug"]!["ActAsPrimaryHost"]!.GetValue<bool>().ShouldBeTrue();
|
||||
o["aFutureKey"]!.GetValue<int>().ShouldBe(42);
|
||||
}
|
||||
|
||||
// --- Sparkplug B authoring -------------------------------------------------------------------
|
||||
//
|
||||
// These pin the defect the P2 live gate found: MqttDriverForm shipped its Sparkplug branch as a
|
||||
// "not implemented yet" placeholder, so once Sparkplug ingest landed the group id — the driver's
|
||||
// ENTIRE subscription filter, spBv1.0/{GroupId}/# — could not be authored from the AdminUI at all.
|
||||
// The driver deployed connected, Healthy, and ingesting nothing.
|
||||
|
||||
[Fact]
|
||||
public void Sparkplug_mode_round_trips_the_group_id_through_a_load_save_load()
|
||||
{
|
||||
var authored = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
|
||||
authored.Mode = MqttMode.SparkplugB;
|
||||
authored.SparkplugGroupId = "OtOpcUaSim";
|
||||
authored.SparkplugRequestRebirthOnGap = true;
|
||||
|
||||
var reloaded = MqttDriverFormModel.FromJson(authored.ToJson());
|
||||
|
||||
reloaded.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
reloaded.SparkplugGroupId.ShouldBe("OtOpcUaSim");
|
||||
reloaded.SparkplugRequestRebirthOnGap.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sparkplug_mode_emits_a_group_id_the_driver_options_actually_bind()
|
||||
{
|
||||
// The whole point of the round-trip: what this form writes must deserialize back through the
|
||||
// SAME shared MqttJson.Options the runtime factory uses, or the driver sees a null group.
|
||||
var authored = MqttDriverFormModel.FromJson(null);
|
||||
authored.Mode = MqttMode.SparkplugB;
|
||||
authored.SparkplugGroupId = "OtOpcUaSim";
|
||||
|
||||
var options = JsonSerializer.Deserialize<MqttDriverOptions>(authored.ToJson(), MqttJson.Options)!;
|
||||
|
||||
options.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
options.Sparkplug.ShouldNotBeNull();
|
||||
options.Sparkplug!.GroupId.ShouldBe("OtOpcUaSim");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sparkplug_authoring_merges_over_the_subobject_rather_than_replacing_it()
|
||||
{
|
||||
// Same preserve-what-you-do-not-author discipline the top-level bag applies: a key a newer
|
||||
// driver adds INSIDE the sub-object must survive an older AdminUI's save.
|
||||
const string inbound = """
|
||||
{"Host":"h","Mode":"SparkplugB","sparkplug":{"GroupId":"Old","aFutureSpbKey":7}}
|
||||
""";
|
||||
|
||||
var model = MqttDriverFormModel.FromJson(inbound);
|
||||
model.SparkplugGroupId = "New";
|
||||
|
||||
var o = Parse(model.ToJson());
|
||||
|
||||
o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("New");
|
||||
o["sparkplug"]!["aFutureSpbKey"]!.GetValue<int>().ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_camelCase_sparkplug_key_is_merged_not_duplicated()
|
||||
{
|
||||
const string inbound = """{"Host":"h","Mode":"SparkplugB","sparkplug":{"GroupId":"Old"}}""";
|
||||
|
||||
var model = MqttDriverFormModel.FromJson(inbound);
|
||||
model.SparkplugGroupId = "New";
|
||||
|
||||
var o = Parse(model.ToJson());
|
||||
|
||||
o.Count(p => string.Equals(p.Key, "Sparkplug", StringComparison.OrdinalIgnoreCase)).ShouldBe(1);
|
||||
o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("New");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plain_mode_never_writes_a_sparkplug_subobject_onto_a_blob_that_had_none()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
|
||||
model.SparkplugGroupId = "TypedThenSwitchedAway"; // Mode is still Plain.
|
||||
|
||||
HasKeyIgnoringCase(Parse(model.ToJson()), "Sparkplug").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_blank_group_id_is_refused_in_sparkplug_mode_only()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
|
||||
|
||||
// Plain does not care — it binds by topic.
|
||||
model.Validate().ShouldBeNull();
|
||||
|
||||
model.Mode = MqttMode.SparkplugB;
|
||||
model.Validate().ShouldNotBeNull();
|
||||
model.Validate()!.ShouldContain("Sparkplug group ID is required");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Plant/1")]
|
||||
[InlineData("Plant+1")]
|
||||
[InlineData("Plant#1")]
|
||||
public void A_group_id_carrying_a_topic_metacharacter_is_refused(string groupId)
|
||||
{
|
||||
// It is a literal segment inside spBv1.0/{GroupId}/#, so '/' silently widens the filter and
|
||||
// '+'/'#' are not even legal mid-segment. Mirrors MqttTagConfigModel's tag-side rule.
|
||||
var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
|
||||
model.Mode = MqttMode.SparkplugB;
|
||||
model.SparkplugGroupId = groupId;
|
||||
|
||||
model.Validate()!.ShouldContain("cannot appear in an MQTT topic segment");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_camelCase_inbound_key_is_replaced_not_duplicated()
|
||||
{
|
||||
// MqttJson.Options is PropertyNameCaseInsensitive, so a hand-edited camelCase blob binds; the
|
||||
// form must overwrite that key rather than leave "host" and "Host" fighting in one object.
|
||||
const string inbound = """{"host":"old","port":1111}""";
|
||||
|
||||
var model = MqttDriverFormModel.FromJson(inbound);
|
||||
model.Host.ShouldBe("old");
|
||||
model.Port.ShouldBe(1111);
|
||||
model.Host = "new";
|
||||
|
||||
var o = Parse(model.ToJson());
|
||||
|
||||
o.Count(p => string.Equals(p.Key, "Host", StringComparison.OrdinalIgnoreCase)).ShouldBe(1);
|
||||
JsonSerializer.Deserialize<MqttDriverOptions>(o.ToJsonString(), MqttJson.Options)!.Host.ShouldBe("new");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blank_optional_strings_are_omitted_so_the_driver_defaults_apply()
|
||||
{
|
||||
var json = MqttDriverFormModel.FromJson(null).ToJson();
|
||||
var options = JsonSerializer.Deserialize<MqttDriverOptions>(json, MqttJson.Options)!;
|
||||
|
||||
options.ClientId.ShouldBeNull();
|
||||
options.Username.ShouldBeNull();
|
||||
options.CaCertificatePath.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_merged_deploy_blob_keeps_the_form_connection_and_gains_the_artifact_RawTags()
|
||||
{
|
||||
// The real deploy seam: DriverDeviceConfigMerger folds this form's DriverConfig together with the
|
||||
// (empty) MQTT DeviceConfig and injects the authored raw tags. MQTT authors its connection on the
|
||||
// DRIVER precisely so this survives 0, 1 or N devices.
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
model.Host = "broker.internal";
|
||||
model.Port = 1883;
|
||||
model.UseTls = false;
|
||||
model.Mode = MqttMode.Plain;
|
||||
model.DefaultQos = 2;
|
||||
|
||||
var rawTags = new[]
|
||||
{
|
||||
new RawTagEntry("Plant/Mqtt/Dev1/Temp", """{"topic":"a/b"}""", WriteIdempotent: false, DeviceName: "Dev1"),
|
||||
};
|
||||
|
||||
foreach (var devices in new[]
|
||||
{
|
||||
Array.Empty<DriverDeviceConfigMerger.DeviceRow>(),
|
||||
[new DriverDeviceConfigMerger.DeviceRow("Dev1", "{}")],
|
||||
[new DriverDeviceConfigMerger.DeviceRow("Dev1", "{}"), new DriverDeviceConfigMerger.DeviceRow("Dev2", "{}")],
|
||||
})
|
||||
{
|
||||
var merged = DriverDeviceConfigMerger.Merge(model.ToJson(), devices, rawTags);
|
||||
var options = JsonSerializer.Deserialize<MqttDriverOptions>(merged, MqttJson.Options)!;
|
||||
|
||||
options.Host.ShouldBe("broker.internal", $"device count {devices.Length}");
|
||||
options.Port.ShouldBe(1883, $"device count {devices.Length}");
|
||||
options.UseTls.ShouldBeFalse($"device count {devices.Length}");
|
||||
options.Plain!.DefaultQos.ShouldBe(2, $"device count {devices.Length}");
|
||||
options.RawTags.Count.ShouldBe(1, $"device count {devices.Length}");
|
||||
options.RawTags[0].RawPath.ShouldBe("Plant/Mqtt/Dev1/Temp");
|
||||
}
|
||||
}
|
||||
|
||||
// --- validation -----------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Validate_accepts_a_default_model()
|
||||
=> MqttDriverFormModel.FromJson(null).Validate().ShouldBeNull();
|
||||
|
||||
[Theory]
|
||||
[InlineData("Host", "")]
|
||||
[InlineData("Port", 0)]
|
||||
[InlineData("Port", 70000)]
|
||||
[InlineData("KeepAliveSeconds", 0)]
|
||||
[InlineData("ConnectTimeoutSeconds", 0)]
|
||||
[InlineData("ReconnectMinBackoffSeconds", 0)]
|
||||
[InlineData("ReconnectMaxBackoffSeconds", -1)]
|
||||
[InlineData("MaxPayloadBytes", 0)]
|
||||
[InlineData("DefaultQos", 3)]
|
||||
[InlineData("DefaultQos", -1)]
|
||||
public void Validate_rejects_an_out_of_range_field(string field, object value)
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
typeof(MqttDriverFormModel).GetProperty(field)!.SetValue(model, value);
|
||||
|
||||
model.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_rejects_a_max_backoff_below_the_min()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
model.ReconnectMinBackoffSeconds = 30;
|
||||
model.ReconnectMaxBackoffSeconds = 5;
|
||||
|
||||
model.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ignored_validation_error_still_cannot_produce_a_driver_bricking_blob()
|
||||
{
|
||||
// Documented finding: `timeoutSeconds: 0` was an operator-authorable driver-brick. Validate()
|
||||
// reports it; the serializer clamps it, so even a saved-anyway blob stays inside the driver's
|
||||
// own [Range] bounds.
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
model.ConnectTimeoutSeconds = 0;
|
||||
model.KeepAliveSeconds = -5;
|
||||
model.ReconnectMinBackoffSeconds = 0;
|
||||
model.ReconnectMaxBackoffSeconds = -3;
|
||||
model.MaxPayloadBytes = 0;
|
||||
model.Port = 99999;
|
||||
model.DefaultQos = 9;
|
||||
|
||||
var options = JsonSerializer.Deserialize<MqttDriverOptions>(model.ToJson(), MqttJson.Options)!;
|
||||
|
||||
options.ConnectTimeoutSeconds.ShouldBeGreaterThanOrEqualTo(1);
|
||||
options.KeepAliveSeconds.ShouldBeGreaterThanOrEqualTo(1);
|
||||
options.ReconnectMinBackoffSeconds.ShouldBeGreaterThanOrEqualTo(1);
|
||||
options.ReconnectMaxBackoffSeconds.ShouldBeGreaterThanOrEqualTo(1);
|
||||
options.MaxPayloadBytes.ShouldBeGreaterThanOrEqualTo(1);
|
||||
options.Port.ShouldBeInRange(1, 65535);
|
||||
options.Plain!.DefaultQos.ShouldBeInRange(0, 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_malformed_inbound_blob_degrades_to_defaults_rather_than_throwing()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson("}{ not json");
|
||||
|
||||
model.Host.ShouldBe(new MqttDriverOptions().Host);
|
||||
model.UseTls.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
// Typed AdminUI editor model for the Mqtt driver — Plain (topic-bound) and Sparkplug B (birth/alias
|
||||
// ingest) modes. The model is a thin typed view over a preserved JsonObject key bag: every key the
|
||||
// editor does not expose (history intent, array config, alarm objects) must survive a load→save
|
||||
// untouched.
|
||||
//
|
||||
// The authoritative consumers of the produced blob are MqttTagDefinitionFactory.FromTagConfig (Plain)
|
||||
// and .FromSparkplugTagConfig (Sparkplug B) (Driver.Mqtt.Contracts) — the key names + strictness rules
|
||||
// asserted here are those factories', not this editor's invention. TagConfig carries NO identity under
|
||||
// v3 (the tag's RawPath is its identity), so there is deliberately no FullName key — in EITHER mode.
|
||||
public sealed class MqttTagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("not json at all")]
|
||||
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||
{
|
||||
var m = MqttTagConfigModel.FromJson(json);
|
||||
|
||||
m.Mode.ShouldBe(MqttMode.Plain);
|
||||
m.Topic.ShouldBe("");
|
||||
m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
|
||||
// An UNAUTHORED tag seeds the document-root path so the common "no extraction needed" case is
|
||||
// visible and one click away — it is a guide, not a requirement (blank is accepted, see below).
|
||||
m.JsonPath.ShouldBe("$");
|
||||
m.DataType.ShouldBe(DriverDataType.String);
|
||||
m.Qos.ShouldBeNull();
|
||||
m.RetainSeed.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_reads_every_plain_field()
|
||||
{
|
||||
var m = MqttTagConfigModel.FromJson(
|
||||
"""{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1,"retainSeed":false}""");
|
||||
|
||||
m.Mode.ShouldBe(MqttMode.Plain);
|
||||
m.Topic.ShouldBe("factory/oven/temp");
|
||||
m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
|
||||
m.JsonPath.ShouldBe("$.value");
|
||||
m.DataType.ShouldBe(DriverDataType.Float64);
|
||||
m.Qos.ShouldBe(1);
|
||||
m.RetainSeed.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_every_plain_field()
|
||||
{
|
||||
var m = new MqttTagConfigModel
|
||||
{
|
||||
Topic = "line3/press",
|
||||
PayloadFormat = MqttPayloadFormat.Scalar,
|
||||
JsonPath = "$.a.b",
|
||||
DataType = DriverDataType.Int32,
|
||||
Qos = 2,
|
||||
RetainSeed = true,
|
||||
};
|
||||
|
||||
var m2 = MqttTagConfigModel.FromJson(m.ToJson());
|
||||
|
||||
m2.Topic.ShouldBe("line3/press");
|
||||
m2.PayloadFormat.ShouldBe(MqttPayloadFormat.Scalar);
|
||||
m2.JsonPath.ShouldBe("$.a.b");
|
||||
m2.DataType.ShouldBe(DriverDataType.Int32);
|
||||
m2.Qos.ShouldBe(2);
|
||||
m2.RetainSeed.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_emits_camelCase_keys_with_enum_names_and_no_identity_key()
|
||||
{
|
||||
var json = new MqttTagConfigModel
|
||||
{
|
||||
Topic = "a/b",
|
||||
PayloadFormat = MqttPayloadFormat.Raw,
|
||||
DataType = DriverDataType.Boolean,
|
||||
}.ToJson();
|
||||
|
||||
json.ShouldContain("\"topic\":\"a/b\"");
|
||||
// Enums MUST serialize as names — the driver factory reads them strictly by name, and an
|
||||
// ordinal would be rejected outright (the systemic enum-serialization trap).
|
||||
json.ShouldContain("\"payloadFormat\":\"Raw\"");
|
||||
json.ShouldContain("\"dataType\":\"Boolean\"");
|
||||
json.ShouldNotContain("\"payloadFormat\":1");
|
||||
json.ShouldNotContain("\"dataType\":0");
|
||||
// TagConfig carries no identity under v3 — the tag's RawPath is the driver-side key.
|
||||
json.ShouldNotContain("FullName", Case.Sensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_omits_absent_optional_keys()
|
||||
{
|
||||
var json = new MqttTagConfigModel { Topic = "a/b", PayloadFormat = MqttPayloadFormat.Raw }.ToJson();
|
||||
|
||||
// Absent stays absent so the driver's own defaults (qos ⇒ DefaultQos, retainSeed ⇒ true,
|
||||
// jsonPath ⇒ "$") apply, rather than this editor freezing them into the blob.
|
||||
json.ShouldNotContain("qos");
|
||||
json.ShouldNotContain("retainSeed");
|
||||
json.ShouldNotContain("jsonPath");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_omits_a_blank_topic()
|
||||
{
|
||||
// Toggling the shape dropdown on an untouched tag must not leave a stray "topic":"" behind.
|
||||
var json = new MqttTagConfigModel { PayloadFormat = MqttPayloadFormat.Raw }.ToJson();
|
||||
|
||||
json.ShouldNotContain("topic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||
{
|
||||
var json = MqttTagConfigModel.FromJson(
|
||||
"""
|
||||
{"topic":"a/b","payloadFormat":"Raw","dataType":"String",
|
||||
"isHistorized":true,"historianTagname":"Plant.A.B",
|
||||
"array":{"valueRank":1,"dimensions":[4]},
|
||||
"alarm":{"type":"Hi","limit":90.5},
|
||||
"someFutureScalar":"keep me"}
|
||||
""").ToJson();
|
||||
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
// History intent is authored by a different seam (TagHistorizeConfig) — dropping it here would
|
||||
// silently un-historize the tag on the next edit.
|
||||
o["isHistorized"]!.GetValue<bool>().ShouldBeTrue();
|
||||
o["historianTagname"]!.GetValue<string>().ShouldBe("Plant.A.B");
|
||||
// Nested objects/arrays must survive structurally, not just as scalars.
|
||||
o["array"]!["valueRank"]!.GetValue<int>().ShouldBe(1);
|
||||
o["array"]!["dimensions"]!.AsArray()[0]!.GetValue<int>().ShouldBe(4);
|
||||
o["alarm"]!["limit"]!.GetValue<double>().ShouldBe(90.5);
|
||||
o["someFutureScalar"]!.GetValue<string>().ShouldBe("keep me");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_sparkplug_descriptor_keys()
|
||||
{
|
||||
var json = MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"E1","deviceId":"D1","metricName":"Temp"}""").ToJson();
|
||||
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o["groupId"]!.GetValue<string>().ShouldBe("Plant1");
|
||||
o["edgeNodeId"]!.GetValue<string>().ShouldBe("E1");
|
||||
o["deviceId"]!.GetValue<string>().ShouldBe("D1");
|
||||
o["metricName"]!.GetValue<string>().ShouldBe("Temp");
|
||||
// TagConfig carries no identity under v3 even for a Sparkplug tag — see the corrections in the
|
||||
// Task 24 brief: FullName is a retired pre-v3 concept, not something this editor re-introduces.
|
||||
json.ShouldNotContain("FullName", Case.Sensitive);
|
||||
}
|
||||
|
||||
// ── Validate: Sparkplug rules (Task 24) ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirrors MqttTagDefinitionFactory.FromSparkplugTagConfig exactly: groupId/edgeNodeId/metricName
|
||||
// required, deviceId optional, dataType/qos read strictly but only when present. See the model's
|
||||
// Validate() remarks for the one place this editor is deliberately STRICTER than the factory
|
||||
// (illegal characters in the id segments) and why metricName is exempt from that rule.
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugMissingMetricName_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugMissingGroupId_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugMissingEdgeNodeId_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","metricName":"Temperature"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugValid_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float32"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// deviceId is genuinely optional per the factory (absent ⇒ a node-level metric) — a Sparkplug tag
|
||||
// must validate cleanly without one.
|
||||
[Fact]
|
||||
public void Validate_SparkplugValidWithoutDeviceId_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// dataType is OPTIONAL in Sparkplug mode (the birth certificate declares it) — unlike Plain mode,
|
||||
// an absent dataType is not an error.
|
||||
[Fact]
|
||||
public void Validate_SparkplugAbsentDataType_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// ...but a PRESENT, typo'd dataType is still rejected — same strict-when-present rule as Plain,
|
||||
// and the same DriverDataType vocabulary (not the raw Sparkplug wire enum).
|
||||
[Fact]
|
||||
public void Validate_SparkplugTypoedDataType_Fails()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature","dataType":"Double"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Theory]
|
||||
[InlineData("3")]
|
||||
[InlineData("-1")]
|
||||
public void Validate_SparkplugInvalidQos_Fails(string qosLiteral)
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
$$"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature","qos":{{qosLiteral}}}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
// Editor-stricter-than-factory: group/edge-node/device ids are literal MQTT topic segments — a
|
||||
// decoded incoming id can never contain '/', '+', or '#', so an authored one that does could never
|
||||
// bind. Covers all three id fields plus both illegal characters.
|
||||
[Theory]
|
||||
[InlineData("groupId", "Plant1/A")]
|
||||
[InlineData("groupId", "Plant+1")]
|
||||
[InlineData("groupId", "Plant#1")]
|
||||
[InlineData("edgeNodeId", "Edge/A")]
|
||||
[InlineData("edgeNodeId", "Edge+A")]
|
||||
[InlineData("deviceId", "Dev/1")]
|
||||
[InlineData("deviceId", "Dev#1")]
|
||||
public void Validate_SparkplugIllegalCharacterInIdSegment_Fails(string field, string value)
|
||||
{
|
||||
var fields = new Dictionary<string, string>
|
||||
{
|
||||
["groupId"] = "Plant1",
|
||||
["edgeNodeId"] = "EdgeA",
|
||||
["deviceId"] = "Filler1",
|
||||
["metricName"] = "Temperature",
|
||||
};
|
||||
fields[field] = value;
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(fields);
|
||||
|
||||
MqttTagConfigModel.FromJson(json).Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
// metricName is explicitly EXEMPT from the id-segment character rule — it is not a topic segment
|
||||
// (it comes from the payload, not the topic), and Sparkplug's own canonical examples use '/' in
|
||||
// metric names (e.g. "Node Control/Rebirth"). Over-rejecting here would block legitimate authoring.
|
||||
[Fact]
|
||||
public void Validate_SparkplugMetricNameWithSlash_IsAccepted()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Node Control/Rebirth"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// ── ToJson: Sparkplug dataType override (Task 24) ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ToJson_SparkplugWithoutOverride_OmitsDataType()
|
||||
{
|
||||
var json = MqttTagConfigModel
|
||||
.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldNotContain("dataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_Sparkplug_RoundTripsDescriptorFieldsAndDataTypeOverride()
|
||||
{
|
||||
var json = MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float32"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("\"groupId\":\"Plant1\"");
|
||||
json.ShouldContain("\"edgeNodeId\":\"EdgeA\"");
|
||||
json.ShouldContain("\"deviceId\":\"Filler1\"");
|
||||
json.ShouldContain("\"metricName\":\"Temperature\"");
|
||||
json.ShouldContain("\"dataType\":\"Float32\"");
|
||||
json.ShouldNotContain("FullName", Case.Sensitive);
|
||||
}
|
||||
|
||||
// A SparkplugB tag has NO 'mode' key to persist — it survives a save→reopen purely because the
|
||||
// descriptor keys it writes re-infer the mode on the next load.
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_then_FromJson_SparkplugMode_SurvivesReopen()
|
||||
{
|
||||
var m = MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""");
|
||||
m.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
|
||||
var json = m.ToJson();
|
||||
json.ShouldNotContain("\"mode\"");
|
||||
|
||||
MqttTagConfigModel.FromJson(json).Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_infers_SparkplugB_mode_from_the_descriptor_keys()
|
||||
=> MqttTagConfigModel
|
||||
.FromJson("""{"groupId":"Plant1","edgeNodeId":"E1","metricName":"Temp"}""")
|
||||
.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
|
||||
// ── Validate: Plain rules ────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidPlain_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingTopic_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"payloadFormat":"Raw","dataType":"String"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Theory]
|
||||
[InlineData("a/+/c")]
|
||||
[InlineData("a/#")]
|
||||
public void Validate_PlainWildcardTopic_Fails(string topic)
|
||||
=> MqttTagConfigModel.FromJson($$"""{"topic":"{{topic}}","payloadFormat":"Raw","dataType":"String"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
// A blank jsonPath under a Json payload is ACCEPTED — the runtime defaults it to the document root
|
||||
// ("$"), which is the real "publisher puts a bare JSON scalar on the topic" case. Rejecting it would
|
||||
// make the editor refuse what the driver accepts, and would block CSV-import batches (this validator
|
||||
// also gates RawManualTagEntryModal's review grid) over a sane default.
|
||||
[Fact]
|
||||
public void Validate_JsonWithoutPath_IsAccepted()
|
||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// …and the key stays ABSENT through a load→save, so the driver applies its own default rather than
|
||||
// this editor freezing "$" into an already-deployed blob.
|
||||
[Fact]
|
||||
public void ToJson_leaves_an_existing_blank_jsonPath_absent()
|
||||
{
|
||||
var json = MqttTagConfigModel
|
||||
.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldNotContain("jsonPath");
|
||||
}
|
||||
|
||||
// The seed applies ONLY to an unauthored tag (no topic AND no jsonPath) — a brand-new tag emits an
|
||||
// explicit "$" once the operator touches anything, which is the guided half of the deal.
|
||||
[Fact]
|
||||
public void ToJson_seeds_the_root_jsonPath_on_a_fresh_tag()
|
||||
{
|
||||
var m = MqttTagConfigModel.FromJson(null);
|
||||
m.Topic = "a/b";
|
||||
|
||||
m.ToJson().ShouldContain("\"jsonPath\":\"$\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_JsonWithPath_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$","dataType":"Float64"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void Validate_NonJsonFormat_DoesNotRequireJsonPath()
|
||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Scalar","dataType":"Float64"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
[Theory]
|
||||
[InlineData("3")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("\"high\"")]
|
||||
[InlineData("1.5")]
|
||||
public void Validate_InvalidQos_Fails(string qosLiteral)
|
||||
=> MqttTagConfigModel.FromJson($$"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","qos":{{qosLiteral}}}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void Validate_LegalQos_ReturnsNull(int qos)
|
||||
=> MqttTagConfigModel.FromJson($$"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","qos":{{qos}}}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// The driver factory reads payloadFormat/dataType STRICTLY — a typo is rejected outright
|
||||
// (BadNodeIdUnknown), never defaulted. The editor must not let such a blob past save while
|
||||
// silently retyping it, so Validate reads the ORIGINAL key bag, not the defaulted typed field.
|
||||
[Fact]
|
||||
public void Validate_TypoedPayloadFormat_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Jason","dataType":"String"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Fact]
|
||||
public void Validate_TypoedDataType_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Raw","dataType":"Double"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
// Once the editor has round-tripped the blob the typo is gone (ToJson rewrites the key with the
|
||||
// typed field's name), so the strict check above can never wedge a tag the editor itself produced.
|
||||
// NB a payloadFormat typo repairs to the Json default, which then legitimately demands a jsonPath —
|
||||
// so the typo-repair claim is isolated here on dataType, whose default carries no follow-on rule.
|
||||
[Fact]
|
||||
public void Validate_AfterEditorRoundTrip_ClearsATypoedEnum()
|
||||
{
|
||||
var typoed = """{"topic":"a/b","payloadFormat":"Raw","dataType":"Double"}""";
|
||||
MqttTagConfigModel.FromJson(typoed).Validate().ShouldNotBeNullOrWhiteSpace(); // pre-condition
|
||||
|
||||
var repaired = MqttTagConfigModel.FromJson(typoed).ToJson();
|
||||
|
||||
repaired.ShouldContain("\"dataType\":\"String\"");
|
||||
MqttTagConfigModel.FromJson(repaired).Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
// A well-formed Sparkplug tag is NOT subject to the Plain rules — it has no topic, and applying the
|
||||
// Plain "topic is required" check would reject every Sparkplug tag.
|
||||
[Fact]
|
||||
public void Validate_SparkplugMode_IsNotSubjectToPlainRules()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"P1","edgeNodeId":"E1","metricName":"Temp"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// ── Dispatch registration ────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TagConfigEditorMap_resolves_the_Mqtt_editor()
|
||||
=> TagConfigEditorMap.Resolve("Mqtt").ShouldBe(
|
||||
typeof(AdminUI.Components.Shared.Uns.TagEditors.MqttTagConfigEditor));
|
||||
|
||||
[Fact]
|
||||
public void TagConfigValidator_dispatches_Mqtt()
|
||||
=> TagConfigValidator.Validate("Mqtt", """{"payloadFormat":"Raw"}""").ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
@@ -96,6 +97,204 @@ public sealed class RawBrowseCommitMapperTests
|
||||
JsonNode.Parse(json)!.AsObject()["address"]!.GetValue<string>().ShouldBe("40001");
|
||||
}
|
||||
|
||||
// ---- BuildTagConfig: MQTT (the address is a DESCRIPTOR, not a single reference string) --------
|
||||
|
||||
/// <summary>The Sparkplug address a browse session states for a metric under a device.</summary>
|
||||
/// <param name="metricName">The metric name to state.</param>
|
||||
/// <returns>The stated address fields.</returns>
|
||||
private static Dictionary<string, string> SparkplugFields(string metricName = "Temperature") => new()
|
||||
{
|
||||
[MqttTagConfigKeys.GroupId] = "OtOpcUaSim",
|
||||
[MqttTagConfigKeys.EdgeNodeId] = "EdgeA",
|
||||
[MqttTagConfigKeys.DeviceId] = "Filler1",
|
||||
[MqttTagConfigKeys.MetricName] = metricName,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_plain_writes_the_topic_key_the_factory_actually_reads()
|
||||
{
|
||||
// The generic "address" fallback produced a blob MqttTagDefinitionFactory cannot read at all —
|
||||
// a browse-committed tag that deploys clean and then reports BadNodeIdUnknown forever.
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
"Mqtt",
|
||||
"otopcua/fixture/oven/temp",
|
||||
new Dictionary<string, string> { [MqttTagConfigKeys.Topic] = "otopcua/fixture/oven/temp" });
|
||||
|
||||
JsonNode.Parse(json)!.AsObject()["topic"]!.GetValue<string>().ShouldBe("otopcua/fixture/oven/temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_plain_falls_back_to_the_node_id_when_no_field_was_stated()
|
||||
{
|
||||
// A Plain browse node id IS the topic, so the fallback is exact — unlike Sparkplug, where the
|
||||
// tuple is unrecoverable and DescribeUncommittableLeaf refuses instead.
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig("Mqtt", "otopcua/fixture/oven/temp");
|
||||
|
||||
JsonNode.Parse(json)!.AsObject()["topic"]!.GetValue<string>().ShouldBe("otopcua/fixture/oven/temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_sparkplug_writes_the_binding_tuple_from_the_stated_fields()
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
"Mqtt", "OtOpcUaSim/EdgeA/Filler1::Temperature", SparkplugFields());
|
||||
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o["groupId"]!.GetValue<string>().ShouldBe("OtOpcUaSim");
|
||||
o["edgeNodeId"]!.GetValue<string>().ShouldBe("EdgeA");
|
||||
o["deviceId"]!.GetValue<string>().ShouldBe("Filler1");
|
||||
o["metricName"]!.GetValue<string>().ShouldBe("Temperature");
|
||||
o.ContainsKey("topic").ShouldBeFalse(); // a Sparkplug tag has no per-tag topic
|
||||
o.ContainsKey("address").ShouldBeFalse(); // and never the generic fallback key
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_sparkplug_node_level_metric_omits_deviceId_entirely()
|
||||
{
|
||||
var fields = SparkplugFields();
|
||||
fields.Remove(MqttTagConfigKeys.DeviceId);
|
||||
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig("Mqtt", "OtOpcUaSim/EdgeA::Temperature", fields);
|
||||
|
||||
JsonNode.Parse(json)!.AsObject().ContainsKey("deviceId").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_sparkplug_takes_the_metric_name_from_the_fields_not_the_node_id()
|
||||
{
|
||||
// THE case the whole seam exists for: a metric name containing '/' makes the node id
|
||||
// '{group}/{edge}/{device}::{metric}' un-splittable — any parse would bind the wrong metric.
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
"Mqtt",
|
||||
"OtOpcUaSim/EdgeA/Filler1::Node Control/Rebirth",
|
||||
SparkplugFields("Node Control/Rebirth"));
|
||||
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o["metricName"]!.GetValue<string>().ShouldBe("Node Control/Rebirth");
|
||||
o["deviceId"]!.GetValue<string>().ShouldBe("Filler1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_sparkplug_survives_a_group_id_containing_the_metric_separator()
|
||||
{
|
||||
// A group id is an arbitrary MQTT topic segment and MAY contain '::'. Splitting the node id on
|
||||
// the first '::' would read the group as 'odd' and the metric as 'group/EdgeA::Temperature'.
|
||||
var fields = SparkplugFields();
|
||||
fields[MqttTagConfigKeys.GroupId] = "odd::group";
|
||||
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
"Mqtt", "odd::group/EdgeA/Filler1::Temperature", fields);
|
||||
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o["groupId"]!.GetValue<string>().ShouldBe("odd::group");
|
||||
o["metricName"]!.GetValue<string>().ShouldBe("Temperature");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_sparkplug_blob_deserializes_through_the_REAL_driver_factory()
|
||||
{
|
||||
// The round trip that would have caught the defect: the committed blob is fed to the factory the
|
||||
// deployed driver actually uses. Anything else is a test of the mapper agreeing with itself.
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
"Mqtt",
|
||||
"OtOpcUaSim/EdgeA/Filler1::Node Control/Rebirth",
|
||||
SparkplugFields("Node Control/Rebirth"));
|
||||
|
||||
MqttTagDefinitionFactory
|
||||
.FromSparkplugTagConfig(json, "Plant/Mqtt/dev1/Rebirth", out var def)
|
||||
.ShouldBeTrue();
|
||||
|
||||
def.Name.ShouldBe("Plant/Mqtt/dev1/Rebirth"); // v3: identity is the RawPath
|
||||
def.GroupId.ShouldBe("OtOpcUaSim");
|
||||
def.EdgeNodeId.ShouldBe("EdgeA");
|
||||
def.DeviceId.ShouldBe("Filler1");
|
||||
def.MetricName.ShouldBe("Node Control/Rebirth");
|
||||
def.DataTypeAuthored.ShouldBeFalse(); // no dataType key ⇒ take the birth's declared type
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_plain_blob_deserializes_through_the_REAL_driver_factory()
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
"Mqtt",
|
||||
"otopcua/fixture/oven/temp",
|
||||
new Dictionary<string, string> { [MqttTagConfigKeys.Topic] = "otopcua/fixture/oven/temp" });
|
||||
|
||||
MqttTagDefinitionFactory.FromTagConfig(json, "Plant/Mqtt/dev1/Temp", out var def).ShouldBeTrue();
|
||||
def.Topic.ShouldBe("otopcua/fixture/oven/temp");
|
||||
def.Name.ShouldBe("Plant/Mqtt/dev1/Temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_round_trip_test_is_falsifiable_a_wrong_key_name_is_rejected_by_the_factory()
|
||||
{
|
||||
// Control: mutate the emitted key name (what the pre-fix mapper effectively did, writing
|
||||
// "address") and the factory must REFUSE it. Without this, the round-trip assertions above could
|
||||
// pass off any blob the factory happened to tolerate.
|
||||
var wrong = new JsonObject
|
||||
{
|
||||
["group"] = "OtOpcUaSim", // not "groupId"
|
||||
["edgeNodeId"] = "EdgeA",
|
||||
["metricName"] = "Temperature",
|
||||
}.ToJsonString();
|
||||
|
||||
MqttTagDefinitionFactory.FromSparkplugTagConfig(wrong, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();
|
||||
|
||||
// And the exact blob the pre-fix generic fallback produced.
|
||||
var legacy = new JsonObject { ["address"] = "OtOpcUaSim/EdgeA/Filler1::Temperature" }.ToJsonString();
|
||||
MqttTagDefinitionFactory.FromSparkplugTagConfig(legacy, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();
|
||||
MqttTagDefinitionFactory.FromTagConfig(legacy, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescribeUncommittableLeaf_refuses_an_mqtt_leaf_whose_address_was_never_stated()
|
||||
{
|
||||
// No stated address ⇒ no bindable tag. Failing here, in words, is the whole point: the old path
|
||||
// committed it silently and the operator learned about it as a runtime BadNodeIdUnknown.
|
||||
var error = RawBrowseCommitMapper.DescribeUncommittableLeaf("Mqtt", "Temperature", addressFields: null);
|
||||
|
||||
error.ShouldNotBeNull();
|
||||
error!.ShouldContain("Temperature");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MqttTagConfigKeys.Topic, "otopcua/fixture/oven/temp")]
|
||||
[InlineData(MqttTagConfigKeys.MetricName, "Temperature")]
|
||||
public void DescribeUncommittableLeaf_accepts_either_stated_mqtt_shape(string key, string value)
|
||||
=> RawBrowseCommitMapper
|
||||
.DescribeUncommittableLeaf("Mqtt", "leaf", new Dictionary<string, string> { [key] = value })
|
||||
.ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void DescribeUncommittableLeaf_never_blocks_a_single_reference_driver()
|
||||
{
|
||||
// Every other driver's address IS the node id — they state no fields and must stay committable.
|
||||
foreach (var driver in new[] { "OpcUaClient", "AbCip", "TwinCAT", "S7", "GalaxyMxGateway", "Modbus" })
|
||||
RawBrowseCommitMapper.DescribeUncommittableLeaf(driver, "leaf", addressFields: null).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapLeaf_flows_the_stated_address_fields_into_the_committed_row()
|
||||
{
|
||||
var row = RawBrowseCommitMapper.MapLeaf(
|
||||
driverType: "Mqtt",
|
||||
fullName: "OtOpcUaSim/EdgeA/Filler1::Temperature",
|
||||
browseName: "Temperature",
|
||||
driverDataType: "Float32",
|
||||
defaultDataType: "Double",
|
||||
groupPrefix: null,
|
||||
folderPath: new[] { "OtOpcUaSim", "EdgeA", "Filler1" },
|
||||
createGroups: false,
|
||||
addressFields: SparkplugFields());
|
||||
|
||||
row.Tag.Name.ShouldBe("Temperature");
|
||||
row.Tag.DataType.ShouldBe("Float");
|
||||
MqttTagDefinitionFactory
|
||||
.FromSparkplugTagConfig(row.Tag.TagConfig, "Plant/Mqtt/dev1/Temperature", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.MetricName.ShouldBe("Temperature");
|
||||
}
|
||||
|
||||
// ---- CombineGroupPath -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
|
||||
Reference in New Issue
Block a user