package configrefresh_test import ( "context" "testing" "iop/apps/edge/internal/configrefresh" ) // TestClassifyProviderCapacityApplied verifies S07: provider capacity change is classified as applied. func TestClassifyProviderCapacityApplied(t *testing.T) { dir := t.TempDir() currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML) 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: 8 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-s07", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusApplied { t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary) } if len(result.Changes) == 0 { t.Fatalf("expected at least one change") } found := false for _, c := range result.Changes { if c.Class == configrefresh.StatusApplied { found = true } if c.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required change: %s", c.Path) } } if !found { t.Errorf("no applied change found in: %+v", result.Changes) } } func TestClassifyProviderStructuralChangeRestartRequired(t *testing.T) { dir := t.TempDir() currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML) 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: "vllm" category: "api" 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) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-provider-structural", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusRestartRequired { t.Fatalf("expected status=%q, got %q changes=%+v", configrefresh.StatusRestartRequired, result.Status, result.Changes) } found := false for _, change := range result.Changes { if change.Path == `nodes[].providers["prov-a"].type` && change.Class == configrefresh.StatusRestartRequired { found = true } } if !found { t.Fatalf("provider structural type change not found in %+v", result.Changes) } } // TestRefreshInvalidProviderRejected verifies S09: invalid provider config is classified as rejected. func TestRefreshInvalidProviderRejected(t *testing.T) { dir := t.TempDir() currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML) // Invalid: shared validator rejects enabled openai_compat without endpoint. invalidProviderYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: openai_compat: enabled: true endpoint: "" ` invalidPath := writeYAML(t, dir, "invalid_provider.yaml", invalidProviderYAML) current := buildNormalizedCurrent(t, currentPath) ctx := context.Background() result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: invalidPath, RequestID: "test-s09-provider", }) 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 (summary: %s)", configrefresh.StatusRejected, result.Status, result.Summary) } if candidate != nil { t.Errorf("expected nil candidate for rejected result") } } // TestClassifyProviderEnabledLiveApply verifies that toggling providers[].enabled // is classified as StatusApplied (live apply, no restart required). func TestClassifyProviderEnabledLiveApply(t *testing.T) { dir := t.TempDir() currentYAML := ` 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 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) // Candidate disables prov-a. 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: 2 enabled: false ` candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-enabled-liveapply", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusApplied { t.Fatalf("expected status=applied for enabled toggle, got %q", result.Status) } foundEnabled := false for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-a"].enabled` { foundEnabled = true if c.Class != configrefresh.StatusApplied { t.Errorf("providers[].enabled class: got %q, want applied", c.Class) } if c.Previous != "true" || c.Next != "false" { t.Errorf("providers[].enabled change: prev=%q next=%q", c.Previous, c.Next) } } if c.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required change for enabled-only diff: %s", c.Path) } } if !foundEnabled { t.Errorf("providers[].enabled change path not found in: %+v", result.Changes) } } // TestClassifyProviderFirstEndpointOnlyRestartRequired verifies that changing // only the provider endpoint (type/category unchanged) is classified as // restart_required and surfaces the correct path. func TestClassifyProviderFirstEndpointOnlyRestartRequired(t *testing.T) { dir := t.TempDir() currentYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" providers: - id: "prov-oc" type: "openai_compat" category: "api" endpoint: "http://127.0.0.1:8000/v1" models: ["model-a"] capacity: 2 ` // Only endpoint changes — type, category, models, capacity are identical. candidateYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" providers: - id: "prov-oc" type: "openai_compat" category: "api" endpoint: "http://127.0.0.1:9000/v1" models: ["model-a"] capacity: 2 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-endpoint-only", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusRestartRequired { t.Fatalf("expected status=restart_required for endpoint-only change, got %q; changes=%+v", result.Status, result.Changes) } found := false for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-oc"].endpoint` && c.Class == configrefresh.StatusRestartRequired { found = true } } if !found { t.Errorf("expected restart_required change for providers[].endpoint; changes=%+v", result.Changes) } } // TestClassifyProviderFirstHeadersChangeRestartRequired verifies that changing // a provider headers map (type/category unchanged) is classified as // restart_required — covers map deep-diff path. func TestClassifyProviderFirstHeadersChangeRestartRequired(t *testing.T) { dir := t.TempDir() currentYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" providers: - id: "prov-oc" type: "openai_compat" category: "api" endpoint: "http://127.0.0.1:8000/v1" headers: Authorization: "Bearer old-token" models: ["model-a"] capacity: 2 ` // Only headers change. candidateYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" providers: - id: "prov-oc" type: "openai_compat" category: "api" endpoint: "http://127.0.0.1:8000/v1" headers: Authorization: "Bearer new-token" models: ["model-a"] capacity: 2 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-headers-only", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusRestartRequired { t.Fatalf("expected status=restart_required for headers change, got %q; changes=%+v", result.Status, result.Changes) } found := false for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-oc"].headers` && c.Class == configrefresh.StatusRestartRequired { found = true } } if !found { t.Errorf("expected restart_required change for providers[].headers; changes=%+v", result.Changes) } } func TestClassifyProviderFirstExecutionFieldRestartRequired(t *testing.T) { dir := t.TempDir() currentYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: vllm: enabled: true endpoint: "http://127.0.0.1:8000/v1" providers: - id: "prov-vllm" type: "vllm" category: "api" endpoint: "http://127.0.0.1:8000/v1" models: ["model-a"] capacity: 2 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) // Candidate changes endpoint (provider-first execution field) → restart required. candidateYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: vllm: enabled: true endpoint: "http://127.0.0.1:8080/v1" providers: - id: "prov-vllm" type: "openai_compat" category: "api" endpoint: "http://127.0.0.1:8080/v1" models: ["model-a"] capacity: 2 ` candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-provfirst-restart", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusRestartRequired { t.Fatalf("expected status=restart_required for provider type change, got %q", result.Status) } hasTypeRestart := false for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-vllm"].type` && c.Class == configrefresh.StatusRestartRequired { hasTypeRestart = true } } if !hasTypeRestart { t.Errorf("expected restart_required for providers[].type change; changes: %+v", result.Changes) } } // TestClassifyProviderPriorityApplied verifies that provider priority change // is classified as StatusApplied (live apply, no restart required). func TestClassifyProviderPriorityApplied(t *testing.T) { dir := t.TempDir() currentYAML := ` 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 priority: 5 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) // Candidate only changes priority. 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: 2 priority: 1 ` candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-priority-applied", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusApplied { t.Fatalf("expected status=applied for priority-only change, got %q (summary: %s)", result.Status, result.Summary) } foundPriority := false for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-a"].priority` { foundPriority = true if c.Class != configrefresh.StatusApplied { t.Errorf("providers[].priority class: got %q, want applied", c.Class) } if c.Previous != "5" || c.Next != "1" { t.Errorf("providers[].priority change: prev=%q next=%q", c.Previous, c.Next) } } if c.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required change for priority-only diff: %s", c.Path) } } if !foundPriority { t.Errorf("providers[].priority change path not found in: %+v", result.Changes) } } // TestClassifyProviderLongContextCapacityApplied verifies that provider // total_context_tokens and long_context_capacity changes are classified as // live-apply (StatusApplied), consistent with capacity/priority. func TestClassifyProviderLongContextCapacityApplied(t *testing.T) { dir := t.TempDir() currentYAML := ` 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 total_context_tokens: 262144 long_context_capacity: 1 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) // Candidate only changes the two long-context fields. 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: 2 total_context_tokens: 524288 long_context_capacity: 2 ` candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-long-capacity-applied", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusApplied { t.Fatalf("expected status=applied for long-context-only change, got %q (summary: %s)", result.Status, result.Summary) } foundTotal := false foundLong := false for _, c := range result.Changes { switch c.Path { case `nodes[].providers["prov-a"].total_context_tokens`: foundTotal = true if c.Class != configrefresh.StatusApplied { t.Errorf("total_context_tokens class: got %q, want applied", c.Class) } if c.Previous != "262144" || c.Next != "524288" { t.Errorf("total_context_tokens change: prev=%q next=%q", c.Previous, c.Next) } case `nodes[].providers["prov-a"].long_context_capacity`: foundLong = true if c.Class != configrefresh.StatusApplied { t.Errorf("long_context_capacity class: got %q, want applied", c.Class) } if c.Previous != "1" || c.Next != "2" { t.Errorf("long_context_capacity change: prev=%q next=%q", c.Previous, c.Next) } } if c.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required change for long-context-only diff: %s", c.Path) } } if !foundTotal { t.Errorf("providers[].total_context_tokens change path not found in: %+v", result.Changes) } if !foundLong { t.Errorf("providers[].long_context_capacity change path not found in: %+v", result.Changes) } } // TestClassifyProviderPoolPolicyLiveApply verifies that changes to the // canonical root provider_pool.max_queue and provider_pool.queue_timeout_ms // are classified as live-apply (StatusApplied) at the root path. func TestClassifyProviderPoolPolicyLiveApply(t *testing.T) { dir := t.TempDir() currentYAML := ` server: listen: "0.0.0.0:9090" provider_pool: max_queue: 16 queue_timeout_ms: 30000 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 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) candidateYAML := ` server: listen: "0.0.0.0:9090" provider_pool: max_queue: 32 queue_timeout_ms: 60000 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 ` candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-pool-policy-liveapply", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusApplied { t.Fatalf("expected status=applied for root provider_pool change, got %q (summary: %s)", result.Status, result.Summary) } foundMaxQ := false foundTimeout := false for _, c := range result.Changes { switch c.Path { case "provider_pool.max_queue": foundMaxQ = true if c.Class != configrefresh.StatusApplied { t.Errorf("provider_pool.max_queue class: got %q, want applied", c.Class) } if c.Previous != "16" || c.Next != "32" { t.Errorf("provider_pool.max_queue change: prev=%q next=%q", c.Previous, c.Next) } case "provider_pool.queue_timeout_ms": foundTimeout = true if c.Class != configrefresh.StatusApplied { t.Errorf("provider_pool.queue_timeout_ms class: got %q, want applied", c.Class) } if c.Previous != "30000" || c.Next != "60000" { t.Errorf("provider_pool.queue_timeout_ms change: prev=%q next=%q", c.Previous, c.Next) } } if c.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required change for policy-only diff: %s", c.Path) } } if !foundMaxQ { t.Errorf("provider_pool.max_queue change not found in: %+v", result.Changes) } if !foundTimeout { t.Errorf("provider_pool.queue_timeout_ms change not found in: %+v", result.Changes) } } // TestClassifyProviderPoolPolicyIgnoresLegacyWhenCanonical verifies that when // canonical provider_pool is set, per-provider max_queue/queue_timeout_ms // changes are not reported as effective policy changes. func TestClassifyProviderPoolPolicyIgnoresLegacyWhenCanonical(t *testing.T) { dir := t.TempDir() currentYAML := ` server: listen: "0.0.0.0:9090" provider_pool: max_queue: 16 queue_timeout_ms: 30000 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 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) // Candidate changes only the legacy per-provider queue fields while // canonical provider_pool remains identical. candidateYAML := ` server: listen: "0.0.0.0:9090" provider_pool: max_queue: 16 queue_timeout_ms: 30000 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: 8 queue_timeout_ms: 10000 ` candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-policy-ignores-legacy", }) if err != nil { t.Fatalf("Evaluate: %v", err) } for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-a"].max_queue` { t.Errorf("legacy per-provider max_queue should not be reported as policy change; got change: %+v", c) } if c.Path == `nodes[].providers["prov-a"].queue_timeout_ms` { t.Errorf("legacy per-provider queue_timeout_ms should not be reported as policy change; got change: %+v", c) } } }