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
150 lines
4.6 KiB
Go
150 lines
4.6 KiB
Go
// Command fakediscourse is the smoke-test Discourse: a tiny in-memory
|
|
// instance enforcing Api-Key auth, serving exactly what the client covers
|
|
// (current user, categories, topics, posts). It exists so the smoke test
|
|
// can drive the REAL CLI binary end to end without ever touching a live
|
|
// forum. Not built into the library, never shipped.
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
const smokeKey = "smoke-key-do-not-use"
|
|
|
|
type server struct {
|
|
mu sync.Mutex
|
|
cats []map[string]any
|
|
posts map[int]map[string]any
|
|
topics []map[string]any
|
|
nextCat int
|
|
nextPost int
|
|
nextT int
|
|
}
|
|
|
|
func main() {
|
|
addr := ":8610"
|
|
if len(os.Args) == 3 && os.Args[1] == "-addr" {
|
|
addr = os.Args[2]
|
|
}
|
|
s := &server{
|
|
cats: []map[string]any{
|
|
{"id": 1, "name": "General", "slug": "general", "color": "0088CC", "text_color": "FFFFFF", "topic_count": 0},
|
|
{"id": 2, "name": "MOPAC Briefings", "slug": "mopac-briefings", "color": "3AB54A", "text_color": "FFFFFF", "topic_count": 0},
|
|
},
|
|
posts: map[int]map[string]any{},
|
|
nextCat: 3,
|
|
nextPost: 100,
|
|
nextT: 10,
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Api-Key") != smokeKey || r.Header.Get("Api-Username") != "smoker" {
|
|
reply(w, 403, map[string]any{"errors": []string{"Bad or missing API key"}})
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
path := r.URL.Path
|
|
switch {
|
|
case path == "/session/current.json" && r.Method == "GET":
|
|
reply(w, 200, map[string]any{"current_user": map[string]any{"id": 7, "username": "smoker", "admin": true}})
|
|
|
|
case path == "/categories.json" && r.Method == "GET":
|
|
reply(w, 200, map[string]any{"category_list": map[string]any{"categories": s.cats}})
|
|
|
|
case path == "/categories.json" && r.Method == "POST":
|
|
var req map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
reply(w, 400, map[string]any{"errors": []string{"bad json"}})
|
|
return
|
|
}
|
|
name, _ := req["name"].(string)
|
|
cat := map[string]any{
|
|
"id": s.nextCat, "name": name,
|
|
"slug": strings.ToLower(strings.ReplaceAll(name, " ", "-")),
|
|
"color": orDefault(req["color"], "3AB54A"), "text_color": orDefault(req["text_color"], "FFFFFF"),
|
|
"topic_count": 0,
|
|
}
|
|
s.nextCat++
|
|
s.cats = append(s.cats, cat)
|
|
reply(w, 200, cat)
|
|
|
|
case path == "/posts.json" && r.Method == "POST":
|
|
var req map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
reply(w, 400, nil)
|
|
return
|
|
}
|
|
topicID, _ := req["topic_id"].(float64)
|
|
pid := s.nextPost
|
|
s.nextPost++
|
|
if topicID == 0 {
|
|
title, _ := req["title"].(string)
|
|
catID, _ := req["category"].(float64)
|
|
tid := s.nextT
|
|
s.nextT++
|
|
slug := strings.ToLower(strings.ReplaceAll(title, " ", "-"))
|
|
s.topics = append(s.topics, map[string]any{"id": tid, "title": title, "slug": slug, "category_id": int(catID)})
|
|
s.posts[pid] = map[string]any{"id": pid, "topic_id": tid, "raw": req["raw"], "post_number": 1}
|
|
reply(w, 200, map[string]any{"id": pid, "topic_id": tid, "topic_slug": slug})
|
|
return
|
|
}
|
|
s.posts[pid] = map[string]any{"id": pid, "topic_id": int(topicID), "raw": req["raw"], "post_number": 2}
|
|
reply(w, 200, map[string]any{"id": pid, "topic_id": int(topicID), "topic_slug": "existing"})
|
|
|
|
case strings.HasPrefix(path, "/posts/") && r.Method == "PUT":
|
|
id, _ := strconv.Atoi(strings.SplitN(strings.TrimPrefix(path, "/posts/"), ".", 2)[0])
|
|
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 {
|
|
reply(w, 400, nil)
|
|
return
|
|
}
|
|
p, ok := s.posts[id]
|
|
if !ok {
|
|
reply(w, 404, nil)
|
|
return
|
|
}
|
|
p["raw"] = body.Post.Raw
|
|
s.posts[id] = p
|
|
reply(w, 200, map[string]any{"post": p})
|
|
|
|
case strings.HasPrefix(path, "/c/") && r.Method == "GET":
|
|
reply(w, 200, map[string]any{"topic_list": map[string]any{"topics": s.topics}})
|
|
|
|
case path == "/latest.json" && r.Method == "GET":
|
|
reply(w, 200, map[string]any{"topic_list": map[string]any{"topics": s.topics}})
|
|
|
|
default:
|
|
reply(w, 404, map[string]any{"errors": []string{"not found on the smoke instance"}})
|
|
}
|
|
})
|
|
fmt.Printf("fakediscourse listening on %s (api-key enforced)\n", addr)
|
|
log.Fatal(http.ListenAndServe(addr, mux))
|
|
}
|
|
|
|
func reply(w http.ResponseWriter, code int, body any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|
|
|
|
func orDefault(v any, def string) any {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
return s
|
|
}
|
|
return def
|
|
}
|