Files
claude 1f38e71d1a the ZIM fallback fires fast, and reads Russian in Russian (V-508)
Verification, as the task asked. Drove что такое фотосинтез through
/api/chat with the search reachable, with the container stopped, and with
the host blackholed. Kiwix claims the turn in both failure cases, and a
stopped container costs nothing: DNS fails and the ZIM answers inside the
same second.

The blackhole is the case that hurts. The search waited its full 8-second
budget before the ZIM was asked and the turn took 15.4s against 3.5, which
he sits through with nothing being said. So the connect phase alone is now
capped at 1.5s. A reachable instance that is merely slow keeps the whole
budget, because it is fanning out to real engines.

The RU Wikipedia ZIM is on the box (owner moved it into the kiwix zims
dir), and kiwix-serve picked it up. A Cyrillic question now searches
book_ru verbatim and skips the RU->EN rewrite: that rewriter is the
workaround for an English book, and against a Russian one it is a
translation of his own words back at him. Catalog names come from the
filename, not the <name> field — books.name=wikipedia_ru_all returns
nothing.

Measurement in docs/evals/2026-08-05-kiwix-offline-fallback.md. The RU book
answering a driven turn needs a rebuild and is not verified yet.
2026-08-05 15:52:54 +04:00

183 lines
6.2 KiB
Go

package websearch
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
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")
}
}
// A host that never answers a SYN must not hold the turn open for the whole
// request budget: the ZIM behind this search is the answer, and he waits
// through every second of the delay (V-508). 192.0.2.1 is TEST-NET-1, which is
// reserved for documentation and routed nowhere.
func TestSearchGivesUpOnAnUnreachableHostFast(t *testing.T) {
c := New("http://192.0.2.1:9563", Options{Timeout: 8 * time.Second})
start := time.Now()
_, err := c.Search(context.Background(), "фотосинтез", 4)
elapsed := time.Since(start)
if err == nil {
t.Fatal("Search reached 192.0.2.1; the address is routed nowhere")
}
if elapsed > 4*time.Second {
t.Errorf("Search took %v to give up; the dial cap is %v", elapsed, dialTimeout)
}
}
// The dial cap must not shorten a request to an instance that did connect. A
// slow SearXNG is fanning out to real engines, which is worth waiting for.
func TestSlowButReachableInstanceKeepsTheFullBudget(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * dialTimeout)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"answers":[],"results":[{"title":"Фотосинтез","url":"http://x","content":"процесс","engine":"test"}]}`))
}))
defer srv.Close()
c := New(srv.URL, Options{Timeout: 8 * time.Second})
resp, err := c.Search(context.Background(), "фотосинтез", 4)
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(resp.Results) != 1 {
t.Errorf("results = %d, want 1", len(resp.Results))
}
}