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.
230 lines
6.6 KiB
Go
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")
|
|
}
|
|
}
|