Files
paliad/internal/handlers/search_test.go
m 9bb9f0c3df feat(search): global search across projects, deadlines, appointments, glossary, courts, checklists, links, users
Adds a sidebar-wide search bar (t-paliad-026) that hits a single GET
/api/search?q=... endpoint returning grouped results. Static content
(glossary, courts, link hub, checklist templates) is scanned in memory
against the curated Go slices; DB content (projects, deadlines,
appointments, checklist instances, users) is visibility-gated through
the same predicates the normal list endpoints use.

Frontend: new sidebar.ts-owned controller debounces 200ms, renders a
grouped dropdown, supports "/" to focus, Escape/arrows/Enter for
navigation, mobile-full-width overlay, and highlights matches.
2026-04-22 23:36:10 +02:00

144 lines
3.9 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// TestSearchStaticContent verifies the curated-content (non-DB) search paths.
// These execute against the static Go slices bundled with the binary, so the
// assertions below are stable as long as those slices contain the checked
// entries (enforced by the acceptance cases in task t-paliad-026).
func TestSearchStaticContent(t *testing.T) {
cases := []struct {
name string
query string
group string
wantTitle string
}{
{
name: "glossary finds Nichtigkeitsklage",
query: "nichtigkeits",
group: "glossary",
wantTitle: "Nichtigkeitsklage",
},
{
name: "courts finds Munich",
query: "münchen",
group: "courts",
wantTitle: "München",
},
{
name: "links finds Espacenet",
query: "espacenet",
group: "links",
wantTitle: "Espacenet",
},
{
name: "checklists finds UPC statement of claim",
query: "statement of claim",
group: "checklists",
wantTitle: "UPC",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/search?q="+url.QueryEscape(tc.query), nil)
rec := httptest.NewRecorder()
handleSearch(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp searchResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
got := groupByName(&resp, tc.group)
if len(got) == 0 {
t.Fatalf("no results in group %q for query %q", tc.group, tc.query)
}
found := false
for _, r := range got {
if strings.Contains(strings.ToLower(r.Title), strings.ToLower(tc.wantTitle)) ||
strings.Contains(strings.ToLower(r.Subtitle), strings.ToLower(tc.wantTitle)) {
found = true
break
}
}
if !found {
t.Errorf("no result containing %q in group %q; got %+v",
tc.wantTitle, tc.group, got)
}
})
}
}
// TestSearchEmptyQuery guarantees we never leak the entire catalog on an
// empty or whitespace-only query.
func TestSearchEmptyQuery(t *testing.T) {
for _, q := range []string{"", " ", "\t\n"} {
req := httptest.NewRequest(http.MethodGet, "/api/search?q="+url.QueryEscape(q), nil)
rec := httptest.NewRecorder()
handleSearch(rec, req)
var resp searchResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
total := len(resp.Projects) + len(resp.Deadlines) + len(resp.Appointments) +
len(resp.Glossary) + len(resp.Courts) + len(resp.Checklists) +
len(resp.Links) + len(resp.Users)
if total != 0 {
t.Errorf("empty query returned %d results; want 0", total)
}
}
}
// TestSearchCapsTotalBudget asserts the overall ceiling is enforced. With a
// broad query like "e" every category contributes something, so the raw
// total would exceed maxTotalResults and the cap must kick in.
func TestSearchCapsTotalBudget(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/search?q=e", nil)
rec := httptest.NewRecorder()
handleSearch(rec, req)
var resp searchResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
total := len(resp.Projects) + len(resp.Deadlines) + len(resp.Appointments) +
len(resp.Glossary) + len(resp.Courts) + len(resp.Checklists) +
len(resp.Links) + len(resp.Users)
if total > maxTotalResults {
t.Errorf("total %d exceeds ceiling %d", total, maxTotalResults)
}
}
func groupByName(r *searchResponse, name string) []SearchResult {
switch name {
case "projects":
return r.Projects
case "deadlines":
return r.Deadlines
case "appointments":
return r.Appointments
case "glossary":
return r.Glossary
case "courts":
return r.Courts
case "checklists":
return r.Checklists
case "links":
return r.Links
case "users":
return r.Users
}
return nil
}