diff --git a/internal/api/router_integration_test.go b/internal/api/router_integration_test.go index b3a25a4..d4af24b 100644 --- a/internal/api/router_integration_test.go +++ b/internal/api/router_integration_test.go @@ -186,6 +186,41 @@ func TestStateEndpointReturnsMockData(t *testing.T) { } } +func TestServerInfoEndpointReportsDevelopment(t *testing.T) { + srv := newIntegrationServer(t) + + res, err := http.Get(srv.server.URL + "/api/server/info") + if err != nil { + t.Fatalf("server info request failed: %v", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", res.StatusCode, http.StatusOK) + } + + var payload map[string]any + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + t.Fatalf("decode server info response: %v", err) + } + + isDev, ok := payload["isDevelopment"].(bool) + if !ok { + t.Fatalf("isDevelopment missing or not bool: %v", payload["isDevelopment"]) + } + if !isDev { + t.Fatalf("expected development mode to be true") + } + + version, ok := payload["version"].(string) + if !ok { + t.Fatalf("version missing or not string: %v", payload["version"]) + } + if version == "" { + t.Fatalf("expected non-empty version string") + } +} + func TestConfigNodesUsesMockTopology(t *testing.T) { srv := newIntegrationServer(t) diff --git a/internal/monitoring/docker_commands_test.go b/internal/monitoring/docker_commands_test.go new file mode 100644 index 0000000..4f57dc0 --- /dev/null +++ b/internal/monitoring/docker_commands_test.go @@ -0,0 +1,207 @@ +package monitoring + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +func newTestMonitorForCommands(t *testing.T) *Monitor { + t.Helper() + state := models.NewState() + return &Monitor{ + state: state, + removedDockerHosts: make(map[string]time.Time), + dockerCommands: make(map[string]*dockerHostCommand), + dockerCommandIndex: make(map[string]string), + } +} + +func findDockerHost(t *testing.T, m *Monitor, id string) models.DockerHost { + t.Helper() + hosts := m.state.GetDockerHosts() + for _, host := range hosts { + if host.ID == id { + return host + } + } + t.Fatalf("docker host %s not found", id) + return models.DockerHost{} +} + +func TestDockerStopCommandLifecycle(t *testing.T) { + t.Parallel() + + monitor := newTestMonitorForCommands(t) + + host := models.DockerHost{ + ID: "host-1", + Hostname: "node-1", + DisplayName: "node-1", + Status: "online", + } + monitor.state.UpsertDockerHost(host) + + cmdStatus, err := monitor.QueueDockerHostStop(host.ID) + if err != nil { + t.Fatalf("queue stop command: %v", err) + } + if cmdStatus.Status != DockerCommandStatusQueued { + t.Fatalf("expected queued status, got %s", cmdStatus.Status) + } + + hostState := findDockerHost(t, monitor, host.ID) + if !hostState.PendingUninstall { + t.Fatalf("expected host to be marked pending uninstall") + } + if hostState.Command == nil || hostState.Command.Status != DockerCommandStatusQueued { + t.Fatalf("expected host command to be stored with queued status") + } + + payload, fetchedStatus := monitor.FetchDockerCommandForHost(host.ID) + if payload != nil { + t.Fatalf("expected no payload, got %v", payload) + } + if fetchedStatus == nil { + t.Fatalf("expected command status from fetch") + } + if fetchedStatus.Status != DockerCommandStatusDispatched { + t.Fatalf("expected dispatched status, got %s", fetchedStatus.Status) + } + + completedStatus, returnedHostID, shouldRemove, err := monitor.AcknowledgeDockerHostCommand(fetchedStatus.ID, host.ID, DockerCommandStatusCompleted, "done") + if err != nil { + t.Fatalf("acknowledge command: %v", err) + } + if returnedHostID != host.ID { + t.Fatalf("expected host id %s, got %s", host.ID, returnedHostID) + } + if completedStatus.Status != DockerCommandStatusCompleted { + t.Fatalf("expected completed status, got %s", completedStatus.Status) + } + if !shouldRemove { + t.Fatalf("expected shouldRemove true for stop command") + } + + if len(monitor.dockerCommands) != 0 { + t.Fatalf("expected command map to be cleared") + } + if len(monitor.dockerCommandIndex) != 0 { + t.Fatalf("expected command index to be cleared") + } + + hostState = findDockerHost(t, monitor, host.ID) + if hostState.Command == nil || hostState.Command.Status != DockerCommandStatusCompleted { + t.Fatalf("expected host command status to be completed in state") + } + if hostState.Command.Message != "done" { + t.Fatalf("expected completion message to be propagated") + } +} + +func TestDockerCommandExpiryCleanup(t *testing.T) { + t.Parallel() + + monitor := newTestMonitorForCommands(t) + + host := models.DockerHost{ + ID: "host-expire", + Hostname: "expire", + DisplayName: "expire", + Status: "online", + } + monitor.state.UpsertDockerHost(host) + + status, err := monitor.QueueDockerHostStop(host.ID) + if err != nil { + t.Fatalf("queue stop command: %v", err) + } + if status.Status != DockerCommandStatusQueued { + t.Fatalf("expected queued status, got %s", status.Status) + } + + monitor.mu.Lock() + cmd := monitor.dockerCommands[host.ID] + past := time.Now().Add(-2 * dockerCommandDefaultTTL) + cmd.status.ExpiresAt = &past + monitor.mu.Unlock() + + payload, fetched := monitor.FetchDockerCommandForHost(host.ID) + if payload != nil || fetched != nil { + t.Fatalf("expected expired command to be removed") + } + + if len(monitor.dockerCommands) != 0 { + t.Fatalf("expected command to be removed after expiry") + } + if len(monitor.dockerCommandIndex) != 0 { + t.Fatalf("expected index to be cleared after expiry") + } + + hostState := findDockerHost(t, monitor, host.ID) + if hostState.Command == nil || hostState.Command.Status != DockerCommandStatusExpired { + t.Fatalf("expected state to record expiry status, got %#v", hostState.Command) + } + if hostState.Command.FailureReason == "" { + t.Fatalf("expected failure reason to be set on expiry") + } +} + +func TestAllowDockerHostReenrollClearsState(t *testing.T) { + t.Parallel() + + monitor := newTestMonitorForCommands(t) + + host := models.DockerHost{ + ID: "host-reenroll", + Hostname: "reenroll", + DisplayName: "reenroll", + Status: "online", + PendingUninstall: true, + } + monitor.state.UpsertDockerHost(host) + monitor.removedDockerHosts[host.ID] = time.Now().Add(-time.Hour) + + status, err := monitor.QueueDockerHostStop(host.ID) + if err != nil { + t.Fatalf("queue stop command: %v", err) + } + if status.Status != DockerCommandStatusQueued { + t.Fatalf("expected queued status, got %s", status.Status) + } + + if err := monitor.AllowDockerHostReenroll(host.ID); err != nil { + t.Fatalf("allow reenroll: %v", err) + } + + if _, exists := monitor.removedDockerHosts[host.ID]; exists { + t.Fatalf("expected host removal block to be cleared") + } + if _, exists := monitor.dockerCommands[host.ID]; exists { + t.Fatalf("expected any queued commands to be cleared") + } + + hostState := findDockerHost(t, monitor, host.ID) + if hostState.Command != nil { + t.Fatalf("expected host command pointer to be cleared") + } +} + +func TestCleanupRemovedDockerHosts(t *testing.T) { + t.Parallel() + + monitor := newTestMonitorForCommands(t) + monitor.removedDockerHosts["old-host"] = time.Now().Add(-2 * removedDockerHostsTTL) + monitor.removedDockerHosts["fresh-host"] = time.Now() + + now := time.Now() + monitor.cleanupRemovedDockerHosts(now) + + if _, exists := monitor.removedDockerHosts["old-host"]; exists { + t.Fatalf("expected old host removal entry to be purged") + } + if _, exists := monitor.removedDockerHosts["fresh-host"]; !exists { + t.Fatalf("expected fresh host removal entry to remain") + } +}