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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user