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
|
// enforces the Api-Key/Api-Username headers, serves the endpoints the
|
||||||
// client covers, and records every request for assertions.
|
// client covers, and records every request for assertions.
|
||||||
type fakeDiscourse struct {
|
type fakeDiscourse struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
requests []string // "METHOD path"
|
requests []string // "METHOD path"
|
||||||
cats map[int]Category
|
uris []string // "METHOD path?query" — for query assertions
|
||||||
nextCat int
|
cats map[int]Category
|
||||||
posts map[int]postRec // post id -> record
|
nextCat int
|
||||||
nextPost int
|
posts map[int]postRec // post id -> record
|
||||||
topics map[int]topicRec
|
nextPost int
|
||||||
nextT int
|
topics map[int]topicRec
|
||||||
srv *httptest.Server
|
nextT int
|
||||||
|
searchFail int
|
||||||
|
srv *httptest.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
type postRec struct {
|
type postRec struct {
|
||||||
@@ -179,6 +181,26 @@ func newFake(t *testing.T) *fakeDiscourse {
|
|||||||
writeJSON(w, 405, nil)
|
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) {
|
mux.HandleFunc("/latest.json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !f.auth(w, r) {
|
if !f.auth(w, r) {
|
||||||
return
|
return
|
||||||
@@ -203,6 +225,7 @@ func newFake(t *testing.T) *fakeDiscourse {
|
|||||||
func (f *fakeDiscourse) auth(w http.ResponseWriter, r *http.Request) bool {
|
func (f *fakeDiscourse) auth(w http.ResponseWriter, r *http.Request) bool {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
|
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
|
||||||
|
f.uris = append(f.uris, r.Method+" "+r.URL.RequestURI())
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if r.Header.Get("Api-Key") != testKey || r.Header.Get("Api-Username") != "system" {
|
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"}})
|
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
|
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) {
|
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(code)
|
w.WriteHeader(code)
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Search: GET /search.json — full-text search across posts, topics,
|
||||||
|
// users and categories. The term supports Discourse's search operators
|
||||||
|
// (@user, #category, order:latest, ...); they pass through untouched.
|
||||||
|
package discourse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchRequest is the GET /search.json query. Term is required; Page is
|
||||||
|
// 1-based (0 means "leave unset" — Discourse then returns page 1).
|
||||||
|
type SearchRequest struct {
|
||||||
|
Term string
|
||||||
|
Page int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchPost is one post hit: the match lives in Blurb (a plain-text
|
||||||
|
// excerpt around the match) while TopicTitleHeadline carries the topic
|
||||||
|
// title with <em> highlights around matched words.
|
||||||
|
type SearchPost struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
TopicID int `json:"topic_id"`
|
||||||
|
PostNumber int `json:"post_number"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Blurb string `json:"blurb"`
|
||||||
|
TopicTitleHeadline string `json:"topic_title_headline"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
LikeCount int `json:"like_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTopic is one topic hit.
|
||||||
|
type SearchTopic struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchUser is one user hit.
|
||||||
|
type SearchUser struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchResult groups the four hit lists Discourse returns; whichever
|
||||||
|
// classes matched are non-empty, the rest are nil.
|
||||||
|
type SearchResult struct {
|
||||||
|
Posts []SearchPost `json:"posts"`
|
||||||
|
Topics []SearchTopic `json:"topics"`
|
||||||
|
Users []SearchUser `json:"users"`
|
||||||
|
Categories []Category `json:"categories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search runs a full-text search and returns the grouped hits.
|
||||||
|
func (c *Client) Search(ctx context.Context, req SearchRequest) (*SearchResult, error) {
|
||||||
|
if strings.TrimSpace(req.Term) == "" {
|
||||||
|
return nil, fmt.Errorf("%w: search term is required", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("term", req.Term)
|
||||||
|
if req.Page > 1 {
|
||||||
|
q.Set("page", strconv.Itoa(req.Page))
|
||||||
|
}
|
||||||
|
var res SearchResult
|
||||||
|
if err := c.Get(ctx, "/search.json?"+q.Encode(), &res); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &res, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package discourse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearchRequiresTerm(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
c := testClient(t, f)
|
||||||
|
_, err := c.Search(context.Background(), SearchRequest{})
|
||||||
|
if !errors.Is(err, ErrInvalidRequest) {
|
||||||
|
t.Fatalf("want ErrInvalidRequest for empty term, got %v", err)
|
||||||
|
}
|
||||||
|
if f.saw("/search.json") {
|
||||||
|
t.Fatal("no request should have been sent for an empty term")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchQueriesAndParses(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
c := testClient(t, f)
|
||||||
|
res, err := c.Search(context.Background(), SearchRequest{Term: "fleet briefing"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
if !f.sawQuery("/search.json", "term=fleet+briefing") {
|
||||||
|
t.Fatal("search term not sent as the term= query parameter")
|
||||||
|
}
|
||||||
|
if len(res.Posts) != 1 || res.Posts[0].TopicID != 12 || res.Posts[0].Username != "reachableceo" {
|
||||||
|
t.Fatalf("posts not parsed: %+v", res.Posts)
|
||||||
|
}
|
||||||
|
if res.Posts[0].Blurb == "" {
|
||||||
|
t.Fatal("blurb not parsed")
|
||||||
|
}
|
||||||
|
if len(res.Topics) != 1 || res.Topics[0].Title != "Fleet briefing 2026-09-01" {
|
||||||
|
t.Fatalf("topics not parsed: %+v", res.Topics)
|
||||||
|
}
|
||||||
|
if len(res.Users) != 1 || res.Users[0].Username != "reachableceo" {
|
||||||
|
t.Fatalf("users not parsed: %+v", res.Users)
|
||||||
|
}
|
||||||
|
if len(res.Categories) != 1 || res.Categories[0].Slug != "mopac-briefings" {
|
||||||
|
t.Fatalf("categories not parsed: %+v", res.Categories)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchEncodesTermAndPage(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
c := testClient(t, f)
|
||||||
|
if _, err := c.Search(context.Background(), SearchRequest{Term: "a b&c=d", Page: 2}); err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
if !f.sawQuery("/search.json", "term=a+b%26c%3Dd") {
|
||||||
|
t.Fatal("term not url-encoded (a b&c=d must not smuggle extra params)")
|
||||||
|
}
|
||||||
|
if !f.sawQuery("/search.json", "page=2") {
|
||||||
|
t.Fatal("page not sent as the page= query parameter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchTypedErrors(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
c := testClient(t, f)
|
||||||
|
f.searchFail = 403
|
||||||
|
_, err := c.Search(context.Background(), SearchRequest{Term: "x"})
|
||||||
|
if !errors.Is(err, ErrForbidden) {
|
||||||
|
t.Fatalf("want ErrForbidden, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user