From f1cf23745e23baf15bcb7d8ac86eb6e7a50420c6 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 11:44:32 +0000 Subject: [PATCH] test: add HTTP API integration tests Test GET /healthz, /, /search, /autocompleter endpoints. Verify response codes, content types, JSON decoding, empty-query redirect, and source URL presence in footer. Also fix dead code in Search handler: the redirect for empty q was unreachable because ParseSearchRequest errors on empty q first. Move the q/format check before ParseSearchRequest to fix the redirect. --- internal/httpapi/handlers.go | 10 +- internal/httpapi/httpapi_test.go | 230 +++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 internal/httpapi/httpapi_test.go diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index cc19b4b..f8db054 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -72,17 +72,19 @@ func (h *Handler) OpenSearch(baseURL string) http.HandlerFunc { } func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { + q := r.FormValue("q") + format := r.FormValue("format") + // For HTML format with no query, redirect to homepage. - if r.FormValue("q") == "" && (r.FormValue("format") == "" || r.FormValue("format") == "html") { + if q == "" && (format == "" || format == "html") { http.Redirect(w, r, "/", http.StatusFound) return } req, err := search.ParseSearchRequest(r) if err != nil { - // For HTML, render error on the results page. - if req.Format == contracts.FormatHTML || r.FormValue("format") == "html" { - pd := views.PageData{SourceURL: h.sourceURL, Query: r.FormValue("q")} + if format == "html" || format == "" { + pd := views.PageData{SourceURL: h.sourceURL, Query: q} if views.IsHTMXRequest(r) { views.RenderSearchFragment(w, pd) } else { diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go new file mode 100644 index 0000000..f33cb8c --- /dev/null +++ b/internal/httpapi/httpapi_test.go @@ -0,0 +1,230 @@ +// kafka — a privacy-respecting metasearch engine +// Copyright (C) 2026-present metamorphosis-dev +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package httpapi_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/metamorphosis-dev/kafka/internal/contracts" + "github.com/metamorphosis-dev/kafka/internal/httpapi" + "github.com/metamorphosis-dev/kafka/internal/search" +) + +// mockUpstreamHandler returns controlled JSON responses. +func mockUpstreamJSON(query string) contracts.SearchResponse { + return contracts.SearchResponse{ + Query: query, + NumberOfResults: 2, + Results: []contracts.MainResult{ + {Title: "Upstream Result 1", URL: ptr("https://upstream.example/1"), Content: "From upstream", Engine: "upstream"}, + {Title: "Upstream Result 2", URL: ptr("https://upstream.example/2"), Content: "From upstream", Engine: "upstream"}, + }, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{"upstream suggestion"}, + UnresponsiveEngines: [][2]string{}, + } +} + +func ptr(s string) *string { return &s } + +func newTestServer(t *testing.T) (*httptest.Server, *httpapi.Handler) { + t.Helper() + + // Mock upstream server that returns controlled JSON. + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query := r.FormValue("q") + resp := mockUpstreamJSON(query) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(upstream.Close) + + svc := search.NewService(search.ServiceConfig{ + UpstreamURL: upstream.URL, + HTTPTimeout: 0, + Cache: nil, + EnginesConfig: nil, + }) + + h := httpapi.NewHandler(svc, nil, "https://src.example.com") + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", h.Healthz) + mux.HandleFunc("/", h.Index) + mux.HandleFunc("/search", h.Search) + mux.HandleFunc("/autocompleter", h.Autocompleter) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server, h +} + +func TestHealthz(t *testing.T) { + server, _ := newTestServer(t) + resp, err := http.Get(server.URL + "/healthz") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/plain") { + t.Errorf("expected text/plain, got %s", ct) + } +} + +func TestIndex(t *testing.T) { + server, _ := newTestServer(t) + resp, err := http.Get(server.URL + "/") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("expected text/html, got %s", ct) + } + + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "