Files
Maven/internal/websearch/searxng_test.go
T
kami 14e98334ad query: let her search the live web before she reads the ZIMs
The offline encyclopedia was the only world source, and it reads what was true
when the ZIM was built. A self-hosted SearXNG now asks first and Kiwix is the
fallback for an empty result, an unreachable instance or no line out. Owner's
ruling, 2026-08-02.

internal/websearch is deliberately thin: no rewriter (SearXNG ranks through
real engines, so the Russian question goes out as he asked it), no page fetch,
no cache. It cannot read the store, so only the query string can leave the box.

The personal boundary is unchanged and still sits above this source, so a
question about him is never searched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaeSbLMEVG5ey8tejU3y2
2026-08-02 02:24:36 +04:00

146 lines
4.6 KiB
Go

package websearch
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const sampleJSON = `{
"query": "почему небо голубое",
"answers": ["Rayleigh scattering makes the sky blue."],
"results": [
{"title": "Рэлеевское рассеяние", "url": "https://ru.wikipedia.org/x", "content": "Рассеяние\n света на молекулах.", "engine": "wikipedia"},
{"title": "", "url": "https://example.org/empty", "content": "", "engine": "duckduckgo"},
{"title": "Why is the sky blue", "url": "https://example.org/2", "content": "Short answer.", "engine": "duckduckgo"}
]
}`
func TestParseResponse(t *testing.T) {
got, err := ParseResponse([]byte(sampleJSON), 5)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(got.Answers) != 1 || got.Answers[0] != "Rayleigh scattering makes the sky blue." {
t.Fatalf("answers = %#v", got.Answers)
}
// The textless middle hit is dropped: it is a link with nothing to read.
if len(got.Results) != 2 {
t.Fatalf("results = %#v", got.Results)
}
if got.Results[0].Content != "Рассеяние света на молекулах." {
t.Fatalf("whitespace not collapsed: %q", got.Results[0].Content)
}
if got.Empty() {
t.Fatal("Empty() on a response with hits")
}
}
// The limit counts usable hits, not raw ones — a textless entry must not push a
// real snippet out of the reply.
func TestParseResponseLimitSkipsEmpty(t *testing.T) {
got, err := ParseResponse([]byte(sampleJSON), 2)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(got.Results) != 2 {
t.Fatalf("results = %d, want 2", len(got.Results))
}
if got.Results[1].Title != "Why is the sky blue" {
t.Fatalf("second hit = %q", got.Results[1].Title)
}
}
// Newer SearXNG emits answers as objects; older ones as bare strings. Both are
// in the wild and both must read.
func TestParseResponseObjectAnswers(t *testing.T) {
got, err := ParseResponse([]byte(`{"answers":[{"answer":"42","url":"x"}],"results":[]}`), 5)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(got.Answers) != 1 || got.Answers[0] != "42" {
t.Fatalf("answers = %#v", got.Answers)
}
}
func TestResponseEmpty(t *testing.T) {
got, err := ParseResponse([]byte(`{"answers":[],"results":[]}`), 5)
if err != nil {
t.Fatalf("parse: %v", err)
}
if !got.Empty() {
t.Fatal("Empty() = false on a reply with nothing in it")
}
}
func TestSnippetsAnswersFirst(t *testing.T) {
got, _ := ParseResponse([]byte(sampleJSON), 5)
lines := got.Snippets()
if len(lines) != 3 {
t.Fatalf("lines = %#v", lines)
}
if lines[0] != "Rayleigh scattering makes the sky blue." {
t.Fatalf("answer did not lead: %q", lines[0])
}
if !strings.Contains(lines[1], " — ") {
t.Fatalf("result line = %q", lines[1])
}
// No URL travels into the evidence: piper reads one out character by
// character and it is not evidence anyway.
for _, l := range lines {
if strings.Contains(l, "http") {
t.Fatalf("url leaked into evidence: %q", l)
}
}
}
// The query goes out verbatim, and the JSON format is always asked for.
func TestSearchRequest(t *testing.T) {
var gotQuery, gotFormat, gotLang, gotEngines string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.Query().Get("q")
gotFormat = r.URL.Query().Get("format")
gotLang = r.URL.Query().Get("language")
gotEngines = r.URL.Query().Get("engines")
w.Write([]byte(sampleJSON))
}))
defer srv.Close()
c := New(srv.URL, Options{Language: "ru", Engines: "duckduckgo"})
got, err := c.Search(context.Background(), "почему небо голубое", 3)
if err != nil {
t.Fatalf("search: %v", err)
}
if gotQuery != "почему небо голубое" {
t.Fatalf("query was rewritten: %q", gotQuery)
}
if gotFormat != "json" || gotLang != "ru" || gotEngines != "duckduckgo" {
t.Fatalf("format=%q language=%q engines=%q", gotFormat, gotLang, gotEngines)
}
if len(got.Results) != 2 {
t.Fatalf("results = %#v", got.Results)
}
}
// A stock SearXNG answers 403 to format=json. The error must say so, because
// that is the one misconfiguration this client cannot work around.
func TestSearchHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
}))
defer srv.Close()
_, err := New(srv.URL, Options{}).Search(context.Background(), "x", 3)
if err == nil || !strings.Contains(err.Error(), "403") {
t.Fatalf("err = %v", err)
}
}
func TestSearchEmptyQuery(t *testing.T) {
if _, err := New("http://example.invalid", Options{}).Search(context.Background(), " ", 3); err == nil {
t.Fatal("empty query accepted")
}
}