diff --git a/cmd/export_test.go b/cmd/export_test.go new file mode 100644 index 0000000..ba3cd4e --- /dev/null +++ b/cmd/export_test.go @@ -0,0 +1,423 @@ +package cmd + +import ( + "strings" + "testing" + + "api/internal/model" + "api/internal/storage" +) + +// ─── isJSON ─────────────────────────────────────────────────────────────────── + +func TestIsJSON_Object(t *testing.T) { + cases := []struct { + input string + want bool + }{ + {`{"key":"value"}`, true}, + {`{"a":1,"b":2}`, true}, + {`{}`, true}, + {`[]`, true}, + {`[1,2,3]`, true}, + {`[{"id":1}]`, true}, + // With whitespace + {" { \"key\": \"value\" } ", true}, + {" [ 1, 2 ] ", true}, + // Non-JSON + {"plain text", false}, + {"", false}, + {"", false}, + {"{incomplete", false}, + {"incomplete}", false}, + {"[incomplete", false}, + } + + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + got := isJSON(tc.input) + if got != tc.want { + t.Errorf("isJSON(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +// ─── parsePostmanURL ────────────────────────────────────────────────────────── + +func TestParsePostmanURL_SimpleHTTPS(t *testing.T) { + pu := parsePostmanURL("https://api.example.com/users") + if pu.Raw != "https://api.example.com/users" { + t.Errorf("Raw mismatch: %q", pu.Raw) + } + if pu.Protocol != "https" { + t.Errorf("Protocol mismatch: %q", pu.Protocol) + } + if len(pu.Host) == 0 { + t.Error("expected non-empty host parts") + } + if strings.Join(pu.Host, ".") != "api.example.com" { + t.Errorf("Host parts mismatch: %v", pu.Host) + } +} + +func TestParsePostmanURL_WithPath(t *testing.T) { + pu := parsePostmanURL("https://api.example.com/v1/users/123") + if len(pu.Path) != 3 { + t.Errorf("expected 3 path segments, got %d: %v", len(pu.Path), pu.Path) + } + if pu.Path[0] != "v1" || pu.Path[1] != "users" || pu.Path[2] != "123" { + t.Errorf("unexpected path segments: %v", pu.Path) + } +} + +func TestParsePostmanURL_WithQueryParams(t *testing.T) { + pu := parsePostmanURL("https://api.example.com/search?q=hello&page=1") + if len(pu.Query) == 0 { + t.Error("expected query params, got none") + } + found := map[string]string{} + for _, qp := range pu.Query { + found[qp.Key] = qp.Value + } + if found["q"] != "hello" { + t.Errorf("expected q=hello, got %q", found["q"]) + } + if found["page"] != "1" { + t.Errorf("expected page=1, got %q", found["page"]) + } +} + +func TestParsePostmanURL_NoPath(t *testing.T) { + pu := parsePostmanURL("https://example.com") + if pu.Raw != "https://example.com" { + t.Errorf("Raw mismatch: %q", pu.Raw) + } + if len(pu.Path) != 0 { + t.Errorf("expected no path segments, got %v", pu.Path) + } +} + +func TestParsePostmanURL_HTTP(t *testing.T) { + pu := parsePostmanURL("http://localhost:8080/api") + if pu.Protocol != "http" { + t.Errorf("Protocol mismatch: %q", pu.Protocol) + } +} + +func TestParsePostmanURL_TrailingSlash(t *testing.T) { + pu := parsePostmanURL("https://api.example.com/users/") + // Trailing slash produces an empty segment which should be filtered + for _, seg := range pu.Path { + if seg == "" { + t.Error("empty path segment should be filtered out") + } + } +} + +// ─── buildPostmanRequest ────────────────────────────────────────────────────── + +func TestBuildPostmanRequest_GET_NoBody(t *testing.T) { + pr := buildPostmanRequest("GET", "https://api.example.com/users", nil, "") + if pr.Method != "GET" { + t.Errorf("Method mismatch: %q", pr.Method) + } + if pr.Body != nil { + t.Errorf("expected no body for GET, got %+v", pr.Body) + } + if pr.URL.Raw != "https://api.example.com/users" { + t.Errorf("URL.Raw mismatch: %q", pr.URL.Raw) + } +} + +func TestBuildPostmanRequest_POST_JSONBody(t *testing.T) { + pr := buildPostmanRequest("POST", "https://api.example.com/users", + map[string]string{"Content-Type": "application/json"}, + `{"name":"Alice"}`) + if pr.Method != "POST" { + t.Errorf("Method mismatch: %q", pr.Method) + } + if pr.Body == nil { + t.Fatal("expected body, got nil") + } + if pr.Body.Mode != "raw" { + t.Errorf("expected mode 'raw', got %q", pr.Body.Mode) + } + if pr.Body.Raw != `{"name":"Alice"}` { + t.Errorf("body Raw mismatch: %q", pr.Body.Raw) + } + if pr.Body.Options == nil || pr.Body.Options.Raw.Language != "json" { + t.Error("expected JSON language in body options") + } +} + +func TestBuildPostmanRequest_POST_TextBody(t *testing.T) { + pr := buildPostmanRequest("POST", "https://api.example.com/data", nil, "plain text body") + if pr.Body == nil { + t.Fatal("expected body, got nil") + } + if pr.Body.Options == nil || pr.Body.Options.Raw.Language != "text" { + t.Error("expected 'text' language for non-JSON body") + } +} + +func TestBuildPostmanRequest_UppercaseMethod(t *testing.T) { + // Method should be uppercased + pr := buildPostmanRequest("delete", "https://api.example.com/resource/1", nil, "") + if pr.Method != "DELETE" { + t.Errorf("expected uppercase method DELETE, got %q", pr.Method) + } +} + +func TestBuildPostmanRequest_Headers(t *testing.T) { + headers := map[string]string{ + "Accept": "application/json", + "X-Request-ID": "req-123", + } + pr := buildPostmanRequest("GET", "https://api.example.com", headers, "") + if len(pr.Header) != 2 { + t.Errorf("expected 2 headers, got %d", len(pr.Header)) + } + found := map[string]string{} + for _, h := range pr.Header { + found[h.Key] = h.Value + } + if found["Accept"] != "application/json" { + t.Errorf("unexpected Accept header: %q", found["Accept"]) + } +} + +// ─── savedRequestToPostmanItem ──────────────────────────────────────────────── + +func TestSavedRequestToPostmanItem_WithName(t *testing.T) { + req := model.SavedRequest{ + Name: "Get All Users", + Method: "GET", + URL: "https://api.example.com/users", + Headers: map[string]string{}, + Body: "", + } + item := savedRequestToPostmanItem(req) + if item.Name != "Get All Users" { + t.Errorf("Name mismatch: %q", item.Name) + } + if item.Request.Method != "GET" { + t.Errorf("Method mismatch: %q", item.Request.Method) + } +} + +func TestSavedRequestToPostmanItem_WithoutName(t *testing.T) { + req := model.SavedRequest{ + Name: "", + Method: "POST", + URL: "https://api.example.com/users", + Headers: map[string]string{}, + Body: "", + } + item := savedRequestToPostmanItem(req) + // When name is empty, it should be generated from method + url + if item.Name == "" { + t.Error("expected generated name for unnamed request") + } + if !strings.Contains(item.Name, "POST") { + t.Errorf("expected name to contain method, got %q", item.Name) + } +} + +// ─── historyRequestToPostmanItem ────────────────────────────────────────────── + +func TestHistoryRequestToPostmanItem(t *testing.T) { + req := model.Request{ + ID: "abc12345", + Method: "DELETE", + URL: "https://api.example.com/users/42", + } + item := historyRequestToPostmanItem(req) + if !strings.Contains(item.Name, "DELETE") { + t.Errorf("expected name to contain method, got %q", item.Name) + } + if !strings.Contains(item.Name, "https://api.example.com/users/42") { + t.Errorf("expected name to contain URL, got %q", item.Name) + } + if item.Request.Method != "DELETE" { + t.Errorf("Method mismatch: %q", item.Request.Method) + } +} + +// ─── buildPostmanFromCollection / buildPostmanFromHistory (integration) ─────── + +func TestBuildPostmanFromCollection_Success(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + store, err := storage.NewStorage() + if err != nil { + t.Fatalf("NewStorage: %v", err) + } + defer store.Close() + + store.CreateCollection("test-col") + store.AddToCollection("test-col", model.SavedRequest{ + Name: "List Users", + Method: "GET", + URL: "https://api.example.com/users", + Headers: map[string]string{}, + }) + store.AddToCollection("test-col", model.SavedRequest{ + Name: "Create User", + Method: "POST", + URL: "https://api.example.com/users", + Headers: map[string]string{"Content-Type": "application/json"}, + Body: `{"name":"Alice"}`, + }) + + pc, err := buildPostmanFromCollection(store, "test-col") + if err != nil { + t.Fatalf("buildPostmanFromCollection: %v", err) + } + if pc.Info.Name != "test-col" { + t.Errorf("collection name mismatch: %q", pc.Info.Name) + } + if len(pc.Items) != 2 { + t.Errorf("expected 2 items, got %d", len(pc.Items)) + } + if !strings.Contains(pc.Info.Schema, "postman") { + t.Errorf("expected Postman schema URL, got %q", pc.Info.Schema) + } +} + +func TestBuildPostmanFromCollection_NotFound(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + store, err := storage.NewStorage() + if err != nil { + t.Fatalf("NewStorage: %v", err) + } + defer store.Close() + + _, err = buildPostmanFromCollection(store, "nonexistent") + if err == nil { + t.Error("expected error for non-existent collection, got nil") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("expected 'not found' error, got: %v", err) + } +} + +func TestBuildPostmanFromHistory_Empty(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + store, err := storage.NewStorage() + if err != nil { + t.Fatalf("NewStorage: %v", err) + } + defer store.Close() + + pc, err := buildPostmanFromHistory(store) + if err != nil { + t.Fatalf("buildPostmanFromHistory: %v", err) + } + if len(pc.Items) != 0 { + t.Errorf("expected 0 items for empty history, got %d", len(pc.Items)) + } + if !strings.Contains(pc.Info.Name, "History Export") { + t.Errorf("expected history export name, got %q", pc.Info.Name) + } +} + +func TestBuildPostmanFromAllCollections_Empty(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + store, err := storage.NewStorage() + if err != nil { + t.Fatalf("NewStorage: %v", err) + } + defer store.Close() + + pc, err := buildPostmanFromAllCollections(store) + if err != nil { + t.Fatalf("buildPostmanFromAllCollections: %v", err) + } + if len(pc.Items) != 0 { + t.Errorf("expected 0 items for empty collections, got %d", len(pc.Items)) + } +} + +func TestBuildPostmanFromAllCollections_Single(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + store, err := storage.NewStorage() + if err != nil { + t.Fatalf("NewStorage: %v", err) + } + defer store.Close() + + store.CreateCollection("my-api") + store.AddToCollection("my-api", model.SavedRequest{ + Name: "Health Check", + Method: "GET", + URL: "https://api.example.com/health", + Headers: map[string]string{}, + }) + + pc, err := buildPostmanFromAllCollections(store) + if err != nil { + t.Fatalf("buildPostmanFromAllCollections: %v", err) + } + // Single collection → use its name + if pc.Info.Name != "my-api" { + t.Errorf("expected collection name 'my-api', got %q", pc.Info.Name) + } + if len(pc.Items) != 1 { + t.Errorf("expected 1 item, got %d", len(pc.Items)) + } +} + +func TestBuildPostmanFromAllCollections_Multiple(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + store, err := storage.NewStorage() + if err != nil { + t.Fatalf("NewStorage: %v", err) + } + defer store.Close() + + store.CreateCollection("col-a") + store.AddToCollection("col-a", model.SavedRequest{Method: "GET", URL: "https://a.com", Headers: map[string]string{}}) + store.AddToCollection("col-a", model.SavedRequest{Method: "POST", URL: "https://a.com", Headers: map[string]string{}}) + store.CreateCollection("col-b") + store.AddToCollection("col-b", model.SavedRequest{Method: "DELETE", URL: "https://b.com", Headers: map[string]string{}}) + + pc, err := buildPostmanFromAllCollections(store) + if err != nil { + t.Fatalf("buildPostmanFromAllCollections: %v", err) + } + // Multiple collections → generic name + if pc.Info.Name != "apicli Export" { + t.Errorf("expected 'apicli Export', got %q", pc.Info.Name) + } + if len(pc.Items) != 3 { + t.Errorf("expected 3 total items, got %d", len(pc.Items)) + } +} + +func TestBuildPostmanFromHistory_WithRequests(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + store, err := storage.NewStorage() + if err != nil { + t.Fatalf("NewStorage: %v", err) + } + defer store.Close() + + import_time := model.Request{ + ID: "hist0001", + Method: "GET", + URL: "https://api.example.com/users", + Headers: map[string]string{}, + } + store.AddToHistory(import_time) + + pc, err := buildPostmanFromHistory(store) + if err != nil { + t.Fatalf("buildPostmanFromHistory: %v", err) + } + if len(pc.Items) != 1 { + t.Errorf("expected 1 item, got %d", len(pc.Items)) + } + if pc.Items[0].Request.Method != "GET" { + t.Errorf("method mismatch: %q", pc.Items[0].Request.Method) + } +} diff --git a/cmd/import_test.go b/cmd/import_test.go new file mode 100644 index 0000000..098b038 --- /dev/null +++ b/cmd/import_test.go @@ -0,0 +1,336 @@ +package cmd + +import ( + "encoding/json" + "strings" + "testing" +) + +// ─── resolvePostmanURL ──────────────────────────────────────────────────────── + +func TestResolvePostmanURL_PlainString(t *testing.T) { + raw := json.RawMessage(`"https://api.example.com/users"`) + got := resolvePostmanURL(raw) + if got != "https://api.example.com/users" { + t.Errorf("expected plain string URL, got %q", got) + } +} + +func TestResolvePostmanURL_ObjectWithRaw(t *testing.T) { + raw := json.RawMessage(`{ + "raw": "https://api.example.com/v1/users", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["v1", "users"] + }`) + got := resolvePostmanURL(raw) + if got != "https://api.example.com/v1/users" { + t.Errorf("expected raw URL from object, got %q", got) + } +} + +func TestResolvePostmanURL_ObjectWithoutRaw_Reconstruct(t *testing.T) { + raw := json.RawMessage(`{ + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["users", "123"] + }`) + got := resolvePostmanURL(raw) + if got == "" { + t.Error("expected reconstructed URL, got empty") + } + if !strings.Contains(got, "api.example.com") { + t.Errorf("expected host in URL, got %q", got) + } + if !strings.Contains(got, "users") { + t.Errorf("expected path in URL, got %q", got) + } +} + +func TestResolvePostmanURL_ObjectHostOnly(t *testing.T) { + raw := json.RawMessage(`{ + "protocol": "https", + "host": ["example", "com"] + }`) + got := resolvePostmanURL(raw) + if got == "" { + t.Error("expected URL with just host, got empty") + } + if !strings.Contains(got, "example.com") { + t.Errorf("expected 'example.com' in URL, got %q", got) + } +} + +func TestResolvePostmanURL_ObjectDefaultProtocol(t *testing.T) { + // No protocol → default to https + raw := json.RawMessage(`{ + "host": ["api", "example", "com"] + }`) + got := resolvePostmanURL(raw) + if !strings.HasPrefix(got, "https://") { + t.Errorf("expected https:// default protocol, got %q", got) + } +} + +func TestResolvePostmanURL_Empty(t *testing.T) { + got := resolvePostmanURL(json.RawMessage{}) + if got != "" { + t.Errorf("expected empty string for empty raw message, got %q", got) + } +} + +func TestResolvePostmanURL_InvalidJSON(t *testing.T) { + // Neither a string nor an object + raw := json.RawMessage(`12345`) + // Should not panic; returns empty or reconstructed + _ = resolvePostmanURL(raw) +} + +func TestResolvePostmanURL_NullValue(t *testing.T) { + raw := json.RawMessage(`null`) + // null is not a string or an object with host, so reconstruct returns "" + _ = resolvePostmanURL(raw) +} + +// ─── flattenPostmanItems ────────────────────────────────────────────────────── + +func makeLeafItem(name, method, url string) postmanItemImport { + rawURL, _ := json.Marshal(url) + return postmanItemImport{ + Name: name, + Request: &postmanRequestImport{ + Method: method, + Header: []postmanHeader{}, + URL: json.RawMessage(rawURL), + }, + } +} + +func makeFolderItem(name string, children []postmanItemImport) postmanItemImport { + return postmanItemImport{ + Name: name, + Items: children, + } +} + +func TestFlattenPostmanItems_Empty(t *testing.T) { + result := flattenPostmanItems([]postmanItemImport{}, "") + if len(result) != 0 { + t.Errorf("expected empty result, got %d items", len(result)) + } +} + +func TestFlattenPostmanItems_FlatLeaves(t *testing.T) { + items := []postmanItemImport{ + makeLeafItem("Get Users", "GET", "https://api.example.com/users"), + makeLeafItem("Create User", "POST", "https://api.example.com/users"), + makeLeafItem("Delete User", "DELETE", "https://api.example.com/users/1"), + } + result := flattenPostmanItems(items, "") + if len(result) != 3 { + t.Fatalf("expected 3 requests, got %d", len(result)) + } + if result[0].Name != "Get Users" { + t.Errorf("name mismatch: %q", result[0].Name) + } + if result[1].Method != "POST" { + t.Errorf("method mismatch: %q", result[1].Method) + } +} + +func TestFlattenPostmanItems_NestedFolder(t *testing.T) { + items := []postmanItemImport{ + makeLeafItem("Health Check", "GET", "https://api.example.com/health"), + makeFolderItem("Users", []postmanItemImport{ + makeLeafItem("List", "GET", "https://api.example.com/users"), + makeLeafItem("Create", "POST", "https://api.example.com/users"), + }), + } + result := flattenPostmanItems(items, "") + if len(result) != 3 { + t.Fatalf("expected 3 requests (1 top-level + 2 from folder), got %d", len(result)) + } + // Nested items should have folder name prepended + if result[1].Name != "Users / List" { + t.Errorf("expected 'Users / List', got %q", result[1].Name) + } + if result[2].Name != "Users / Create" { + t.Errorf("expected 'Users / Create', got %q", result[2].Name) + } +} + +func TestFlattenPostmanItems_DeepNesting(t *testing.T) { + items := []postmanItemImport{ + makeFolderItem("Auth", []postmanItemImport{ + makeFolderItem("OAuth", []postmanItemImport{ + makeLeafItem("Token", "POST", "https://auth.example.com/token"), + }), + }), + } + result := flattenPostmanItems(items, "") + if len(result) != 1 { + t.Fatalf("expected 1 request, got %d", len(result)) + } + if result[0].Name != "Auth / OAuth / Token" { + t.Errorf("expected 'Auth / OAuth / Token', got %q", result[0].Name) + } +} + +func TestFlattenPostmanItems_FolderWithNoRequests(t *testing.T) { + // Empty folder — no leaf items + items := []postmanItemImport{ + makeFolderItem("EmptyFolder", []postmanItemImport{}), + } + result := flattenPostmanItems(items, "") + if len(result) != 0 { + t.Errorf("expected 0 results from empty folder, got %d", len(result)) + } +} + +func TestFlattenPostmanItems_WithPrefix(t *testing.T) { + items := []postmanItemImport{ + makeLeafItem("Get", "GET", "https://example.com"), + } + result := flattenPostmanItems(items, "ParentFolder / ") + if len(result) != 1 { + t.Fatalf("expected 1 result, got %d", len(result)) + } + if result[0].Name != "ParentFolder / Get" { + t.Errorf("expected prefixed name, got %q", result[0].Name) + } +} + +// ─── convertPostmanRequest ──────────────────────────────────────────────────── + +func TestConvertPostmanRequest_GET(t *testing.T) { + rawURL, _ := json.Marshal("https://api.example.com/users") + pr := &postmanRequestImport{ + Method: "get", + Header: []postmanHeader{}, + URL: json.RawMessage(rawURL), + } + req := convertPostmanRequest("Get Users", pr) + + if req.Name != "Get Users" { + t.Errorf("Name mismatch: %q", req.Name) + } + if req.Method != "GET" { + t.Errorf("Method should be uppercased: %q", req.Method) + } + if req.URL != "https://api.example.com/users" { + t.Errorf("URL mismatch: %q", req.URL) + } + if req.Body != "" { + t.Errorf("expected no body, got %q", req.Body) + } +} + +func TestConvertPostmanRequest_WithHeaders(t *testing.T) { + rawURL, _ := json.Marshal("https://api.example.com/users") + pr := &postmanRequestImport{ + Method: "POST", + Header: []postmanHeader{ + {Key: "Content-Type", Value: "application/json"}, + {Key: "Accept", Value: "application/json"}, + {Key: "", Value: "should-be-skipped"}, + }, + URL: json.RawMessage(rawURL), + } + req := convertPostmanRequest("Create User", pr) + + if req.Headers["Content-Type"] != "application/json" { + t.Errorf("Content-Type header mismatch: %q", req.Headers["Content-Type"]) + } + if req.Headers["Accept"] != "application/json" { + t.Errorf("Accept header mismatch: %q", req.Headers["Accept"]) + } + // Empty key should be skipped + if _, exists := req.Headers[""]; exists { + t.Error("empty key header should be skipped") + } +} + +func TestConvertPostmanRequest_WithRawBody(t *testing.T) { + rawURL, _ := json.Marshal("https://api.example.com/users") + pr := &postmanRequestImport{ + Method: "POST", + Header: []postmanHeader{}, + URL: json.RawMessage(rawURL), + Body: &postmanBody{ + Mode: "raw", + Raw: `{"name":"Alice","email":"alice@example.com"}`, + }, + } + req := convertPostmanRequest("Create User", pr) + + if req.Body != `{"name":"Alice","email":"alice@example.com"}` { + t.Errorf("body mismatch: %q", req.Body) + } +} + +func TestConvertPostmanRequest_NonRawBodySkipped(t *testing.T) { + rawURL, _ := json.Marshal("https://api.example.com/upload") + pr := &postmanRequestImport{ + Method: "POST", + Header: []postmanHeader{}, + URL: json.RawMessage(rawURL), + Body: &postmanBody{ + Mode: "formdata", + Raw: "should be ignored", + }, + } + req := convertPostmanRequest("Upload", pr) + if req.Body != "" { + t.Errorf("non-raw body mode should be ignored, got %q", req.Body) + } +} + +func TestConvertPostmanRequest_NilBody(t *testing.T) { + rawURL, _ := json.Marshal("https://api.example.com/users") + pr := &postmanRequestImport{ + Method: "GET", + Header: []postmanHeader{}, + URL: json.RawMessage(rawURL), + Body: nil, + } + req := convertPostmanRequest("Get Users", pr) + if req.Body != "" { + t.Errorf("nil body should result in empty body, got %q", req.Body) + } +} + +func TestConvertPostmanRequest_BodyModeCaseInsensitive(t *testing.T) { + rawURL, _ := json.Marshal("https://api.example.com/data") + pr := &postmanRequestImport{ + Method: "POST", + Header: []postmanHeader{}, + URL: json.RawMessage(rawURL), + Body: &postmanBody{ + Mode: "RAW", // uppercase + Raw: `{"key":"value"}`, + }, + } + req := convertPostmanRequest("Post Data", pr) + if req.Body != `{"key":"value"}` { + t.Errorf("case-insensitive mode match failed, got body %q", req.Body) + } +} + +func TestConvertPostmanRequest_URLAsObject(t *testing.T) { + urlObj := map[string]interface{}{ + "raw": "https://api.example.com/users", + "protocol": "https", + "host": []string{"api", "example", "com"}, + "path": []string{"users"}, + } + rawURL, _ := json.Marshal(urlObj) + pr := &postmanRequestImport{ + Method: "GET", + Header: []postmanHeader{}, + URL: json.RawMessage(rawURL), + } + req := convertPostmanRequest("Get Users", pr) + if req.URL != "https://api.example.com/users" { + t.Errorf("URL from object mismatch: %q", req.URL) + } +} diff --git a/cmd/request_test.go b/cmd/request_test.go new file mode 100644 index 0000000..2dcc80f --- /dev/null +++ b/cmd/request_test.go @@ -0,0 +1,377 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "api/internal/storage" +) + +// ─── parseHeaders ───────────────────────────────────────────────────────────── + +func TestParseHeaders_Single(t *testing.T) { + got := parseHeaders([]string{"Content-Type: application/json"}) + if got["Content-Type"] != "application/json" { + t.Errorf("expected 'application/json', got %q", got["Content-Type"]) + } +} + +func TestParseHeaders_Multiple(t *testing.T) { + got := parseHeaders([]string{ + "Content-Type: application/json", + "Accept: */*", + "X-Custom: myvalue", + }) + if len(got) != 3 { + t.Errorf("expected 3 headers, got %d", len(got)) + } + if got["Accept"] != "*/*" { + t.Errorf("unexpected Accept: %q", got["Accept"]) + } + if got["X-Custom"] != "myvalue" { + t.Errorf("unexpected X-Custom: %q", got["X-Custom"]) + } +} + +func TestParseHeaders_TrimsWhitespace(t *testing.T) { + got := parseHeaders([]string{" Authorization : Bearer token123 "}) + if got["Authorization"] != "Bearer token123" { + t.Errorf("expected trimmed value, got %q", got["Authorization"]) + } +} + +func TestParseHeaders_Malformed_NoColon(t *testing.T) { + got := parseHeaders([]string{"NoColonHeader"}) + if len(got) != 0 { + t.Errorf("expected no headers from malformed input, got %v", got) + } +} + +func TestParseHeaders_ValueWithColon(t *testing.T) { + // Value contains a colon (e.g. Authorization: Bearer base64:encoded) + got := parseHeaders([]string{"Authorization: Bearer base64:encoded"}) + if got["Authorization"] != "Bearer base64:encoded" { + t.Errorf("expected 'Bearer base64:encoded', got %q", got["Authorization"]) + } +} + +func TestParseHeaders_Empty(t *testing.T) { + got := parseHeaders([]string{}) + if len(got) != 0 { + t.Errorf("expected empty map for no headers, got %v", got) + } +} + +func TestParseHeaders_EmptyEntry(t *testing.T) { + got := parseHeaders([]string{"Valid: yes", ""}) + // Empty string has no colon → skipped + if len(got) != 1 { + t.Errorf("expected 1 header (empty entry skipped), got %d", len(got)) + } +} + +// ─── filterSensitiveHeaders ─────────────────────────────────────────────────── + +func TestFilterSensitiveHeaders_RedactsAuthorization(t *testing.T) { + h := map[string]string{ + "Authorization": "Bearer super-secret-token", + "Content-Type": "application/json", + } + filtered := filterSensitiveHeaders(h) + if filtered["Authorization"] != "[REDACTED]" { + t.Errorf("Authorization should be redacted, got %q", filtered["Authorization"]) + } + if filtered["Content-Type"] != "application/json" { + t.Errorf("Content-Type should not be redacted, got %q", filtered["Content-Type"]) + } +} + +func TestFilterSensitiveHeaders_CaseInsensitive(t *testing.T) { + h := map[string]string{ + "AUTHORIZATION": "Bearer secret", + "X-API-KEY": "my-key", + "x-auth-token": "token-value", + "Cookie": "session=abc", + } + filtered := filterSensitiveHeaders(h) + for _, k := range []string{"AUTHORIZATION", "X-API-KEY", "x-auth-token", "Cookie"} { + if filtered[k] != "[REDACTED]" { + t.Errorf("header %q should be redacted, got %q", k, filtered[k]) + } + } +} + +func TestFilterSensitiveHeaders_AllSensitiveHeaders(t *testing.T) { + sensitiveOnes := []string{ + "Authorization", "Proxy-Authorization", "WWW-Authenticate", + "Cookie", "Set-Cookie", "X-Api-Key", "Api-Key", + "X-Auth-Token", "X-CSRF-Token", "X-XSRF-Token", + "X-Amz-Security-Token", "X-Amz-Credential", "X-Amz-Signature", + "X-Goog-Authenticated-User-Email", "X-Goog-Authenticated-User-Id", "X-Goog-Iap-Jwt-Assertion", + "X-Ms-Client-Principal", "X-Ms-Client-Principal-Id", "X-Ms-Token-Aad-Id-Token", + "X-Access-Token", "X-Refresh-Token", "X-Session-Token", "X-Secret-Key", "X-Private-Key", + } + h := make(map[string]string) + for _, k := range sensitiveOnes { + h[k] = "secret-value" + } + filtered := filterSensitiveHeaders(h) + for _, k := range sensitiveOnes { + if filtered[k] != "[REDACTED]" { + t.Errorf("expected %q to be redacted, got %q", k, filtered[k]) + } + } +} + +func TestFilterSensitiveHeaders_SafeHeadersUnchanged(t *testing.T) { + h := map[string]string{ + "Content-Type": "application/json", + "Accept": "*/*", + "X-Request-ID": "abc-123", + "User-Agent": "apicli/1.0", + } + filtered := filterSensitiveHeaders(h) + for k, v := range h { + if filtered[k] != v { + t.Errorf("header %q should not be redacted: got %q, want %q", k, filtered[k], v) + } + } +} + +func TestFilterSensitiveHeaders_Nil(t *testing.T) { + filtered := filterSensitiveHeaders(nil) + if filtered != nil { + t.Errorf("expected nil for nil input, got %v", filtered) + } +} + +func TestFilterSensitiveHeaders_Empty(t *testing.T) { + filtered := filterSensitiveHeaders(map[string]string{}) + if len(filtered) != 0 { + t.Errorf("expected empty map, got %v", filtered) + } +} + +// ─── warnIfSensitiveBody ────────────────────────────────────────────────────── + +func TestWarnIfSensitiveBody_EmptyBody(t *testing.T) { + // Should not panic + warnIfSensitiveBody("") +} + +func TestWarnIfSensitiveBody_NoSensitiveData(t *testing.T) { + // Should not panic + warnIfSensitiveBody(`{"name":"Alice","email":"alice@example.com"}`) +} + +func TestWarnIfSensitiveBody_ContainsPassword(t *testing.T) { + // Verify it doesn't panic with sensitive content + warnIfSensitiveBody(`{"username":"alice","password":"s3cr3t"}`) +} + +func TestWarnIfSensitiveBody_ContainsToken(t *testing.T) { + warnIfSensitiveBody(`{"access_token":"eyJhbGci..."}`) +} + +func TestWarnIfSensitiveBody_ContainsAPIKey(t *testing.T) { + warnIfSensitiveBody(`{"api_key":"abc123xyz"}`) +} + +func TestWarnIfSensitiveBody_ContainsSecret(t *testing.T) { + warnIfSensitiveBody(`{"client_secret":"very-secret"}`) +} + +func TestWarnIfSensitiveBody_CaseInsensitive(t *testing.T) { + // Uppercase should still be detected + warnIfSensitiveBody(`{"PASSWORD":"S3CR3T"}`) +} + +// ─── resolveAlias ───────────────────────────────────────────────────────────── + +func TestResolveAlias_FullHTTPSURL(t *testing.T) { + url := "https://example.com/api/v1" + got := resolveAlias(url) + if got != url { + t.Errorf("full https URL should be unchanged: got %q, want %q", got, url) + } +} + +func TestResolveAlias_FullHTTPURL(t *testing.T) { + url := "http://example.com/api" + got := resolveAlias(url) + if got != url { + t.Errorf("full http URL should be unchanged: got %q, want %q", got, url) + } +} + +func TestResolveAlias_UnknownAlias(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + // Alias doesn't exist → returned as-is + input := "unknownalias/some/path" + got := resolveAlias(input) + if got != input { + t.Errorf("unknown alias should be unchanged: got %q, want %q", got, input) + } +} + +func TestResolveAlias_KnownAlias_WithPath(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + store, err := storage.NewStorage() + if err != nil { + t.Fatalf("NewStorage: %v", err) + } + defer store.Close() + if err := store.CreateAlias("starwars", "https://swapi.tech/api"); err != nil { + t.Fatalf("CreateAlias: %v", err) + } + + got := resolveAlias("starwars/people/1") + want := "https://swapi.tech/api/people/1" + if got != want { + t.Errorf("resolveAlias = %q, want %q", got, want) + } +} + +func TestResolveAlias_KnownAlias_NoPath(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + store, err := storage.NewStorage() + if err != nil { + t.Fatalf("NewStorage: %v", err) + } + defer store.Close() + if err := store.CreateAlias("myapi", "https://api.example.com"); err != nil { + t.Fatalf("CreateAlias: %v", err) + } + + got := resolveAlias("myapi") + want := "https://api.example.com" + if got != want { + t.Errorf("resolveAlias (no path) = %q, want %q", got, want) + } +} + +func TestResolveAlias_TrailingSlashNormalization(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + store, err := storage.NewStorage() + if err != nil { + t.Fatalf("NewStorage: %v", err) + } + defer store.Close() + // Base URL has trailing slash + if err := store.CreateAlias("api", "https://api.example.com/"); err != nil { + t.Fatalf("CreateAlias: %v", err) + } + + got := resolveAlias("api/users") + // Should not produce double slash + if strings.Contains(got, "//users") { + t.Errorf("double slash in resolved URL: %q", got) + } + if got != "https://api.example.com/users" { + t.Errorf("resolveAlias = %q, want %q", got, "https://api.example.com/users") + } +} + +// ─── readBodyFromFile ───────────────────────────────────────────────────────── + +// setupWorkDir creates a temp directory, changes to it, and restores after test. +func setupWorkDir(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Chdir: %v", err) + } + t.Cleanup(func() { os.Chdir(origDir) }) + return tmpDir +} + +func TestReadBodyFromFile_ValidFile(t *testing.T) { + dir := setupWorkDir(t) + content := `{"name":"test","value":42}` + fpath := filepath.Join(dir, "body.json") + if err := os.WriteFile(fpath, []byte(content), 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got, err := readBodyFromFile("body.json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != content { + t.Errorf("content mismatch: got %q, want %q", got, content) + } +} + +func TestReadBodyFromFile_NonExistent(t *testing.T) { + setupWorkDir(t) + _, err := readBodyFromFile("nonexistent.json") + if err == nil { + t.Error("expected error for non-existent file, got nil") + } +} + +func TestReadBodyFromFile_PathTraversal(t *testing.T) { + setupWorkDir(t) + // Attempt to read a file outside the working directory + _, err := readBodyFromFile("../../../etc/passwd") + if err == nil { + t.Error("expected error for path traversal, got nil") + } + if !strings.Contains(err.Error(), "access denied") { + t.Errorf("expected 'access denied' error, got: %v", err) + } +} + +func TestReadBodyFromFile_AbsolutePathOutsideWD(t *testing.T) { + setupWorkDir(t) + // Absolute path outside working directory + _, err := readBodyFromFile("/etc/hostname") + if err == nil { + t.Error("expected error for absolute path outside working dir, got nil") + } + if !strings.Contains(err.Error(), "access denied") { + t.Errorf("expected 'access denied' error, got: %v", err) + } +} + +func TestReadBodyFromFile_SubdirFile(t *testing.T) { + dir := setupWorkDir(t) + subdir := filepath.Join(dir, "subdir") + if err := os.MkdirAll(subdir, 0700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + content := "subdir content" + if err := os.WriteFile(filepath.Join(subdir, "data.txt"), []byte(content), 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got, err := readBodyFromFile("subdir/data.txt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != content { + t.Errorf("content mismatch: got %q, want %q", got, content) + } +} + +func TestReadBodyFromFile_EmptyFile(t *testing.T) { + dir := setupWorkDir(t) + fpath := filepath.Join(dir, "empty.json") + if err := os.WriteFile(fpath, []byte(""), 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got, err := readBodyFromFile("empty.json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Errorf("expected empty content, got %q", got) + } +} diff --git a/go.mod b/go.mod index 1aad737..1e94fdf 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,19 @@ require ( ) require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/sys v0.14.0 // indirect + golang.org/x/sys v0.16.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.41.0 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect ) diff --git a/go.sum b/go.sum index c28e358..a479c7e 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,22 @@ github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= @@ -18,6 +26,21 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.29.0 h1:lQVw+ZsFM3aRG5m4myG70tbXpr3S/J1ej0KHIP4EvjM= modernc.org/sqlite v1.29.0/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/format/output_test.go b/internal/format/output_test.go new file mode 100644 index 0000000..6dd20a0 --- /dev/null +++ b/internal/format/output_test.go @@ -0,0 +1,435 @@ +package format + +import ( + "strings" + "testing" + "time" + + "api/internal/model" +) + +// ─── sanitizeOutput ───────────────────────────────────────────────────────── + +func TestSanitizeOutput_PlainText(t *testing.T) { + input := "Hello, World!" + got := sanitizeOutput(input) + if got != input { + t.Errorf("expected %q unchanged, got %q", input, got) + } +} + +func TestSanitizeOutput_AllowedWhitespace(t *testing.T) { + input := "line1\nline2\r\n\ttabbed" + got := sanitizeOutput(input) + if got != input { + t.Errorf("newline/tab/CR should pass through unchanged, got %q", got) + } +} + +func TestSanitizeOutput_ANSIEscape(t *testing.T) { + // ESC character (0x1b) should be replaced with \x1b + input := "\x1b[31mred text\x1b[0m" + got := sanitizeOutput(input) + if strings.Contains(got, "\x1b") { + t.Errorf("ESC character should be replaced, got %q", got) + } + if !strings.Contains(got, `\x1b`) { + t.Errorf("expected literal \\x1b in output, got %q", got) + } +} + +func TestSanitizeOutput_ControlChars(t *testing.T) { + // Control characters below 0x20 (except \n, \r, \t) should be escaped + for _, r := range []rune{0x01, 0x07, 0x08, 0x0c, 0x0e, 0x1a} { + input := string(r) + got := sanitizeOutput(input) + if strings.ContainsRune(got, r) { + t.Errorf("control char 0x%02x should be escaped, got %q", r, got) + } + if !strings.Contains(got, `\x`) { + t.Errorf("expected \\x escape for 0x%02x, got %q", r, got) + } + } +} + +func TestSanitizeOutput_DELCharacter(t *testing.T) { + input := "before\x7fafter" + got := sanitizeOutput(input) + if strings.Contains(got, "\x7f") { + t.Errorf("DEL character should be replaced, got %q", got) + } + if !strings.Contains(got, `\x7f`) { + t.Errorf("expected literal \\x7f in output, got %q", got) + } +} + +func TestSanitizeOutput_Unicode(t *testing.T) { + input := "Hello, 世界! 🌍" + got := sanitizeOutput(input) + if got != input { + t.Errorf("unicode text should pass through unchanged, got %q", got) + } +} + +func TestSanitizeOutput_Empty(t *testing.T) { + got := sanitizeOutput("") + if got != "" { + t.Errorf("empty string should return empty, got %q", got) + } +} + +func TestSanitizeOutput_MixedContent(t *testing.T) { + // Mix of safe and unsafe characters + input := "normal\x1b[0mtext\x07bell" + got := sanitizeOutput(input) + if strings.ContainsAny(got, "\x1b\x07") { + t.Errorf("unsafe chars should be escaped in mixed content, got %q", got) + } + if !strings.Contains(got, "normal") || !strings.Contains(got, "text") || !strings.Contains(got, "bell") { + t.Errorf("safe text should remain in output, got %q", got) + } +} + +// ─── prettyJSON ────────────────────────────────────────────────────────────── + +func TestPrettyJSON_ValidObject(t *testing.T) { + input := `{"key":"value","num":42}` + got := prettyJSON(input) + if !strings.Contains(got, "\n") { + t.Errorf("expected formatted JSON with newlines, got %q", got) + } + if !strings.Contains(got, `"key"`) || !strings.Contains(got, `"value"`) { + t.Errorf("expected JSON content preserved, got %q", got) + } +} + +func TestPrettyJSON_ValidArray(t *testing.T) { + input := `[1,2,3]` + got := prettyJSON(input) + if !strings.Contains(got, "\n") { + t.Errorf("expected formatted JSON array with newlines, got %q", got) + } +} + +func TestPrettyJSON_AlreadyFormatted(t *testing.T) { + input := "{\n \"key\": \"value\"\n}" + got := prettyJSON(input) + if !strings.Contains(got, `"key"`) { + t.Errorf("expected JSON content preserved, got %q", got) + } +} + +func TestPrettyJSON_InvalidJSON(t *testing.T) { + input := "not json at all" + got := prettyJSON(input) + if got != input { + t.Errorf("invalid JSON should return input unchanged, got %q (want %q)", got, input) + } +} + +func TestPrettyJSON_Empty(t *testing.T) { + got := prettyJSON("") + // Empty string is not valid JSON, should return as-is + if got != "" { + t.Errorf("empty input should return empty, got %q", got) + } +} + +func TestPrettyJSON_PlainText(t *testing.T) { + input := "plain text response" + got := prettyJSON(input) + if got != input { + t.Errorf("plain text should be returned as-is, got %q", got) + } +} + +func TestPrettyJSON_HTMLResponse(t *testing.T) { + input := "Not JSON" + got := prettyJSON(input) + if got != input { + t.Errorf("HTML should be returned as-is, got %q", got) + } +} + +// ─── getStatusColor ────────────────────────────────────────────────────────── + +func TestGetStatusColor_2xx(t *testing.T) { + for _, code := range []int{200, 201, 204, 299} { + c := getStatusColor(code) + if c == nil { + t.Errorf("expected non-nil color for status %d", code) + } + // 2xx should use successColor + if c != successColor { + t.Errorf("expected successColor for status %d", code) + } + } +} + +func TestGetStatusColor_3xx(t *testing.T) { + for _, code := range []int{301, 302, 304, 307} { + c := getStatusColor(code) + if c != redirectColor { + t.Errorf("expected redirectColor for status %d, got different color", code) + } + } +} + +func TestGetStatusColor_4xx(t *testing.T) { + for _, code := range []int{400, 401, 403, 404, 422, 429} { + c := getStatusColor(code) + if c != clientErrColor { + t.Errorf("expected clientErrColor for status %d", code) + } + } +} + +func TestGetStatusColor_5xx(t *testing.T) { + for _, code := range []int{500, 502, 503, 504} { + c := getStatusColor(code) + if c != serverErrColor { + t.Errorf("expected serverErrColor for status %d", code) + } + } +} + +func TestGetStatusColor_100(t *testing.T) { + // 1xx falls through to the default (serverErrColor) + c := getStatusColor(100) + if c != serverErrColor { + t.Errorf("expected serverErrColor for status 100 (default case)") + } +} + +// ─── PrintHistoryList smoke tests ──────────────────────────────────────────── + +func TestPrintHistoryList_Empty(t *testing.T) { + // Should not panic on empty slice + PrintHistoryList([]model.Request{}, 10) +} + +func TestPrintHistoryList_WithItems(t *testing.T) { + reqs := []model.Request{ + { + ID: "abc12345", + Timestamp: time.Now(), + Method: "GET", + URL: "https://api.example.com/users", + Response: &model.Response{StatusCode: 200, Status: "200 OK", DurationMs: 50}, + }, + { + ID: "def67890", + Timestamp: time.Now(), + Method: "POST", + URL: "https://api.example.com/users", + Response: &model.Response{StatusCode: 201, Status: "201 Created", DurationMs: 80}, + }, + } + // Should not panic + PrintHistoryList(reqs, 10) +} + +func TestPrintHistoryList_LimitApplied(t *testing.T) { + reqs := make([]model.Request, 20) + for i := range reqs { + reqs[i] = model.Request{ + ID: "id", + Timestamp: time.Now(), + Method: "GET", + URL: "https://example.com", + } + } + // Should not panic with limit less than total + PrintHistoryList(reqs, 5) +} + +func TestPrintHistoryList_NoLimit(t *testing.T) { + reqs := []model.Request{ + { + ID: "abc12345", + Timestamp: time.Now(), + Method: "DELETE", + URL: "https://example.com/resource/1", + }, + } + // limit=0 means no limit + PrintHistoryList(reqs, 0) +} + +func TestPrintHistoryList_LongURL(t *testing.T) { + longURL := "https://api.example.com/" + strings.Repeat("very-long-path-segment/", 5) + reqs := []model.Request{ + { + ID: "abc12345", + Timestamp: time.Now(), + Method: "GET", + URL: longURL, + }, + } + // Should not panic with long URL (truncation logic) + PrintHistoryList(reqs, 10) +} + +// ─── PrintCollectionList smoke tests ───────────────────────────────────────── + +func TestPrintCollectionList_Empty(t *testing.T) { + cols := &model.Collections{Collections: map[string]model.Collection{}} + PrintCollectionList(cols) +} + +func TestPrintCollectionList_WithCollections(t *testing.T) { + cols := &model.Collections{ + Collections: map[string]model.Collection{ + "my-api": { + Name: "my-api", + Requests: []model.SavedRequest{ + {Name: "Get Users", Method: "GET", URL: "https://api.example.com/users"}, + }, + }, + "other": { + Name: "other", + Requests: []model.SavedRequest{}, + }, + }, + } + PrintCollectionList(cols) +} + +// ─── PrintCollectionRequests smoke tests ───────────────────────────────────── + +func TestPrintCollectionRequests_Empty(t *testing.T) { + col := &model.Collection{Name: "empty-col", Requests: []model.SavedRequest{}} + PrintCollectionRequests(col) +} + +func TestPrintCollectionRequests_WithRequests(t *testing.T) { + col := &model.Collection{ + Name: "test-col", + Requests: []model.SavedRequest{ + {Name: "Get Users", Method: "GET", URL: "https://api.example.com/users"}, + {Name: "", Method: "POST", URL: "https://api.example.com/users"}, + }, + } + PrintCollectionRequests(col) +} + +// ─── PrintAliasList smoke tests ─────────────────────────────────────────────── + +func TestPrintAliasList_Empty(t *testing.T) { + aliases := &model.Aliases{Aliases: map[string]string{}} + PrintAliasList(aliases) +} + +func TestPrintAliasList_WithAliases(t *testing.T) { + aliases := &model.Aliases{ + Aliases: map[string]string{ + "myapi": "https://api.example.com", + "starwars": "https://swapi.tech/api", + }, + } + PrintAliasList(aliases) +} + +// ─── PrintRequest / PrintRequestDetail smoke tests ─────────────────────────── + +func TestPrintRequest_NoResponse(t *testing.T) { + req := &model.Request{ + ID: "abc12345", + Timestamp: time.Now(), + Method: "GET", + URL: "https://example.com", + } + PrintRequest(req) +} + +func TestPrintRequest_WithResponse(t *testing.T) { + req := &model.Request{ + ID: "abc12345", + Timestamp: time.Now(), + Method: "POST", + URL: "https://example.com/users", + Response: &model.Response{ + StatusCode: 201, + Status: "201 Created", + }, + } + PrintRequest(req) +} + +func TestPrintRequestDetail_Full(t *testing.T) { + req := &model.Request{ + ID: "abc12345", + Timestamp: time.Now(), + Method: "POST", + URL: "https://api.example.com/users", + Headers: map[string]string{"Content-Type": "application/json"}, + Body: `{"name":"Alice"}`, + Response: &model.Response{ + StatusCode: 201, + Status: "201 Created", + Headers: map[string]string{"Location": "/users/1"}, + Body: `{"id":1,"name":"Alice"}`, + DurationMs: 120, + }, + } + PrintRequestDetail(req) +} + +func TestPrintRequestDetail_NoBody(t *testing.T) { + req := &model.Request{ + ID: "abc12345", + Timestamp: time.Now(), + Method: "GET", + URL: "https://api.example.com/users", + } + PrintRequestDetail(req) +} + +// ─── PrintResponse smoke tests ──────────────────────────────────────────────── + +func TestPrintResponse_WithHeaders(t *testing.T) { + resp := &model.Response{ + StatusCode: 200, + Status: "200 OK", + Headers: map[string]string{"Content-Type": "application/json", "X-Request-Id": "abc"}, + Body: `{"result":"ok"}`, + DurationMs: 42, + } + PrintResponse(resp, true) +} + +func TestPrintResponse_WithoutHeaders(t *testing.T) { + resp := &model.Response{ + StatusCode: 404, + Status: "404 Not Found", + Headers: map[string]string{}, + Body: `{"error":"not found"}`, + DurationMs: 15, + } + PrintResponse(resp, false) +} + +func TestPrintResponse_EmptyBody(t *testing.T) { + resp := &model.Response{ + StatusCode: 204, + Status: "204 No Content", + Headers: map[string]string{}, + Body: "", + DurationMs: 10, + } + PrintResponse(resp, false) +} + +// ─── PrintSuccess / PrintError smoke tests ─────────────────────────────────── + +func TestPrintSuccess(t *testing.T) { + PrintSuccess("operation completed") +} + +func TestPrintError(t *testing.T) { + PrintError("something went wrong") +} + +func TestPrintAlias(t *testing.T) { + PrintAlias("myapi", "https://api.example.com") +} diff --git a/internal/http/client_test.go b/internal/http/client_test.go new file mode 100644 index 0000000..ed85414 --- /dev/null +++ b/internal/http/client_test.go @@ -0,0 +1,436 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// ─── validateURL ──────────────────────────────────────────────────────────── + +func TestValidateURL_ValidHTTPS(t *testing.T) { + if err := validateURL("https://example.com/api/v1"); err != nil { + t.Errorf("unexpected error for valid https URL: %v", err) + } +} + +func TestValidateURL_ValidHTTP(t *testing.T) { + if err := validateURL("http://example.com/api"); err != nil { + t.Errorf("unexpected error for valid http URL: %v", err) + } +} + +func TestValidateURL_UnsupportedScheme(t *testing.T) { + cases := []string{"ftp://example.com", "file:///etc/passwd", "ssh://host.com", "data:text/plain,hello"} + for _, u := range cases { + if err := validateURL(u); err == nil { + t.Errorf("expected error for scheme in %q, got nil", u) + } else if !strings.Contains(err.Error(), "unsupported URL scheme") { + t.Errorf("expected 'unsupported URL scheme' error for %q, got: %v", u, err) + } + } +} + +func TestValidateURL_NoScheme(t *testing.T) { + // A URL without a scheme gets an empty scheme, which is neither http nor https. + if err := validateURL("example.com/path"); err == nil { + t.Error("expected error for URL without scheme, got nil") + } +} + +func TestValidateURL_EmptyString(t *testing.T) { + if err := validateURL(""); err == nil { + t.Error("expected error for empty URL, got nil") + } +} + +func TestValidateURL_CloudMetadataAWS(t *testing.T) { + err := validateURL("http://169.254.169.254/latest/meta-data/") + if err == nil { + t.Fatal("expected error for AWS metadata endpoint, got nil") + } + if !strings.Contains(err.Error(), "blocked request to cloud metadata endpoint") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestValidateURL_CloudMetadataGCP(t *testing.T) { + err := validateURL("https://metadata.google.internal/computeMetadata/v1/") + if err == nil { + t.Fatal("expected error for GCP metadata endpoint, got nil") + } + if !strings.Contains(err.Error(), "blocked request to cloud metadata endpoint") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestValidateURL_CloudMetadataGoog(t *testing.T) { + err := validateURL("https://metadata.goog/") + if err == nil { + t.Fatal("expected error for metadata.goog endpoint, got nil") + } +} + +func TestValidateURL_CloudMetadataAlibaba(t *testing.T) { + err := validateURL("http://100.100.100.200/") + if err == nil { + t.Fatal("expected error for Alibaba Cloud metadata endpoint, got nil") + } +} + +func TestValidateURL_CloudMetadataECS(t *testing.T) { + err := validateURL("http://169.254.170.2/") + if err == nil { + t.Fatal("expected error for AWS ECS metadata endpoint, got nil") + } +} + +// Localhost and private IPs should warn but NOT return errors. +func TestValidateURL_LocalhostNoError(t *testing.T) { + if err := validateURL("http://localhost/"); err != nil { + t.Errorf("unexpected error for localhost: %v", err) + } +} + +func TestValidateURL_LoopbackNoError(t *testing.T) { + if err := validateURL("http://127.0.0.1/"); err != nil { + t.Errorf("unexpected error for 127.0.0.1: %v", err) + } +} + +func TestValidateURL_PrivateIPNoError(t *testing.T) { + for _, u := range []string{ + "http://10.0.0.1/", + "http://192.168.1.100/", + "http://172.16.0.1/", + } { + if err := validateURL(u); err != nil { + t.Errorf("unexpected error for private IP %q: %v", u, err) + } + } +} + +// ─── isPrivateOrReservedHost ──────────────────────────────────────────────── + +func TestIsPrivateOrReservedHost(t *testing.T) { + tests := []struct { + host string + want bool + }{ + // Private RFC-1918 ranges + {"10.0.0.1", true}, + {"10.255.255.255", true}, + {"192.168.0.1", true}, + {"192.168.1.100", true}, + {"172.16.0.1", true}, + {"172.20.0.1", true}, + {"172.31.255.255", true}, + // Link-local + {"169.254.1.1", true}, + {"169.254.0.0", true}, + // 0.x + {"0.0.0.1", true}, + // Outside private ranges + {"172.15.0.1", false}, + {"172.32.0.1", false}, + {"8.8.8.8", false}, + {"1.1.1.1", false}, + {"93.184.216.34", false}, + {"example.com", false}, + } + + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + got := isPrivateOrReservedHost(tt.host) + if got != tt.want { + t.Errorf("isPrivateOrReservedHost(%q) = %v, want %v", tt.host, got, tt.want) + } + }) + } +} + +// ─── isCloudMetadataEndpoint ──────────────────────────────────────────────── + +func TestIsCloudMetadataEndpoint(t *testing.T) { + tests := []struct { + host string + want bool + }{ + {"169.254.169.254", true}, + {"metadata.google.internal", true}, + {"metadata.goog", true}, + {"100.100.100.200", true}, + {"169.254.170.2", true}, + // Case-insensitive + {"METADATA.GOOGLE.INTERNAL", true}, + {"169.254.169.254", true}, + // Non-metadata + {"example.com", false}, + {"8.8.8.8", false}, + {"169.254.169.253", false}, + {"100.100.100.201", false}, + } + + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + got := isCloudMetadataEndpoint(tt.host) + if got != tt.want { + t.Errorf("isCloudMetadataEndpoint(%q) = %v, want %v", tt.host, got, tt.want) + } + }) + } +} + +// ─── HTTP Client (httptest) ────────────────────────────────────────────────── + +func TestClient_GET_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) + })) + defer srv.Close() + + c := NewClient() + resp, err := c.Get(srv.URL, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + if resp.Body != `{"status":"ok"}` { + t.Errorf("unexpected body: %q", resp.Body) + } +} + +func TestClient_POST_WithBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + // Default Content-Type should be set by the client + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("expected Content-Type application/json, got %q", ct) + } + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"id":42}`)) + })) + defer srv.Close() + + c := NewClient() + resp, err := c.Post(srv.URL, nil, `{"name":"test"}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 201 { + t.Errorf("expected 201, got %d", resp.StatusCode) + } +} + +func TestClient_POST_ContentTypeNotOverridden(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ct := r.Header.Get("Content-Type"); ct != "text/plain" { + t.Errorf("expected Content-Type text/plain, got %q", ct) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient() + _, err := c.Post(srv.URL, map[string]string{"Content-Type": "text/plain"}, "hello world") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_PUT(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient() + resp, err := c.Put(srv.URL, nil, `{"key":"value"}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } +} + +func TestClient_PATCH(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient() + resp, err := c.Patch(srv.URL, nil, `{"field":"updated"}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } +} + +func TestClient_DELETE(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := NewClient() + resp, err := c.Delete(srv.URL, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 204 { + t.Errorf("expected 204, got %d", resp.StatusCode) + } +} + +func TestClient_CustomHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Api-Key") != "secret123" { + t.Errorf("expected X-Api-Key secret123, got %q", r.Header.Get("X-Api-Key")) + } + if r.Header.Get("Accept") != "application/json" { + t.Errorf("expected Accept application/json, got %q", r.Header.Get("Accept")) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient() + _, err := c.Get(srv.URL, map[string]string{ + "X-Api-Key": "secret123", + "Accept": "application/json", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_ResponseHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Custom-Header", "response-value") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient() + resp, err := c.Get(srv.URL, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Headers["X-Custom-Header"] != "response-value" { + t.Errorf("expected response header 'response-value', got %q", resp.Headers["X-Custom-Header"]) + } +} + +func TestClient_404Response(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":"not found"}`)) + })) + defer srv.Close() + + c := NewClient() + resp, err := c.Get(srv.URL, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 404 { + t.Errorf("expected 404, got %d", resp.StatusCode) + } +} + +func TestClient_DurationRecorded(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient() + resp, err := c.Get(srv.URL, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.DurationMs < 0 { + t.Errorf("expected non-negative duration, got %d", resp.DurationMs) + } +} + +func TestClient_InvalidSchemeError(t *testing.T) { + c := NewClient() + _, err := c.Get("ftp://example.com", nil) + if err == nil { + t.Fatal("expected error for ftp scheme, got nil") + } + if !strings.Contains(err.Error(), "unsupported URL scheme") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestClient_CloudMetadataBlocked(t *testing.T) { + c := NewClient() + _, err := c.Get("http://169.254.169.254/latest/meta-data/", nil) + if err == nil { + t.Fatal("expected error for cloud metadata URL, got nil") + } + if !strings.Contains(err.Error(), "blocked request to cloud metadata endpoint") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestClient_EmptyBody_NoContentType(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // When there's no body, Content-Type should not be set by the client + if ct := r.Header.Get("Content-Type"); ct != "" { + t.Errorf("expected no Content-Type for empty body, got %q", ct) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient() + _, err := c.Get(srv.URL, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_StatusLine(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + c := NewClient() + resp, err := c.Get(srv.URL, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 401 { + t.Errorf("expected 401, got %d", resp.StatusCode) + } + if !strings.Contains(resp.Status, "401") { + t.Errorf("expected Status to contain '401', got %q", resp.Status) + } +} diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go new file mode 100644 index 0000000..1059f7a --- /dev/null +++ b/internal/storage/sqlite_test.go @@ -0,0 +1,713 @@ +package storage + +import ( + "testing" + "time" + + "api/internal/model" +) + +// newTestStorage creates a SQLiteStorage backed by a temporary directory. +// The HOME env var is redirected so NewStorage uses an isolated location. +func newTestStorage(t *testing.T) *SQLiteStorage { + t.Helper() + t.Setenv("HOME", t.TempDir()) + s, err := NewStorage() + if err != nil { + t.Fatalf("NewStorage: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +// ─── History ───────────────────────────────────────────────────────────────── + +func TestLoadHistory_Empty(t *testing.T) { + s := newTestStorage(t) + h, err := s.LoadHistory() + if err != nil { + t.Fatalf("LoadHistory: %v", err) + } + if len(h.Requests) != 0 { + t.Errorf("expected 0 requests, got %d", len(h.Requests)) + } +} + +func TestAddToHistory_SingleRequest(t *testing.T) { + s := newTestStorage(t) + + req := model.Request{ + ID: "test0001", + Timestamp: time.Now().UTC().Truncate(time.Second), + Method: "GET", + URL: "https://example.com/api", + Headers: map[string]string{"Accept": "application/json"}, + Body: "", + } + + if err := s.AddToHistory(req); err != nil { + t.Fatalf("AddToHistory: %v", err) + } + + h, err := s.LoadHistory() + if err != nil { + t.Fatalf("LoadHistory: %v", err) + } + if len(h.Requests) != 1 { + t.Fatalf("expected 1 request, got %d", len(h.Requests)) + } + got := h.Requests[0] + if got.ID != req.ID { + t.Errorf("ID mismatch: got %q, want %q", got.ID, req.ID) + } + if got.Method != req.Method { + t.Errorf("Method mismatch: got %q, want %q", got.Method, req.Method) + } + if got.URL != req.URL { + t.Errorf("URL mismatch: got %q, want %q", got.URL, req.URL) + } +} + +func TestAddToHistory_WithResponse(t *testing.T) { + s := newTestStorage(t) + + req := model.Request{ + ID: "resp0001", + Timestamp: time.Now().UTC(), + Method: "POST", + URL: "https://api.example.com/users", + Headers: map[string]string{}, + Body: `{"name":"Alice"}`, + Response: &model.Response{ + StatusCode: 201, + Status: "201 Created", + Headers: map[string]string{"Content-Type": "application/json"}, + Body: `{"id":1}`, + DurationMs: 100, + }, + } + + if err := s.AddToHistory(req); err != nil { + t.Fatalf("AddToHistory: %v", err) + } + + h, err := s.LoadHistory() + if err != nil { + t.Fatalf("LoadHistory: %v", err) + } + if len(h.Requests) != 1 { + t.Fatalf("expected 1 request, got %d", len(h.Requests)) + } + + r := h.Requests[0] + if r.Response == nil { + t.Fatal("expected response to be present") + } + if r.Response.StatusCode != 201 { + t.Errorf("status code mismatch: got %d, want 201", r.Response.StatusCode) + } + if r.Response.Body != `{"id":1}` { + t.Errorf("response body mismatch: got %q", r.Response.Body) + } + if r.Response.DurationMs != 100 { + t.Errorf("duration mismatch: got %d, want 100", r.Response.DurationMs) + } +} + +func TestAddToHistory_MultipleRequests_OrderedByTime(t *testing.T) { + s := newTestStorage(t) + + base := time.Now().UTC() + for i := 0; i < 3; i++ { + req := model.Request{ + ID: "order00" + string(rune('0'+i)), + Timestamp: base.Add(time.Duration(i) * time.Second), + Method: "GET", + URL: "https://example.com/" + string(rune('a'+i)), + Headers: map[string]string{}, + } + if err := s.AddToHistory(req); err != nil { + t.Fatalf("AddToHistory(%d): %v", i, err) + } + } + + h, err := s.LoadHistory() + if err != nil { + t.Fatalf("LoadHistory: %v", err) + } + if len(h.Requests) != 3 { + t.Fatalf("expected 3 requests, got %d", len(h.Requests)) + } + // History is ordered newest-first + if h.Requests[0].URL != "https://example.com/c" { + t.Errorf("expected newest request first, got URL %q", h.Requests[0].URL) + } +} + +func TestHistoryLimit_100(t *testing.T) { + s := newTestStorage(t) + + base := time.Now().UTC() + for i := 0; i < 105; i++ { + req := model.Request{ + ID: "hist" + padInt(i), + Timestamp: base.Add(time.Duration(i) * time.Millisecond), + Method: "GET", + URL: "https://example.com/" + padInt(i), + Headers: map[string]string{}, + } + if err := s.AddToHistory(req); err != nil { + t.Fatalf("AddToHistory(%d): %v", i, err) + } + } + + h, err := s.LoadHistory() + if err != nil { + t.Fatalf("LoadHistory: %v", err) + } + if len(h.Requests) > 100 { + t.Errorf("history should be capped at 100, got %d", len(h.Requests)) + } +} + +// padInt zero-pads an integer to 4 digits. +func padInt(n int) string { + s := "0000" + itoa(n) + return s[len(s)-4:] +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + digits := []byte{} + for n > 0 { + digits = append([]byte{byte('0' + n%10)}, digits...) + n /= 10 + } + return string(digits) +} + +func TestClearHistory(t *testing.T) { + s := newTestStorage(t) + + req := model.Request{ + ID: "clr00001", + Timestamp: time.Now().UTC(), + Method: "GET", + URL: "https://example.com", + Headers: map[string]string{}, + } + if err := s.AddToHistory(req); err != nil { + t.Fatalf("AddToHistory: %v", err) + } + + if err := s.ClearHistory(); err != nil { + t.Fatalf("ClearHistory: %v", err) + } + + h, err := s.LoadHistory() + if err != nil { + t.Fatalf("LoadHistory after clear: %v", err) + } + if len(h.Requests) != 0 { + t.Errorf("expected empty history after clear, got %d requests", len(h.Requests)) + } +} + +func TestGetHistoryRequest_ByID(t *testing.T) { + s := newTestStorage(t) + + req := model.Request{ + ID: "findme1", + Timestamp: time.Now().UTC(), + Method: "DELETE", + URL: "https://example.com/resource/1", + Headers: map[string]string{}, + } + if err := s.AddToHistory(req); err != nil { + t.Fatalf("AddToHistory: %v", err) + } + + got, err := s.GetHistoryRequest("findme1") + if err != nil { + t.Fatalf("GetHistoryRequest: %v", err) + } + if got == nil { + t.Fatal("expected request, got nil") + } + if got.ID != "findme1" { + t.Errorf("ID mismatch: got %q", got.ID) + } + if got.Method != "DELETE" { + t.Errorf("Method mismatch: got %q", got.Method) + } +} + +func TestGetHistoryRequest_NotFound(t *testing.T) { + s := newTestStorage(t) + + got, err := s.GetHistoryRequest("nonexistent") + if err != nil { + t.Fatalf("GetHistoryRequest: %v", err) + } + if got != nil { + t.Errorf("expected nil for non-existent ID, got %+v", got) + } +} + +func TestSaveHistory_ReplacesAll(t *testing.T) { + s := newTestStorage(t) + + // Add some requests first + for i := 0; i < 3; i++ { + s.AddToHistory(model.Request{ + ID: "old0000" + string(rune('0'+i)), + Timestamp: time.Now().UTC(), + Method: "GET", + URL: "https://example.com/old", + Headers: map[string]string{}, + }) + } + + // SaveHistory should replace everything + newHistory := &model.History{ + Requests: []model.Request{ + { + ID: "new00001", + Timestamp: time.Now().UTC(), + Method: "POST", + URL: "https://example.com/new", + Headers: map[string]string{}, + }, + }, + } + if err := s.SaveHistory(newHistory); err != nil { + t.Fatalf("SaveHistory: %v", err) + } + + h, err := s.LoadHistory() + if err != nil { + t.Fatalf("LoadHistory: %v", err) + } + if len(h.Requests) != 1 { + t.Errorf("expected 1 request after SaveHistory, got %d", len(h.Requests)) + } + if h.Requests[0].ID != "new00001" { + t.Errorf("unexpected request ID: %q", h.Requests[0].ID) + } +} + +// ─── Collections ───────────────────────────────────────────────────────────── + +func TestCreateCollection(t *testing.T) { + s := newTestStorage(t) + + if err := s.CreateCollection("my-api"); err != nil { + t.Fatalf("CreateCollection: %v", err) + } + + col, err := s.GetCollection("my-api") + if err != nil { + t.Fatalf("GetCollection: %v", err) + } + if col == nil { + t.Fatal("expected collection, got nil") + } + if col.Name != "my-api" { + t.Errorf("Name mismatch: got %q", col.Name) + } + if len(col.Requests) != 0 { + t.Errorf("expected empty collection, got %d requests", len(col.Requests)) + } +} + +func TestCreateCollection_Idempotent(t *testing.T) { + s := newTestStorage(t) + + // Creating the same collection twice should not error + if err := s.CreateCollection("dup"); err != nil { + t.Fatalf("first CreateCollection: %v", err) + } + if err := s.CreateCollection("dup"); err != nil { + t.Fatalf("second CreateCollection (duplicate): %v", err) + } +} + +func TestGetCollection_NotFound(t *testing.T) { + s := newTestStorage(t) + + col, err := s.GetCollection("nonexistent") + if err != nil { + t.Fatalf("GetCollection: %v", err) + } + if col != nil { + t.Errorf("expected nil for non-existent collection, got %+v", col) + } +} + +func TestAddToCollection_CreatesCollectionIfNeeded(t *testing.T) { + s := newTestStorage(t) + + req := model.SavedRequest{ + Name: "Get Users", + Method: "GET", + URL: "https://api.example.com/users", + Headers: map[string]string{"Accept": "application/json"}, + Body: "", + } + + // Collection doesn't exist yet — AddToCollection should create it + if err := s.AddToCollection("auto-create", req); err != nil { + t.Fatalf("AddToCollection: %v", err) + } + + col, err := s.GetCollection("auto-create") + if err != nil { + t.Fatalf("GetCollection: %v", err) + } + if col == nil { + t.Fatal("expected collection to be created, got nil") + } + if len(col.Requests) != 1 { + t.Fatalf("expected 1 request, got %d", len(col.Requests)) + } + if col.Requests[0].Name != "Get Users" { + t.Errorf("Name mismatch: got %q", col.Requests[0].Name) + } +} + +func TestAddToCollection_PreservesOrder(t *testing.T) { + s := newTestStorage(t) + + if err := s.CreateCollection("ordered"); err != nil { + t.Fatalf("CreateCollection: %v", err) + } + + methods := []string{"GET", "POST", "PUT", "DELETE"} + for _, m := range methods { + if err := s.AddToCollection("ordered", model.SavedRequest{ + Name: m + " request", + Method: m, + URL: "https://example.com", + Headers: map[string]string{}, + }); err != nil { + t.Fatalf("AddToCollection (%s): %v", m, err) + } + } + + col, err := s.GetCollection("ordered") + if err != nil { + t.Fatalf("GetCollection: %v", err) + } + if len(col.Requests) != 4 { + t.Fatalf("expected 4 requests, got %d", len(col.Requests)) + } + for i, m := range methods { + if col.Requests[i].Method != m { + t.Errorf("position %d: expected method %q, got %q", i, m, col.Requests[i].Method) + } + } +} + +func TestAddToCollection_WithBody(t *testing.T) { + s := newTestStorage(t) + + req := model.SavedRequest{ + Name: "Create User", + Method: "POST", + URL: "https://api.example.com/users", + Headers: map[string]string{"Content-Type": "application/json"}, + Body: `{"name":"Bob"}`, + } + + if err := s.AddToCollection("withbody", req); err != nil { + t.Fatalf("AddToCollection: %v", err) + } + + col, err := s.GetCollection("withbody") + if err != nil { + t.Fatalf("GetCollection: %v", err) + } + if col.Requests[0].Body != `{"name":"Bob"}` { + t.Errorf("body mismatch: got %q", col.Requests[0].Body) + } + if col.Requests[0].Headers["Content-Type"] != "application/json" { + t.Errorf("header mismatch: got %q", col.Requests[0].Headers["Content-Type"]) + } +} + +func TestDeleteCollection(t *testing.T) { + s := newTestStorage(t) + + if err := s.CreateCollection("to-delete"); err != nil { + t.Fatalf("CreateCollection: %v", err) + } + if err := s.AddToCollection("to-delete", model.SavedRequest{ + Method: "GET", + URL: "https://example.com", + Headers: map[string]string{}, + }); err != nil { + t.Fatalf("AddToCollection: %v", err) + } + + if err := s.DeleteCollection("to-delete"); err != nil { + t.Fatalf("DeleteCollection: %v", err) + } + + col, err := s.GetCollection("to-delete") + if err != nil { + t.Fatalf("GetCollection after delete: %v", err) + } + if col != nil { + t.Errorf("expected nil after delete, got %+v", col) + } +} + +func TestDeleteCollection_NonExistent(t *testing.T) { + s := newTestStorage(t) + // Deleting a non-existent collection should not error + if err := s.DeleteCollection("ghost"); err != nil { + t.Errorf("unexpected error deleting non-existent collection: %v", err) + } +} + +func TestLoadCollections_MultipleCollections(t *testing.T) { + s := newTestStorage(t) + + s.CreateCollection("col-a") + s.CreateCollection("col-b") + s.AddToCollection("col-a", model.SavedRequest{Method: "GET", URL: "https://a.example.com", Headers: map[string]string{}}) + s.AddToCollection("col-a", model.SavedRequest{Method: "POST", URL: "https://a.example.com", Headers: map[string]string{}}) + s.AddToCollection("col-b", model.SavedRequest{Method: "DELETE", URL: "https://b.example.com", Headers: map[string]string{}}) + + cols, err := s.LoadCollections() + if err != nil { + t.Fatalf("LoadCollections: %v", err) + } + if len(cols.Collections) != 2 { + t.Errorf("expected 2 collections, got %d", len(cols.Collections)) + } + if len(cols.Collections["col-a"].Requests) != 2 { + t.Errorf("expected 2 requests in col-a, got %d", len(cols.Collections["col-a"].Requests)) + } + if len(cols.Collections["col-b"].Requests) != 1 { + t.Errorf("expected 1 request in col-b, got %d", len(cols.Collections["col-b"].Requests)) + } +} + +func TestSaveCollections_ReplacesAll(t *testing.T) { + s := newTestStorage(t) + + s.CreateCollection("old-col") + s.AddToCollection("old-col", model.SavedRequest{Method: "GET", URL: "https://old.example.com", Headers: map[string]string{}}) + + newCols := &model.Collections{ + Collections: map[string]model.Collection{ + "new-col": { + Name: "new-col", + Requests: []model.SavedRequest{ + {Name: "New Request", Method: "POST", URL: "https://new.example.com", Headers: map[string]string{}}, + }, + }, + }, + } + if err := s.SaveCollections(newCols); err != nil { + t.Fatalf("SaveCollections: %v", err) + } + + cols, err := s.LoadCollections() + if err != nil { + t.Fatalf("LoadCollections: %v", err) + } + if _, exists := cols.Collections["old-col"]; exists { + t.Error("old collection should have been replaced") + } + if _, exists := cols.Collections["new-col"]; !exists { + t.Error("new collection should exist") + } +} + +// ─── Aliases ────────────────────────────────────────────────────────────────── + +func TestCreateAlias_And_GetAlias(t *testing.T) { + s := newTestStorage(t) + + if err := s.CreateAlias("myapi", "https://api.example.com"); err != nil { + t.Fatalf("CreateAlias: %v", err) + } + + url, exists, err := s.GetAlias("myapi") + if err != nil { + t.Fatalf("GetAlias: %v", err) + } + if !exists { + t.Fatal("expected alias to exist") + } + if url != "https://api.example.com" { + t.Errorf("URL mismatch: got %q", url) + } +} + +func TestGetAlias_NotFound(t *testing.T) { + s := newTestStorage(t) + + _, exists, err := s.GetAlias("ghost") + if err != nil { + t.Fatalf("GetAlias: %v", err) + } + if exists { + t.Error("expected alias not to exist") + } +} + +func TestCreateAlias_Upsert(t *testing.T) { + s := newTestStorage(t) + + if err := s.CreateAlias("sw", "https://swapi.dev/api"); err != nil { + t.Fatalf("first CreateAlias: %v", err) + } + // Update with new URL + if err := s.CreateAlias("sw", "https://swapi.tech/api"); err != nil { + t.Fatalf("second CreateAlias (upsert): %v", err) + } + + url, _, err := s.GetAlias("sw") + if err != nil { + t.Fatalf("GetAlias: %v", err) + } + if url != "https://swapi.tech/api" { + t.Errorf("expected updated URL, got %q", url) + } +} + +func TestDeleteAlias(t *testing.T) { + s := newTestStorage(t) + + if err := s.CreateAlias("del-me", "https://example.com"); err != nil { + t.Fatalf("CreateAlias: %v", err) + } + if err := s.DeleteAlias("del-me"); err != nil { + t.Fatalf("DeleteAlias: %v", err) + } + + _, exists, err := s.GetAlias("del-me") + if err != nil { + t.Fatalf("GetAlias after delete: %v", err) + } + if exists { + t.Error("expected alias to be deleted") + } +} + +func TestDeleteAlias_NonExistent(t *testing.T) { + s := newTestStorage(t) + // Should not error + if err := s.DeleteAlias("ghost"); err != nil { + t.Errorf("unexpected error deleting non-existent alias: %v", err) + } +} + +func TestLoadAliases_Multiple(t *testing.T) { + s := newTestStorage(t) + + s.CreateAlias("api1", "https://api1.example.com") + s.CreateAlias("api2", "https://api2.example.com") + s.CreateAlias("api3", "https://api3.example.com") + + aliases, err := s.LoadAliases() + if err != nil { + t.Fatalf("LoadAliases: %v", err) + } + if len(aliases.Aliases) != 3 { + t.Errorf("expected 3 aliases, got %d", len(aliases.Aliases)) + } + if aliases.Aliases["api1"] != "https://api1.example.com" { + t.Errorf("unexpected alias value for api1: %q", aliases.Aliases["api1"]) + } +} + +func TestLoadAliases_Empty(t *testing.T) { + s := newTestStorage(t) + + aliases, err := s.LoadAliases() + if err != nil { + t.Fatalf("LoadAliases: %v", err) + } + if len(aliases.Aliases) != 0 { + t.Errorf("expected 0 aliases, got %d", len(aliases.Aliases)) + } +} + +func TestSaveAliases_ReplacesAll(t *testing.T) { + s := newTestStorage(t) + + s.CreateAlias("old", "https://old.example.com") + + newAliases := &model.Aliases{ + Aliases: map[string]string{ + "new1": "https://new1.example.com", + "new2": "https://new2.example.com", + }, + } + if err := s.SaveAliases(newAliases); err != nil { + t.Fatalf("SaveAliases: %v", err) + } + + aliases, err := s.LoadAliases() + if err != nil { + t.Fatalf("LoadAliases: %v", err) + } + if _, exists := aliases.Aliases["old"]; exists { + t.Error("old alias should have been replaced") + } + if len(aliases.Aliases) != 2 { + t.Errorf("expected 2 new aliases, got %d", len(aliases.Aliases)) + } +} + +// ─── parseJSONHeaders ───────────────────────────────────────────────────────── + +func TestParseJSONHeaders_Valid(t *testing.T) { + got, err := parseJSONHeaders(`{"Content-Type":"application/json","Accept":"*/*"}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got["Content-Type"] != "application/json" { + t.Errorf("unexpected value: %q", got["Content-Type"]) + } + if got["Accept"] != "*/*" { + t.Errorf("unexpected value: %q", got["Accept"]) + } +} + +func TestParseJSONHeaders_Empty(t *testing.T) { + got, err := parseJSONHeaders("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty map, got %v", got) + } +} + +func TestParseJSONHeaders_EmptyObject(t *testing.T) { + got, err := parseJSONHeaders("{}") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty map for {}, got %v", got) + } +} + +func TestParseJSONHeaders_Invalid(t *testing.T) { + got, err := parseJSONHeaders("not-json") + if err == nil { + t.Error("expected error for invalid JSON, got nil") + } + // Should still return an empty map (not nil) + if got == nil { + t.Error("expected non-nil map even on error") + } +}