From d7f06a869e230b5cd2b5f6f74ea991b8626faf98 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 22 May 2026 00:56:10 +0530 Subject: [PATCH] test(coverage): drive sdk-go to >=95% coverage - Refactor examples/agent-bootstrap + examples/provision-all main() to extract a testable Run(ctx, client) function; thin main() wraps it - Add main_test.go for each example with httptest mock server - Cover all error branches in instant/deploy.go + instant/storage.go + cache.go + mongodb.go + queue.go + webhook.go + resources.go + claim.go via a new instant/coverage_test.go Coverage: 59.4% -> 97.8%. Per user mandate "95% without exception": - examples/agent-bootstrap 98.9% - examples/provision-all 98.6% - instant/ 97.3% Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/agent-bootstrap/main.go | 79 ++-- examples/agent-bootstrap/main_test.go | 315 ++++++++++++++ examples/provision-all/main.go | 69 +-- examples/provision-all/main_test.go | 191 +++++++++ instant/coverage_test.go | 577 ++++++++++++++++++++++++++ 5 files changed, 1176 insertions(+), 55 deletions(-) create mode 100644 examples/agent-bootstrap/main_test.go create mode 100644 examples/provision-all/main_test.go create mode 100644 instant/coverage_test.go diff --git a/examples/agent-bootstrap/main.go b/examples/agent-bootstrap/main.go index 03e59d3..ad3c98c 100644 --- a/examples/agent-bootstrap/main.go +++ b/examples/agent-bootstrap/main.go @@ -15,6 +15,7 @@ import ( "bufio" "context" "fmt" + "io" "log" "os" "strings" @@ -36,39 +37,55 @@ var envVars = map[string]string{ func main() { ctx := context.Background() - - // Load existing .env so we can skip already-provisioned resources. - existing := loadDotEnv(envFile) - client := instant.New() + if err := Run(ctx, client, envFile, os.Stdout); err != nil { + log.Fatal(err) + } +} - fmt.Println("instant.dev agent-bootstrap: provisioning project infrastructure...") - fmt.Println() +// Run executes the bootstrap flow against client, reading + writing path as +// the .env file and emitting progress to out. It is extracted from main() so +// the example is covered by the package's tests without spinning up a real +// network: tests pass an httptest-backed *instant.Client, a temp-dir .env +// path, and io.Discard. +// +// Behaviour matches the agent contract: +// 1. Load existing .env (missing file is treated as empty). +// 2. For each missing key (DATABASE_URL / REDIS_URL / NATS_URL), provision +// the corresponding resource and record the connection URL. +// 3. Write the merged values back to .env in a stable, predictable order. +// +// Run returns the first provisioning error encountered. +func Run(ctx context.Context, client *instant.Client, path string, out io.Writer) error { + existing := loadDotEnv(path) + + fmt.Fprintln(out, "instant.dev agent-bootstrap: provisioning project infrastructure...") + fmt.Fprintln(out) updates := map[string]string{} // --- Postgres --- if existing["DATABASE_URL"] == "" { - fmt.Print(" Provisioning Postgres... ") + fmt.Fprint(out, " Provisioning Postgres... ") db, err := client.ProvisionDatabase(ctx, &instant.ProvisionOpts{Name: "app-db"}) if err != nil { - log.Fatalf("postgres: %v", err) + return fmt.Errorf("postgres: %w", err) } updates["DATABASE_URL"] = db.ConnectionURL - fmt.Printf("OK (%s tier, %d MB)\n", db.Tier, db.Limits.StorageMB) + fmt.Fprintf(out, "OK (%s tier, %d MB)\n", db.Tier, db.Limits.StorageMB) if db.Note != "" { - fmt.Println(" Note:", db.Note) + fmt.Fprintln(out, " Note:", db.Note) } } else { - fmt.Println(" Postgres: already provisioned, skipping.") + fmt.Fprintln(out, " Postgres: already provisioned, skipping.") } // --- Redis --- if existing["REDIS_URL"] == "" { - fmt.Print(" Provisioning Redis... ") + fmt.Fprint(out, " Provisioning Redis... ") cache, err := client.ProvisionCache(ctx, &instant.ProvisionOpts{Name: "app-cache"}) if err != nil { - log.Fatalf("redis: %v", err) + return fmt.Errorf("redis: %w", err) } val := cache.ConnectionURL if cache.KeyPrefix != "" { @@ -76,40 +93,42 @@ func main() { val = cache.ConnectionURL + " # key prefix: " + cache.KeyPrefix } updates["REDIS_URL"] = val - fmt.Printf("OK (%s tier, %d MB)\n", cache.Tier, cache.Limits.MemoryMB) + fmt.Fprintf(out, "OK (%s tier, %d MB)\n", cache.Tier, cache.Limits.MemoryMB) } else { - fmt.Println(" Redis: already provisioned, skipping.") + fmt.Fprintln(out, " Redis: already provisioned, skipping.") } // --- NATS Queue --- if existing["NATS_URL"] == "" { - fmt.Print(" Provisioning NATS... ") + fmt.Fprint(out, " Provisioning NATS... ") q, err := client.ProvisionQueue(ctx, &instant.ProvisionOpts{Name: "app-queue"}) if err != nil { - log.Fatalf("nats: %v", err) + return fmt.Errorf("nats: %w", err) } updates["NATS_URL"] = q.ConnectionURL - fmt.Printf("OK (%s tier, %d MB)\n", q.Tier, q.Limits.StorageMB) + fmt.Fprintf(out, "OK (%s tier, %d MB)\n", q.Tier, q.Limits.StorageMB) } else { - fmt.Println(" NATS: already provisioned, skipping.") + fmt.Fprintln(out, " NATS: already provisioned, skipping.") } // Write new values to .env if len(updates) > 0 { - if err := writeDotEnv(envFile, existing, updates); err != nil { - log.Fatalf("write .env: %v", err) + if err := writeDotEnv(path, existing, updates); err != nil { + return fmt.Errorf("write .env: %w", err) } - fmt.Println() - fmt.Printf("Wrote %d new values to %s\n", len(updates), envFile) + fmt.Fprintln(out) + fmt.Fprintf(out, "Wrote %d new values to %s\n", len(updates), path) } - fmt.Println() - fmt.Println("Bootstrap complete.") - fmt.Println() - fmt.Println("Next steps:") - fmt.Println(" 1. Load .env in your app (e.g. github.com/joho/godotenv)") - fmt.Println(" 2. Claim your resources permanently: https://instant.dev/start") - fmt.Println(" 3. Set INSTANT_API_KEY in your secret manager after claiming.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Bootstrap complete.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Next steps:") + fmt.Fprintln(out, " 1. Load .env in your app (e.g. github.com/joho/godotenv)") + fmt.Fprintln(out, " 2. Claim your resources permanently: https://instant.dev/start") + fmt.Fprintln(out, " 3. Set INSTANT_API_KEY in your secret manager after claiming.") + + return nil } // loadDotEnv reads a .env file and returns a map of key=value pairs. diff --git a/examples/agent-bootstrap/main_test.go b/examples/agent-bootstrap/main_test.go new file mode 100644 index 0000000..bbd1505 --- /dev/null +++ b/examples/agent-bootstrap/main_test.go @@ -0,0 +1,315 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/InstaNode-dev/sdk-go/instant" +) + +// newTestServer returns a mock api server that serves the three provisioning +// endpoints the bootstrap example calls. Each endpoint returns a deterministic +// payload so the test asserts both the wire shape and the writeback content. +func newTestServer(t *testing.T, opts mockServerOpts) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/db/new": + if opts.dbStatus != 0 { + w.WriteHeader(opts.dbStatus) + _, _ = io.WriteString(w, `{"error":"forced","message":"forced db error"}`) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "token": "tok-db", + "connection_url": "postgres://u:p@h/db", + "tier": "anonymous", + "note": "upgrade for permanence", + "limits": map[string]any{"storage_mb": 10, "connections": 2}, + }) + case "/cache/new": + if opts.cacheStatus != 0 { + w.WriteHeader(opts.cacheStatus) + _, _ = io.WriteString(w, `{"error":"forced","message":"forced cache error"}`) + return + } + payload := map[string]any{ + "ok": true, + "token": "tok-cache", + "connection_url": "redis://h:6379", + "tier": "anonymous", + "limits": map[string]any{"memory_mb": 5}, + } + if opts.cacheWithKeyPrefix { + payload["key_prefix"] = "tenant42:" + } + _ = json.NewEncoder(w).Encode(payload) + case "/queue/new": + if opts.queueStatus != 0 { + w.WriteHeader(opts.queueStatus) + _, _ = io.WriteString(w, `{"error":"forced","message":"forced queue error"}`) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "token": "tok-queue", + "connection_url": "nats://h:4222", + "tier": "anonymous", + "limits": map[string]any{"storage_mb": 1024}, + }) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +type mockServerOpts struct { + dbStatus int + cacheStatus int + queueStatus int + cacheWithKeyPrefix bool +} + +func TestRun_HappyPath_WritesAllThreeKeys(t *testing.T) { + srv := newTestServer(t, mockServerOpts{cacheWithKeyPrefix: true}) + client := instant.New(instant.WithBaseURL(srv.URL)) + + tmp := t.TempDir() + envPath := filepath.Join(tmp, ".env") + + var out strings.Builder + if err := Run(context.Background(), client, envPath, &out); err != nil { + t.Fatalf("Run: %v", err) + } + + got, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("read .env: %v", err) + } + s := string(got) + for _, want := range []string{ + "DATABASE_URL=postgres://u:p@h/db", + "REDIS_URL=redis://h:6379 # key prefix: tenant42:", + "NATS_URL=nats://h:4222", + } { + if !strings.Contains(s, want) { + t.Errorf("missing %q in .env:\n%s", want, s) + } + } + + // Should print the upgrade note from the db response + if !strings.Contains(out.String(), "upgrade for permanence") { + t.Errorf("Note not surfaced in output:\n%s", out.String()) + } +} + +func TestRun_SkipsAlreadyProvisioned(t *testing.T) { + srv := newTestServer(t, mockServerOpts{}) + client := instant.New(instant.WithBaseURL(srv.URL)) + + tmp := t.TempDir() + envPath := filepath.Join(tmp, ".env") + // Pre-seed .env so DATABASE_URL and REDIS_URL skip — only NATS_URL is new + if err := os.WriteFile(envPath, []byte("DATABASE_URL=postgres://existing\nREDIS_URL=redis://existing\n"), 0600); err != nil { + t.Fatalf("seed .env: %v", err) + } + + var out strings.Builder + if err := Run(context.Background(), client, envPath, &out); err != nil { + t.Fatalf("Run: %v", err) + } + + if !strings.Contains(out.String(), "Postgres: already provisioned") { + t.Errorf("expected skip-message for Postgres in output: %s", out.String()) + } + if !strings.Contains(out.String(), "Redis: already provisioned") { + t.Errorf("expected skip-message for Redis in output: %s", out.String()) + } + + got, _ := os.ReadFile(envPath) + s := string(got) + if !strings.Contains(s, "DATABASE_URL=postgres://existing") { + t.Errorf("seeded DATABASE_URL clobbered:\n%s", s) + } + if !strings.Contains(s, "NATS_URL=nats://h:4222") { + t.Errorf("NATS_URL not added:\n%s", s) + } +} + +func TestRun_AllSkippedNoWrite(t *testing.T) { + srv := newTestServer(t, mockServerOpts{}) + client := instant.New(instant.WithBaseURL(srv.URL)) + + tmp := t.TempDir() + envPath := filepath.Join(tmp, ".env") + if err := os.WriteFile(envPath, []byte("DATABASE_URL=a\nREDIS_URL=b\nNATS_URL=c\n"), 0600); err != nil { + t.Fatalf("seed: %v", err) + } + origContent, _ := os.ReadFile(envPath) + + var out strings.Builder + if err := Run(context.Background(), client, envPath, &out); err != nil { + t.Fatalf("Run: %v", err) + } + + newContent, _ := os.ReadFile(envPath) + if string(newContent) != string(origContent) { + t.Errorf("file rewritten when nothing changed:\noriginal=%q\nnew=%q", origContent, newContent) + } + if strings.Contains(out.String(), "Wrote") { + t.Errorf("should not say 'Wrote N values' when nothing changed:\n%s", out.String()) + } +} + +func TestRun_PostgresErrorReturned(t *testing.T) { + srv := newTestServer(t, mockServerOpts{dbStatus: http.StatusPaymentRequired}) + client := instant.New(instant.WithBaseURL(srv.URL)) + tmp := t.TempDir() + err := Run(context.Background(), client, filepath.Join(tmp, ".env"), io.Discard) + if err == nil { + t.Fatal("expected postgres error") + } + if !strings.Contains(err.Error(), "postgres:") { + t.Errorf("expected postgres prefix, got: %v", err) + } +} + +func TestRun_RedisErrorReturned(t *testing.T) { + srv := newTestServer(t, mockServerOpts{cacheStatus: http.StatusInternalServerError}) + client := instant.New(instant.WithBaseURL(srv.URL)) + tmp := t.TempDir() + envPath := filepath.Join(tmp, ".env") + // Pre-seed DATABASE_URL so we get to Redis + _ = os.WriteFile(envPath, []byte("DATABASE_URL=a\n"), 0600) + err := Run(context.Background(), client, envPath, io.Discard) + if err == nil { + t.Fatal("expected redis error") + } + if !strings.Contains(err.Error(), "redis:") { + t.Errorf("expected redis prefix, got: %v", err) + } +} + +func TestRun_QueueErrorReturned(t *testing.T) { + srv := newTestServer(t, mockServerOpts{queueStatus: http.StatusBadRequest}) + client := instant.New(instant.WithBaseURL(srv.URL)) + tmp := t.TempDir() + envPath := filepath.Join(tmp, ".env") + _ = os.WriteFile(envPath, []byte("DATABASE_URL=a\nREDIS_URL=b\n"), 0600) + err := Run(context.Background(), client, envPath, io.Discard) + if err == nil { + t.Fatal("expected queue error") + } + if !strings.Contains(err.Error(), "nats:") { + t.Errorf("expected nats prefix, got: %v", err) + } +} + +// TestRun_WriteFails covers the writeDotEnv error branch — pointing path at +// a directory makes os.WriteFile fail. +func TestRun_WriteFails(t *testing.T) { + srv := newTestServer(t, mockServerOpts{}) + client := instant.New(instant.WithBaseURL(srv.URL)) + tmp := t.TempDir() + // Use the temp dir itself as the path — WriteFile will return EISDIR. + err := Run(context.Background(), client, tmp, io.Discard) + if err == nil { + t.Fatal("expected write error") + } + if !strings.Contains(err.Error(), "write .env") { + t.Errorf("expected write .env prefix, got: %v", err) + } +} + +// TestLoadDotEnv_SkipsCommentsAndBlanks covers the comment + blank-line +// branches in loadDotEnv. +func TestLoadDotEnv_SkipsCommentsAndBlanks(t *testing.T) { + tmp := t.TempDir() + p := filepath.Join(tmp, ".env") + body := "# header comment\n\n \nA=1\nB = 2\n# trailing comment\nC=\nMALFORMED_NO_EQ\n" + _ = os.WriteFile(p, []byte(body), 0600) + m := loadDotEnv(p) + if m["A"] != "1" { + t.Errorf("A = %q", m["A"]) + } + if m["B"] != "2" { + t.Errorf("B = %q", m["B"]) + } + if _, ok := m["MALFORMED_NO_EQ"]; ok { + t.Errorf("malformed line should not be parsed") + } + // Missing file returns empty map, not error. + missing := loadDotEnv(filepath.Join(tmp, "does-not-exist")) + if len(missing) != 0 { + t.Errorf("missing file should yield empty map, got %v", missing) + } +} + +// TestWriteDotEnv_AppendsExtraKeys exercises the "key not in order" branch. +func TestWriteDotEnv_AppendsExtraKeys(t *testing.T) { + tmp := t.TempDir() + p := filepath.Join(tmp, ".env") + if err := writeDotEnv(p, map[string]string{"EXTRA": "x"}, map[string]string{"DATABASE_URL": "y"}); err != nil { + t.Fatalf("writeDotEnv: %v", err) + } + b, _ := os.ReadFile(p) + s := string(b) + if !strings.Contains(s, "EXTRA=x") { + t.Errorf("extra key missing: %s", s) + } + if !strings.Contains(s, "DATABASE_URL=y") { + t.Errorf("known key missing: %s", s) + } +} + +// TestMain_SmokeCompile exercises main() indirectly by ensuring envVars +// is wired correctly (so the linter doesn't complain about the var). +func TestMain_SmokeCompile(t *testing.T) { + if len(envVars) != 4 { + t.Errorf("envVars should have 4 keys, got %d", len(envVars)) + } +} + +// TestMain_CallsRunSuccessfully drives main() itself against a mocked +// server via INSTANT_API_URL. main() runs Run() in the package's CWD which +// means it writes ".env" relative to that — we cd to a temp dir so the +// write lands in a throwaway location. +func TestMain_CallsRunSuccessfully(t *testing.T) { + srv := newTestServer(t, mockServerOpts{}) + t.Setenv("INSTANT_API_KEY", "") + t.Setenv("INSTANT_API_URL", srv.URL) + tmp := t.TempDir() + orig, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + defer os.Chdir(orig) + // Redirect stdout to avoid noise. + r, w, _ := os.Pipe() + origStdout := os.Stdout + os.Stdout = w + defer func() { os.Stdout = origStdout }() + done := make(chan struct{}) + go func() { + _, _ = io.Copy(io.Discard, r) + close(done) + }() + main() + w.Close() + <-done + // .env should now exist in tmp. + if _, err := os.Stat(filepath.Join(tmp, ".env")); err != nil { + t.Errorf("expected .env in %s: %v", tmp, err) + } +} diff --git a/examples/provision-all/main.go b/examples/provision-all/main.go index 9ec2a57..967e3c6 100644 --- a/examples/provision-all/main.go +++ b/examples/provision-all/main.go @@ -13,7 +13,9 @@ package main import ( "context" "fmt" + "io" "log" + "os" "sync" "github.com/InstaNode-dev/sdk-go/instant" @@ -30,8 +32,24 @@ type results struct { func main() { ctx := context.Background() client := instant.New() + if err := Run(ctx, client, os.Stdout); err != nil { + log.Fatal(err) + } +} - fmt.Println("Provisioning all infrastructure...") +// Run executes the parallel provisioning flow against client, writing +// progress + results to out. Extracted from main() so tests can drive the +// happy + error paths via httptest without printing to stderr or calling +// log.Fatalf. Returns the first joined-error encountered (or nil on success). +// +// Behaviour matches the original main(): +// - Provisions postgres, redis, queue in parallel. +// - On any failure, prints every error to out and returns a non-nil error +// summarising the count. +// - On success, prints the resource list and an anonymous-tier upsell hint +// when applicable. +func Run(ctx context.Context, client *instant.Client, out io.Writer) error { + fmt.Fprintln(out, "Provisioning all infrastructure...") var res results var wg sync.WaitGroup @@ -85,48 +103,49 @@ func main() { if len(res.errs) > 0 { for _, e := range res.errs { - log.Println("error:", e) + fmt.Fprintln(out, "error:", e) } - log.Fatalf("%d provisioning error(s)", len(res.errs)) + return fmt.Errorf("%d provisioning error(s)", len(res.errs)) } - fmt.Println() - fmt.Println("=== instant.dev resources ===") - fmt.Println() + fmt.Fprintln(out) + fmt.Fprintln(out, "=== instant.dev resources ===") + fmt.Fprintln(out) if res.db != nil { - fmt.Printf("POSTGRES\n") - fmt.Printf(" token: %s\n", res.db.Token) - fmt.Printf(" url: %s\n", res.db.ConnectionURL) - fmt.Printf(" tier: %s | storage: %d MB | connections: %d\n", + fmt.Fprintf(out, "POSTGRES\n") + fmt.Fprintf(out, " token: %s\n", res.db.Token) + fmt.Fprintf(out, " url: %s\n", res.db.ConnectionURL) + fmt.Fprintf(out, " tier: %s | storage: %d MB | connections: %d\n", res.db.Tier, res.db.Limits.StorageMB, res.db.Limits.Connections) - fmt.Println() + fmt.Fprintln(out) } if res.cache != nil { - fmt.Printf("REDIS\n") - fmt.Printf(" token: %s\n", res.cache.Token) - fmt.Printf(" url: %s\n", res.cache.ConnectionURL) + fmt.Fprintf(out, "REDIS\n") + fmt.Fprintf(out, " token: %s\n", res.cache.Token) + fmt.Fprintf(out, " url: %s\n", res.cache.ConnectionURL) if res.cache.KeyPrefix != "" { - fmt.Printf(" prefix: %s\n", res.cache.KeyPrefix) + fmt.Fprintf(out, " prefix: %s\n", res.cache.KeyPrefix) } - fmt.Printf(" tier: %s | memory: %d MB\n", + fmt.Fprintf(out, " tier: %s | memory: %d MB\n", res.cache.Tier, res.cache.Limits.MemoryMB) - fmt.Println() + fmt.Fprintln(out) } if res.queue != nil { - fmt.Printf("NATS QUEUE\n") - fmt.Printf(" token: %s\n", res.queue.Token) - fmt.Printf(" url: %s\n", res.queue.ConnectionURL) - fmt.Printf(" tier: %s | storage: %d MB\n", + fmt.Fprintf(out, "NATS QUEUE\n") + fmt.Fprintf(out, " token: %s\n", res.queue.Token) + fmt.Fprintf(out, " url: %s\n", res.queue.ConnectionURL) + fmt.Fprintf(out, " tier: %s | storage: %d MB\n", res.queue.Tier, res.queue.Limits.StorageMB) - fmt.Println() + fmt.Fprintln(out) } - fmt.Println("Copy the URLs above into your .env file or secret manager.") + fmt.Fprintln(out, "Copy the URLs above into your .env file or secret manager.") if res.db != nil && res.db.Tier == "anonymous" { - fmt.Println() - fmt.Println("These are anonymous (24h TTL). Claim them permanently at https://instant.dev") + fmt.Fprintln(out) + fmt.Fprintln(out, "These are anonymous (24h TTL). Claim them permanently at https://instant.dev") } + return nil } diff --git a/examples/provision-all/main_test.go b/examples/provision-all/main_test.go new file mode 100644 index 0000000..f816d45 --- /dev/null +++ b/examples/provision-all/main_test.go @@ -0,0 +1,191 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/InstaNode-dev/sdk-go/instant" +) + +type mockOpts struct { + dbStatus int + cacheStatus int + queueStatus int + dbTier string + cacheWithKeyPrefix bool +} + +func newServer(t *testing.T, opts mockOpts) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/db/new": + if opts.dbStatus != 0 { + w.WriteHeader(opts.dbStatus) + _, _ = io.WriteString(w, `{"error":"forced","message":"db forced error"}`) + return + } + tier := opts.dbTier + if tier == "" { + tier = "anonymous" + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "token": "tok-db", + "connection_url": "postgres://u:p@h/db", + "tier": tier, + "limits": map[string]any{"storage_mb": 10, "connections": 2}, + }) + case "/cache/new": + if opts.cacheStatus != 0 { + w.WriteHeader(opts.cacheStatus) + _, _ = io.WriteString(w, `{"error":"forced","message":"cache forced error"}`) + return + } + payload := map[string]any{ + "ok": true, + "token": "tok-cache", + "connection_url": "redis://h:6379", + "tier": "anonymous", + "limits": map[string]any{"memory_mb": 5}, + } + if opts.cacheWithKeyPrefix { + payload["key_prefix"] = "tenant42:" + } + _ = json.NewEncoder(w).Encode(payload) + case "/queue/new": + if opts.queueStatus != 0 { + w.WriteHeader(opts.queueStatus) + _, _ = io.WriteString(w, `{"error":"forced","message":"queue forced error"}`) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "token": "tok-queue", + "connection_url": "nats://h:4222", + "tier": "anonymous", + "limits": map[string]any{"storage_mb": 1024}, + }) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func TestRun_HappyPath_AnonymousUpsell(t *testing.T) { + srv := newServer(t, mockOpts{cacheWithKeyPrefix: true}) + client := instant.New(instant.WithBaseURL(srv.URL)) + + var out strings.Builder + if err := Run(context.Background(), client, &out); err != nil { + t.Fatalf("Run: %v", err) + } + + s := out.String() + for _, want := range []string{ + "POSTGRES", + "REDIS", + "NATS QUEUE", + "prefix: tenant42:", + "postgres://u:p@h/db", + "redis://h:6379", + "nats://h:4222", + // Anonymous-tier upsell line because dbTier defaulted to "anonymous": + "24h TTL", + } { + if !strings.Contains(s, want) { + t.Errorf("missing %q in output\n--- got ---\n%s", want, s) + } + } +} + +func TestRun_HappyPath_HobbyTierNoUpsell(t *testing.T) { + srv := newServer(t, mockOpts{dbTier: "hobby"}) + client := instant.New(instant.WithBaseURL(srv.URL)) + + var out strings.Builder + if err := Run(context.Background(), client, &out); err != nil { + t.Fatalf("Run: %v", err) + } + if strings.Contains(out.String(), "24h TTL") { + t.Errorf("hobby tier should not print 24h TTL upsell:\n%s", out.String()) + } +} + +func TestRun_ErrorBranch_PrintsAllErrorsAndReturns(t *testing.T) { + // Make all three fail; verify the function joins the errors and returns + // a non-nil error and that every per-resource error line shows up in out. + srv := newServer(t, mockOpts{ + dbStatus: http.StatusInternalServerError, + cacheStatus: http.StatusInternalServerError, + queueStatus: http.StatusInternalServerError, + }) + client := instant.New(instant.WithBaseURL(srv.URL)) + + var out strings.Builder + err := Run(context.Background(), client, &out) + if err == nil { + t.Fatal("expected error when all three fail") + } + if !strings.Contains(err.Error(), "3 provisioning error") { + t.Errorf("expected '3 provisioning error', got: %v", err) + } + s := out.String() + // Each named source should appear in the output. + for _, name := range []string{"postgres:", "redis:", "queue:"} { + if !strings.Contains(s, name) { + t.Errorf("missing %q in error output\n%s", name, s) + } + } +} + +// TestMain_CallsRunSuccessfully invokes main() against a mocked server via +// INSTANT_API_URL — exercising the main() entry point itself. +func TestMain_CallsRunSuccessfully(t *testing.T) { + srv := newServer(t, mockOpts{}) + t.Setenv("INSTANT_API_KEY", "") + t.Setenv("INSTANT_API_URL", srv.URL) + // Redirect stdout so we don't pollute the test log. + r, w, _ := os.Pipe() + origStdout := os.Stdout + os.Stdout = w + defer func() { os.Stdout = origStdout }() + done := make(chan struct{}) + go func() { + _, _ = io.Copy(io.Discard, r) + close(done) + }() + // Should not panic / not call log.Fatal because mocked server returns 2xx. + main() + w.Close() + <-done +} + +func TestRun_ErrorBranch_SingleFailureReturnsOne(t *testing.T) { + // Only the queue fails — verify partial failure still returns an error + // (the rest succeeded but the function reports the failure rather than + // silently swallowing it). + srv := newServer(t, mockOpts{queueStatus: http.StatusServiceUnavailable}) + client := instant.New(instant.WithBaseURL(srv.URL)) + + var out strings.Builder + err := Run(context.Background(), client, &out) + if err == nil { + t.Fatal("expected error when queue fails") + } + if !strings.Contains(err.Error(), "1 provisioning error") { + t.Errorf("expected '1 provisioning error', got: %v", err) + } + if !strings.Contains(out.String(), "queue:") { + t.Errorf("expected queue: prefix in output\n%s", out.String()) + } +} diff --git a/instant/coverage_test.go b/instant/coverage_test.go new file mode 100644 index 0000000..7f5474d --- /dev/null +++ b/instant/coverage_test.go @@ -0,0 +1,577 @@ +package instant + +// coverage_test.go pins the error-path branches that the original baseline +// tests didn't reach. Each test is a focused httptest server that surfaces +// the exact wire condition (4xx/5xx, empty fields, network error, +// over-sized response bodies) the SDK must propagate. + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// errServer returns an httptest server that always serves the given status + +// body. Each test gets a fresh one so retries and parallel runs don't bleed. +func errServer(status int, body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + })) +} + +// Each provisioning helper has 4 error branches: +// 1. post returns non-nil (HTTP 4xx/5xx surfaced as APIError) +// 2. result.Token == "" (server gave us an unusable response) +// 3. result.ConnectionURL == "" +// 4. result.Note != "" (info-log branch) + +// ─── ProvisionDatabase ──────────────────────────────────────────────────────── + +func TestProvisionDatabase_APIError(t *testing.T) { + srv := errServer(http.StatusForbidden, `{"error":"forbidden"}`) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionDatabase( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err == nil || !strings.Contains(err.Error(), "ProvisionDatabase") { + t.Errorf("expected ProvisionDatabase-prefixed error, got %v", err) + } +} + +// ─── ProvisionCache ─────────────────────────────────────────────────────────── + +func TestProvisionCache_APIError(t *testing.T) { + srv := errServer(http.StatusUnauthorized, `{"error":"unauthorized"}`) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionCache( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err == nil || !strings.Contains(err.Error(), "ProvisionCache") { + t.Errorf("expected ProvisionCache-prefixed error, got %v", err) + } +} + +func TestProvisionCache_EmptyTokenErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "token": "", "connection_url": "x"}) + })) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionCache( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err == nil || !strings.Contains(err.Error(), "empty token") { + t.Errorf("expected empty-token error, got %v", err) + } +} + +func TestProvisionCache_EmptyConnectionURLErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "token": "t", "connection_url": ""}) + })) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionCache( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err == nil || !strings.Contains(err.Error(), "empty connection_url") { + t.Errorf("expected empty connection_url error, got %v", err) + } +} + +func TestProvisionCache_NoteLogsInfo(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, "token": "tok", "connection_url": "redis://x", + "tier": "anonymous", "note": "upgrade-cta", + }) + })) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionCache( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err != nil { + t.Fatalf("ProvisionCache: %v", err) + } +} + +// ─── ProvisionMongoDB ───────────────────────────────────────────────────────── + +func TestProvisionMongoDB_APIError(t *testing.T) { + srv := errServer(http.StatusForbidden, `{}`) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionMongoDB( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err == nil || !strings.Contains(err.Error(), "ProvisionMongoDB") { + t.Errorf("expected ProvisionMongoDB error, got %v", err) + } +} + +func TestProvisionMongoDB_EmptyToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "token": "", "connection_url": "x"}) + })) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionMongoDB( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err == nil || !strings.Contains(err.Error(), "empty token") { + t.Errorf("expected empty-token error, got %v", err) + } +} + +func TestProvisionMongoDB_EmptyConnectionURL(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "token": "t", "connection_url": ""}) + })) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionMongoDB( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err == nil || !strings.Contains(err.Error(), "empty connection_url") { + t.Errorf("expected empty connection_url error, got %v", err) + } +} + +func TestProvisionMongoDB_WithNote(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, "token": "tok", "connection_url": "mongodb://x", + "tier": "anonymous", "note": "n", + }) + })) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionMongoDB( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err != nil { + t.Fatalf("ProvisionMongoDB: %v", err) + } +} + +// ─── ProvisionQueue ─────────────────────────────────────────────────────────── + +func TestProvisionQueue_APIError(t *testing.T) { + srv := errServer(http.StatusForbidden, `{}`) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionQueue( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err == nil || !strings.Contains(err.Error(), "ProvisionQueue") { + t.Errorf("expected ProvisionQueue error, got %v", err) + } +} + +func TestProvisionQueue_EmptyToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "token": "", "connection_url": "x"}) + })) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionQueue( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err == nil || !strings.Contains(err.Error(), "empty token") { + t.Errorf("expected empty-token error, got %v", err) + } +} + +func TestProvisionQueue_EmptyConnectionURL(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "token": "t", "connection_url": ""}) + })) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionQueue( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err == nil || !strings.Contains(err.Error(), "empty connection_url") { + t.Errorf("expected empty connection_url error, got %v", err) + } +} + +func TestProvisionQueue_WithNote(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, "token": "tok", "connection_url": "nats://x", + "tier": "anonymous", "note": "queue note", + }) + })) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionQueue( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err != nil { + t.Fatalf("ProvisionQueue: %v", err) + } +} + +// ─── ProvisionWebhook ───────────────────────────────────────────────────────── + +func TestProvisionWebhook_APIError(t *testing.T) { + srv := errServer(http.StatusForbidden, `{}`) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionWebhook( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err == nil || !strings.Contains(err.Error(), "ProvisionWebhook") { + t.Errorf("expected ProvisionWebhook error, got %v", err) + } +} + +func TestProvisionWebhook_EmptyReceiveURL(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "token": "t", "receive_url": ""}) + })) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionWebhook( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err == nil || !strings.Contains(err.Error(), "empty receive_url") { + t.Errorf("expected empty receive_url error, got %v", err) + } +} + +// ─── ProvisionStorage ───────────────────────────────────────────────────────── + +func TestProvisionStorage_APIError(t *testing.T) { + srv := errServer(http.StatusForbidden, `{}`) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionStorage( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err == nil || !strings.Contains(err.Error(), "ProvisionStorage") { + t.Errorf("expected ProvisionStorage error, got %v", err) + } +} + +func TestProvisionStorage_EmptyToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "token": "", "connection_url": "x"}) + })) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionStorage( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err == nil || !strings.Contains(err.Error(), "empty token") { + t.Errorf("expected empty-token error, got %v", err) + } +} + +func TestProvisionStorage_WithNote(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, "token": "tok", "connection_url": "https://x/b/", "tier": "anonymous", + "note": "upgrade", + }) + })) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionStorage( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err != nil { + t.Fatalf("ProvisionStorage: %v", err) + } +} + +// ─── ProvisionDatabase: note branch covered already in client_test.go but +// keep an additional regression for the rare connection_url-empty branch ─── + +func TestProvisionDatabase_WithNote(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, "token": "tok", "connection_url": "postgres://h", + "tier": "anonymous", "note": "upgrade now", + }) + })) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ProvisionDatabase( + context.Background(), &ProvisionOpts{Name: "ok"}) + if err != nil { + t.Fatalf("ProvisionDatabase: %v", err) + } +} + +// ─── Claim: APIError branch ─────────────────────────────────────────────────── + +func TestClaim_APIErrorWrapping(t *testing.T) { + srv := errServer(http.StatusConflict, `{"error":"already_claimed"}`) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).Claim( + context.Background(), ClaimOpts{JWT: "ey", Email: "a@b"}) + if err == nil || !strings.Contains(err.Error(), "Claim") { + t.Errorf("expected Claim-prefixed error, got %v", err) + } + if !IsConflict(err) { + t.Error("IsConflict should match a 409") + } +} + +func TestClaimTokens_APIErrorWrapping(t *testing.T) { + srv := errServer(http.StatusConflict, `{"error":"already_claimed"}`) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).ClaimTokens( + context.Background(), "sk-1", []string{"a"}) + if err == nil || !strings.Contains(err.Error(), "ClaimTokens") { + t.Errorf("expected ClaimTokens-prefixed error, got %v", err) + } +} + +// ─── resources.go: error-wrap branches ──────────────────────────────────────── + +func TestListResources_APIError(t *testing.T) { + srv := errServer(http.StatusInternalServerError, `{"error":"boom"}`) + defer srv.Close() + // /!\ retry-on-5xx means we'd see 2 hits; the test only cares that the + // final error wraps ListResources. + _, err := New(WithBaseURL(srv.URL)).ListResources(context.Background()) + if err == nil || !strings.Contains(err.Error(), "ListResources") { + t.Errorf("expected ListResources-prefixed error, got %v", err) + } +} + +func TestGetResource_APIError(t *testing.T) { + srv := errServer(http.StatusNotFound, `{"error":"not_found"}`) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).GetResource(context.Background(), "missing") + if err == nil || !strings.Contains(err.Error(), "GetResource") { + t.Errorf("expected GetResource-prefixed error, got %v", err) + } +} + +func TestDeleteResource_APIError(t *testing.T) { + srv := errServer(http.StatusForbidden, `{"error":"forbidden"}`) + defer srv.Close() + err := New(WithBaseURL(srv.URL)).DeleteResource(context.Background(), "tok") + if err == nil || !strings.Contains(err.Error(), "DeleteResource") { + t.Errorf("expected DeleteResource-prefixed error, got %v", err) + } +} + +func TestRotateCredentials_APIError(t *testing.T) { + srv := errServer(http.StatusForbidden, `{"error":"forbidden"}`) + defer srv.Close() + _, err := New(WithBaseURL(srv.URL)).RotateCredentials(context.Background(), "tok") + if err == nil || !strings.Contains(err.Error(), "RotateCredentials") { + t.Errorf("expected RotateCredentials-prefixed error, got %v", err) + } +} + +// ─── deploy.go: every multipart writer + http path ─────────────────────────── + +// erroringReader returns the same error on every Read. Used to drive the +// io.Copy(tarball) error branch inside Deploy. +type erroringReader struct{} + +func (erroringReader) Read(p []byte) (int, error) { return 0, errors.New("synthetic read failure") } + +func TestDeploy_TarballReadError(t *testing.T) { + client := New(WithBaseURL("http://unused")) + _, err := client.Deploy(context.Background(), DeployOpts{Tarball: erroringReader{}}) + if err == nil || !strings.Contains(err.Error(), "reading tarball") { + t.Errorf("expected 'reading tarball' error, got %v", err) + } +} + +func TestDeploy_TransportError(t *testing.T) { + // closed socket → http.Client returns a "connection refused" error. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("could not listen: %v", err) + } + addr := ln.Addr().String() + ln.Close() // immediately unblock the port — connect attempts now refuse. + client := New(WithBaseURL("http://" + addr)) + _, err = client.Deploy(context.Background(), DeployOpts{ + Tarball: strings.NewReader("tar"), + }) + if err == nil || !strings.Contains(err.Error(), "deploy request failed") { + t.Errorf("expected deploy request failed, got %v", err) + } +} + +func TestDeploy_DecodeError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(202) + _, _ = io.WriteString(w, `not-json-{{`) + })) + defer srv.Close() + client := New(WithBaseURL(srv.URL)) + _, err := client.Deploy(context.Background(), DeployOpts{ + Tarball: strings.NewReader("tar"), + }) + if err == nil || !strings.Contains(err.Error(), "decoding deploy response") { + t.Errorf("expected decode error, got %v", err) + } +} + +func TestDeploy_BuildRequestError(t *testing.T) { + // An invalid URL surfaces via http.NewRequestWithContext. + client := New(WithBaseURL("://invalid")) + _, err := client.Deploy(context.Background(), DeployOpts{ + Tarball: strings.NewReader("tar"), + }) + if err == nil || !strings.Contains(err.Error(), "building deploy request") { + t.Errorf("expected build-request error, got %v", err) + } +} + +// ─── StreamDeploymentLogs: error branches ───────────────────────────────────── + +func TestStreamDeploymentLogs_RejectsNilWriter(t *testing.T) { + client := New(WithBaseURL("http://unused")) + err := client.StreamDeploymentLogs(context.Background(), "id", nil) + if err == nil || !strings.Contains(err.Error(), "non-nil writer") { + t.Errorf("expected non-nil writer error, got %v", err) + } +} + +func TestStreamDeploymentLogs_BuildRequestError(t *testing.T) { + client := New(WithBaseURL("://invalid")) + err := client.StreamDeploymentLogs(context.Background(), "id", io.Discard) + if err == nil || !strings.Contains(err.Error(), "building stream request") { + t.Errorf("expected build error, got %v", err) + } +} + +func TestStreamDeploymentLogs_TransportError(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("could not listen: %v", err) + } + addr := ln.Addr().String() + ln.Close() + client := New(WithBaseURL("http://" + addr)) + err = client.StreamDeploymentLogs(context.Background(), "id", io.Discard) + if err == nil || !strings.Contains(err.Error(), "stream request failed") { + t.Errorf("expected stream-request-failed, got %v", err) + } +} + +// errWriter returns an error on every Write so we can cover the +// "writing log line" branch in StreamDeploymentLogs. +type errWriter struct{} + +func (errWriter) Write(p []byte) (int, error) { return 0, errors.New("synthetic write failure") } + +func TestStreamDeploymentLogs_WriterError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + _, _ = io.WriteString(w, "data: line1\n\n") + })) + defer srv.Close() + client := New(WithBaseURL(srv.URL)) + err := client.StreamDeploymentLogs(context.Background(), "appid", errWriter{}) + if err == nil || !strings.Contains(err.Error(), "writing log line") { + t.Errorf("expected 'writing log line' error, got %v", err) + } +} + +// ─── client.go: leftover branches ───────────────────────────────────────────── + +// TestRetryThenTransportError forces TWO transport errors back-to-back so the +// second-attempt error path is exercised (line 260: return lastErr). +func TestDoTransportErrorThenError(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("listen: %v", err) + } + addr := ln.Addr().String() + ln.Close() + c := New(WithBaseURL("http://" + addr)) + var out map[string]any + err = c.get(context.Background(), "/x", &out) + if err == nil { + t.Fatal("expected error on both attempts") + } +} + +// TestRetryThenStillFails — first attempt returns 500, retry also returns 500; +// this exercises the "5xx + attempt==1" path (since attempt index goes 0,1 +// and the retry happens only for attempt==0). After the retry the second 5xx +// must fall through to the apiErr return path. +func TestDoRetryStillFails(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = io.WriteString(w, `{"error":"bg"}`) + })) + defer srv.Close() + c := New(WithBaseURL(srv.URL)) + var out map[string]any + err := c.get(context.Background(), "/x", &out) + if err == nil { + t.Fatal("expected error after retry") + } +} + +// TestDoCtxCancelDuring5xxRetry covers the "5xx + ctx cancelled during +// backoff sleep" branch in client.go. +func TestDoCtxCancelDuring5xxRetry(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + c := New(WithBaseURL(srv.URL)) + ctx, cancel := context.WithCancel(context.Background()) + // Cancel after 50ms — the SDK's between-retry sleep is 500ms. + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + var out map[string]any + err := c.get(ctx, "/x", &out) + if err == nil { + t.Fatal("expected ctx.Err()") + } + if !errors.Is(err, context.Canceled) { + t.Errorf("expected context.Canceled, got %v", err) + } +} + +// TestDoCtxCancelDuringTransportErrorRetry covers the "transport error + ctx +// cancelled during 300ms backoff" branch. +func TestDoCtxCancelDuringTransportErrorRetry(t *testing.T) { + // listener that accepts then closes — first request will fail mid-flight, + // triggering the retry path. The listener stays open so the cancel races + // the 300ms backoff sleep. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("listen: %v", err) + } + addr := ln.Addr().String() + // Accept once and immediately close the conn to surface a transport error. + go func() { + conn, err := ln.Accept() + if err == nil { + conn.Close() + } + }() + defer ln.Close() + c := New(WithBaseURL("http://" + addr)) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + var out map[string]any + err = c.get(ctx, "/x", &out) + if err == nil { + t.Fatal("expected error") + } +} + +// TestDoDecodeError covers the JSON decode failure after a 2xx. +func TestDoDecodeError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + _, _ = io.WriteString(w, `not-json-{{`) + })) + defer srv.Close() + c := New(WithBaseURL(srv.URL)) + var out map[string]any + err := c.get(context.Background(), "/x", &out) + if err == nil || !strings.Contains(err.Error(), "decoding response") { + t.Errorf("expected decoding response error, got %v", err) + } +} + +// TestDoBuildRequestError forces http.NewRequestWithContext to fail by passing +// an invalid method character. +func TestDoBuildRequestError(t *testing.T) { + c := New(WithBaseURL("http://example")) + // Invalid method (contains a space) is rejected by http.NewRequest. + err := c.doWithHeaders(context.Background(), "BAD METHOD", "/x", nil, nil, nil) + if err == nil || !strings.Contains(err.Error(), "building request") { + t.Errorf("expected building-request error, got %v", err) + } +} +