diff --git a/internal/monitoring/diagnostic_snapshots_test.go b/internal/monitoring/diagnostic_snapshots_test.go index 6082c2e..e4d259b 100644 --- a/internal/monitoring/diagnostic_snapshots_test.go +++ b/internal/monitoring/diagnostic_snapshots_test.go @@ -3,6 +3,8 @@ package monitoring import ( "testing" "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" ) func TestMakeNodeSnapshotKey(t *testing.T) { @@ -524,6 +526,238 @@ func TestRecordGuestSnapshot(t *testing.T) { }) } +func TestLogNodeMemorySource(t *testing.T) { + t.Run("nil Monitor is no-op", func(t *testing.T) { + var m *Monitor + // Should not panic + m.logNodeMemorySource("instance", "node1", NodeMemorySnapshot{ + MemorySource: "rrd-data", + }) + }) + + t.Run("same source as existing snapshot - no log emitted", func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // Pre-populate with existing snapshot + key := makeNodeSnapshotKey("pve1", "node1") + m.nodeSnapshots[key] = NodeMemorySnapshot{ + MemorySource: "rrd-data", + } + + // Should not panic and should return early (same source) + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: "rrd-data", + }) + }) + + t.Run("empty source triggers warn level", func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // Should not panic - empty source triggers Warn + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: "", + }) + }) + + t.Run("nodes-endpoint source triggers warn level", func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // Should not panic - nodes-endpoint triggers Warn + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: "nodes-endpoint", + }) + }) + + t.Run("node-status-used source triggers warn level", func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // Should not panic - node-status-used triggers Warn + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: "node-status-used", + }) + }) + + t.Run("previous-snapshot source triggers warn level", func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // Should not panic - previous-snapshot triggers Warn + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: "previous-snapshot", + }) + }) + + t.Run("rrd-data source triggers debug level", func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // Should not panic - rrd-data triggers Debug + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: "rrd-data", + }) + }) + + t.Run("rrd-available source triggers debug level", func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // Should not panic - rrd-available triggers Debug + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: "rrd-available", + }) + }) + + t.Run("source change from existing triggers log", func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // Pre-populate with existing snapshot + key := makeNodeSnapshotKey("pve1", "node1") + m.nodeSnapshots[key] = NodeMemorySnapshot{ + MemorySource: "rrd-data", + } + + // Different source should trigger logging + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: "rrd-available", + }) + }) + + t.Run("FallbackReason is logged when present", func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // Should not panic - FallbackReason present + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: "previous-snapshot", + FallbackReason: "no rrd data available", + }) + }) + + t.Run("Raw fields are logged when > 0", func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // Should not panic - various Raw fields > 0 + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: "rrd-data", + Raw: NodeMemoryRaw{ + Available: 1000000000, + Buffers: 200000000, + Cached: 500000000, + TotalMinusUsed: 800000000, + RRDAvailable: 900000000, + RRDUsed: 100000000, + RRDTotal: 1000000000, + ProxmoxMemorySource: "rrd", + }, + }) + }) + + t.Run("Memory fields are logged when > 0", func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // Should not panic - Memory fields > 0 + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: "rrd-data", + Memory: models.Memory{ + Total: 16000000000, + Used: 8000000000, + Free: 8000000000, + Usage: 0.5, + }, + }) + }) + + t.Run("all warn sources", func(t *testing.T) { + warnSources := []string{"", "nodes-endpoint", "node-status-used", "previous-snapshot"} + + for _, source := range warnSources { + t.Run("source_"+source, func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // Should not panic + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: source, + }) + }) + } + }) + + t.Run("debug sources", func(t *testing.T) { + debugSources := []string{"rrd-data", "rrd-available", "node-status-available", "calculated"} + + for _, source := range debugSources { + t.Run("source_"+source, func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // Should not panic + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: source, + }) + }) + } + }) + + t.Run("new node with no prior snapshot logs", func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // No existing snapshot, empty prevSource should not match non-empty source + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: "rrd-data", + }) + }) + + t.Run("new node with empty source matches no prior snapshot", func(t *testing.T) { + m := &Monitor{ + nodeSnapshots: make(map[string]NodeMemorySnapshot), + guestSnapshots: make(map[string]GuestMemorySnapshot), + } + + // No existing snapshot means prevSource is "", matching empty MemorySource + // This should skip logging due to prevSource == snapshot.MemorySource + m.logNodeMemorySource("pve1", "node1", NodeMemorySnapshot{ + MemorySource: "", + }) + }) +} + func TestGetDiagnosticSnapshots(t *testing.T) { t.Run("nil Monitor returns empty set with non-nil slices", func(t *testing.T) { var m *Monitor diff --git a/internal/monitoring/docker_commands_test.go b/internal/monitoring/docker_commands_test.go index 533bb0b..e0752e2 100644 --- a/internal/monitoring/docker_commands_test.go +++ b/internal/monitoring/docker_commands_test.go @@ -287,6 +287,84 @@ func TestDockerCommandHasExpired(t *testing.T) { } } +func TestMarkFailed(t *testing.T) { + t.Parallel() + + t.Run("sets status to failed", func(t *testing.T) { + t.Parallel() + + cmd := newDockerHostCommand(DockerCommandTypeStop, "test", dockerCommandDefaultTTL, nil) + cmd.markFailed("connection lost") + + if cmd.status.Status != DockerCommandStatusFailed { + t.Errorf("expected status %q, got %q", DockerCommandStatusFailed, cmd.status.Status) + } + }) + + t.Run("sets FailedAt to current time", func(t *testing.T) { + t.Parallel() + + cmd := newDockerHostCommand(DockerCommandTypeStop, "test", dockerCommandDefaultTTL, nil) + before := time.Now().UTC() + cmd.markFailed("timeout") + after := time.Now().UTC() + + if cmd.status.FailedAt == nil { + t.Fatal("expected FailedAt to be set") + } + if cmd.status.FailedAt.Before(before) || cmd.status.FailedAt.After(after) { + t.Errorf("FailedAt %v not in expected range [%v, %v]", *cmd.status.FailedAt, before, after) + } + }) + + t.Run("sets UpdatedAt to current time", func(t *testing.T) { + t.Parallel() + + cmd := newDockerHostCommand(DockerCommandTypeStop, "test", dockerCommandDefaultTTL, nil) + originalUpdatedAt := cmd.status.UpdatedAt + time.Sleep(time.Millisecond) // Ensure time advances + before := time.Now().UTC() + cmd.markFailed("error") + after := time.Now().UTC() + + if !cmd.status.UpdatedAt.After(originalUpdatedAt) { + t.Errorf("UpdatedAt should be updated; original=%v, new=%v", originalUpdatedAt, cmd.status.UpdatedAt) + } + if cmd.status.UpdatedAt.Before(before) || cmd.status.UpdatedAt.After(after) { + t.Errorf("UpdatedAt %v not in expected range [%v, %v]", cmd.status.UpdatedAt, before, after) + } + }) + + t.Run("sets FailureReason to provided reason", func(t *testing.T) { + t.Parallel() + + cmd := newDockerHostCommand(DockerCommandTypeStop, "test", dockerCommandDefaultTTL, nil) + reason := "agent unreachable: connection refused" + cmd.markFailed(reason) + + if cmd.status.FailureReason != reason { + t.Errorf("expected FailureReason %q, got %q", reason, cmd.status.FailureReason) + } + }) + + t.Run("accepts empty reason string", func(t *testing.T) { + t.Parallel() + + cmd := newDockerHostCommand(DockerCommandTypeStop, "test", dockerCommandDefaultTTL, nil) + cmd.markFailed("") + + if cmd.status.Status != DockerCommandStatusFailed { + t.Errorf("expected status %q, got %q", DockerCommandStatusFailed, cmd.status.Status) + } + if cmd.status.FailureReason != "" { + t.Errorf("expected empty FailureReason, got %q", cmd.status.FailureReason) + } + if cmd.status.FailedAt == nil { + t.Error("expected FailedAt to be set even with empty reason") + } + }) +} + func TestAcknowledgeDockerCommandErrorPaths(t *testing.T) { t.Parallel() diff --git a/internal/monitoring/monitor_test.go b/internal/monitoring/monitor_test.go new file mode 100644 index 0000000..327b63f --- /dev/null +++ b/internal/monitoring/monitor_test.go @@ -0,0 +1,164 @@ +package monitoring + +import ( + "testing" + "time" +) + +func TestParseDurationEnv(t *testing.T) { + const testKey = "TEST_DURATION_ENV" + defaultVal := 30 * time.Second + + t.Run("empty env var returns default", func(t *testing.T) { + t.Setenv(testKey, "") + result := parseDurationEnv(testKey, defaultVal) + if result != defaultVal { + t.Errorf("expected %v, got %v", defaultVal, result) + } + }) + + t.Run("unset env var returns default", func(t *testing.T) { + // t.Setenv automatically cleans up, so not setting means unset + result := parseDurationEnv("UNSET_DURATION_KEY_12345", defaultVal) + if result != defaultVal { + t.Errorf("expected %v, got %v", defaultVal, result) + } + }) + + t.Run("valid duration seconds", func(t *testing.T) { + t.Setenv(testKey, "1s") + result := parseDurationEnv(testKey, defaultVal) + expected := 1 * time.Second + if result != expected { + t.Errorf("expected %v, got %v", expected, result) + } + }) + + t.Run("valid duration minutes", func(t *testing.T) { + t.Setenv(testKey, "5m") + result := parseDurationEnv(testKey, defaultVal) + expected := 5 * time.Minute + if result != expected { + t.Errorf("expected %v, got %v", expected, result) + } + }) + + t.Run("valid duration composite", func(t *testing.T) { + t.Setenv(testKey, "2h30m") + result := parseDurationEnv(testKey, defaultVal) + expected := 2*time.Hour + 30*time.Minute + if result != expected { + t.Errorf("expected %v, got %v", expected, result) + } + }) + + t.Run("valid duration milliseconds", func(t *testing.T) { + t.Setenv(testKey, "500ms") + result := parseDurationEnv(testKey, defaultVal) + expected := 500 * time.Millisecond + if result != expected { + t.Errorf("expected %v, got %v", expected, result) + } + }) + + t.Run("invalid duration returns default", func(t *testing.T) { + t.Setenv(testKey, "invalid") + result := parseDurationEnv(testKey, defaultVal) + if result != defaultVal { + t.Errorf("expected default %v, got %v", defaultVal, result) + } + }) + + t.Run("numeric without unit returns default", func(t *testing.T) { + t.Setenv(testKey, "100") + result := parseDurationEnv(testKey, defaultVal) + if result != defaultVal { + t.Errorf("expected default %v, got %v", defaultVal, result) + } + }) + + t.Run("negative duration parses correctly", func(t *testing.T) { + t.Setenv(testKey, "-5s") + result := parseDurationEnv(testKey, defaultVal) + expected := -5 * time.Second + if result != expected { + t.Errorf("expected %v, got %v", expected, result) + } + }) +} + +func TestParseIntEnv(t *testing.T) { + const testKey = "TEST_INT_ENV" + defaultVal := 42 + + t.Run("empty env var returns default", func(t *testing.T) { + t.Setenv(testKey, "") + result := parseIntEnv(testKey, defaultVal) + if result != defaultVal { + t.Errorf("expected %d, got %d", defaultVal, result) + } + }) + + t.Run("unset env var returns default", func(t *testing.T) { + result := parseIntEnv("UNSET_INT_KEY_12345", defaultVal) + if result != defaultVal { + t.Errorf("expected %d, got %d", defaultVal, result) + } + }) + + t.Run("valid positive integer", func(t *testing.T) { + t.Setenv(testKey, "100") + result := parseIntEnv(testKey, defaultVal) + if result != 100 { + t.Errorf("expected 100, got %d", result) + } + }) + + t.Run("valid zero", func(t *testing.T) { + t.Setenv(testKey, "0") + result := parseIntEnv(testKey, defaultVal) + if result != 0 { + t.Errorf("expected 0, got %d", result) + } + }) + + t.Run("valid negative integer", func(t *testing.T) { + t.Setenv(testKey, "-50") + result := parseIntEnv(testKey, defaultVal) + if result != -50 { + t.Errorf("expected -50, got %d", result) + } + }) + + t.Run("invalid string returns default", func(t *testing.T) { + t.Setenv(testKey, "not-a-number") + result := parseIntEnv(testKey, defaultVal) + if result != defaultVal { + t.Errorf("expected default %d, got %d", defaultVal, result) + } + }) + + t.Run("float returns default", func(t *testing.T) { + t.Setenv(testKey, "3.14") + result := parseIntEnv(testKey, defaultVal) + if result != defaultVal { + t.Errorf("expected default %d, got %d", defaultVal, result) + } + }) + + t.Run("whitespace returns default", func(t *testing.T) { + t.Setenv(testKey, " ") + result := parseIntEnv(testKey, defaultVal) + if result != defaultVal { + t.Errorf("expected default %d, got %d", defaultVal, result) + } + }) + + t.Run("number with trailing text returns default", func(t *testing.T) { + t.Setenv(testKey, "100abc") + result := parseIntEnv(testKey, defaultVal) + if result != defaultVal { + t.Errorf("expected default %d, got %d", defaultVal, result) + } + }) +}