Deploy MQTT test backend with private viewer and Gitea CI/CD
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
// Package backend observes contracts and correlates only this process's own commands.
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"robot-market/test-mqtt-5/internal/contracts"
|
||||
"robot-market/test-mqtt-5/internal/events"
|
||||
"robot-market/test-mqtt-5/internal/mqtt"
|
||||
|
||||
"github.com/eclipse/paho.golang/paho"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Ack *contracts.Ack
|
||||
Err error
|
||||
}
|
||||
type pending struct {
|
||||
command contracts.Command
|
||||
correlation []byte
|
||||
started, expires time.Time
|
||||
result chan Result
|
||||
terminal bool
|
||||
accepted bool
|
||||
}
|
||||
type Backend struct {
|
||||
Client *mqtt.Client
|
||||
mu sync.Mutex
|
||||
pending map[string]*pending
|
||||
receipts chan *paho.Publish
|
||||
PublishReceipt func(context.Context, *paho.Publish) error
|
||||
}
|
||||
|
||||
func New(client *mqtt.Client) *Backend {
|
||||
return &Backend{Client: client, pending: make(map[string]*pending), receipts: make(chan *paho.Publish, client.Config.Limits.InboundQueue), PublishReceipt: client.PublishRaw}
|
||||
}
|
||||
func ID() string {
|
||||
var data [16]byte
|
||||
if _, err := rand.Read(data[:]); err != nil {
|
||||
panic("system random source unavailable")
|
||||
}
|
||||
return hex.EncodeToString(data[:])
|
||||
}
|
||||
func Timestamp(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) }
|
||||
func NewCommand(device, name, id string, lifetime time.Duration) contracts.Command {
|
||||
now := time.Now().UTC()
|
||||
return contracts.Command{SchemaVersion: 1, DeviceID: device, RequestID: id, Command: name, IssuedAt: Timestamp(now), ExpiresAt: Timestamp(now.Add(lifetime))}
|
||||
}
|
||||
func (b *Backend) register(cmd contracts.Command) (*pending, error) {
|
||||
if err := cmd.Validate(b.Client.Config.Topics.DeviceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cmd.CheckDeadline(time.Now()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expires, _ := time.Parse(time.RFC3339Nano, cmd.ExpiresAt)
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if len(b.pending) >= b.Client.Config.Limits.PendingCommands {
|
||||
return nil, errors.New("pending command limit reached")
|
||||
}
|
||||
if _, ok := b.pending[cmd.RequestID]; ok {
|
||||
return nil, errors.New("request ID already pending")
|
||||
}
|
||||
p := &pending{command: cmd, correlation: []byte(cmd.RequestID), started: time.Now(), expires: expires, result: make(chan Result, 1)}
|
||||
b.pending[cmd.RequestID] = p
|
||||
return p, nil
|
||||
}
|
||||
func (b *Backend) Command(ctx context.Context, cmd contracts.Command) (*contracts.Ack, error) {
|
||||
if !b.Client.Ready() {
|
||||
return nil, mqtt.ErrNotReady
|
||||
}
|
||||
p, err := b.register(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { b.mu.Lock(); delete(b.pending, cmd.RequestID); b.mu.Unlock() }()
|
||||
packet := mqtt.Packet(mqtt.Topic(b.Client.Config, "commands"), 1, false, cmd)
|
||||
packet.Properties.ResponseTopic = mqtt.Topic(b.Client.Config, "acks")
|
||||
packet.Properties.CorrelationData = append([]byte(nil), p.correlation...)
|
||||
expiry := uint32(max(1, int(time.Until(p.expires).Seconds()+0.999)))
|
||||
packet.Properties.MessageExpiry = &expiry
|
||||
// Register before publishing: accepted/completed can arrive before Publish returns.
|
||||
publishCtx, stopPublish := context.WithDeadline(ctx, p.expires)
|
||||
err = b.Client.Publish(publishCtx, packet)
|
||||
stopPublish()
|
||||
if err != nil {
|
||||
// An authenticated correlated terminal ACK is execution evidence even if
|
||||
// transport confirmation was lost. Never replace it with a generic timeout.
|
||||
select {
|
||||
case result := <-p.result:
|
||||
return result.Ack, result.Err
|
||||
default:
|
||||
}
|
||||
if !time.Now().Before(p.expires) {
|
||||
b.Client.Record(events.CommandTimeout, events.Internal, packet, cmd.RequestID, "deadline elapsed during publish; execution unknown")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
timer := time.NewTimer(time.Until(p.expires))
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case result := <-p.result:
|
||||
return result.Ack, result.Err
|
||||
case <-ctx.Done():
|
||||
return nil, errors.New("command interrupted; execution unknown")
|
||||
case <-timer.C:
|
||||
b.Client.Record(events.CommandTimeout, events.Internal, packet, cmd.RequestID, "deadline elapsed; execution unknown")
|
||||
return nil, errors.New("command deadline elapsed; execution unknown")
|
||||
}
|
||||
}
|
||||
func (b *Backend) Disconnect() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for id, p := range b.pending {
|
||||
if !p.terminal {
|
||||
p.terminal = true
|
||||
p.result <- Result{Err: errors.New("connection lost; execution unknown")}
|
||||
b.Client.Record(events.Kind("command_unknown"), events.Internal, nil, id, "connection lost; no automatic retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
func (b *Backend) Handle(packet *paho.Publish) {
|
||||
if b.Client.Config.Topics.ObserveAll {
|
||||
b.Client.Record(events.MessageReceived, events.In, packet, "", "raw observation; contract validation disabled")
|
||||
if packet.Topic == b.Client.Config.Topics.Namespace+"/scenarios/results" {
|
||||
var result events.ScenarioResult
|
||||
if json.Unmarshal(packet.Payload, &result) == nil && result.Feature != "" && result.Environment != "" {
|
||||
b.Client.Events.Add(events.Event{Kind: "scenario_result", Direction: events.In, Topic: packet.Topic, Scenario: &result, Outcome: "reported test evidence; not independently verified hardware"})
|
||||
}
|
||||
}
|
||||
b.enqueueReceipt(packet)
|
||||
return
|
||||
}
|
||||
device, kind := mqtt.Identity(b.Client.Config, packet.Topic)
|
||||
msg, err := contracts.Decode(kind, packet.Payload, device)
|
||||
if err != nil {
|
||||
b.Client.Record(events.ValidationFailed, events.In, packet, "", "invalid contract: "+err.Error())
|
||||
return
|
||||
}
|
||||
request := ""
|
||||
switch v := msg.(type) {
|
||||
case *contracts.Ack:
|
||||
request = v.RequestID
|
||||
case *contracts.Command:
|
||||
request = v.RequestID
|
||||
}
|
||||
b.Client.Record(events.MessageReceived, events.In, packet, request, "")
|
||||
ack, ok := msg.(*contracts.Ack)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
p, ok := b.pending[ack.RequestID]
|
||||
if !ok || p.terminal || ack.DeviceID != p.command.DeviceID {
|
||||
b.Client.Record(events.LateAck, events.In, packet, ack.RequestID, "no active own request; latency not measured")
|
||||
return
|
||||
}
|
||||
if !time.Now().Before(p.expires) {
|
||||
b.Client.Record(events.LateAck, events.In, packet, ack.RequestID, "ack arrived after deadline")
|
||||
return
|
||||
}
|
||||
if packet.Retain || packet.Properties == nil || !bytes.Equal(packet.Properties.CorrelationData, p.correlation) || packet.Properties.ResponseTopic != "" {
|
||||
b.Client.Record(events.ValidationFailed, events.In, packet, ack.RequestID, "invalid native ACK correlation or retained ACK")
|
||||
return
|
||||
}
|
||||
if ack.Stage == "completed" && ((p.command.Command == "ping" && ack.Result.Pong == nil) || (p.command.Command == "get_version" && ack.Result.Version == "")) {
|
||||
b.Client.Record(events.ValidationFailed, events.In, packet, ack.RequestID, "result does not match command")
|
||||
return
|
||||
}
|
||||
if ack.Stage == "accepted" {
|
||||
if !p.accepted {
|
||||
p.accepted = true
|
||||
b.Client.Record(events.CommandAccepted, events.In, packet, ack.RequestID, "")
|
||||
}
|
||||
return
|
||||
}
|
||||
p.terminal = true
|
||||
elapsed := time.Since(p.started).Milliseconds()
|
||||
b.Client.Events.Add(events.Event{Kind: events.CommandCompleted, Direction: events.Internal, RequestID: ack.RequestID, DeviceID: ack.DeviceID, Outcome: ack.Stage, LatencyMS: &elapsed})
|
||||
if !*ack.OK {
|
||||
p.result <- Result{Ack: ack, Err: errors.New("device rejected or failed command")}
|
||||
} else {
|
||||
p.result <- Result{Ack: ack}
|
||||
}
|
||||
}
|
||||
func (b *Backend) Serve(ctx context.Context) error {
|
||||
defer b.Disconnect()
|
||||
workerCtx, cancelWorker := context.WithCancel(ctx)
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); b.receiptWorker(workerCtx) }()
|
||||
defer func() { cancelWorker(); <-done }()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case err := <-b.Client.Fatal():
|
||||
return err
|
||||
case <-b.Client.Down:
|
||||
b.Disconnect()
|
||||
case packet := <-b.Client.Incoming:
|
||||
b.Handle(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
func (b *Backend) Summary() string {
|
||||
_, counts := b.Client.Events.Snapshot()
|
||||
return fmt.Sprintf("received=%d; completed=%d; timeouts=%d; late_acks=%d", counts[events.MessageReceived], counts[events.CommandCompleted], counts[events.CommandTimeout], counts[events.LateAck])
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/eclipse/paho.golang/paho"
|
||||
"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"
|
||||
)
|
||||
|
||||
func testBackend() *Backend {
|
||||
c := config.Defaults()
|
||||
return New(mqtt.New(c, events.NewStore(32, slog.New(slog.NewTextHandler(io.Discard, nil)))))
|
||||
}
|
||||
func TestNativeCorrelationAndResult(t *testing.T) {
|
||||
for _, bad := range []string{"topic", "device", "correlation", "retained", "response", "result", "expired"} {
|
||||
t.Run(bad, func(t *testing.T) {
|
||||
b := testBackend()
|
||||
cmd := NewCommand("sim-001", "ping", ID(), 15*time.Second)
|
||||
p, err := b.register(cmd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yes := true
|
||||
ack := contracts.Ack{Envelope: cmd.Envelope, RequestID: cmd.RequestID, Timestamp: Timestamp(time.Now()), Stage: "completed", OK: &yes, Result: &contracts.Result{Pong: &yes}}
|
||||
if bad == "device" {
|
||||
ack.DeviceID = "other"
|
||||
}
|
||||
if bad == "result" {
|
||||
ack.Result = &contracts.Result{Version: "1.0.0"}
|
||||
}
|
||||
packet := mqtt.Packet(mqtt.Topic(b.Client.Config, "acks"), 1, false, ack)
|
||||
packet.Properties.CorrelationData = []byte(cmd.RequestID)
|
||||
switch bad {
|
||||
case "topic":
|
||||
packet.Topic = "robot-market/test/v1/other/acks"
|
||||
case "correlation":
|
||||
packet.Properties.CorrelationData = []byte("wrong")
|
||||
case "retained":
|
||||
packet.Retain = true
|
||||
case "response":
|
||||
packet.Properties.ResponseTopic = "unexpected"
|
||||
case "expired":
|
||||
p.expires = time.Now().Add(-time.Second)
|
||||
}
|
||||
b.Handle(packet)
|
||||
select {
|
||||
case <-p.result:
|
||||
t.Fatal("invalid ACK completed request")
|
||||
default:
|
||||
}
|
||||
})
|
||||
}
|
||||
b := testBackend()
|
||||
cmd := NewCommand("sim-001", "ping", ID(), 15*time.Second)
|
||||
p, _ := b.register(cmd)
|
||||
yes := true
|
||||
ack := contracts.Ack{Envelope: cmd.Envelope, RequestID: cmd.RequestID, Timestamp: Timestamp(time.Now()), Stage: "completed", OK: &yes, Result: &contracts.Result{Pong: &yes}}
|
||||
packet := mqtt.Packet(mqtt.Topic(b.Client.Config, "acks"), 1, false, ack)
|
||||
packet.Properties.CorrelationData = []byte(cmd.RequestID)
|
||||
// Completion may precede accepted. A subsequent accepted/duplicate cannot regress it.
|
||||
b.Handle(packet)
|
||||
ack.Stage = "accepted"
|
||||
ack.Result = nil
|
||||
accepted := mqtt.Packet(packet.Topic, 1, false, ack)
|
||||
accepted.Properties.CorrelationData = []byte(cmd.RequestID)
|
||||
b.Handle(accepted)
|
||||
b.Handle(packet)
|
||||
select {
|
||||
case result := <-p.result:
|
||||
if result.Err != nil || result.Ack.Stage != "completed" {
|
||||
t.Fatal("wrong completion")
|
||||
}
|
||||
default:
|
||||
t.Fatal("valid completion ignored")
|
||||
}
|
||||
select {
|
||||
case <-p.result:
|
||||
t.Fatal("completion delivered twice")
|
||||
default:
|
||||
}
|
||||
}
|
||||
func TestPendingLimitsAndDisconnect(t *testing.T) {
|
||||
b := testBackend()
|
||||
b.Client.Config.Limits.PendingCommands = 1
|
||||
cmd := NewCommand("sim-001", "ping", ID(), 15*time.Second)
|
||||
p, _ := b.register(cmd)
|
||||
if _, err := b.register(NewCommand("sim-001", "ping", ID(), 15*time.Second)); err == nil {
|
||||
t.Fatal("capacity ignored")
|
||||
}
|
||||
b.Disconnect()
|
||||
b.Disconnect()
|
||||
result := <-p.result
|
||||
if result.Err == nil {
|
||||
t.Fatal("disconnect claimed success")
|
||||
}
|
||||
if _, err := b.Command(context.Background(), cmd); err == nil {
|
||||
t.Fatal("disconnected command admitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveDeviceAndInvalidPayloadDiagnostics(t *testing.T) {
|
||||
b := testBackend()
|
||||
b.Client.Config.Topics.ObserveDevices = "sim-001,esp32-lab-001"
|
||||
b.Client.Config.Logging.RawPayloads = true
|
||||
payload := []byte(`{"schema_version":1,"device_id":"esp32-lab-001","message_id":"real-device","boot_id":"boot-1","sequence":1,"timestamp":"2026-10-07T00:00:00Z","readings":{"uptime_s":12}}`)
|
||||
b.Handle(&paho.Publish{Topic: "robot-market/test/v1/esp32-lab-001/telemetry", Payload: payload})
|
||||
b.Handle(&paho.Publish{Topic: "robot-market/test/v1/esp32-lab-001/telemetry", Payload: []byte("hello from esp32")})
|
||||
history, counts := b.Client.Events.Snapshot()
|
||||
if counts[events.MessageReceived] != 1 || counts[events.ValidationFailed] != 1 {
|
||||
t.Fatal("device receipt and contract failure must be distinct")
|
||||
}
|
||||
if history[0].DeviceID != "esp32-lab-001" || history[0].Payload.Text != string(payload) {
|
||||
t.Fatal("ESP32 identity/payload was lost")
|
||||
}
|
||||
if history[1].Payload.Text != "hello from esp32" || history[1].Outcome == "" {
|
||||
t.Fatal("invalid external payload needs diagnostic visibility")
|
||||
}
|
||||
b.Client.Config.Logging.RawPayloads = false
|
||||
b.Handle(&paho.Publish{Topic: "robot-market/test/v1/esp32-lab-001/telemetry", Payload: []byte("private invalid input")})
|
||||
history, _ = b.Client.Events.Snapshot()
|
||||
if !history[2].Payload.Omitted || history[2].Payload.Text != "" {
|
||||
t.Fatal("raw payload display must require explicit opt-in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOtherObservedDeviceCannotCompletePendingCommand(t *testing.T) {
|
||||
b := testBackend()
|
||||
b.Client.Config.Topics.ObserveDevices = "esp32-lab-001"
|
||||
cmd := NewCommand("sim-001", "ping", ID(), 15*time.Second)
|
||||
pending, _ := b.register(cmd)
|
||||
yes := true
|
||||
ack := contracts.Ack{Envelope: contracts.Envelope{SchemaVersion: 1, DeviceID: "esp32-lab-001"}, RequestID: cmd.RequestID, Timestamp: Timestamp(time.Now()), Stage: "completed", OK: &yes, Result: &contracts.Result{Pong: &yes}}
|
||||
packet := mqtt.Packet("robot-market/test/v1/esp32-lab-001/acks", 1, false, ack)
|
||||
packet.Properties.CorrelationData = []byte(cmd.RequestID)
|
||||
b.Handle(packet)
|
||||
select {
|
||||
case <-pending.result:
|
||||
t.Fatal("another device completed an own command")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawObservationAcceptsArbitraryPayloadsAndTopics(t *testing.T) {
|
||||
b := testBackend()
|
||||
b.Client.Config.Topics.ObserveAll = true
|
||||
for _, payload := range []string{"hello", `{"device_id":"different","timestamp":"{{$isoTimestamp}}"}`, "", "[1,2,3]"} {
|
||||
b.Handle(&paho.Publish{Topic: "robot-market/test/v1/arbitrary/custom-topic", Payload: []byte(payload)})
|
||||
}
|
||||
history, counts := b.Client.Events.Snapshot()
|
||||
if counts[events.MessageReceived] != 4 || counts[events.ValidationFailed] != 0 {
|
||||
t.Fatal("raw observation incorrectly validated a payload")
|
||||
}
|
||||
if history[1].Payload.Text != `{"device_id":"different","timestamp":"{{$isoTimestamp}}"}` || history[1].DeviceID != "arbitrary" {
|
||||
t.Fatal("raw observation lost payload or topic metadata")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/eclipse/paho.golang/paho"
|
||||
"robot-market/test-mqtt-5/internal/events"
|
||||
"robot-market/test-mqtt-5/internal/mqtt"
|
||||
)
|
||||
|
||||
// ReceiptAck confirms observation only; it does not claim device execution,
|
||||
// schema validity, durable storage, or delivery of this reply to the device.
|
||||
type ReceiptAck struct {
|
||||
Type string `json:"type"`
|
||||
OK bool `json:"ok"`
|
||||
SourceTopic string `json:"source_topic"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
MessageID string `json:"message_id,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
ReceivedAt string `json:"received_at"`
|
||||
PayloadBytes int `json:"payload_bytes"`
|
||||
PayloadSHA256 string `json:"payload_sha256"`
|
||||
}
|
||||
|
||||
func (b *Backend) receipt(packet *paho.Publish) *paho.Publish {
|
||||
if packet == nil || packet.Retain || !mqtt.InNamespace(b.Client.Config, packet.Topic) {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(packet.Topic, "/")
|
||||
category := parts[len(parts)-1]
|
||||
// Receipt messages and command replies must never form an ACK feedback loop.
|
||||
if category == "acks" || category == "receipts" || category == "commands" {
|
||||
return nil
|
||||
}
|
||||
var fields struct {
|
||||
Type string `json:"type"`
|
||||
MessageID string `json:"message_id"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
// Extraction is optional: malformed JSON and missing fields still get a reply.
|
||||
_ = json.Unmarshal(packet.Payload, &fields)
|
||||
if fields.Type == "receipt_ack" {
|
||||
return nil
|
||||
}
|
||||
device, _ := mqtt.Identity(b.Client.Config, packet.Topic)
|
||||
topic := b.Client.Config.Topics.Namespace + "/acks"
|
||||
if device != "" {
|
||||
topic = b.Client.Config.Topics.Namespace + "/" + device + "/acks"
|
||||
}
|
||||
if packet.Properties != nil && packet.Properties.ResponseTopic != "" {
|
||||
response := packet.Properties.ResponseTopic
|
||||
if mqtt.InNamespace(b.Client.Config, response) && !strings.ContainsAny(response, "+#\x00") && len(response) <= 256 && response != packet.Topic {
|
||||
topic = response
|
||||
}
|
||||
}
|
||||
digest := sha256.Sum256(packet.Payload)
|
||||
ack := ReceiptAck{Type: "receipt_ack", OK: true, SourceTopic: packet.Topic, DeviceID: device, ReceivedAt: Timestamp(time.Now()), PayloadBytes: len(packet.Payload), PayloadSHA256: hex.EncodeToString(digest[:])}
|
||||
if len(fields.MessageID) <= 256 {
|
||||
ack.MessageID = fields.MessageID
|
||||
}
|
||||
if len(fields.RequestID) <= 256 {
|
||||
ack.RequestID = fields.RequestID
|
||||
}
|
||||
reply := mqtt.Packet(topic, 1, false, ack)
|
||||
if packet.Properties != nil {
|
||||
reply.Properties.CorrelationData = append([]byte(nil), packet.Properties.CorrelationData...)
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
func (b *Backend) enqueueReceipt(packet *paho.Publish) {
|
||||
if !b.Client.Config.Receipts.Enabled {
|
||||
return
|
||||
}
|
||||
reply := b.receipt(packet)
|
||||
if reply == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case b.receipts <- reply:
|
||||
default:
|
||||
b.Client.Record(events.Kind("receipt_ack_dropped"), events.Internal, reply, "", "receipt queue full; no reply sent")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Backend) receiptWorker(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case reply := <-b.receipts:
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := b.PublishReceipt(ctx, reply); err != nil {
|
||||
b.Client.Record(events.Kind("receipt_ack_failed"), events.Internal, reply, "", "reply not confirmed; no automatic retry")
|
||||
} else {
|
||||
b.Client.Record(events.Kind("receipt_ack_sent"), events.Out, reply, "", "backend receipt reply acknowledged by broker; device receipt not proven")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/eclipse/paho.golang/paho"
|
||||
"robot-market/test-mqtt-5/internal/events"
|
||||
)
|
||||
|
||||
func TestReceiptRawPayloadRoutingAndCorrelation(t *testing.T) {
|
||||
b := testBackend()
|
||||
b.Client.Config.Topics.ObserveAll = true
|
||||
for _, payload := range []string{"hello", "", "[1,2]", `{"device_id":"different","timestamp":"{{$isoTimestamp}}","message_id":"msg-1"}`} {
|
||||
reply := b.receipt(&paho.Publish{Topic: "robot-market/test/v1/real-device/telemetry", Payload: []byte(payload)})
|
||||
if reply == nil || reply.Topic != "robot-market/test/v1/real-device/acks" || reply.Retain || reply.QoS != 1 {
|
||||
t.Fatal("missing/routed/retained reply")
|
||||
}
|
||||
var ack ReceiptAck
|
||||
if json.Unmarshal(reply.Payload, &ack) != nil || !ack.OK || ack.Type != "receipt_ack" || ack.PayloadBytes != len(payload) || len(ack.PayloadSHA256) != 64 {
|
||||
t.Fatal("invalid receipt")
|
||||
}
|
||||
}
|
||||
p := &paho.Publish{Topic: "robot-market/test/v1/device/telemetry", Payload: []byte("x"), Properties: &paho.PublishProperties{ResponseTopic: "robot-market/test/v1/replies/custom", CorrelationData: []byte{0, 255, 1}}}
|
||||
reply := b.receipt(p)
|
||||
p.Properties.CorrelationData[0] = 99
|
||||
if reply.Topic != "robot-market/test/v1/replies/custom" || reply.Properties.CorrelationData[0] != 0 || reply.Properties.ResponseTopic != "" {
|
||||
t.Fatal("native response/correlation lost or aliased")
|
||||
}
|
||||
p.Properties.ResponseTopic = "outside/test"
|
||||
if b.receipt(p).Topic != "robot-market/test/v1/device/acks" {
|
||||
t.Fatal("unusable response topic should use default receipt topic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiptLoopAndRetainedReplayPrevention(t *testing.T) {
|
||||
b := testBackend()
|
||||
b.Client.Config.Topics.ObserveAll = true
|
||||
for _, topic := range []string{"acks", "commands", "receipts"} {
|
||||
if b.receipt(&paho.Publish{Topic: "robot-market/test/v1/device/" + topic, Payload: []byte("hello")}) != nil {
|
||||
t.Fatal("ACK loop possible")
|
||||
}
|
||||
}
|
||||
for _, p := range []*paho.Publish{
|
||||
{Topic: "robot-market/test/v1/device/status", Retain: true, Payload: []byte("online")},
|
||||
{Topic: "robot-market/test/v1/custom/topic", Payload: []byte(`{"type":"receipt_ack","ok":true}`)},
|
||||
{Topic: "outside/device/topic", Payload: []byte("hello")},
|
||||
} {
|
||||
if b.receipt(p) != nil {
|
||||
t.Fatal("reply created for replay, receipt or external topic")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiptDuplicateDeliveryRepliesWithoutCommandExecution(t *testing.T) {
|
||||
b := testBackend()
|
||||
b.Client.Config.Topics.ObserveAll = true
|
||||
b.Client.Config.Receipts.Enabled = true
|
||||
p := &paho.Publish{Topic: "robot-market/test/v1/device/telemetry", Payload: []byte("same message")}
|
||||
b.Handle(p)
|
||||
b.Handle(p)
|
||||
if len(b.receipts) != 2 || len(b.pending) != 0 {
|
||||
t.Fatal("duplicate receipt handling must not execute commands")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiptQueueFailureAndShutdown(t *testing.T) {
|
||||
b := testBackend()
|
||||
b.Client.Config.Topics.ObserveAll = true
|
||||
b.Client.Config.Receipts.Enabled = true
|
||||
b.receipts = make(chan *paho.Publish, 1)
|
||||
p := &paho.Publish{Topic: "robot-market/test/v1/device/telemetry", Payload: []byte("hello")}
|
||||
b.Handle(p)
|
||||
b.Handle(p)
|
||||
_, counts := b.Client.Events.Snapshot()
|
||||
if counts[events.Kind("receipt_ack_dropped")] != 1 {
|
||||
t.Fatal("queue overflow invisible")
|
||||
}
|
||||
b.PublishReceipt = func(context.Context, *paho.Publish) error { return errors.New("test disconnect") }
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { b.receiptWorker(ctx); close(done) }()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
_, counts = b.Client.Events.Snapshot()
|
||||
if counts[events.Kind("receipt_ack_failed")] == 1 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("failure not recorded")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("worker shutdown blocked")
|
||||
}
|
||||
history, _ := b.Client.Events.Snapshot()
|
||||
if !strings.Contains(history[len(history)-1].Outcome, "not confirmed") {
|
||||
t.Fatal("failure claims success")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user