From 317d931e877d3cd266cfca7cb9414953a71a6b73 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:52:27 -0800 Subject: [PATCH] test: add 32 tests for extract_api_error_message in shared/common.sh (#820) This function parses JSON error responses from cloud provider APIs (used by Hetzner, DigitalOcean, Vultr, and Contabo) and had zero test coverage. Tests cover: field priority order, fallback behavior, realistic cloud provider responses, and edge cases (non-object JSON, null/empty fields). Agent: test-engineer Co-authored-by: A <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- .../shared-common-error-extraction.test.ts | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 cli/src/__tests__/shared-common-error-extraction.test.ts diff --git a/cli/src/__tests__/shared-common-error-extraction.test.ts b/cli/src/__tests__/shared-common-error-extraction.test.ts new file mode 100644 index 00000000..d32ef71d --- /dev/null +++ b/cli/src/__tests__/shared-common-error-extraction.test.ts @@ -0,0 +1,351 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { resolve, join } from "path"; +import { mkdirSync, rmSync, existsSync } from "fs"; +import { tmpdir } from "os"; +import { spawnSync } from "child_process"; + +/** + * Tests for extract_api_error_message in shared/common.sh. + * + * This function parses JSON error responses from cloud provider APIs and + * extracts human-readable error messages. It is used by Hetzner, DigitalOcean, + * Vultr, and Contabo cloud providers. It tries these fields in priority order: + * 1. error.message (when error is a dict) + * 2. error.error_message (when error is a dict) + * 3. message (top-level) + * 4. reason (top-level) + * 5. error (when error is a string) + * 6. fallback argument (default: "Unknown error") + * + * Agent: test-engineer + */ + +const REPO_ROOT = resolve(import.meta.dir, "../../.."); +const COMMON_SH = resolve(REPO_ROOT, "shared/common.sh"); + +let testDir: string; + +beforeEach(() => { + testDir = join(tmpdir(), `spawn-err-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); +}); + +afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } +}); + +/** + * Run a bash snippet that sources shared/common.sh first. + */ +function runBash(script: string): { exitCode: number; stdout: string; stderr: string } { + const fullScript = `source "${COMMON_SH}"\n${script}`; + const result = spawnSync("bash", ["-c", fullScript], { + encoding: "utf-8", + timeout: 15000, + stdio: ["pipe", "pipe", "pipe"], + }); + return { + exitCode: result.status ?? 1, + stdout: (result.stdout || "").trim(), + stderr: (result.stderr || "").trim(), + }; +} + +// ── extract_api_error_message ─────────────────────────────────────────────── + +describe("extract_api_error_message", () => { + // ── Priority 1: error.message (error is a dict) ─────────────────── + + describe("error.message field (nested dict)", () => { + it("should extract error.message from Hetzner-style response", () => { + const result = runBash(` + extract_api_error_message '{"error":{"message":"server limit exceeded","code":"limit_exceeded"}}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("server limit exceeded"); + }); + + it("should extract error.message from DigitalOcean-style response", () => { + const result = runBash(` + extract_api_error_message '{"id":"service_unavailable","error":{"message":"Server is temporarily unavailable"}}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Server is temporarily unavailable"); + }); + }); + + // ── Priority 2: error.error_message (error is a dict) ───────────── + + describe("error.error_message field (nested dict)", () => { + it("should extract error.error_message when error.message is absent", () => { + const result = runBash(` + extract_api_error_message '{"error":{"error_message":"Rate limit exceeded","code":429}}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Rate limit exceeded"); + }); + + it("should prefer error.message over error.error_message", () => { + const result = runBash(` + extract_api_error_message '{"error":{"message":"primary msg","error_message":"secondary msg"}}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("primary msg"); + }); + }); + + // ── Priority 3: top-level message ───────────────────────────────── + + describe("top-level message field", () => { + it("should extract top-level message when no error dict", () => { + const result = runBash(` + extract_api_error_message '{"message":"Unauthorized","status":401}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Unauthorized"); + }); + + it("should extract top-level message when error is empty string", () => { + const result = runBash(` + extract_api_error_message '{"error":"","message":"Invalid API key"}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Invalid API key"); + }); + }); + + // ── Priority 4: top-level reason ────────────────────────────────── + + describe("top-level reason field", () => { + it("should extract reason when no message or error fields", () => { + const result = runBash(` + extract_api_error_message '{"reason":"Quota exceeded","code":403}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Quota exceeded"); + }); + }); + + // ── Priority 5: error as string ─────────────────────────────────── + + describe("error as string", () => { + it("should extract error string from Vultr-style response", () => { + const result = runBash(` + extract_api_error_message '{"error":"Invalid API token","status":401}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Invalid API token"); + }); + + it("should prefer top-level message over error string", () => { + const result = runBash(` + extract_api_error_message '{"error":"short error","message":"Detailed error message"}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Detailed error message"); + }); + }); + + // ── Fallback behavior ───────────────────────────────────────────── + + describe("fallback behavior", () => { + it("should use default fallback for empty JSON object", () => { + const result = runBash(` + extract_api_error_message '{}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Unknown error"); + }); + + it("should use custom fallback when provided", () => { + const result = runBash(` + extract_api_error_message '{}' 'Custom fallback message' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Custom fallback message"); + }); + + it("should use fallback for invalid JSON", () => { + const result = runBash(` + extract_api_error_message 'not valid json' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Unknown error"); + }); + + it("should use custom fallback for invalid JSON", () => { + const result = runBash(` + extract_api_error_message 'not valid json' 'Parse failed' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Parse failed"); + }); + + it("should use fallback for empty string input", () => { + const result = runBash(` + extract_api_error_message '' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Unknown error"); + }); + + it("should use fallback when error dict has no message fields", () => { + const result = runBash(` + extract_api_error_message '{"error":{"code":"LIMIT_EXCEEDED"}}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Unknown error"); + }); + }); + + // ── Realistic cloud provider responses ──────────────────────────── + + describe("realistic cloud provider API responses", () => { + it("should handle Hetzner uniqueness error", () => { + const result = runBash(` + extract_api_error_message '{"error":{"message":"SSH key with the same fingerprint already exists","code":"uniqueness_error"}}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("SSH key with the same fingerprint already exists"); + }); + + it("should handle Vultr authentication error", () => { + const result = runBash(` + extract_api_error_message '{"error":"Invalid API key. Check the key and try again.","status":401}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Invalid API key. Check the key and try again."); + }); + + it("should handle DigitalOcean rate limit", () => { + const result = runBash(` + extract_api_error_message '{"id":"too_many_requests","message":"API rate limit exceeded"}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("API rate limit exceeded"); + }); + + it("should handle Contabo insufficient balance", () => { + const result = runBash(` + extract_api_error_message '{"error":{"message":"Insufficient balance to create instance","code":"insufficient_balance"}}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Insufficient balance to create instance"); + }); + + it("should handle raw HTML error page as fallback", () => { + const result = runBash(` + extract_api_error_message '502 Bad Gateway' 'Unable to parse error' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Unable to parse error"); + }); + + it("should handle response passed as its own fallback", () => { + // Cloud providers sometimes pass $response as fallback + const rawResponse = '{"some_unknown_field":"value"}'; + const result = runBash(` + extract_api_error_message '${rawResponse}' '${rawResponse}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(rawResponse); + }); + }); + + // ── Edge cases ──────────────────────────────────────────────────── + + describe("edge cases", () => { + it("should handle JSON array (not object)", () => { + const result = runBash(` + extract_api_error_message '[1,2,3]' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Unknown error"); + }); + + it("should handle JSON number", () => { + const result = runBash(` + extract_api_error_message '42' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Unknown error"); + }); + + it("should handle JSON null", () => { + const result = runBash(` + extract_api_error_message 'null' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Unknown error"); + }); + + it("should handle JSON boolean", () => { + const result = runBash(` + extract_api_error_message 'false' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Unknown error"); + }); + + it("should handle error message with special characters", () => { + const result = runBash(` + extract_api_error_message '{"message":"Error: Can'\\''t connect to server (port 443)"}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Error:"); + expect(result.stdout).toContain("connect to server"); + }); + + it("should handle deeply nested but irrelevant structure", () => { + const result = runBash(` + extract_api_error_message '{"data":{"nested":{"deep":"value"}}}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Unknown error"); + }); + + it("should handle error field set to null", () => { + const result = runBash(` + extract_api_error_message '{"error":null}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Unknown error"); + }); + + it("should handle error field set to numeric value", () => { + const result = runBash(` + extract_api_error_message '{"error":500}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Unknown error"); + }); + + it("should handle message field with empty string", () => { + const result = runBash(` + extract_api_error_message '{"message":""}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Unknown error"); + }); + + it("should handle multiple valid fields - priority order", () => { + // When both error.message and top-level message exist, error.message wins + const result = runBash(` + extract_api_error_message '{"error":{"message":"nested error"},"message":"top level","reason":"a reason"}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("nested error"); + }); + + it("should handle error dict with empty message falling to top-level", () => { + // error.message is empty string, so it's falsy -> falls to top-level message + const result = runBash(` + extract_api_error_message '{"error":{"message":""},"message":"top level msg"}' + `); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("top level msg"); + }); + }); +});