Files
mopac-discourse-go/discourse_test.go
T
mrcharles 6891f3febc feat: private-message send via POST /posts.json targets
Client.SendMessage(title, raw, targets) creates a private-message
topic by posting to /posts.json with target_usernames /
target_group_names / target_emails (comma-joined on the wire, as
Discourse expects, never JSON arrays) instead of a category. The
result reuses the topic ids type, so URL() renders the PM link.
Local validation requires title, body and at least one target;
undeliverable recipients surface as the typed ErrForbidden class.
Fake-server tests assert the wire shape and the recorded recipients.

Part of Redmine 507 (Discourse Go client): PM delivery is how the
495 pipeline pings humans that a briefing has landed.
2026-08-29 16:36:54 -05:00

549 lines
16 KiB
Go

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)
}
}