Files
Ryan 71ef622b4f
Test and deploy MQTT backend / test (push) Successful in 2m8s
Test and deploy MQTT backend / deploy (push) Successful in 18s
Deploy MQTT test backend with private viewer and Gitea CI/CD
2026-10-08 14:48:53 +03:30

173 lines
5.0 KiB
Go

package integration
import (
"context"
"io"
"net"
"strings"
"sync"
"testing"
"time"
"robot-market/test-mqtt-5/internal/backend"
"robot-market/test-mqtt-5/internal/events"
"robot-market/test-mqtt-5/internal/mqtt"
"robot-market/test-mqtt-5/internal/simulator"
)
// A loopback byte relay drops only this test client's connection. No broker changes.
type relay struct {
listener net.Listener
target string
mu sync.Mutex
paused bool
connections map[net.Conn]bool
wg sync.WaitGroup
}
func newRelay(t *testing.T, target string) *relay {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
r := &relay{listener: listener, target: target, connections: make(map[net.Conn]bool)}
r.wg.Add(1)
go func() {
defer r.wg.Done()
for {
local, err := listener.Accept()
if err != nil {
return
}
r.mu.Lock()
if r.paused {
r.mu.Unlock()
local.Close()
continue
}
r.connections[local] = true
r.wg.Add(1)
r.mu.Unlock()
go r.forward(local)
}
}()
return r
}
func (r *relay) forward(local net.Conn) {
defer r.wg.Done()
defer local.Close()
defer func() { r.mu.Lock(); delete(r.connections, local); r.mu.Unlock() }()
remote, err := net.DialTimeout("tcp", r.target, 3*time.Second)
if err != nil {
return
}
defer remote.Close()
r.mu.Lock()
if r.paused {
r.mu.Unlock()
return
}
r.connections[remote] = true
r.mu.Unlock()
defer func() { r.mu.Lock(); delete(r.connections, remote); r.mu.Unlock() }()
done := make(chan struct{})
go func() { io.Copy(remote, local); remote.Close(); close(done) }()
io.Copy(local, remote)
local.Close()
<-done
}
func (r *relay) pause() {
r.mu.Lock()
defer r.mu.Unlock()
r.paused = true
for c := range r.connections {
c.Close()
}
}
func (r *relay) resume() { r.mu.Lock(); r.paused = false; r.mu.Unlock() }
func (r *relay) close() { r.listener.Close(); r.pause(); r.wg.Wait() }
func TestRemoteReconnectSessionAndUnknown(t *testing.T) {
for _, resumed := range []bool{true, false} {
name := "SessionResumed"
if !resumed {
name = "SessionLostSubscriptionsRestored"
}
t.Run(name, func(t *testing.T) { testReconnect(t, resumed) })
}
}
func TestRemoteSessionExpiry(t *testing.T) {
testReconnectExpiry(t, false, time.Second)
}
func testReconnect(t *testing.T, expectResumed bool) { testReconnectExpiry(t, expectResumed, 0) }
func testReconnectExpiry(t *testing.T, expectResumed bool, expiry time.Duration) {
cfg := remoteConfig(t, "test-backend", false)
if !expectResumed {
cfg.MQTT.SessionExpiry = expiry
}
target := strings.TrimPrefix(cfg.MQTT.URL, "tls://")
proxy := newRelay(t, target)
defer proxy.close()
// The generated server certificate explicitly includes 127.0.0.1 for health checks.
cfg.MQTT.URL = "tls://" + proxy.listener.Addr().String()
cfg.MQTT.ReconnectMin = 100 * time.Millisecond
cfg.MQTT.ReconnectMax = 300 * time.Millisecond
client := mqtt.New(cfg, store(cfg))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := client.Start(ctx, []string{"acks", "telemetry"}, nil); err != nil {
t.Fatal(err)
}
defer client.Close()
b := backend.New(client)
workerCtx, stop := context.WithCancel(ctx)
workerDone := make(chan error, 1)
go func() { workerDone <- b.Serve(workerCtx) }()
defer func() { stop(); <-workerDone }()
simCfg := remoteConfig(t, "sim-001", false)
simCfg.Simulator.AckDelay = 500 * time.Millisecond
simClient := mqtt.New(simCfg, store(simCfg))
sim := simulator.New(simClient)
simCtx, simStop := context.WithCancel(ctx)
simDone := make(chan error, 1)
go func() { simDone <- sim.Run(simCtx) }()
defer func() { simStop(); <-simDone }()
wait(t, simClient.Ready)
cmdDone := make(chan error, 1)
go func() {
_, err := b.Command(ctx, backend.NewCommand("sim-001", "ping", backend.ID(), 5*time.Second))
cmdDone <- err
}()
wait(t, func() bool { _, counts := client.Events.Snapshot(); return counts[events.CommandAccepted] > 0 })
proxy.pause()
wait(t, func() bool { return !client.Ready() })
select {
case err := <-cmdDone:
if err == nil || !strings.Contains(err.Error(), "unknown") {
t.Fatal("connection loss did not produce unknown outcome")
}
case <-time.After(3 * time.Second):
t.Fatal("pending request did not stop on connection loss")
}
if expiry > 0 {
time.Sleep(expiry + 500*time.Millisecond)
}
proxy.resume()
wait(t, client.Ready)
history, _ := client.Events.Snapshot()
resumed := false
for _, e := range history {
if e.Kind == events.ConnectionUp && strings.Contains(e.Outcome, "session_present=true") {
resumed = true
}
}
if resumed != expectResumed {
t.Fatal("unexpected broker session present result")
}
ack, err := b.Command(ctx, backend.NewCommand("sim-001", "get_version", backend.ID(), 5*time.Second))
if err != nil || ack == nil || ack.Result.Version == "" {
t.Fatal("command did not recover after reconnect")
}
t.Log("actual connection loss -> unknown, TLS reconnect, session-dependent subscription restoration and command recovery: PASS")
}