From 9393574f058ad73b227f09929bd8eb8a5995c2bf Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 29 Nov 2025 19:49:43 +0000 Subject: [PATCH] ADA: Add unit tests for Ceph helper functions Test coverage for isCephStorageType, countServiceDaemons, extractCephCheckSummary, and summarizeCephHealth functions. These parse Ceph storage types and health status JSON. --- internal/monitoring/ceph_test.go | 383 +++++++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 internal/monitoring/ceph_test.go diff --git a/internal/monitoring/ceph_test.go b/internal/monitoring/ceph_test.go new file mode 100644 index 0000000..416f320 --- /dev/null +++ b/internal/monitoring/ceph_test.go @@ -0,0 +1,383 @@ +package monitoring + +import ( + "encoding/json" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" +) + +func TestIsCephStorageType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + storageType string + want bool + }{ + // Valid Ceph types + {"rbd lowercase", "rbd", true}, + {"cephfs lowercase", "cephfs", true}, + {"ceph lowercase", "ceph", true}, + + // Case variations + {"RBD uppercase", "RBD", true}, + {"CephFS mixed case", "CephFS", true}, + {"CEPH uppercase", "CEPH", true}, + + // Whitespace handling + {"rbd with leading space", " rbd", true}, + {"rbd with trailing space", "rbd ", true}, + {"rbd with surrounding spaces", " rbd ", true}, + + // Non-Ceph storage types + {"local storage", "local", false}, + {"dir storage", "dir", false}, + {"nfs storage", "nfs", false}, + {"lvm storage", "lvm", false}, + {"zfs storage", "zfs", false}, + {"zfspool storage", "zfspool", false}, + {"iscsi storage", "iscsi", false}, + + // Edge cases + {"empty string", "", false}, + {"whitespace only", " ", false}, + {"partial match rbd", "rbd-pool", false}, + {"partial match ceph", "ceph-pool", false}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := isCephStorageType(tc.storageType); got != tc.want { + t.Fatalf("isCephStorageType(%q) = %v, want %v", tc.storageType, got, tc.want) + } + }) + } +} + +func TestCountServiceDaemons(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + services map[string]proxmox.CephServiceDefinition + serviceType string + want int + }{ + { + name: "nil services map", + services: nil, + serviceType: "mon", + want: 0, + }, + { + name: "empty services map", + services: map[string]proxmox.CephServiceDefinition{}, + serviceType: "mon", + want: 0, + }, + { + name: "service type not found", + services: map[string]proxmox.CephServiceDefinition{ + "mon": { + Daemons: map[string]proxmox.CephServiceDaemon{ + "a": {Host: "node1", Status: "running"}, + }, + }, + }, + serviceType: "osd", + want: 0, + }, + { + name: "single daemon", + services: map[string]proxmox.CephServiceDefinition{ + "mon": { + Daemons: map[string]proxmox.CephServiceDaemon{ + "a": {Host: "node1", Status: "running"}, + }, + }, + }, + serviceType: "mon", + want: 1, + }, + { + name: "multiple daemons", + services: map[string]proxmox.CephServiceDefinition{ + "mon": { + Daemons: map[string]proxmox.CephServiceDaemon{ + "a": {Host: "node1", Status: "running"}, + "b": {Host: "node2", Status: "running"}, + "c": {Host: "node3", Status: "running"}, + }, + }, + }, + serviceType: "mon", + want: 3, + }, + { + name: "multiple service types", + services: map[string]proxmox.CephServiceDefinition{ + "mon": { + Daemons: map[string]proxmox.CephServiceDaemon{ + "a": {Host: "node1", Status: "running"}, + "b": {Host: "node2", Status: "running"}, + }, + }, + "mgr": { + Daemons: map[string]proxmox.CephServiceDaemon{ + "node1": {Host: "node1", Status: "active"}, + }, + }, + }, + serviceType: "mgr", + want: 1, + }, + { + name: "empty daemons map", + services: map[string]proxmox.CephServiceDefinition{ + "mon": { + Daemons: map[string]proxmox.CephServiceDaemon{}, + }, + }, + serviceType: "mon", + want: 0, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := countServiceDaemons(tc.services, tc.serviceType); got != tc.want { + t.Fatalf("countServiceDaemons(%v, %q) = %d, want %d", tc.services, tc.serviceType, got, tc.want) + } + }) + } +} + +func TestExtractCephCheckSummary(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw json.RawMessage + want string + }{ + // Empty/nil cases + { + name: "nil raw message", + raw: nil, + want: "", + }, + { + name: "empty raw message", + raw: json.RawMessage{}, + want: "", + }, + + // Object format with message field + { + name: "object with message", + raw: json.RawMessage(`{"message": "1 slow ops"}`), + want: "1 slow ops", + }, + { + name: "object with summary", + raw: json.RawMessage(`{"summary": "health warning"}`), + want: "health warning", + }, + { + name: "object with both message and summary prefers message", + raw: json.RawMessage(`{"message": "from message", "summary": "from summary"}`), + want: "from message", + }, + { + name: "object with empty message falls back to summary", + raw: json.RawMessage(`{"message": "", "summary": "fallback summary"}`), + want: "fallback summary", + }, + + // Array format + { + name: "array with single item message", + raw: json.RawMessage(`[{"message": "array message"}]`), + want: "array message", + }, + { + name: "array with single item summary", + raw: json.RawMessage(`[{"summary": "array summary"}]`), + want: "array summary", + }, + { + name: "array with multiple items returns first", + raw: json.RawMessage(`[{"message": "first"}, {"message": "second"}]`), + want: "first", + }, + { + name: "array skips empty to find message", + raw: json.RawMessage(`[{"message": ""}, {"message": "second"}]`), + want: "second", + }, + { + name: "empty array", + raw: json.RawMessage(`[]`), + want: "", + }, + + // Plain string format + { + name: "plain string", + raw: json.RawMessage(`"simple string message"`), + want: "simple string message", + }, + { + name: "empty string", + raw: json.RawMessage(`""`), + want: "", + }, + + // Invalid JSON + { + name: "invalid JSON", + raw: json.RawMessage(`{invalid`), + want: "", + }, + { + name: "number value", + raw: json.RawMessage(`123`), + want: "", + }, + { + name: "boolean value", + raw: json.RawMessage(`true`), + want: "", + }, + { + name: "null value", + raw: json.RawMessage(`null`), + want: "", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := extractCephCheckSummary(tc.raw); got != tc.want { + t.Fatalf("extractCephCheckSummary(%s) = %q, want %q", tc.raw, got, tc.want) + } + }) + } +} + +func TestSummarizeCephHealth(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status *proxmox.CephStatus + want string + }{ + { + name: "nil status", + status: nil, + want: "", + }, + { + name: "empty status", + status: &proxmox.CephStatus{}, + want: "", + }, + { + name: "single summary message", + status: &proxmox.CephStatus{ + Health: proxmox.CephHealth{ + Summary: []proxmox.CephHealthSummary{ + {Message: "HEALTH_WARN"}, + }, + }, + }, + want: "HEALTH_WARN", + }, + { + name: "summary with summary field instead of message", + status: &proxmox.CephStatus{ + Health: proxmox.CephHealth{ + Summary: []proxmox.CephHealthSummary{ + {Summary: "1 pool(s) have no replicas configured"}, + }, + }, + }, + want: "1 pool(s) have no replicas configured", + }, + { + name: "multiple summary messages", + status: &proxmox.CephStatus{ + Health: proxmox.CephHealth{ + Summary: []proxmox.CephHealthSummary{ + {Message: "first warning"}, + {Message: "second warning"}, + }, + }, + }, + want: "first warning; second warning", + }, + { + name: "health check with summary object", + status: &proxmox.CephStatus{ + Health: proxmox.CephHealth{ + Checks: map[string]proxmox.CephHealthCheckRaw{ + "SLOW_OPS": { + Summary: json.RawMessage(`{"message": "1 slow ops"}`), + }, + }, + }, + }, + want: "SLOW_OPS: 1 slow ops", + }, + { + name: "health check with detail fallback", + status: &proxmox.CephStatus{ + Health: proxmox.CephHealth{ + Checks: map[string]proxmox.CephHealthCheckRaw{ + "OSD_DOWN": { + Summary: json.RawMessage(`{}`), + Detail: []proxmox.CephCheckDetail{ + {Message: "osd.0 is down"}, + }, + }, + }, + }, + }, + want: "OSD_DOWN: osd.0 is down", + }, + { + name: "combined summary and checks", + status: &proxmox.CephStatus{ + Health: proxmox.CephHealth{ + Summary: []proxmox.CephHealthSummary{ + {Message: "HEALTH_WARN"}, + }, + Checks: map[string]proxmox.CephHealthCheckRaw{ + "PG_DEGRADED": { + Summary: json.RawMessage(`{"message": "Degraded data redundancy"}`), + }, + }, + }, + }, + want: "HEALTH_WARN; PG_DEGRADED: Degraded data redundancy", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := summarizeCephHealth(tc.status); got != tc.want { + t.Fatalf("summarizeCephHealth() = %q, want %q", got, tc.want) + } + }) + } +}