v0 landed with a few misaligned struct tags and fields; gofmt -l flagged categories.go, topics.go, discourse_test.go and the smoke fake. Alignment only, no behavior change, so the tree passes the gofmt gate cleanly.
454 lines
13 KiB
Go
454 lines
13 KiB
Go
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)
|
|
}
|
|
}
|