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.
This commit is contained in:
parent
f6128689f1
commit
f1cf23745e
2 changed files with 236 additions and 4 deletions
|
|
@ -72,17 +72,19 @@ func (h *Handler) OpenSearch(baseURL string) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
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.
|
// 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)
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := search.ParseSearchRequest(r)
|
req, err := search.ParseSearchRequest(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// For HTML, render error on the results page.
|
if format == "html" || format == "" {
|
||||||
if req.Format == contracts.FormatHTML || r.FormValue("format") == "html" {
|
pd := views.PageData{SourceURL: h.sourceURL, Query: q}
|
||||||
pd := views.PageData{SourceURL: h.sourceURL, Query: r.FormValue("q")}
|
|
||||||
if views.IsHTMXRequest(r) {
|
if views.IsHTMXRequest(r) {
|
||||||
views.RenderSearchFragment(w, pd)
|
views.RenderSearchFragment(w, pd)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
230
internal/httpapi/httpapi_test.go
Normal file
230
internal/httpapi/httpapi_test.go
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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), "<!DOCTYPE html") {
|
||||||
|
t.Error("expected HTML DOCTYPE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_RedirectOnEmptyQuery(t *testing.T) {
|
||||||
|
server, _ := newTestServer(t)
|
||||||
|
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}}
|
||||||
|
req, _ := http.NewRequest("GET", server.URL+"/search?format=html", nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusFound {
|
||||||
|
t.Errorf("expected redirect 302, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
loc, _ := resp.Location()
|
||||||
|
if loc == nil || loc.Path != "/" {
|
||||||
|
t.Errorf("expected redirect to /, got %v", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_JSONResponse(t *testing.T) {
|
||||||
|
server, _ := newTestServer(t)
|
||||||
|
resp, err := http.Get(server.URL + "/search?q=test&format=json")
|
||||||
|
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, "application/json") {
|
||||||
|
t.Errorf("expected application/json, got %s", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result contracts.SearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
t.Fatalf("failed to decode JSON: %v", err)
|
||||||
|
}
|
||||||
|
if result.Query != "test" {
|
||||||
|
t.Errorf("expected query 'test', got %q", result.Query)
|
||||||
|
}
|
||||||
|
if len(result.Results) == 0 {
|
||||||
|
t.Error("expected at least one result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_HTMLResponse(t *testing.T) {
|
||||||
|
server, _ := newTestServer(t)
|
||||||
|
resp, err := http.Get(server.URL + "/search?q=test&format=html")
|
||||||
|
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), "<!DOCTYPE html") {
|
||||||
|
t.Error("expected HTML DOCTYPE in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutocompleter_EmptyQuery(t *testing.T) {
|
||||||
|
server, _ := newTestServer(t)
|
||||||
|
resp, err := http.Get(server.URL + "/autocompleter?q=")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutocompleter_NoQuery(t *testing.T) {
|
||||||
|
server, _ := newTestServer(t)
|
||||||
|
resp, err := http.Get(server.URL + "/autocompleter")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_SourceURLInFooter(t *testing.T) {
|
||||||
|
server, _ := newTestServer(t)
|
||||||
|
resp, err := http.Get(server.URL + "/?q=test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if !strings.Contains(string(body), "https://src.example.com") {
|
||||||
|
t.Error("expected source URL in footer")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "AGPLv3") {
|
||||||
|
t.Error("expected AGPLv3 link in footer")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue