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.
144 lines
3.9 KiB
Go
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
|
|
}
|