v0: stdlib-only Discourse client — categories/topics/posts + raw passthrough

Covers the MOPAC briefing/report surface (Redmine 495 Part A): category
list/create (create needs the admin-scoped key; current key 403s, typed
as ErrForbidden), topic create/list/latest/get, post create/update/get,
current-user identity probe, and a Do() JSON passthrough so unmodeled
endpoints need no client release. Key is env/constructor-only, never a
flag, never logged; errors classify via errors.Is. Fake-server unit
tests + containerized end-to-end smoke (redaction sweep included); live
reads verified against community.turnsys.com.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-08-29 06:12:22 -05:00
commit f9cf30bc72
16 changed files with 2559 additions and 0 deletions
+453
View File
@@ -0,0 +1,453 @@
package discourse
import (
"context"
"encoding/json"
"errors"
"fmt"
"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"
cats map[int]Category
nextCat int
posts map[int]postRec // post id -> record
nextPost int
topics map[int]topicRec
nextT int
srv *httptest.Server
}
type postRec struct {
Post
EditReason string
}
type topicRec struct {
ID int
Title string
Slug string
Cat int
}
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
}
var req CreatePostRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, 400, map[string]any{"errors": []string{"bad json"}})
return
}
f.mu.Lock()
defer f.mu.Unlock()
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("/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.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
}
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
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)
}
}