kafka/internal/httpapi/httpapi_test.go
Franz Kafka f1cf23745e
Some checks failed
Mirror to GitHub / mirror (push) Waiting to run
Tests / test (push) Waiting to run
Build and Push Docker Image / build-and-push (push) Has been cancelled
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.
2026-03-22 11:44:48 +00:00

230 lines
6.6 KiB
Go

// 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")
}
}