package discourse import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" ) const testKey = "test-api-key-0123456789" // fakeDiscourse is a minimal in-memory Discourse for client tests: it // 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" 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 pms []pmRec // private messages, in creation order lastBody string // raw JSON body of the last POST /posts.json srv *httptest.Server } type postRec struct { Post EditReason string } type topicRec struct { ID int Title string Slug string Cat int } type pmRec struct { TopicID int Title string Targets []string Raw string } func newFake(t *testing.T) *fakeDiscourse { t.Helper() f := &fakeDiscourse{ cats: map[int]Category{ 1: {ID: 1, Name: "General", Slug: "general", Color: "0088CC", TextColor: "FFFFFF"}, 2: {ID: 2, Name: "MOPAC Briefings", Slug: "mopac-briefings", Color: "3AB54A", TextColor: "FFFFFF"}, }, nextCat: 3, posts: map[int]postRec{}, nextPost: 100, topics: map[int]topicRec{}, nextT: 10, } mux := http.NewServeMux() mux.HandleFunc("/session/current.json", func(w http.ResponseWriter, r *http.Request) { if !f.auth(w, r) { return } writeJSON(w, 200, map[string]any{ "current_user": map[string]any{"id": 5, "username": "reachableceo", "admin": false}, }) }) mux.HandleFunc("/categories.json", func(w http.ResponseWriter, r *http.Request) { if !f.auth(w, r) { return } switch r.Method { case http.MethodGet: cats := make([]Category, 0, len(f.cats)) for _, c := range f.cats { cats = append(cats, c) } writeJSON(w, 200, map[string]any{"category_list": map[string]any{"categories": cats}}) case http.MethodPost: var req CreateCategoryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, 400, map[string]any{"errors": []string{"bad json"}}) return } if req.Name == "denied" { // simulate a non-admin key writeJSON(w, 403, map[string]any{ "errors": []string{"You are not permitted to view the requested resource."}, "error_description": "Access denied", }) return } f.mu.Lock() cat := Category{ ID: f.nextCat, Name: req.Name, Slug: strings.ToLower(strings.ReplaceAll(req.Name, " ", "-")), Color: req.Color, TextColor: req.TextColor, Position: req.Position, } f.nextCat++ f.cats[cat.ID] = cat f.mu.Unlock() writeJSON(w, 200, cat) default: writeJSON(w, 405, nil) } }) mux.HandleFunc("/posts.json", func(w http.ResponseWriter, r *http.Request) { if !f.auth(w, r) { return } if r.Method != http.MethodPost { writeJSON(w, 405, nil) return } body, err := io.ReadAll(r.Body) if err != nil { writeJSON(w, 400, map[string]any{"errors": []string{"bad body"}}) return } var req struct { CreatePostRequest TargetUsernames string `json:"target_usernames"` TargetGroupNames string `json:"target_group_names"` TargetEmails string `json:"target_emails"` } if err := json.Unmarshal(body, &req); err != nil { writeJSON(w, 400, map[string]any{"errors": []string{"bad json"}}) return } f.mu.Lock() defer f.mu.Unlock() f.lastBody = string(body) if req.TargetUsernames != "" || req.TargetGroupNames != "" || req.TargetEmails != "" { // private-message creation targets := splitCSV(req.TargetUsernames) for _, name := range append(append(targets, splitCSV(req.TargetGroupNames)...), splitCSV(req.TargetEmails)...) { if strings.HasPrefix(name, "denied") { writeJSON(w, 403, map[string]any{"errors": []string{"You are not permitted to view the requested resource."}}) return } } if strings.TrimSpace(req.Title) == "" { writeJSON(w, 422, map[string]any{"errors": []string{"Title can't be blank"}}) return } tid := f.nextT f.nextT++ slug := strings.ToLower(strings.ReplaceAll(req.Title, " ", "-")) f.pms = append(f.pms, pmRec{TopicID: tid, Title: req.Title, Targets: targets, Raw: req.Raw}) pid := f.nextPost f.nextPost++ f.posts[pid] = postRec{Post: Post{ID: pid, TopicID: tid, PostNumber: 1, Raw: req.Raw}} writeJSON(w, 200, map[string]any{"id": pid, "topic_id": tid, "topic_slug": slug}) return } if req.TopicID == 0 { // topic creation if strings.TrimSpace(req.Title) == "" || req.Category == 0 { writeJSON(w, 422, map[string]any{"errors": []string{"Title can't be blank"}}) return } tid := f.nextT f.nextT++ slug := strings.ToLower(strings.ReplaceAll(req.Title, " ", "-")) f.topics[tid] = topicRec{ID: tid, Title: req.Title, Slug: slug, Cat: req.Category} pid := f.nextPost f.nextPost++ f.posts[pid] = postRec{Post: Post{ID: pid, TopicID: tid, PostNumber: 1, Raw: req.Raw, CreatedAt: "2026-08-29T06:30:00.000Z"}} writeJSON(w, 200, map[string]any{"id": pid, "topic_id": tid, "topic_slug": slug}) return } pid := f.nextPost f.nextPost++ f.posts[pid] = postRec{Post: Post{ID: pid, TopicID: req.TopicID, PostNumber: 2, Raw: req.Raw}} writeJSON(w, 200, map[string]any{"id": pid, "topic_id": req.TopicID, "topic_slug": "existing"}) }) mux.HandleFunc("/posts/", func(w http.ResponseWriter, r *http.Request) { if !f.auth(w, r) { return } var id int if _, err := fmt.Sscanf(strings.Split(r.URL.Path, "/")[2], "%d", &id); err != nil { writeJSON(w, 404, nil) return } f.mu.Lock() defer f.mu.Unlock() switch r.Method { case http.MethodGet: p, ok := f.posts[id] if !ok { writeJSON(w, 404, map[string]any{"errors": []string{"The requested URL or resource could not be found."}}) return } writeJSON(w, 200, map[string]any{"post": p}) case http.MethodPut: var body struct { Post struct { Raw string `json:"raw"` EditReason string `json:"edit_reason"` } `json:"post"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeJSON(w, 400, nil) return } p, ok := f.posts[id] if !ok { writeJSON(w, 404, nil) return } p.Raw = body.Post.Raw p.EditReason = body.Post.EditReason f.posts[id] = p writeJSON(w, 200, map[string]any{"post": p}) default: 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 } writeJSON(w, 200, map[string]any{"topic_list": map[string]any{"topics": []map[string]any{ {"id": 7, "title": "Latest one", "slug": "latest-one", "category_id": 1, "posts_count": 2}, }}}) }) mux.HandleFunc("/c/", func(w http.ResponseWriter, r *http.Request) { if !f.auth(w, r) { return } writeJSON(w, 200, map[string]any{"topic_list": map[string]any{"topics": []map[string]any{ {"id": 9, "title": "In category", "slug": "in-category", "category_id": 2, "posts_count": 1}, }}}) }) f.srv = httptest.NewServer(mux) t.Cleanup(f.srv.Close) return f } 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"}}) return false } return true } func (f *fakeDiscourse) saw(substr string) bool { f.mu.Lock() defer f.mu.Unlock() for _, r := range f.requests { if strings.Contains(r, substr) { return true } } 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) _ = json.NewEncoder(w).Encode(body) } // splitCSV splits a comma-joined target list ("ops,reachableceo"). func splitCSV(s string) []string { if s == "" { return nil } parts := strings.Split(s, ",") out := parts[:0] for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out } func testClient(t *testing.T, f *fakeDiscourse) *Client { t.Helper() c, err := New(f.srv.URL, testKey, "") if err != nil { t.Fatalf("New: %v", err) } return c } func TestNewRejectsBadInput(t *testing.T) { if _, err := New("https://ok.example", "", ""); !errors.Is(err, ErrInvalidRequest) { t.Fatalf("empty key: want ErrInvalidRequest, got %v", err) } if _, err := New("not-a-url", testKey, ""); !errors.Is(err, ErrInvalidRequest) { t.Fatalf("bad url: want ErrInvalidRequest, got %v", err) } } func TestCurrentUser(t *testing.T) { f := newFake(t) c := testClient(t, f) u, err := c.CurrentUser(context.Background()) if err != nil { t.Fatalf("CurrentUser: %v", err) } if u.Username != "reachableceo" || u.Admin { t.Fatalf("unexpected user %+v", u) } if !f.saw("GET /session/current.json") { t.Fatal("wrong path hit") } } func TestListCategoriesParsesAndAuths(t *testing.T) { f := newFake(t) c := testClient(t, f) cats, err := c.ListCategories(context.Background()) if err != nil { t.Fatalf("ListCategories: %v", err) } if len(cats) != 2 || cats[0].Name == "" || cats[1].Slug != "mopac-briefings" && cats[0].Slug != "mopac-briefings" { t.Fatalf("unexpected categories: %+v", cats) } found, err := c.FindCategory(context.Background(), 0, "MOPAC-Briefings") if err != nil || found.ID != 2 { t.Fatalf("FindCategory by slug: %v %+v", err, found) } found, err = c.FindCategory(context.Background(), 1, "") if err != nil || found.ID != 1 { t.Fatalf("FindCategory by id: %v %+v", err, found) } if _, err := c.FindCategory(context.Background(), 99, ""); !errors.Is(err, ErrNotFound) { t.Fatalf("missing category: want ErrNotFound, got %v", err) } } func TestCreateCategory(t *testing.T) { f := newFake(t) c := testClient(t, f) cat, err := c.CreateCategory(context.Background(), CreateCategoryRequest{ Name: "Ops Reports", Color: "#25AAE2", Permissions: map[string]int{"staff": 3}, }) if err != nil { t.Fatalf("CreateCategory: %v", err) } if cat.ID != 3 || cat.Slug != "ops-reports" { t.Fatalf("unexpected created category: %+v", cat) } if cat.Color != "25AAE2" { // normalized: # stripped, uppercased t.Fatalf("color not normalized: %q", cat.Color) } } func TestCreateCategoryForbiddenIsTypedAndRedacted(t *testing.T) { f := newFake(t) c := testClient(t, f) _, err := c.CreateCategory(context.Background(), CreateCategoryRequest{Name: "denied"}) if !errors.Is(err, ErrForbidden) { t.Fatalf("want ErrForbidden, got %v", err) } var apiErr *APIError if !errors.As(err, &apiErr) || apiErr.Status != 403 { t.Fatalf("want APIError 403, got %v", err) } if strings.Contains(err.Error(), testKey) { t.Fatalf("error leaks the api key: %v", err) } if !strings.Contains(err.Error(), "not permitted") { t.Fatalf("error misses discourse reasons: %v", err) } } func TestCreateTopicAndURL(t *testing.T) { f := newFake(t) c := testClient(t, f) res, err := c.CreateTopic(context.Background(), CreateTopicRequest{ Title: "MOPAC briefing 2026-08-29", Raw: "body", Category: 2, }) if err != nil { t.Fatalf("CreateTopic: %v", err) } if res.TopicID == 0 || res.PostID == 0 { t.Fatalf("bad result: %+v", res) } want := f.srv.URL + "/t/mopac-briefing-2026-08-29/" + fmt.Sprint(res.TopicID) if got := res.URL(f.srv.URL); got != want { t.Fatalf("URL: want %s got %s", want, got) } if !f.saw("POST /posts.json") { t.Fatal("topic create must go through POST /posts.json") } if _, err := c.CreateTopic(context.Background(), CreateTopicRequest{Title: "x", Raw: "y"}); !errors.Is(err, ErrInvalidRequest) { t.Fatalf("missing category: want ErrInvalidRequest, got %v", err) } } func TestListTopicsByCategoryAndLatest(t *testing.T) { f := newFake(t) c := testClient(t, f) topics, err := c.ListTopics(context.Background(), 2, "mopac-briefings") if err != nil { t.Fatalf("ListTopics id+slug: %v", err) } if len(topics) != 1 || topics[0].ID != 9 { t.Fatalf("unexpected topics: %+v", topics) } topics, err = c.ListTopics(context.Background(), 2, "") if err != nil || len(topics) != 1 { t.Fatalf("ListTopics id only: %v %+v", err, topics) } topics, err = c.ListTopics(context.Background(), 0, "mopac-briefings") if err != nil || len(topics) != 1 { t.Fatalf("ListTopics slug only: %v %+v", err, topics) } if _, err := c.ListTopics(context.Background(), 0, ""); !errors.Is(err, ErrInvalidRequest) { t.Fatalf("no ref: want ErrInvalidRequest, got %v", err) } if _, err := c.LatestTopics(context.Background()); err != nil { t.Fatalf("LatestTopics: %v", err) } } func TestCreateAndUpdatePost(t *testing.T) { f := newFake(t) c := testClient(t, f) res, err := c.CreatePost(context.Background(), CreatePostRequest{TopicID: 5, Raw: "reply body"}) if err != nil { t.Fatalf("CreatePost: %v", err) } if res.TopicID != 5 { t.Fatalf("bad result: %+v", res) } post, err := c.UpdatePost(context.Background(), res.PostID, "edited body", "typo") if err != nil { t.Fatalf("UpdatePost: %v", err) } if post.ID != res.PostID { t.Fatalf("unexpected post: %+v", post) } got, err := c.GetPost(context.Background(), res.PostID) if err != nil { t.Fatalf("GetPost: %v", err) } if got.Raw != "edited body" { t.Fatalf("update did not stick: %+v", got) } if _, err := c.CreatePost(context.Background(), CreatePostRequest{TopicID: 0, Raw: "x"}); !errors.Is(err, ErrInvalidRequest) { t.Fatalf("no topic: want ErrInvalidRequest, got %v", err) } if _, err := c.UpdatePost(context.Background(), 99999, "x", ""); !errors.Is(err, ErrNotFound) { t.Fatalf("missing post: want ErrNotFound, got %v", err) } } func TestRawPassthrough(t *testing.T) { f := newFake(t) c := testClient(t, f) var out map[string]any if err := c.Get(context.Background(), "/latest.json", &out); err != nil { t.Fatalf("raw get: %v", err) } if err := c.Do(context.Background(), http.MethodPost, "/echo.json", map[string]string{"a": "b"}, nil); err == nil || !errors.Is(err, ErrNotFound) { t.Fatalf("raw post to unknown path: want ErrNotFound, got %v", err) } } func TestUnreachableIsTypedAndRedacted(t *testing.T) { c, err := New("http://127.0.0.1:1", testKey, "system") if err != nil { t.Fatalf("New: %v", err) } _, err = c.CurrentUser(context.Background()) if !errors.Is(err, ErrUnreachable) { t.Fatalf("want ErrUnreachable, got %v", err) } if strings.Contains(err.Error(), testKey) { t.Fatalf("error leaks the api key: %v", err) } } func TestBadKeyIsUnauthorized(t *testing.T) { f := newFake(t) c, err := New(f.srv.URL, "wrong-key", "system") if err != nil { t.Fatalf("New: %v", err) } _, err = c.CurrentUser(context.Background()) if !errors.Is(err, ErrForbidden) { t.Fatalf("want ErrForbidden (auth header rejected), got %v", err) } } func TestClientStringNeverLeaksKey(t *testing.T) { c, err := New("https://forum.example.com", testKey, "system") if err != nil { t.Fatal(err) } if s := c.String(); strings.Contains(s, testKey) { t.Fatalf("String leaks key: %s", s) } }