mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
- Add persistent volume mounts for Go/npm caches (faster rebuilds) - Add shell config with helpful aliases and custom prompt - Add comprehensive devcontainer documentation - Add pre-commit hooks for Go formatting and linting - Use go-version-file in CI workflows instead of hardcoded versions - Simplify docker compose commands with --wait flag - Add gitignore entries for devcontainer auth files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
483 lines
12 KiB
Go
483 lines
12 KiB
Go
package context
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
)
|
|
|
|
func TestNewBuilder(t *testing.T) {
|
|
builder := NewBuilder()
|
|
if builder == nil {
|
|
t.Fatal("Expected non-nil builder")
|
|
}
|
|
}
|
|
|
|
func TestBuilder_WithMethods(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
// Test chaining methods return the builder
|
|
result := builder.
|
|
WithMetricsHistory(nil).
|
|
WithKnowledge(nil).
|
|
WithFindings(nil).
|
|
WithBaseline(nil)
|
|
|
|
if result != builder {
|
|
t.Error("Expected method chaining to return same builder instance")
|
|
}
|
|
}
|
|
|
|
func TestBuilder_BuildForInfrastructure_Empty(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
// Empty state
|
|
state := models.StateSnapshot{}
|
|
|
|
ctx := builder.BuildForInfrastructure(state)
|
|
|
|
if ctx == nil {
|
|
t.Fatal("Expected non-nil context")
|
|
}
|
|
|
|
// Empty state should have zero totals
|
|
if ctx.TotalResources != 0 {
|
|
t.Errorf("Expected 0 total resources for empty state, got %d", ctx.TotalResources)
|
|
}
|
|
|
|
// GeneratedAt should be set
|
|
if ctx.GeneratedAt.IsZero() {
|
|
t.Error("Expected GeneratedAt to be set")
|
|
}
|
|
}
|
|
|
|
func TestBuilder_BuildForInfrastructure_WithNodes(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{
|
|
ID: "node-1",
|
|
Name: "pve-primary",
|
|
Status: "online",
|
|
CPU: 0.45,
|
|
},
|
|
{
|
|
ID: "node-2",
|
|
Name: "pve-secondary",
|
|
Status: "online",
|
|
CPU: 0.25,
|
|
},
|
|
},
|
|
}
|
|
|
|
ctx := builder.BuildForInfrastructure(state)
|
|
|
|
if len(ctx.Nodes) != 2 {
|
|
t.Errorf("Expected 2 nodes, got %d", len(ctx.Nodes))
|
|
}
|
|
}
|
|
|
|
func TestBuilder_BuildForInfrastructure_WithGuests(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{ID: "node-1", Name: "pve-primary", Status: "online"},
|
|
},
|
|
VMs: []models.VM{
|
|
{ID: "vm-100", Name: "web-server", Node: "pve-primary", Status: "running", CPU: 0.30},
|
|
{ID: "vm-101", Name: "database", Node: "pve-primary", Status: "running", CPU: 0.50},
|
|
},
|
|
Containers: []models.Container{
|
|
{ID: "ct-200", Name: "nginx-proxy", Node: "pve-primary", Status: "running", CPU: 0.10},
|
|
},
|
|
}
|
|
|
|
ctx := builder.BuildForInfrastructure(state)
|
|
|
|
if len(ctx.VMs) != 2 {
|
|
t.Errorf("Expected 2 VMs, got %d", len(ctx.VMs))
|
|
}
|
|
|
|
if len(ctx.Containers) != 1 {
|
|
t.Errorf("Expected 1 container, got %d", len(ctx.Containers))
|
|
}
|
|
}
|
|
|
|
func TestBuilder_BuildForInfrastructure_SkipsTemplates(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
state := models.StateSnapshot{
|
|
VMs: []models.VM{
|
|
{ID: "vm-100", Name: "web-server", Status: "running", Template: false},
|
|
{ID: "vm-101", Name: "template", Status: "stopped", Template: true},
|
|
},
|
|
Containers: []models.Container{
|
|
{ID: "ct-200", Name: "nginx", Status: "running", Template: false},
|
|
{ID: "ct-201", Name: "template", Status: "stopped", Template: true},
|
|
},
|
|
}
|
|
|
|
ctx := builder.BuildForInfrastructure(state)
|
|
|
|
// Should skip templates
|
|
if len(ctx.VMs) != 1 {
|
|
t.Errorf("Expected 1 VM (template skipped), got %d", len(ctx.VMs))
|
|
}
|
|
|
|
if len(ctx.Containers) != 1 {
|
|
t.Errorf("Expected 1 container (template skipped), got %d", len(ctx.Containers))
|
|
}
|
|
}
|
|
|
|
func TestBuilder_BuildForInfrastructure_WithStorage(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
state := models.StateSnapshot{
|
|
Storage: []models.Storage{
|
|
{
|
|
ID: "storage-1",
|
|
Name: "local-zfs",
|
|
Type: "zfspool",
|
|
Status: "available",
|
|
},
|
|
{
|
|
ID: "storage-2",
|
|
Name: "nfs-share",
|
|
Type: "nfs",
|
|
Status: "available",
|
|
},
|
|
},
|
|
}
|
|
|
|
ctx := builder.BuildForInfrastructure(state)
|
|
|
|
if len(ctx.Storage) != 2 {
|
|
t.Errorf("Expected 2 storage, got %d", len(ctx.Storage))
|
|
}
|
|
}
|
|
|
|
func TestBuilder_BuildForInfrastructure_WithDocker(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
state := models.StateSnapshot{
|
|
DockerHosts: []models.DockerHost{
|
|
{
|
|
ID: "docker-1",
|
|
Hostname: "docker-host-1",
|
|
Containers: []models.DockerContainer{
|
|
{ID: "container-1", Name: "nginx", State: "running"},
|
|
{ID: "container-2", Name: "redis", State: "running"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
ctx := builder.BuildForInfrastructure(state)
|
|
|
|
if len(ctx.DockerHosts) != 1 {
|
|
t.Errorf("Expected 1 docker host, got %d", len(ctx.DockerHosts))
|
|
}
|
|
}
|
|
|
|
func TestBuilder_BuildForInfrastructure_WithHosts(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
state := models.StateSnapshot{
|
|
Hosts: []models.Host{
|
|
{
|
|
ID: "host-1",
|
|
Hostname: "server-1",
|
|
Status: "online",
|
|
CPUCount: 8,
|
|
},
|
|
},
|
|
}
|
|
|
|
ctx := builder.BuildForInfrastructure(state)
|
|
|
|
if len(ctx.Hosts) != 1 {
|
|
t.Errorf("Expected 1 host, got %d", len(ctx.Hosts))
|
|
}
|
|
}
|
|
|
|
func TestBuilder_BuildForInfrastructure_TotalResources(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{ID: "node-1", Name: "pve-1", Status: "online"},
|
|
},
|
|
VMs: []models.VM{
|
|
{ID: "vm-100", Name: "web", Status: "running"},
|
|
{ID: "vm-101", Name: "db", Status: "running"},
|
|
},
|
|
Containers: []models.Container{
|
|
{ID: "ct-200", Name: "nginx", Status: "running"},
|
|
},
|
|
Storage: []models.Storage{
|
|
{ID: "local", Name: "local", Status: "available"},
|
|
},
|
|
}
|
|
|
|
ctx := builder.BuildForInfrastructure(state)
|
|
|
|
// 1 node + 2 VMs + 1 container + 1 storage = 5
|
|
expected := 5
|
|
if ctx.TotalResources != expected {
|
|
t.Errorf("Expected %d total resources, got %d", expected, ctx.TotalResources)
|
|
}
|
|
}
|
|
|
|
func TestBuilder_BuildForInfrastructure_OCI(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
state := models.StateSnapshot{
|
|
Containers: []models.Container{
|
|
{
|
|
ID: "ct-200",
|
|
Name: "nginx-oci",
|
|
Status: "running",
|
|
IsOCI: true,
|
|
OSTemplate: "docker.io/library/nginx:latest",
|
|
},
|
|
},
|
|
}
|
|
|
|
ctx := builder.BuildForInfrastructure(state)
|
|
|
|
if len(ctx.Containers) != 1 {
|
|
t.Fatalf("Expected 1 container, got %d", len(ctx.Containers))
|
|
}
|
|
|
|
// OCI container should have type oci_container
|
|
if ctx.Containers[0].ResourceType != "oci_container" {
|
|
t.Errorf("Expected resource type 'oci_container', got '%s'", ctx.Containers[0].ResourceType)
|
|
}
|
|
|
|
// Should have metadata with image
|
|
if ctx.Containers[0].Metadata == nil {
|
|
t.Error("Expected metadata to be set for OCI container")
|
|
} else if ctx.Containers[0].Metadata["oci_image"] != "docker.io/library/nginx:latest" {
|
|
t.Errorf("Expected oci_image metadata, got %v", ctx.Containers[0].Metadata)
|
|
}
|
|
}
|
|
|
|
func TestBuilder_MergeContexts(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
target := &ResourceContext{
|
|
ResourceID: "vm-100",
|
|
ResourceType: "vm",
|
|
ResourceName: "web-server",
|
|
Status: "running",
|
|
Node: "pve-1",
|
|
}
|
|
|
|
infra := &InfrastructureContext{
|
|
TotalResources: 10,
|
|
GeneratedAt: time.Now(),
|
|
VMs: []ResourceContext{
|
|
{ResourceID: "vm-100", ResourceName: "web-server", Node: "pve-1"},
|
|
{ResourceID: "vm-101", ResourceName: "database", Node: "pve-1"},
|
|
},
|
|
}
|
|
|
|
result := builder.MergeContexts(target, infra)
|
|
|
|
if result == "" {
|
|
t.Error("Expected non-empty merged context")
|
|
}
|
|
|
|
// Should contain target resource section
|
|
if !containsSubstring(result, "Target Resource") {
|
|
t.Error("Expected merged context to contain 'Target Resource' section")
|
|
}
|
|
}
|
|
|
|
func TestBuilder_MergeContexts_IncludesRelated(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
target := &ResourceContext{
|
|
ResourceID: "vm-100",
|
|
ResourceType: "vm",
|
|
ResourceName: "web-server",
|
|
Node: "pve-1",
|
|
}
|
|
|
|
infra := &InfrastructureContext{
|
|
VMs: []ResourceContext{
|
|
{ResourceID: "vm-100", ResourceName: "web-server", Node: "pve-1"},
|
|
{ResourceID: "vm-101", ResourceName: "database", Node: "pve-1"},
|
|
{ResourceID: "vm-102", ResourceName: "other", Node: "pve-2"}, // Different node
|
|
},
|
|
}
|
|
|
|
result := builder.MergeContexts(target, infra)
|
|
|
|
// Should include related resources section
|
|
if !containsSubstring(result, "Related Resources") {
|
|
t.Error("Expected merged context to contain 'Related Resources' section when target has a node")
|
|
}
|
|
}
|
|
|
|
func TestResourceContext_Fields(t *testing.T) {
|
|
ctx := ResourceContext{
|
|
ResourceID: "vm-100",
|
|
ResourceType: "vm",
|
|
ResourceName: "test-vm",
|
|
Node: "node-1",
|
|
CurrentCPU: 0.45,
|
|
CurrentMemory: 0.65,
|
|
CurrentDisk: 0.30,
|
|
Status: "running",
|
|
}
|
|
|
|
if ctx.ResourceID != "vm-100" {
|
|
t.Errorf("Expected ResourceID 'vm-100', got '%s'", ctx.ResourceID)
|
|
}
|
|
|
|
if ctx.CurrentCPU != 0.45 {
|
|
t.Errorf("Expected CurrentCPU 0.45, got %f", ctx.CurrentCPU)
|
|
}
|
|
|
|
if ctx.Node != "node-1" {
|
|
t.Errorf("Expected Node 'node-1', got '%s'", ctx.Node)
|
|
}
|
|
}
|
|
|
|
func TestInfrastructureContext_Fields(t *testing.T) {
|
|
now := time.Now()
|
|
ctx := InfrastructureContext{
|
|
GeneratedAt: now,
|
|
TotalResources: 25,
|
|
ResourcesWithData: 20,
|
|
}
|
|
|
|
if ctx.TotalResources != 25 {
|
|
t.Errorf("Expected 25 total resources, got %d", ctx.TotalResources)
|
|
}
|
|
|
|
if ctx.GeneratedAt != now {
|
|
t.Error("Expected GeneratedAt to match")
|
|
}
|
|
|
|
if ctx.ResourcesWithData != 20 {
|
|
t.Errorf("Expected 20 resources with data, got %d", ctx.ResourcesWithData)
|
|
}
|
|
}
|
|
|
|
// Helper function
|
|
func containsSubstring(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestFilterRecentPoints_Empty(t *testing.T) {
|
|
points := []MetricPoint{}
|
|
result := filterRecentPoints(points, time.Hour)
|
|
|
|
if len(result) != 0 {
|
|
t.Errorf("Expected empty result, got %d points", len(result))
|
|
}
|
|
}
|
|
|
|
func TestFilterRecentPoints_AllRecent(t *testing.T) {
|
|
now := time.Now()
|
|
points := []MetricPoint{
|
|
{Timestamp: now.Add(-30 * time.Minute), Value: 1.0},
|
|
{Timestamp: now.Add(-15 * time.Minute), Value: 2.0},
|
|
{Timestamp: now.Add(-5 * time.Minute), Value: 3.0},
|
|
}
|
|
|
|
result := filterRecentPoints(points, time.Hour)
|
|
|
|
if len(result) != 3 {
|
|
t.Errorf("Expected 3 points, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
func TestFilterRecentPoints_FilterOld(t *testing.T) {
|
|
now := time.Now()
|
|
points := []MetricPoint{
|
|
{Timestamp: now.Add(-3 * time.Hour), Value: 1.0}, // Old
|
|
{Timestamp: now.Add(-2 * time.Hour), Value: 2.0}, // Old
|
|
{Timestamp: now.Add(-30 * time.Minute), Value: 3.0}, // Recent
|
|
}
|
|
|
|
result := filterRecentPoints(points, time.Hour)
|
|
|
|
if len(result) != 1 {
|
|
t.Errorf("Expected 1 recent point, got %d", len(result))
|
|
}
|
|
if result[0].Value != 3.0 {
|
|
t.Errorf("Expected value 3.0, got %f", result[0].Value)
|
|
}
|
|
}
|
|
|
|
func TestFormatAnomalyDescription(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
metric string
|
|
current float64
|
|
mean float64
|
|
stddev float64
|
|
severity string
|
|
direction string
|
|
wantContains []string
|
|
}{
|
|
{
|
|
name: "cpu high",
|
|
metric: "cpu",
|
|
current: 95.0,
|
|
mean: 50.0,
|
|
stddev: 10.0,
|
|
severity: "significantly",
|
|
direction: "above",
|
|
wantContains: []string{"Cpu", "significantly", "above", "95%", "50%"},
|
|
},
|
|
{
|
|
name: "memory low",
|
|
metric: "memory",
|
|
current: 20.0,
|
|
mean: 60.0,
|
|
stddev: 15.0,
|
|
severity: "slightly",
|
|
direction: "below",
|
|
wantContains: []string{"Memory", "slightly", "below", "20%", "60%"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := formatAnomalyDescription(tt.metric, tt.current, tt.mean, tt.stddev, tt.severity, tt.direction)
|
|
for _, want := range tt.wantContains {
|
|
if !containsSubstring(result, want) {
|
|
t.Errorf("formatAnomalyDescription() = %q, want to contain %q", result, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFormatPredictionBasis(t *testing.T) {
|
|
trend := Trend{
|
|
RatePerDay: 2.5,
|
|
Period: 7 * 24 * time.Hour, // 7 days
|
|
}
|
|
|
|
result := formatPredictionBasis(trend)
|
|
|
|
if !containsSubstring(result, "Growing") {
|
|
t.Errorf("Expected 'Growing' in result, got %q", result)
|
|
}
|
|
if !containsSubstring(result, "based on") {
|
|
t.Errorf("Expected 'based on' in result, got %q", result)
|
|
}
|
|
}
|