diff --git a/cmd/coverage_login_test.go b/cmd/coverage_login_test.go new file mode 100644 index 0000000..7cf4dac --- /dev/null +++ b/cmd/coverage_login_test.go @@ -0,0 +1,167 @@ +package cmd + +// coverage_login_test.go — drives the login HTTP helpers (createCLISession, +// pollForAuthCompletion) through an httptest server so their success, error, +// and malformed-response branches are covered without real network access. +// All servers respond immediately (HTTP 200), so no test waits on the 2s +// pollInterval — only the happy-path / error branches that return on the +// first iteration are exercised. + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCreateCLISession(t *testing.T) { + // Success: server returns a valid session. + t.Run("ok", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/auth/cli" { + t.Errorf("path = %s", r.URL.Path) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"session_id":"sess_1","auth_url":"https://x/login"}`)) + })) + defer srv.Close() + withTestAPI(t, srv.URL) + + s, err := createCLISession([]string{"tok1"}) + if err != nil || s.SessionID != "sess_1" || s.AuthURL != "https://x/login" { + t.Fatalf("createCLISession = %+v / %v", s, err) + } + }) + + // Non-2xx status surfaces a server-returned error. + t.Run("error_status", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + defer srv.Close() + withTestAPI(t, srv.URL) + + if _, err := createCLISession(nil); err == nil || !strings.Contains(err.Error(), "500") { + t.Fatalf("expected 500 error, got %v", err) + } + }) + + // Malformed JSON body. + t.Run("bad_json", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("not json")) + })) + defer srv.Close() + withTestAPI(t, srv.URL) + + if _, err := createCLISession(nil); err == nil || !strings.Contains(err.Error(), "parsing session") { + t.Fatalf("expected parse error, got %v", err) + } + }) + + // Valid JSON but missing required fields. + t.Run("incomplete", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"session_id":""}`)) + })) + defer srv.Close() + withTestAPI(t, srv.URL) + + if _, err := createCLISession(nil); err == nil || !strings.Contains(err.Error(), "invalid session") { + t.Fatalf("expected invalid-session error, got %v", err) + } + }) +} + +func TestPollForAuthCompletion(t *testing.T) { + // Immediate success on the first poll. + t.Run("ok", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"api_key":"sk_live","email":"a@b.c","tier":"hobby"}`)) + })) + defer srv.Close() + withTestAPI(t, srv.URL) + + res, err := pollForAuthCompletion("sess_1") + if err != nil || res.APIKey != "sk_live" { + t.Fatalf("poll = %+v / %v", res, err) + } + }) + + // 200 but no API key -> error. + t.Run("no_key", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"api_key":""}`)) + })) + defer srv.Close() + withTestAPI(t, srv.URL) + + if _, err := pollForAuthCompletion("s"); err == nil || !strings.Contains(err.Error(), "no API key") { + t.Fatalf("expected no-key error, got %v", err) + } + }) + + // 200 but malformed JSON -> parse error. + t.Run("bad_json", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("xx")) + })) + defer srv.Close() + withTestAPI(t, srv.URL) + + if _, err := pollForAuthCompletion("s"); err == nil || !strings.Contains(err.Error(), "parsing auth result") { + t.Fatalf("expected parse error, got %v", err) + } + }) + + // Unexpected status (not 200/202) returns immediately with an error. + t.Run("unexpected_status", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("nope")) + })) + defer srv.Close() + withTestAPI(t, srv.URL) + + if _, err := pollForAuthCompletion("s"); err == nil || !strings.Contains(err.Error(), "unexpected status 403") { + t.Fatalf("expected 403 error, got %v", err) + } + }) +} + +// TestUpHelpers covers the small pure helpers in up.go that the integration +// flow doesn't fully exercise. +func TestUpHelpers(t *testing.T) { + // truncate: under-limit returns unchanged, over-limit clamps + ellipsis. + if got := truncate("short", 10); got != "short" { + t.Errorf("truncate under = %q", got) + } + if got := truncate("abcdefghij", 3); got != "abc…" { + t.Errorf("truncate over = %q", got) + } + + // apiResourceType: known types pass through lowercased; unknown also + // returns lowercased trimmed (single-site mapping seam). + if got := apiResourceType(" Postgres "); got != "postgres" { + t.Errorf("known type = %q", got) + } + if got := apiResourceType("MONGODB"); got != "mongodb" { + t.Errorf("mongodb = %q", got) + } + if got := apiResourceType("Custom"); got != "custom" { + t.Errorf("unknown type = %q", got) + } + + // shortToken + webhookReceiveURL exercise the token-derivation seams. + if got := shortToken("abcdefghijkl"); got != "abcdefgh" { + t.Errorf("shortToken = %q", got) + } + withTestAPI(t, "https://api.example.com/") + if got := webhookReceiveURL("tok_9"); got != "https://api.example.com/webhook/receive/tok_9" { + t.Errorf("webhookReceiveURL = %q", got) + } +} diff --git a/cmd/coverage_units_test.go b/cmd/coverage_units_test.go new file mode 100644 index 0000000..1129fb9 --- /dev/null +++ b/cmd/coverage_units_test.go @@ -0,0 +1,324 @@ +package cmd + +// coverage_units_test.go — table-driven unit tests for the small pure +// helpers whose error/edge branches the integration suite doesn't exercise. +// These push the package over the ≥95% patch-coverage mandate by hitting the +// nil-receiver, empty-input, and default-arm branches directly rather than +// through a full command invocation. + +import ( + "errors" + "fmt" + "net" + "net/url" + "strings" + "testing" +) + +// ── errors.go ─────────────────────────────────────────────────────────────── + +func TestExitCodeError_NilAndZeroPaths(t *testing.T) { + var nilEC *ExitCodeError + + // Nil receiver Error() falls through to the "exit N" default path. + if got := nilEC.Error(); got != fmt.Sprintf("exit %d", ExitGeneric) { + t.Errorf("nil.Error() = %q", got) + } + // Nil receiver Unwrap() is nil. + if nilEC.Unwrap() != nil { + t.Error("nil.Unwrap() should be nil") + } + // Nil receiver codeOrDefault() defaults to ExitGeneric. + if got := nilEC.codeOrDefault(); got != ExitGeneric { + t.Errorf("nil.codeOrDefault() = %d", got) + } + + // Non-nil but Err==nil also falls to the "exit N" default with its code. + ecNoErr := &ExitCodeError{Code: ExitResourceFailed} + if got := ecNoErr.Error(); got != fmt.Sprintf("exit %d", ExitResourceFailed) { + t.Errorf("Error() with nil inner = %q", got) + } + if ecNoErr.Unwrap() != nil { + t.Error("Unwrap() with nil inner should be nil") + } + + // Code==0 defaults to ExitGeneric. + if got := (&ExitCodeError{}).codeOrDefault(); got != ExitGeneric { + t.Errorf("zero-code codeOrDefault() = %d", got) + } + // Explicit code is preserved. + if got := (&ExitCodeError{Code: ExitAuthRequired}).codeOrDefault(); got != ExitAuthRequired { + t.Errorf("codeOrDefault() = %d", got) + } +} + +func TestWithExitCode_NilPassThrough(t *testing.T) { + if withExitCode(ExitResourceFailed, nil) != nil { + t.Error("withExitCode(_, nil) should return nil") + } + err := withExitCode(ExitResourceFailed, errors.New("boom")) + if ExitCodeFor(err) != ExitResourceFailed { + t.Errorf("ExitCodeFor = %d", ExitCodeFor(err)) + } +} + +func TestErrHelpers(t *testing.T) { + // errResourceFailed wraps with code 2. + if ExitCodeFor(errResourceFailed(errors.New("x"))) != ExitResourceFailed { + t.Error("errResourceFailed code mismatch") + } + // errAuthRequired with empty detail uses the uniform default string. + def := errAuthRequired("") + if !strings.Contains(def.Error(), "authentication required") { + t.Errorf("default auth msg = %q", def.Error()) + } + if ExitCodeFor(def) != ExitAuthRequired { + t.Error("errAuthRequired code mismatch") + } + // errAuthRequired with a custom detail preserves it. + custom := errAuthRequired("custom detail") + if !strings.Contains(custom.Error(), "custom detail") { + t.Errorf("custom auth msg = %q", custom.Error()) + } + // errSessionExpired keeps the literal phrase the suite greps for. + if !strings.Contains(errSessionExpired().Error(), "session expired") { + t.Error("errSessionExpired must contain 'session expired'") + } +} + +func TestExitCodeFor_Defaults(t *testing.T) { + if ExitCodeFor(nil) != ExitOK { + t.Error("nil should be ExitOK") + } + // A plain (non-ExitCodeError) error defaults to ExitGeneric. + if ExitCodeFor(errors.New("plain")) != ExitGeneric { + t.Error("plain error should be ExitGeneric") + } +} + +// ── apierror.go ────────────────────────────────────────────────────────────── + +func TestCodeOrDefault(t *testing.T) { + if got := codeOrDefault("", "fallback"); got != "fallback" { + t.Errorf("empty -> %q", got) + } + if got := codeOrDefault("present", "fallback"); got != "present" { + t.Errorf("present -> %q", got) + } +} + +func TestEnvelopeCode(t *testing.T) { + if got := (&apiErrorEnvelope{ErrorCode: "ec", Error: "e"}).code(); got != "ec" { + t.Errorf("error_code preferred: %q", got) + } + if got := (&apiErrorEnvelope{Error: "e"}).code(); got != "e" { + t.Errorf("error fallback: %q", got) + } +} + +func TestParseAPIError_AllBranches(t *testing.T) { + // Empty body. + if e := parseAPIError(500, []byte(" ")); !strings.Contains(e.Error(), "no body") { + t.Errorf("empty body: %q", e.Error()) + } + // Non-JSON body falls back to truncated raw. + if e := parseAPIError(503, []byte("down")); !strings.Contains(e.Error(), "down") { + t.Errorf("non-json: %q", e.Error()) + } + // JSON envelope but all interesting fields empty -> raw-body fallback. + if e := parseAPIError(400, []byte(`{"ok":false}`)); !strings.Contains(e.Error(), `{"ok":false}`) { + t.Errorf("empty envelope: %q", e.Error()) + } + // 402 tier wall with code + agent_action + upgrade_url + request_id. + e402 := parseAPIError(402, []byte(`{"error":"quota_exceeded","message":"hit limit","agent_action":"upgrade now","upgrade_url":"https://x/billing","request_id":"req_1"}`)) + for _, want := range []string{"402", "quota_exceeded", "hit limit", "→ upgrade now", "upgrade: https://x/billing", "request_id=req_1"} { + if !strings.Contains(e402.Error(), want) { + t.Errorf("402 missing %q in %q", want, e402.Error()) + } + } + // 402 with no code uses the default label. + if e := parseAPIError(402, []byte(`{"message":"m"}`)); !strings.Contains(e.Error(), "tier limit reached") { + t.Errorf("402 default: %q", e.Error()) + } + // 429 with retry-after. + if e := parseAPIError(429, []byte(`{"error":"rate","retry_after_seconds":30}`)); !strings.Contains(e.Error(), "retry in 30s") { + t.Errorf("429 retry: %q", e.Error()) + } + // 429 without retry-after. + if e := parseAPIError(429, []byte(`{"error":"rate"}`)); !strings.Contains(e.Error(), "429 rate limited") { + t.Errorf("429 no-retry: %q", e.Error()) + } + // 5xx default label when code empty. + if e := parseAPIError(502, []byte(`{"message":"m"}`)); !strings.Contains(e.Error(), "server error, retry later") { + t.Errorf("5xx default: %q", e.Error()) + } + // 4xx default label + legacy "upgrade" field. + e4 := parseAPIError(403, []byte(`{"message":"m","upgrade":"https://legacy"}`)) + if !strings.Contains(e4.Error(), "request rejected") || !strings.Contains(e4.Error(), "upgrade: https://legacy") { + t.Errorf("4xx default/legacy upgrade: %q", e4.Error()) + } + // agent_action equal to message is not duplicated. + dup := parseAPIError(400, []byte(`{"error":"c","message":"same","agent_action":"same"}`)) + if strings.Contains(dup.Error(), "→ same") { + t.Errorf("agent_action should not duplicate message: %q", dup.Error()) + } +} + +// ── json_error.go : classifyError ──────────────────────────────────────────── + +func TestClassifyError_AllBranches(t *testing.T) { + if c, _, _ := classifyError(nil); c != "" { + t.Errorf("nil -> %q", c) + } + // ExitCodeError auth. + if c, _, _ := classifyError(errAuthRequired("")); c != "auth_required" { + t.Errorf("auth -> %q", c) + } + // ExitCodeError resource_failed. + if c, _, _ := classifyError(errResourceFailed(errors.New("x"))); c != "resource_failed" { + t.Errorf("resource -> %q", c) + } + // errSessionExpired is an *ExitCodeError with Code==ExitAuthRequired, so it + // classifies as auth_required (the switch matches the code before the + // message phrase). The dedicated "session_expired" branch is only reached + // for a *plain* error whose message contains the phrase. + if c, _, _ := classifyError(errSessionExpired()); c != "auth_required" { + t.Errorf("session-as-exitcode -> %q", c) + } + if c, _, _ := classifyError(errors.New("the session expired, sorry")); c != "session_expired" { + t.Errorf("session phrase -> %q", c) + } + // plain error -> cli_error. + if c, _, _ := classifyError(errors.New("whatever")); c != "cli_error" { + t.Errorf("plain -> %q", c) + } + // DNS error wrapped in url.Error. + dns := &url.Error{Op: "Get", URL: "http://x", Err: &net.DNSError{Name: "x", Err: "no such host"}} + if c, m, _ := classifyError(dns); c != "network_error" || !strings.Contains(m, "DNS lookup failed") { + t.Errorf("dns -> %q / %q", c, m) + } + // net.OpError wrapped in url.Error. + op := &url.Error{Op: "Get", URL: "http://x", Err: &net.OpError{Op: "dial", Err: errors.New("connection refused")}} + if c, m, _ := classifyError(op); c != "network_error" || !strings.Contains(m, "network error reaching") { + t.Errorf("op -> %q / %q", c, m) + } + // generic url.Error (neither DNS nor OpError). + generic := &url.Error{Op: "Get", URL: "http://x", Err: errors.New("some tls thing")} + if c, _, _ := classifyError(generic); c != "network_error" { + t.Errorf("generic url -> %q", c) + } +} + +// ── discover.go : filter helpers ───────────────────────────────────────────── + +func TestParseResourceFilters(t *testing.T) { + // Empty input -> nil, nil. + if m, err := parseResourceFilters(nil); m != nil || err != nil { + t.Errorf("empty -> %v / %v", m, err) + } + // Valid pairs, key lowercased. + m, err := parseResourceFilters([]string{"Type=postgres", "env=prod"}) + if err != nil || m["type"] != "postgres" || m["env"] != "prod" { + t.Errorf("valid -> %v / %v", m, err) + } + // Malformed (no '='). + if _, err := parseResourceFilters([]string{"bogus"}); err == nil { + t.Error("missing '=' should error") + } + // Leading '=' (empty key). + if _, err := parseResourceFilters([]string{"=v"}); err == nil { + t.Error("empty key should error") + } + // Trailing '=' (empty value). + if _, err := parseResourceFilters([]string{"type="}); err == nil { + t.Error("empty value should error") + } + // Unknown key. + if _, err := parseResourceFilters([]string{"color=red"}); err == nil { + t.Error("unknown key should error") + } +} + +func TestMatchResourceFilters(t *testing.T) { + // No filters -> always matches. + if !matchResourceFilters(nil, "postgres", "prod", "active", "pro", "db1") { + t.Error("nil filters should match") + } + // All match (case-insensitive on value). + if !matchResourceFilters(map[string]string{"type": "POSTGRES", "name": "DB1"}, + "postgres", "prod", "active", "pro", "db1") { + t.Error("case-insensitive match expected") + } + // One mismatch fails the whole row. + if matchResourceFilters(map[string]string{"env": "staging"}, + "postgres", "prod", "active", "pro", "db1") { + t.Error("env mismatch should fail") + } + // Each key arm. + for _, k := range []string{"status", "tier", "type", "env", "name"} { + if !matchResourceFilters(map[string]string{k: vals(k)}, + "postgres", "prod", "active", "pro", "db1") { + t.Errorf("arm %q should match", k) + } + } +} + +func vals(k string) string { + switch k { + case "type": + return "postgres" + case "env": + return "prod" + case "status": + return "active" + case "tier": + return "pro" + default: + return "db1" + } +} + +func TestLowerAndEqFold(t *testing.T) { + if lower("AbC123_-") != "abc123_-" { + t.Errorf("lower = %q", lower("AbC123_-")) + } + if !eqFold("Foo", "fOO") { + t.Error("eqFold should be case-insensitive") + } + if eqFold("a", "b") { + t.Error("eqFold a/b should differ") + } +} + +// ── deploy_stub.go : default arms ──────────────────────────────────────────── + +func TestMcpAliasFor(t *testing.T) { + cases := map[string]string{ + "new": "create_deploy", + "list": "list_deployments", + "get": "get_deployment", + "logs": "get_deployment", + "redeploy": "redeploy", + "delete": "delete_deployment", + "unknown": "", + } + for verb, want := range cases { + if got := mcpAliasFor(verb); got != want { + t.Errorf("mcpAliasFor(%q) = %q, want %q", verb, got, want) + } + } +} + +func TestCurlHintFor(t *testing.T) { + // Each known verb renders a curl line; the default arm covers unknown. + for _, verb := range []string{"new", "list", "get", "logs", "redeploy", "delete", "unknown"} { + got := curlHintFor(verb, nil, "") + if !strings.HasPrefix(got, "curl") { + t.Errorf("curlHintFor(%q) = %q (no curl prefix)", verb, got) + } + } + // An explicit id arg is interpolated for id-bearing verbs. + if got := curlHintFor("get", []string{"dep_42"}, ""); !strings.Contains(got, "dep_42") { + t.Errorf("id not interpolated: %q", got) + } +} diff --git a/internal/cliconfig/coverage_test.go b/internal/cliconfig/coverage_test.go new file mode 100644 index 0000000..5b4a922 --- /dev/null +++ b/internal/cliconfig/coverage_test.go @@ -0,0 +1,135 @@ +package cliconfig + +// coverage_test.go — exercises the Load / Save / Clear / SecretBackendName +// branches the existing suite leaves uncovered. White-box (same package) so +// we can redirect file I/O via HOME and the unexported path field, and uses +// the in-memory secret backend installed by TestMain so the real OS keychain +// is never touched. + +import ( + "os" + "path/filepath" + "testing" + + "github.com/InstaNode-dev/cli/internal/secretstore" +) + +func TestSecretBackendName_Branches(t *testing.T) { + resetSecretStore(t) + + // No API key -> "none". + if got := (&Config{}).SecretBackendName(); got != "none" { + t.Errorf("empty -> %q", got) + } + // Nil config -> "none". + if got := (*Config)(nil).SecretBackendName(); got != "none" { + t.Errorf("nil -> %q", got) + } + // Fallback key present -> "file-fallback". + if got := (&Config{APIKey: "k", FallbackAPIKey: "k"}).SecretBackendName(); got != "file-fallback" { + t.Errorf("fallback -> %q", got) + } + // Otherwise -> the backend's Name() (in tests: the memory backend). + if got := (&Config{APIKey: "k"}).SecretBackendName(); got != secretstore.Name() { + t.Errorf("backend -> %q want %q", got, secretstore.Name()) + } +} + +func TestLoad_ResolvesKeyFromSecretStore(t *testing.T) { + resetSecretStore(t) + dir := t.TempDir() + t.Setenv("HOME", dir) + + // Write a config with no key on disk; stash the key in the secretstore. + cfg := &Config{path: filepath.Join(dir, ".instant-config"), Tier: "pro"} + if err := cfg.Save(); err != nil { + t.Fatalf("seed save: %v", err) + } + if err := secretstore.Set("sk_from_store"); err != nil { + t.Fatalf("set: %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.APIKey != "sk_from_store" { + t.Errorf("APIKey = %q, want from secretstore", loaded.APIKey) + } + if loaded.Tier != "pro" { + t.Errorf("Tier = %q", loaded.Tier) + } +} + +func TestLoad_FallbackKeyWhenStoreEmpty(t *testing.T) { + resetSecretStore(t) + dir := t.TempDir() + t.Setenv("HOME", dir) + + // On-disk fallback key, empty secretstore -> fallback wins. + path := filepath.Join(dir, ".instant-config") + if err := os.WriteFile(path, []byte(`{"api_key_fallback":"sk_fallback","tier":"hobby"}`), 0600); err != nil { + t.Fatal(err) + } + loaded, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.APIKey != "sk_fallback" { + t.Errorf("APIKey = %q, want fallback", loaded.APIKey) + } +} + +func TestLoad_ParseErrorOnCorruptFile(t *testing.T) { + resetSecretStore(t) + dir := t.TempDir() + t.Setenv("HOME", dir) + + path := filepath.Join(dir, ".instant-config") + if err := os.WriteFile(path, []byte("{not valid json"), 0600); err != nil { + t.Fatal(err) + } + if _, err := Load(); err == nil { + t.Error("expected parse error on corrupt config") + } +} + +func TestSave_LogoutClearsSecretStore(t *testing.T) { + resetSecretStore(t) + dir := t.TempDir() + t.Setenv("HOME", dir) + + // Seed a stored key, then Save a config with an empty APIKey: the empty + // branch must Delete from the secretstore. + if err := secretstore.Set("sk_old"); err != nil { + t.Fatal(err) + } + cfg := &Config{path: filepath.Join(dir, ".instant-config")} + if err := cfg.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + if v, err := secretstore.Get(); err == nil && v != "" { + t.Errorf("expected secretstore cleared, got %q", v) + } +} + +func TestClear_ClearsStoreAndFile(t *testing.T) { + resetSecretStore(t) + dir := t.TempDir() + t.Setenv("HOME", dir) + + cfg := &Config{path: filepath.Join(dir, ".instant-config"), APIKey: "sk"} + if err := cfg.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + if err := Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + if _, err := os.Stat(cfg.path); !os.IsNotExist(err) { + t.Error("config file should be removed after Clear") + } + // Clear again is idempotent (no file present). + if err := Clear(); err != nil { + t.Errorf("second Clear should be a no-op, got %v", err) + } +} diff --git a/internal/tokens/coverage_test.go b/internal/tokens/coverage_test.go new file mode 100644 index 0000000..87132b1 --- /dev/null +++ b/internal/tokens/coverage_test.go @@ -0,0 +1,45 @@ +package tokens + +// coverage_test.go — closes the Load-with-existing-data and Load-parse-error +// branches the existing suite leaves uncovered. CI-safe: all file I/O is +// redirected to a temp HOME. + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_ParsesExistingFile(t *testing.T) { + dir := setupTempHome(t) + path := filepath.Join(dir, ".instant-tokens") + if err := os.WriteFile(path, []byte(`{"entries":[{"token":"t1","name":"db","type":"postgres","url":"postgres://x"}]}`), 0600); err != nil { + t.Fatal(err) + } + s, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(s.Entries) != 1 || s.Entries[0].Token != "t1" { + t.Fatalf("Load entries = %+v", s.Entries) + } + // The loaded store can round-trip a Save back to the same path. + if err := s.Add(Entry{Token: "t2", Name: "c", Type: "redis", URL: "redis://y"}); err != nil { + t.Fatalf("Add after Load: %v", err) + } + reload, err := Load() + if err != nil || len(reload.Entries) != 2 { + t.Fatalf("reload = %+v / %v", reload, err) + } +} + +func TestLoad_ParseErrorOnCorruptFile(t *testing.T) { + dir := setupTempHome(t) + path := filepath.Join(dir, ".instant-tokens") + if err := os.WriteFile(path, []byte("{not valid"), 0600); err != nil { + t.Fatal(err) + } + if _, err := Load(); err == nil { + t.Error("expected parse error on corrupt tokens file") + } +}