diff --git a/discourse_test.go b/discourse_test.go index e50fed7..77cc7d3 100644 --- a/discourse_test.go +++ b/discourse_test.go @@ -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) diff --git a/search.go b/search.go new file mode 100644 index 0000000..7cc92af --- /dev/null +++ b/search.go @@ -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 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 +} diff --git a/search_test.go b/search_test.go new file mode 100644 index 0000000..d19e597 --- /dev/null +++ b/search_test.go @@ -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) + } +}