package app import ( "bytes" "context" "log" "strings" "testing" "time" "git.az-it.net/az/morz-infoboard/player/agent/internal/config" "git.az-it.net/az/morz-infoboard/player/agent/internal/statusreporter" ) type recordingReporter struct { callCount int err error } func (r *recordingReporter) Send(ctx context.Context, snapshot statusreporter.Snapshot) error { r.callCount++ return r.err } func TestAppRunUpdatesHealthAndLogsStructuredEvents(t *testing.T) { var logBuffer bytes.Buffer logger := log.New(&logBuffer, "", 0) application := newApp(config.Config{ ScreenID: "info01-dev", ServerBaseURL: "http://127.0.0.1:8080", MQTTBroker: "tcp://127.0.0.1:1883", HeartbeatEvery: 1, StatusReportEvery: 1, }, logger, time.Now, &recordingReporter{}) if got, want := application.Snapshot().Status, StatusStarting; got != want { t.Fatalf("initial status = %q, want %q", got, want) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() errCh := make(chan error, 1) go func() { errCh <- application.Run(ctx) }() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { snapshot := application.Snapshot() if snapshot.Status == StatusRunning && !snapshot.LastHeartbeatAt.IsZero() { break } time.Sleep(10 * time.Millisecond) } snapshot := application.Snapshot() if got, want := snapshot.Status, StatusRunning; got != want { t.Fatalf("running status = %q, want %q", got, want) } if snapshot.LastHeartbeatAt.IsZero() { t.Fatal("LastHeartbeatAt = zero, want heartbeat timestamp") } cancel() select { case err := <-errCh: if err != nil { t.Fatalf("Run() error = %v", err) } case <-time.After(2 * time.Second): t.Fatal("Run() did not return after cancel") } if got, want := application.Snapshot().Status, StatusStopped; got != want { t.Fatalf("final status = %q, want %q", got, want) } logs := logBuffer.String() for _, needle := range []string{ "event=agent_configured", "screen_id=info01-dev", "event=heartbeat_tick", "event=agent_stopped", } { if !strings.Contains(logs, needle) { t.Fatalf("logs missing %q: %s", needle, logs) } } } func TestAppSnapshotIncludesConfiguredTargets(t *testing.T) { application := newApp(config.Config{ ScreenID: "screen-77", ServerBaseURL: "https://backend.example", MQTTBroker: "tcp://mqtt.example:1883", HeartbeatEvery: 15, StatusReportEvery: 60, }, log.New(&bytes.Buffer{}, "", 0), time.Now, &recordingReporter{}) snapshot := application.Snapshot() if got, want := snapshot.ScreenID, "screen-77"; got != want { t.Fatalf("ScreenID = %q, want %q", got, want) } if got, want := snapshot.ServerBaseURL, "https://backend.example"; got != want { t.Fatalf("ServerBaseURL = %q, want %q", got, want) } if got, want := snapshot.MQTTBroker, "tcp://mqtt.example:1883"; got != want { t.Fatalf("MQTTBroker = %q, want %q", got, want) } if got, want := snapshot.HeartbeatEvery, 15; got != want { t.Fatalf("HeartbeatEvery = %d, want %d", got, want) } } func TestAppRunWithCanceledContextDoesNotLogConfiguredOrHeartbeat(t *testing.T) { var logBuffer bytes.Buffer application := newApp(config.Config{ ScreenID: "screen-canceled", ServerBaseURL: "http://127.0.0.1:8080", MQTTBroker: "tcp://127.0.0.1:1883", HeartbeatEvery: 5, StatusReportEvery: 60, }, log.New(&logBuffer, "", 0), time.Now, &recordingReporter{}) ctx, cancel := context.WithCancel(context.Background()) cancel() if err := application.Run(ctx); err != nil { t.Fatalf("Run() error = %v", err) } if got, want := application.Snapshot().Status, StatusStopped; got != want { t.Fatalf("final status = %q, want %q", got, want) } logs := logBuffer.String() for _, needle := range []string{"event=agent_configured", "event=heartbeat_tick"} { if strings.Contains(logs, needle) { t.Fatalf("logs unexpectedly contain %q: %s", needle, logs) } } } func TestAppRunReportsStatusWithoutStoppingOnReporterError(t *testing.T) { var logBuffer bytes.Buffer reporter := &recordingReporter{err: context.DeadlineExceeded} application := newApp(config.Config{ ScreenID: "screen-reporter", ServerBaseURL: "http://127.0.0.1:8080", MQTTBroker: "tcp://127.0.0.1:1883", HeartbeatEvery: 1, StatusReportEvery: 1, }, log.New(&logBuffer, "", 0), time.Now, reporter) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { errCh <- application.Run(ctx) }() deadline := time.Now().Add(2500 * time.Millisecond) for time.Now().Before(deadline) { if reporter.callCount > 0 { break } time.Sleep(10 * time.Millisecond) } if reporter.callCount == 0 { cancel() t.Fatal("reporter was not called") } cancel() select { case err := <-errCh: if err != nil { t.Fatalf("Run() error = %v", err) } case <-time.After(2 * time.Second): t.Fatal("Run() did not return after cancel") } logs := logBuffer.String() if !strings.Contains(logs, "event=status_report_failed") { t.Fatalf("logs missing status_report_failed event: %s", logs) } }