feat: search — GET /search.json with grouped hits
Client.Search(term, page) url-encodes the term (operators like @user / #category / order:latest pass through) and decodes the four hit groups Discourse returns: posts (with blurb and highlighted topic title), topics, users and categories. Empty term is rejected locally with ErrInvalidRequest; server failures keep the typed error classes. Fake-server tests cover encoding, parsing and the 403 path. Part of Redmine 507 (Discourse Go client): search is how the 495 briefing pipeline finds landing spots and prior threads.
This commit is contained in:
+45
-9
@@ -18,15 +18,17 @@ const testKey = "test-api-key-0123456789"
|
||||
// enforces the Api-Key/Api-Username headers, serves the endpoints the
|
||||
// client covers, and records every request for assertions.
|
||||
type fakeDiscourse struct {
|
||||
mu sync.Mutex
|
||||
requests []string // "METHOD path"
|
||||
cats map[int]Category
|
||||
nextCat int
|
||||
posts map[int]postRec // post id -> record
|
||||
nextPost int
|
||||
topics map[int]topicRec
|
||||
nextT int
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
requests []string // "METHOD path"
|
||||
uris []string // "METHOD path?query" — for query assertions
|
||||
cats map[int]Category
|
||||
nextCat int
|
||||
posts map[int]postRec // post id -> record
|
||||
nextPost int
|
||||
topics map[int]topicRec
|
||||
nextT int
|
||||
searchFail int
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
type postRec struct {
|
||||
@@ -179,6 +181,26 @@ func newFake(t *testing.T) *fakeDiscourse {
|
||||
writeJSON(w, 405, nil)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/search.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !f.auth(w, r) {
|
||||
return
|
||||
}
|
||||
if f.searchFail != 0 {
|
||||
writeJSON(w, f.searchFail, map[string]any{"errors": []string{"You are not permitted to view the requested resource."}})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"posts": []map[string]any{{
|
||||
"id": 55, "topic_id": 12, "username": "reachableceo", "post_number": 1,
|
||||
"blurb": "the fleet briefing for September",
|
||||
"created_at": "2026-08-29T10:00:00.000Z",
|
||||
"topic_title_headline": "Fleet briefing 2026-09-01",
|
||||
}},
|
||||
"topics": []map[string]any{{"id": 12, "title": "Fleet briefing 2026-09-01", "slug": "fleet-briefing-2026-09-01"}},
|
||||
"users": []map[string]any{{"id": 5, "username": "reachableceo", "name": "Charles"}},
|
||||
"categories": []map[string]any{{"id": 2, "name": "MOPAC Briefings", "slug": "mopac-briefings"}},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/latest.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !f.auth(w, r) {
|
||||
return
|
||||
@@ -203,6 +225,7 @@ func newFake(t *testing.T) *fakeDiscourse {
|
||||
func (f *fakeDiscourse) auth(w http.ResponseWriter, r *http.Request) bool {
|
||||
f.mu.Lock()
|
||||
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
|
||||
f.uris = append(f.uris, r.Method+" "+r.URL.RequestURI())
|
||||
f.mu.Unlock()
|
||||
if r.Header.Get("Api-Key") != testKey || r.Header.Get("Api-Username") != "system" {
|
||||
writeJSON(w, 403, map[string]any{"errors": []string{"Bad or missing API key"}})
|
||||
@@ -222,6 +245,19 @@ func (f *fakeDiscourse) saw(substr string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// sawQuery asserts a GET hit path with a query substring ("term=x",
|
||||
// "page=2").
|
||||
func (f *fakeDiscourse) sawQuery(path, param string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, u := range f.uris {
|
||||
if strings.HasPrefix(u, "GET "+path+"?") && strings.Contains(u, param) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
|
||||
Reference in New Issue
Block a user