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") }