package docs import ( "encoding/json" "net/http" "strings" "testing" ) func TestSearch(t *testing.T) { srv := newFullServer(t) createDoc(t, srv, "My Essay", "The quick brown fox jumps over the lazy dog") createDoc(t, srv, "我的日记", "今天天气很好,我去公园散步,心情非常愉快") createDoc(t, srv, "Notes", "groceries and errands for the weekend") search := func(q string) []searchResult { t.Helper() rec := do(t, srv, http.MethodGet, "/search?q="+urlQuery(q), "") if rec.Code != http.StatusOK { t.Fatalf("search %q: %d %s", q, rec.Code, rec.Body) } var out []searchResult if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode search %q: %v", q, err) } return out } // English, ≥3 chars → FTS path. Snippet wraps the match in sentinels. res := search("quick") if len(res) != 1 || res[0].Title != "My Essay" { t.Fatalf("english search: %+v", res) } if !strings.Contains(res[0].Snippet, hlStart+"quick"+hlEnd) { t.Fatalf("snippet not highlighted: %q", res[0].Snippet) } // Chinese, ≥3 chars → FTS path (trigram handles CJK). res = search("天气很好") if len(res) != 1 || res[0].Title != "我的日记" { t.Fatalf("chinese fts search: %+v", res) } if !strings.Contains(res[0].Snippet, hlStart+"天气很好"+hlEnd) { t.Fatalf("cjk snippet not highlighted: %q", res[0].Snippet) } // 2-character Chinese word → LIKE fallback (below the trigram minimum). res = search("公园") if len(res) != 1 || res[0].Title != "我的日记" { t.Fatalf("cjk short (LIKE) search: %+v", res) } if !strings.Contains(res[0].Snippet, hlStart+"公园"+hlEnd) { t.Fatalf("short cjk snippet not highlighted: %q", res[0].Snippet) } // Title-only match still returns the doc, highlighting the title. res = search("Notes") if len(res) != 1 || res[0].Title != "Notes" { t.Fatalf("title search: %+v", res) } // Case-insensitive. if got := search("QUICK"); len(got) != 1 { t.Fatalf("case-insensitive search: %+v", got) } // No match → empty (non-nil) array. if got := search("zzzznotthere"); len(got) != 0 { t.Fatalf("expected no results, got %+v", got) } // Empty query → empty array, no error. rec := do(t, srv, http.MethodGet, "/search?q=", "") if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" { t.Fatalf("empty query: %d %s", rec.Code, rec.Body) } // An edit re-indexes via the update trigger: the old term stops matching, the // new term starts. res = search("quick") docID := res[0].ID body := `{"content":"{}","content_text":"the slow purple turtle ambles along","word_count":6}` do(t, srv, http.MethodPut, "/docs/"+docID, body) if got := search("quick"); len(got) != 0 { t.Fatalf("stale term still matches after edit: %+v", got) } if got := search("purple turtle"); len(got) != 1 || got[0].ID != docID { t.Fatalf("new term not indexed after edit: %+v", got) } } // urlQuery percent-encodes a search term for the query string. func urlQuery(s string) string { var b strings.Builder for _, r := range []byte(s) { if r == ' ' { b.WriteByte('+') } else if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { b.WriteByte(r) } else { b.WriteString(percentByte(r)) } } return b.String() } func percentByte(b byte) string { const hex = "0123456789ABCDEF" return string([]byte{'%', hex[b>>4], hex[b&0xf]}) }