Deploy MQTT test backend with private viewer and Gitea CI/CD
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"robot-market/test-mqtt-5/internal/backend"
|
||||
"robot-market/test-mqtt-5/internal/config"
|
||||
"robot-market/test-mqtt-5/internal/contracts"
|
||||
"robot-market/test-mqtt-5/internal/events"
|
||||
"robot-market/test-mqtt-5/internal/mqtt"
|
||||
"robot-market/test-mqtt-5/internal/simulator"
|
||||
)
|
||||
|
||||
func remoteConfig(t *testing.T, role string, plaintext bool) config.Config {
|
||||
t.Helper()
|
||||
root := os.Getenv("MQTT5_INTEGRATION_CREDENTIAL_DIR")
|
||||
if root == "" {
|
||||
t.Skip("opt-in remote test: set MQTT5_INTEGRATION_CREDENTIAL_DIR")
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, role+".json"))
|
||||
if err != nil {
|
||||
t.Fatal("cannot read private credential handoff")
|
||||
}
|
||||
var record struct {
|
||||
Host string `json:"host"`
|
||||
TLS int `json:"tls_port"`
|
||||
Plain int `json:"plaintext_port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if json.Unmarshal(data, &record) != nil {
|
||||
t.Fatal("invalid credential handoff")
|
||||
}
|
||||
cfg := config.Defaults()
|
||||
cfg.MQTT.Username = record.Username
|
||||
cfg.MQTT.Password = record.Password
|
||||
cfg.MQTT.ClientID = "rm5-integration-" + backend.ID()[:16]
|
||||
scheme, port := "tls", record.TLS
|
||||
if plaintext {
|
||||
scheme, port = "mqtt", record.Plain
|
||||
cfg.MQTT.AllowPlaintext = true
|
||||
}
|
||||
cfg.MQTT.URL = scheme + "://" + record.Host + ":" + jsonNumber(port)
|
||||
cfg.TLS.CAFile = filepath.Join(root, "ca.crt")
|
||||
cfg.Simulator.TelemetryInterval = 200 * time.Millisecond
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reportScenario(t, cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
var registeredResults sync.Map
|
||||
|
||||
func reportScenario(t *testing.T, cfg config.Config) {
|
||||
if _, loaded := registeredResults.LoadOrStore(t, true); loaded {
|
||||
return
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
registeredResults.Delete(t)
|
||||
status := "passed"
|
||||
if t.Failed() {
|
||||
status = "failed"
|
||||
}
|
||||
if t.Skipped() {
|
||||
status = "blocked"
|
||||
}
|
||||
result := events.ScenarioResult{Feature: t.Name(), Environment: "independent Go integration clients and remote test broker; no ESP32 hardware", Endpoint: cfg.MQTT.URL, Version: "MQTT 5 / Mosquitto 2.1.2", Status: status, Observation: "See named Go test assertions and verbose output; application duplicates are separate from wire retransmission", Time: time.Now().UTC()}
|
||||
data, _ := json.Marshal(result)
|
||||
t.Log("scenario_result " + string(data))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
cfg.MQTT.ClientID = "rm5-result-" + backend.ID()[:12]
|
||||
cfg.MQTT.SessionExpiry = 0
|
||||
cfg.Topics.ObserveAll = true
|
||||
client := mqtt.New(cfg, store(cfg))
|
||||
if err := client.Start(ctx, nil, nil); err != nil {
|
||||
t.Error("scenario reporting connection failed")
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.PublishRaw(ctx, mqtt.Packet(cfg.Topics.Namespace+"/scenarios/results", 1, false, result)); err != nil {
|
||||
t.Error("scenario reporting publication failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
func jsonNumber(n int) string { data, _ := json.Marshal(n); return string(data) }
|
||||
func store(cfg config.Config) *events.Store {
|
||||
return events.NewStore(cfg.Limits.EventHistory, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
func wait(t *testing.T, f func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !f() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("condition deadline exceeded")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
func TestRemoteLoop(t *testing.T) {
|
||||
for _, plain := range []bool{false, true} {
|
||||
name := "TLS"
|
||||
if plain {
|
||||
name = "Plaintext"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := remoteConfig(t, "sim-001", plain)
|
||||
simClient := mqtt.New(cfg, store(cfg))
|
||||
sim := simulator.New(simClient)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
simDone := make(chan error, 1)
|
||||
go func() { simDone <- sim.Run(ctx) }()
|
||||
defer func() {
|
||||
start := time.Now()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-simDone:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(6 * time.Second):
|
||||
t.Error("simulator shutdown blocked")
|
||||
}
|
||||
if time.Since(start) > cfg.Deadlines.Shutdown {
|
||||
t.Error("simulator exceeded configured shutdown bound")
|
||||
}
|
||||
}()
|
||||
wait(t, simClient.Ready)
|
||||
backendCfg := remoteConfig(t, "test-backend", plain)
|
||||
backendClient := mqtt.New(backendCfg, store(backendCfg))
|
||||
if err := backendClient.Start(ctx, []string{"telemetry", "status", "commands", "acks"}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer backendClient.Close()
|
||||
b := backend.New(backendClient)
|
||||
workerCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- b.Serve(workerCtx) }()
|
||||
defer func() {
|
||||
stop()
|
||||
if err := <-done; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
wait(t, func() bool {
|
||||
history, _ := backendClient.Events.Snapshot()
|
||||
seenTelemetry, seenStatus := false, false
|
||||
for _, event := range history {
|
||||
if event.Kind == events.MessageReceived && event.Topic == mqtt.Topic(backendCfg, "telemetry") {
|
||||
seenTelemetry = true
|
||||
}
|
||||
if event.Kind == events.MessageReceived && event.Topic == mqtt.Topic(backendCfg, "status") && strings.Contains(event.Payload.Text, `"state":"online"`) {
|
||||
seenStatus = true
|
||||
}
|
||||
}
|
||||
return seenTelemetry && seenStatus
|
||||
})
|
||||
command := backend.NewCommand("sim-001", "ping", backend.ID(), 5*time.Second)
|
||||
for n := 0; n < 2; n++ {
|
||||
ack, err := b.Command(ctx, command)
|
||||
if err != nil || ack == nil || ack.Result.Pong == nil || !*ack.Result.Pong {
|
||||
t.Fatal("ping/replay did not complete")
|
||||
}
|
||||
}
|
||||
_, counts := simClient.Events.Snapshot()
|
||||
if counts[events.Kind("simulator_execution")] != 1 || counts[events.DuplicateRequest] < 1 {
|
||||
t.Fatal("duplicate caused re-execution or replay missing")
|
||||
}
|
||||
conflict := command
|
||||
conflict.Command = "get_version"
|
||||
if _, err := b.Command(ctx, conflict); err == nil {
|
||||
t.Fatal("conflicting ID was not rejected")
|
||||
}
|
||||
ack, err := b.Command(ctx, backend.NewCommand("sim-001", "get_version", backend.ID(), 5*time.Second))
|
||||
if err != nil || ack.Result.Version != "test-sim-1.0.0" {
|
||||
t.Fatal("version result failed")
|
||||
}
|
||||
t.Log("observer telemetry/status, native correlated ping/version, cached duplicate and conflict rejection: PASS")
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestRemoteWrongCorrelationTimeoutAndDisconnect(t *testing.T) {
|
||||
cfg := remoteConfig(t, "sim-001", false)
|
||||
cfg.Simulator.AckDelay = 2 * time.Second
|
||||
simClient := mqtt.New(cfg, store(cfg))
|
||||
sim := simulator.New(simClient)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
simDone := make(chan error, 1)
|
||||
go func() { simDone <- sim.Run(ctx) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
select {
|
||||
case <-simDone:
|
||||
case <-time.After(6 * time.Second):
|
||||
t.Error("sim shutdown blocked")
|
||||
}
|
||||
}()
|
||||
wait(t, simClient.Ready)
|
||||
bc := remoteConfig(t, "test-backend", false)
|
||||
client := mqtt.New(bc, store(bc))
|
||||
if err := client.Start(ctx, []string{"acks"}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := backend.New(client)
|
||||
workerCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- b.Serve(workerCtx) }()
|
||||
defer func() { stop(); <-done }()
|
||||
command := backend.NewCommand("sim-001", "ping", backend.ID(), time.Second)
|
||||
commandDone := make(chan error, 1)
|
||||
go func() { _, err := b.Command(ctx, command); commandDone <- err }()
|
||||
wait(t, func() bool { _, counts := client.Events.Snapshot(); return counts[events.PublishAttempt] >= 1 })
|
||||
yes := true
|
||||
badAck := contracts.Ack{Envelope: command.Envelope, RequestID: command.RequestID, Timestamp: backend.Timestamp(time.Now()), Stage: "completed", OK: &yes, Result: &contracts.Result{Pong: &yes}}
|
||||
packet := mqtt.Packet(mqtt.Topic(cfg, "acks"), 1, false, badAck)
|
||||
packet.Properties.CorrelationData = []byte("wrong-correlation")
|
||||
if err := simClient.Publish(ctx, packet); err != nil {
|
||||
t.Fatal("invalid-correlation test publication failed")
|
||||
}
|
||||
if err := <-commandDone; err == nil || !strings.Contains(err.Error(), "deadline") {
|
||||
t.Fatal("invalid correlation completed request or truthful timeout missing")
|
||||
}
|
||||
wait(t, func() bool { _, counts := client.Events.Snapshot(); return counts[events.LateAck] > 0 })
|
||||
command = backend.NewCommand("sim-001", "ping", backend.ID(), 5*time.Second)
|
||||
go func() { _, err := b.Command(ctx, command); commandDone <- err }()
|
||||
wait(t, func() bool { _, counts := client.Events.Snapshot(); return counts[events.PublishAttempt] >= 2 })
|
||||
if err := client.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-commandDone; err == nil {
|
||||
t.Fatal("disconnect claimed execution success")
|
||||
}
|
||||
t.Log("wrong correlation ignored, missing valid completion timeout, late ACK classification and disconnect unknown outcome: PASS")
|
||||
}
|
||||
Reference in New Issue
Block a user