|
1 | 1 | package migrate |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bytes" |
| 5 | + "context" |
| 6 | + "os" |
| 7 | + "path/filepath" |
| 8 | + "strings" |
4 | 9 | "testing" |
| 10 | + |
| 11 | + "go.yaml.in/yaml/v3" |
| 12 | + |
| 13 | + "github.com/isometry/platform-health/pkg/phctx" |
5 | 14 | ) |
6 | 15 |
|
7 | 16 | func TestTransformRest(t *testing.T) { |
@@ -72,3 +81,261 @@ func TestTypeRewrites(t *testing.T) { |
72 | 81 | t.Errorf("expected rest -> http rewrite, got %q (ok=%v)", newType, ok) |
73 | 82 | } |
74 | 83 | } |
| 84 | + |
| 85 | +func TestTransformChecks(t *testing.T) { |
| 86 | + tests := []struct { |
| 87 | + name string |
| 88 | + input map[string]any |
| 89 | + wantCh string // expected "check" value in first entry |
| 90 | + }{ |
| 91 | + { |
| 92 | + name: "rewrites expr to check", |
| 93 | + input: map[string]any{ |
| 94 | + "checks": []any{ |
| 95 | + map[string]any{"expr": "response.status == 200", "message": "bad status"}, |
| 96 | + }, |
| 97 | + }, |
| 98 | + wantCh: "response.status == 200", |
| 99 | + }, |
| 100 | + { |
| 101 | + name: "rewrites expression to check", |
| 102 | + input: map[string]any{ |
| 103 | + "checks": []any{ |
| 104 | + map[string]any{"expression": "response.status == 200", "message": "bad status"}, |
| 105 | + }, |
| 106 | + }, |
| 107 | + wantCh: "response.status == 200", |
| 108 | + }, |
| 109 | + { |
| 110 | + name: "leaves existing check key alone", |
| 111 | + input: map[string]any{ |
| 112 | + "checks": []any{ |
| 113 | + map[string]any{"check": "response.status == 200", "message": "ok"}, |
| 114 | + }, |
| 115 | + }, |
| 116 | + wantCh: "response.status == 200", |
| 117 | + }, |
| 118 | + { |
| 119 | + name: "no checks key is a no-op", |
| 120 | + input: map[string]any{"url": "https://example.com"}, |
| 121 | + }, |
| 122 | + { |
| 123 | + name: "non-slice checks is a no-op", |
| 124 | + input: map[string]any{"checks": "not-a-slice"}, |
| 125 | + }, |
| 126 | + } |
| 127 | + |
| 128 | + for _, tt := range tests { |
| 129 | + t.Run(tt.name, func(t *testing.T) { |
| 130 | + transformChecks(tt.input) |
| 131 | + if tt.wantCh == "" { |
| 132 | + return |
| 133 | + } |
| 134 | + checksSlice, ok := tt.input["checks"].([]any) |
| 135 | + if !ok { |
| 136 | + t.Fatal("checks is not []any after transform") |
| 137 | + } |
| 138 | + entry, ok := checksSlice[0].(map[string]any) |
| 139 | + if !ok { |
| 140 | + t.Fatal("first check entry is not map[string]any") |
| 141 | + } |
| 142 | + if got, ok := entry["check"].(string); !ok || got != tt.wantCh { |
| 143 | + t.Errorf("expected check=%q, got %q", tt.wantCh, got) |
| 144 | + } |
| 145 | + if _, ok := entry["expr"]; ok { |
| 146 | + t.Error("expected expr key to be removed") |
| 147 | + } |
| 148 | + if _, ok := entry["expression"]; ok { |
| 149 | + t.Error("expected expression key to be removed") |
| 150 | + } |
| 151 | + }) |
| 152 | + } |
| 153 | +} |
| 154 | + |
| 155 | +func TestTransformHTTPStatus(t *testing.T) { |
| 156 | + tests := []struct { |
| 157 | + name string |
| 158 | + input map[string]any |
| 159 | + wantExpr string |
| 160 | + wantNote bool |
| 161 | + wantCount int // expected number of checks entries |
| 162 | + }{ |
| 163 | + { |
| 164 | + name: "single status value", |
| 165 | + input: map[string]any{"status": []any{200}}, |
| 166 | + wantExpr: "response.status == 200", |
| 167 | + wantNote: true, |
| 168 | + wantCount: 1, |
| 169 | + }, |
| 170 | + { |
| 171 | + name: "multiple status values", |
| 172 | + input: map[string]any{"status": []any{200, 201}}, |
| 173 | + wantExpr: "response.status in [200, 201]", |
| 174 | + wantNote: true, |
| 175 | + wantCount: 1, |
| 176 | + }, |
| 177 | + { |
| 178 | + name: "merges with existing checks", |
| 179 | + input: map[string]any{ |
| 180 | + "status": []any{200}, |
| 181 | + "checks": []any{ |
| 182 | + map[string]any{"check": "response.json.ok == true", "message": "not ok"}, |
| 183 | + }, |
| 184 | + }, |
| 185 | + wantExpr: "response.status == 200", |
| 186 | + wantNote: true, |
| 187 | + wantCount: 2, |
| 188 | + }, |
| 189 | + { |
| 190 | + name: "no status key is a no-op", |
| 191 | + input: map[string]any{"url": "https://example.com"}, |
| 192 | + }, |
| 193 | + { |
| 194 | + name: "non-slice status is a no-op", |
| 195 | + input: map[string]any{"status": "200"}, |
| 196 | + }, |
| 197 | + { |
| 198 | + name: "empty status slice is a no-op", |
| 199 | + input: map[string]any{"status": []any{}}, |
| 200 | + }, |
| 201 | + } |
| 202 | + |
| 203 | + for _, tt := range tests { |
| 204 | + t.Run(tt.name, func(t *testing.T) { |
| 205 | + note := transformHTTPStatus(tt.input) |
| 206 | + if tt.wantNote && note == "" { |
| 207 | + t.Error("expected a migration note, got empty") |
| 208 | + } |
| 209 | + if !tt.wantNote && note != "" { |
| 210 | + t.Errorf("expected no migration note, got %q", note) |
| 211 | + } |
| 212 | + if tt.wantExpr == "" { |
| 213 | + return |
| 214 | + } |
| 215 | + // status key should be removed |
| 216 | + if _, ok := tt.input["status"]; ok { |
| 217 | + t.Error("expected status key to be removed") |
| 218 | + } |
| 219 | + checksSlice, ok := tt.input["checks"].([]any) |
| 220 | + if !ok { |
| 221 | + t.Fatal("checks is not []any after transform") |
| 222 | + } |
| 223 | + if len(checksSlice) != tt.wantCount { |
| 224 | + t.Fatalf("expected %d checks entries, got %d", tt.wantCount, len(checksSlice)) |
| 225 | + } |
| 226 | + // The generated check is always the last entry |
| 227 | + last, ok := checksSlice[len(checksSlice)-1].(map[string]any) |
| 228 | + if !ok { |
| 229 | + t.Fatal("last check entry is not map[string]any") |
| 230 | + } |
| 231 | + if got := last["check"]; got != tt.wantExpr { |
| 232 | + t.Errorf("expected check=%q, got %q", tt.wantExpr, got) |
| 233 | + } |
| 234 | + }) |
| 235 | + } |
| 236 | +} |
| 237 | + |
| 238 | +func TestRunIntegration(t *testing.T) { |
| 239 | + const input = `http: |
| 240 | + - name: google |
| 241 | + url: https://google.com |
| 242 | + status: [200] |
| 243 | +rest: |
| 244 | + - name: api |
| 245 | + request: |
| 246 | + url: https://api.example.com/health |
| 247 | + checks: |
| 248 | + - expr: "response.status == 200" |
| 249 | + message: "HTTP request failed" |
| 250 | + - expr: 'response.json.status == "ok"' |
| 251 | + message: "API unhealthy" |
| 252 | +` |
| 253 | + |
| 254 | + // Write input to temp file |
| 255 | + tmpDir := t.TempDir() |
| 256 | + inputPath := filepath.Join(tmpDir, "old-config.yaml") |
| 257 | + if err := os.WriteFile(inputPath, []byte(input), 0644); err != nil { |
| 258 | + t.Fatal(err) |
| 259 | + } |
| 260 | + |
| 261 | + // Build command with context |
| 262 | + cmd := New() |
| 263 | + ctx := phctx.ContextWithViper(context.Background(), phctx.NewViper()) |
| 264 | + cmd.SetContext(ctx) |
| 265 | + |
| 266 | + var stdout, stderr bytes.Buffer |
| 267 | + cmd.SetOut(&stdout) |
| 268 | + cmd.SetErr(&stderr) |
| 269 | + cmd.SetArgs([]string{inputPath}) |
| 270 | + |
| 271 | + if err := cmd.Execute(); err != nil { |
| 272 | + t.Fatalf("run failed: %v\nstderr: %s", err, stderr.String()) |
| 273 | + } |
| 274 | + |
| 275 | + // Parse output |
| 276 | + var result map[string]any |
| 277 | + if err := yaml.Unmarshal(stdout.Bytes(), &result); err != nil { |
| 278 | + t.Fatalf("failed to parse output YAML: %v\noutput: %s", err, stdout.String()) |
| 279 | + } |
| 280 | + |
| 281 | + components, ok := result["components"].(map[string]any) |
| 282 | + if !ok { |
| 283 | + t.Fatalf("expected components map, got %T", result["components"]) |
| 284 | + } |
| 285 | + |
| 286 | + // Verify google component: status should be converted to check, no spec.status |
| 287 | + google, ok := components["google"].(map[string]any) |
| 288 | + if !ok { |
| 289 | + t.Fatal("missing google component") |
| 290 | + } |
| 291 | + if google["type"] != "http" { |
| 292 | + t.Errorf("expected google type=http, got %v", google["type"]) |
| 293 | + } |
| 294 | + if spec, ok := google["spec"].(map[string]any); ok { |
| 295 | + if _, hasStatus := spec["status"]; hasStatus { |
| 296 | + t.Error("expected status to be removed from spec") |
| 297 | + } |
| 298 | + } |
| 299 | + googleChecks, ok := google["checks"].([]any) |
| 300 | + if !ok || len(googleChecks) == 0 { |
| 301 | + t.Fatal("expected google to have checks") |
| 302 | + } |
| 303 | + firstCheck, ok := googleChecks[0].(map[string]any) |
| 304 | + if !ok { |
| 305 | + t.Fatal("first google check is not a map") |
| 306 | + } |
| 307 | + if firstCheck["check"] != "response.status == 200" { |
| 308 | + t.Errorf("expected status CEL check, got %v", firstCheck["check"]) |
| 309 | + } |
| 310 | + |
| 311 | + // Verify api component: expr should be rewritten to check |
| 312 | + api, ok := components["api"].(map[string]any) |
| 313 | + if !ok { |
| 314 | + t.Fatal("missing api component") |
| 315 | + } |
| 316 | + if api["type"] != "http" { |
| 317 | + t.Errorf("expected api type=http, got %v", api["type"]) |
| 318 | + } |
| 319 | + apiChecks, ok := api["checks"].([]any) |
| 320 | + if !ok || len(apiChecks) != 2 { |
| 321 | + t.Fatalf("expected api to have 2 checks, got %v", apiChecks) |
| 322 | + } |
| 323 | + for i, c := range apiChecks { |
| 324 | + entry, ok := c.(map[string]any) |
| 325 | + if !ok { |
| 326 | + t.Fatalf("api check[%d] is not a map", i) |
| 327 | + } |
| 328 | + if _, hasExpr := entry["expr"]; hasExpr { |
| 329 | + t.Errorf("api check[%d] still has legacy 'expr' key", i) |
| 330 | + } |
| 331 | + if _, hasCheck := entry["check"]; !hasCheck { |
| 332 | + t.Errorf("api check[%d] missing 'check' key", i) |
| 333 | + } |
| 334 | + } |
| 335 | + |
| 336 | + // Verify stderr contains migration notes |
| 337 | + stderrStr := stderr.String() |
| 338 | + if !strings.Contains(stderrStr, "migration notes") { |
| 339 | + t.Errorf("expected migration notes in stderr, got: %s", stderrStr) |
| 340 | + } |
| 341 | +} |
0 commit comments