diff --git a/internal/handler/api_v1.go b/internal/handler/api_v1.go index ee735fdc..d9cb8c0b 100644 --- a/internal/handler/api_v1.go +++ b/internal/handler/api_v1.go @@ -235,7 +235,7 @@ func (h *Handler) v1OpenAPI(w http.ResponseWriter, r *http.Request) { "application/json": map[string]interface{}{ "schema": map[string]interface{}{ "$ref": "#/components/schemas/ReplayResponse", - }, + }, }, }, }, @@ -292,18 +292,18 @@ func (h *Handler) v1OpenAPI(w http.ResponseWriter, r *http.Request) { "StoredRequest": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "id": map[string]interface{}{"type": "string"}, - "endpoint_id": map[string]interface{}{"type": "string"}, - "method": map[string]interface{}{"type": "string"}, - "path": map[string]interface{}{"type": "string"}, - "headers": map[string]interface{}{"type": "string"}, - "query": map[string]interface{}{"type": "string"}, - "body": map[string]interface{}{"type": "string", "format": "byte"}, - "created_at": map[string]interface{}{"type": "string", "format": "date-time"}, + "id": map[string]interface{}{"type": "string"}, + "endpoint_id": map[string]interface{}{"type": "string"}, + "method": map[string]interface{}{"type": "string"}, + "path": map[string]interface{}{"type": "string"}, + "headers": map[string]interface{}{"type": "string"}, + "query": map[string]interface{}{"type": "string"}, + "body": map[string]interface{}{"type": "string", "format": "byte"}, + "created_at": map[string]interface{}{"type": "string", "format": "date-time"}, }, }, "ReplayRequest": map[string]interface{}{ - "type": "object", + "type": "object", "required": []string{"target_url"}, "properties": map[string]interface{}{ "target_url": map[string]interface{}{"type": "string", "format": "uri"}, @@ -332,4 +332,4 @@ func (h *Handler) v1OpenAPI(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(spec) -} \ No newline at end of file +} diff --git a/internal/handler/api_v1_test.go b/internal/handler/api_v1_test.go index d2fbbcb7..8c379a9f 100644 --- a/internal/handler/api_v1_test.go +++ b/internal/handler/api_v1_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/elfoundation/hatch/internal/store" @@ -178,6 +179,54 @@ func TestV1OpenAPI(t *testing.T) { } } +func TestV1ReplayRequest(t *testing.T) { + repo := testutil.NewFakeRepository() + repo.CreateEndpoint(nil, "replay-ep") + repo.AppendRequest(nil, "replay-ep", &store.Request{ + Method: "POST", + Path: "/webhook", + Headers: `{"Content-Type":"application/json"}`, + Query: "foo=bar", + Body: []byte(`{"msg":"hello"}`), + }) + reqID := repo.Requests[0].ID + + // Start a sink server. + sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Sink", "yes") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"received": true}`)) + })) + defer sink.Close() + + // Set env to allow private replay. + t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true") + + r := testRouter(repo) + body := `{"target_url": "` + sink.URL + `"}` + req := httptest.NewRequest(http.MethodPost, "/v1/endpoints/replay-ep/requests/"+reqID+"/replay", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Status int `json:"status"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` + } + json.NewDecoder(w.Body).Decode(&resp) + if resp.Status != 201 { + t.Errorf("expected status 201, got %d", resp.Status) + } + if resp.Headers["X-Sink"] != "yes" { + t.Errorf("expected X-Sink header, got %v", resp.Headers) + } +} + // Import store package for the fakeRepo usage (already imported via handler_test.go) // We just need to ensure the test compiles. -var _ = store.Request{} \ No newline at end of file +var _ = store.Request{} diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index 08c97064..c160367c 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -15,7 +15,6 @@ import ( "github.com/go-chi/chi/v5" ) - // testRouter creates a chi router with all routes registered using a fake repo. func testRouter(repo store.Repository) chi.Router { r := chi.NewRouter() @@ -380,3 +379,138 @@ func TestMockAutoCreatesEndpointOnSet(t *testing.T) { t.Errorf("expected endpoint id 'auto-mock', got %q", ep.ID) } } + +func TestCaptureMissingEndpointID(t *testing.T) { + repo := testutil.NewFakeRepository() + r := testRouter(repo) + + // Request to root path - chi router returns 404 since /{endpointID} doesn't match. + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Chi returns 404 for unmatched routes. + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestV1CaptureRequestMissingEndpoint(t *testing.T) { + repo := testutil.NewFakeRepository() + r := testRouter(repo) + + body := `{"method":"POST","path":"/test"}` + req := httptest.NewRequest(http.MethodPost, "/v1/endpoints//requests", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestV1ListRequestsMissingEndpoint(t *testing.T) { + repo := testutil.NewFakeRepository() + r := testRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/v1/endpoints//requests", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestPrettyJSON(t *testing.T) { + // Valid JSON. + input := `{"key":"value","num":42}` + result := prettyJSON(input) + if !strings.Contains(result, "key") || !strings.Contains(result, "value") { + t.Errorf("prettyJSON should contain key-value pairs: %s", result) + } + + // Invalid JSON (returns raw string). + invalid := `not json` + result = prettyJSON(invalid) + if result != invalid { + t.Errorf("prettyJSON should return raw string for invalid JSON: %s", result) + } +} + +func TestFormatTime(t *testing.T) { + // Recent timestamp (should show relative time). + now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00") + result := formatTime(now) + if result != "just now" { + t.Errorf("expected 'just now', got %q", result) + } + + // Older timestamp. + old := time.Now().Add(-2 * time.Hour).UTC().Format("2006-01-02T15:04:05.000Z07:00") + result = formatTime(old) + if !strings.Contains(result, "h ago") { + t.Errorf("expected relative time with 'h ago', got %q", result) + } + + // Invalid format (returns raw string). + result = formatTime("invalid") + if result != "invalid" { + t.Errorf("expected raw string for invalid format, got %q", result) + } +} + +func TestJoinPath(t *testing.T) { + tests := []struct { + base, extra, want string + }{ + {"/api", "/users", "/api/users"}, + {"/api/", "/users", "/api/users"}, + {"/api", "/users/", "/api/users/"}, + {"/api", "", "/api"}, + {"", "/users", "/users"}, + {"", "", "/"}, + } + for _, tc := range tests { + got := joinPath(tc.base, tc.extra) + if got != tc.want { + t.Errorf("joinPath(%q, %q) = %q, want %q", tc.base, tc.extra, got, tc.want) + } + } +} + +func TestHandleMockReturnsErrorOnInvalidJSON(t *testing.T) { + repo := testutil.NewFakeRepository() + repo.CreateEndpoint(nil, "mock-err") + r := testRouter(repo) + + // Send invalid JSON. + req := httptest.NewRequest(http.MethodPut, "/e/mock-err/mock", strings.NewReader("not json")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // The handler should handle the error gracefully. + // It may return 200 (if it ignores parse errors) or 400. + if w.Code != http.StatusOK && w.Code != http.StatusBadRequest { + t.Errorf("expected 200 or 400, got %d", w.Code) + } +} + +func TestInspectReturnsHTMLErrorOnRepoFailure(t *testing.T) { + // Test that the inspect page works with a valid endpoint ID. + r := testRouter(testutil.NewFakeRepository()) + req := httptest.NewRequest(http.MethodGet, "/e/test-ep", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Should return 200 and auto-create the endpoint. + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "test-ep") { + t.Error("missing endpoint ID in HTML") + } +} diff --git a/internal/store/db.go b/internal/store/db.go index 9d08e6ba..53ab8c2f 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -4,22 +4,26 @@ import ( "database/sql" _ "embed" "fmt" + _ "modernc.org/sqlite" "os" "path/filepath" - _ "modernc.org/sqlite" ) //go:embed schema.sql var schemaSQL string func Open(dbPath string) (Repository, error) { - if dbPath == "" { dbPath = filepath.Join("data", "hatch.db") } + if dbPath == "" { + dbPath = filepath.Join("data", "hatch.db") + } dir := filepath.Dir(dbPath) if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("store: create db dir %s: %w", dir, err) } conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") - if err != nil { return nil, fmt.Errorf("store: open %s: %w", dbPath, err) } + if err != nil { + return nil, fmt.Errorf("store: open %s: %w", dbPath, err) + } conn.SetMaxOpenConns(1) if err := migrate(conn); err != nil { conn.Close() @@ -30,6 +34,8 @@ func Open(dbPath string) (Repository, error) { func migrate(db *sql.DB) error { _, err := db.Exec(schemaSQL) - if err != nil { return fmt.Errorf("store/migrate: %w", err) } + if err != nil { + return fmt.Errorf("store/migrate: %w", err) + } return nil } diff --git a/internal/store/sqlite_repo_test.go b/internal/store/sqlite_repo_test.go index 7e0a6a33..c4aee7f2 100644 --- a/internal/store/sqlite_repo_test.go +++ b/internal/store/sqlite_repo_test.go @@ -232,3 +232,84 @@ func TestMigrateIdempotent(t *testing.T) { t.Fatalf("requests table: %v", err) } } + +func TestSearchRequests(t *testing.T) { + repo := openTestRepo(t) + ctx := context.Background() + e, _ := repo.CreateEndpoint(ctx, "search-test") + + // Add requests with different content. + repo.AppendRequest(ctx, e.ID, &Request{Method: "GET", Path: "/users", Headers: `{"Accept":"json"}`}) + repo.AppendRequest(ctx, e.ID, &Request{Method: "POST", Path: "/orders", Body: []byte(`{"item":"apple"}`)}) + repo.AppendRequest(ctx, e.ID, &Request{Method: "DELETE", Path: "/users/123"}) + repo.AppendRequest(ctx, e.ID, &Request{Method: "PUT", Path: "/products", Query: "category=electronics"}) + + // Search for "users". + reqs, err := repo.SearchRequests(ctx, e.ID, "users", 10) + if err != nil { + t.Fatal(err) + } + if len(reqs) != 2 { + t.Errorf("expected 2 requests matching 'users', got %d", len(reqs)) + } + + // Search for "apple" (in body). + reqs, err = repo.SearchRequests(ctx, e.ID, "apple", 10) + if err != nil { + t.Fatal(err) + } + if len(reqs) != 1 { + t.Errorf("expected 1 request matching 'apple', got %d", len(reqs)) + } + + // Search for "electronics" (in query). + reqs, err = repo.SearchRequests(ctx, e.ID, "electronics", 10) + if err != nil { + t.Fatal(err) + } + if len(reqs) != 1 { + t.Errorf("expected 1 request matching 'electronics', got %d", len(reqs)) + } + + // Empty query returns all. + reqs, err = repo.SearchRequests(ctx, e.ID, "", 10) + if err != nil { + t.Fatal(err) + } + if len(reqs) != 4 { + t.Errorf("expected 4 requests with empty query, got %d", len(reqs)) + } + + // Search with limit. + reqs, err = repo.SearchRequests(ctx, e.ID, "users", 1) + if err != nil { + t.Fatal(err) + } + if len(reqs) != 1 { + t.Errorf("expected 1 request with limit=1, got %d", len(reqs)) + } +} + +func TestOpenInMemory(t *testing.T) { + repo, err := Open(":memory:") + if err != nil { + t.Fatalf("Open: %v", err) + } + defer repo.Close() + + // Verify the repo works. + e, err := repo.CreateEndpoint(context.Background(), "test") + if err != nil { + t.Fatalf("CreateEndpoint: %v", err) + } + if e.ID != "test" { + t.Errorf("expected ID 'test', got %q", e.ID) + } +} + +func TestNewSQLiteRepoNilDB(t *testing.T) { + _, err := NewSQLiteRepo(nil) + if err == nil { + t.Fatal("expected error for nil db") + } +} diff --git a/internal/testutil/fake_repo.go b/internal/testutil/fake_repo.go index b9a48f7e..74387439 100644 --- a/internal/testutil/fake_repo.go +++ b/internal/testutil/fake_repo.go @@ -95,4 +95,4 @@ type notFoundError struct{} func (e *notFoundError) Error() string { return "not found" } -var errNotFound = ¬FoundError{} \ No newline at end of file +var errNotFound = ¬FoundError{}