package configrefresh_test import ( "context" "testing" "iop/apps/edge/internal/configrefresh" ) // TestClassifyListenPortRestartRequired verifies S08: listen port change is classified as restart_required. func TestClassifyListenPortRestartRequired(t *testing.T) { dir := t.TempDir() currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML) candidateYAML := ` server: listen: "0.0.0.0:19090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 max_queue: 4 queue_timeout_ms: 5000 ` candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) ctx := context.Background() result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-s08", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusRestartRequired { t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusRestartRequired, result.Status, result.Summary) } found := false for _, c := range result.Changes { if c.Path == "server.listen" && c.Class == configrefresh.StatusRestartRequired { found = true } } if !found { t.Errorf("server.listen restart_required change not found in: %+v", result.Changes) } } // TestRefreshInvalidYAMLRejected verifies S09: invalid YAML is classified as rejected. func TestRefreshInvalidYAMLRejected(t *testing.T) { dir := t.TempDir() currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML) invalidPath := writeYAML(t, dir, "invalid.yaml", "nodes: [\n - bad: [unterminated") current := buildNormalizedCurrent(t, currentPath) ctx := context.Background() result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: invalidPath, RequestID: "test-s09-yaml", }) if err != nil { t.Fatalf("Evaluate should not return error for rejected config: %v", err) } if result.Status != configrefresh.StatusRejected { t.Errorf("expected status=%q, got %q", configrefresh.StatusRejected, result.Status) } if candidate != nil { t.Errorf("expected nil candidate for rejected result") } } // TestClassifyServeNormalizedRefreshApplied verifies the archived bug: // when current config is serve-normalized (logging.path absolute, // bootstrap.artifact_dir absolute) and candidate uses source-level defaults // (empty logging.path, relative bootstrap.artifact_dir) with only provider // capacity changes, the result must be applied, NOT restart_required. func TestClassifyServeNormalizedRefreshApplied(t *testing.T) { dir := t.TempDir() // Source-level YAML with empty logging.path and relative artifact_dir. // Only capacity differs (2 → 8). sourceYAML := ` server: listen: "0.0.0.0:9090" bootstrap: artifact_dir: artifacts logging: level: info nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 max_queue: 4 queue_timeout_ms: 5000 ` // Candidate: only capacity changes to 8; logging/artifact_dir are same source-level defaults. candidateYAML := ` server: listen: "0.0.0.0:9090" bootstrap: artifact_dir: artifacts logging: level: info nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 8 max_queue: 4 queue_timeout_ms: 5000 ` currentPath := writeYAML(t, dir, "current.yaml", sourceYAML) candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) // Build current as serve-normalized: explicit absolute paths. current := buildNormalizedCurrent(t, currentPath) // Verify that the current config actually has normalized paths. if current.Logging.Path == "" { t.Fatalf("expected normalized logging.path, got empty") } if current.Bootstrap.ArtifactDir == "" { t.Fatalf("expected normalized bootstrap.artifact_dir, got empty") } // Evaluate the candidate via LoadCandidate (which now applies normalization). ctx := context.Background() result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-serve-normalized", }) if err != nil { t.Fatalf("Evaluate: %v", err) } // Key assertion: capacity-only refresh must NOT be restart_required. if result.Status != configrefresh.StatusApplied { t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary) } // Ensure no restart_required changes for logging or bootstrap.artifact_dir. for _, c := range result.Changes { if c.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required change for capacity-only refresh: %s (prev=%s, next=%s)", c.Path, c.Previous, c.Next) } } // Verify the capacity change is captured as applied. foundCapacity := false for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-a"].capacity` && c.Class == configrefresh.StatusApplied { foundCapacity = true if c.Previous != "2" || c.Next != "8" { t.Errorf("expected capacity change 2→8, got %s→%s", c.Previous, c.Next) } } } if !foundCapacity { t.Fatalf("capacity change not found in applied changes: %+v", result.Changes) } // Verify the candidate is also normalized. if candidate == nil { t.Fatalf("expected non-nil candidate") } if candidate.Logging.Path == "" { t.Fatalf("expected normalized candidate logging.path, got empty") } } // TestClassifyExplicitRelativeLoggingPathNoRestartRequired verifies that when // both current and candidate use the same explicit relative logging.path // (e.g. "./logs/edge.log") and only provider capacity differs, the refresh // is classified as applied – NOT restart_required. // // This is the regression test for the archived bug where applyRuntimeNormalization // called filepath.Clean on explicit relative paths, producing a false diff // against the serve-side value that preserves "./logs/edge.log" unchanged. func TestClassifyExplicitRelativeLoggingPathNoRestartRequired(t *testing.T) { dir := t.TempDir() // Current config with explicit relative logging.path – mimics what serve // holds in memory after loading YAML with logging.path: "./logs/edge.log". currentYAML := ` server: listen: "0.0.0.0:9090" logging: path: "./logs/edge.log" level: info nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 max_queue: 4 queue_timeout_ms: 5000 ` // Candidate: same explicit relative logging.path, only capacity changes. candidateYAML := ` server: listen: "0.0.0.0:9090" logging: path: "./logs/edge.log" level: info nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 8 max_queue: 4 queue_timeout_ms: 5000 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) // buildNormalizedCurrent preserves the explicit relative logging.path. current := buildNormalizedCurrent(t, currentPath) // Verify the current config still has the explicit relative path. if current.Logging.Path != "./logs/edge.log" { t.Fatalf("expected current logging.path to remain './logs/edge.log', got %q", current.Logging.Path) } ctx := context.Background() result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-explicit-relative-logpath", }) if err != nil { t.Fatalf("Evaluate: %v", err) } // Key assertion: capacity-only refresh with same explicit relative path must be applied. if result.Status != configrefresh.StatusApplied { t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary) } // Ensure no restart_required changes at all. for _, c := range result.Changes { if c.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required change for capacity-only refresh: %s (prev=%s, next=%s)", c.Path, c.Previous, c.Next) } } // Verify the capacity change is captured as applied. foundCapacity := false for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-a"].capacity` && c.Class == configrefresh.StatusApplied { foundCapacity = true if c.Previous != "2" || c.Next != "8" { t.Errorf("expected capacity change 2→8, got %s→%s", c.Previous, c.Next) } } } if !foundCapacity { t.Fatalf("capacity change not found in applied changes: %+v", result.Changes) } // Candidate should also preserve the explicit relative path. if candidate == nil { t.Fatalf("expected non-nil candidate") } if candidate.Logging.Path != "./logs/edge.log" { t.Errorf("expected candidate logging.path to remain './logs/edge.log', got %q", candidate.Logging.Path) } } // TestClassifyDryRunTwiceNoDiff verifies that after apply, re-running dry-run // on the same candidate produces no diff. func TestClassifyDryRunTwiceNoDiff(t *testing.T) { dir := t.TempDir() baseYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 ` candidateYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 4 ` currentPath := writeYAML(t, dir, "current.yaml", baseYAML) candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) ctx := context.Background() // First dry-run: should detect capacity change. result1, cand1, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-first-dryrun", }) if err != nil { t.Fatalf("first Evaluate: %v", err) } if result1.Status != configrefresh.StatusApplied { t.Fatalf("first dry-run: expected status=%q, got %q", configrefresh.StatusApplied, result1.Status) } // Second dry-run with the same candidate loaded again. // The candidate path still exists, so LoadCandidate re-reads the same YAML. result2, cand2, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-second-dryrun", }) if err != nil { t.Fatalf("second Evaluate: %v", err) } // Both results should be identical (same changes). if len(result1.Changes) != len(result2.Changes) { t.Fatalf("expected same number of changes, got %d vs %d: result1=%+v result2=%+v", len(result1.Changes), len(result2.Changes), result1.Changes, result2.Changes) } // Both candidates should be normalized identically. if cand1 == nil || cand2 == nil { t.Fatalf("expected non-nil candidates") } if cand1.Logging.Path != cand2.Logging.Path { t.Fatalf("candidate logging.path mismatch: %s vs %s", cand1.Logging.Path, cand2.Logging.Path) } }