// 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 pms []map[string]any // recorded private messages, creation order } 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) if tu, _ := req["target_usernames"].(string); tu != "" || hasAnyKey(req, "target_group_names", "target_emails") { // private-message creation if strings.Contains(str(req["target_usernames"]), "denied") { reply(w, 403, map[string]any{"errors": []string{"You are not permitted to view the requested resource."}}) return } tid := s.nextT s.nextT++ slug := strings.ToLower(strings.ReplaceAll(title, " ", "-")) s.pms = append(s.pms, map[string]any{ "topic_id": tid, "title": title, "target_usernames": req["target_usernames"], "raw": req["raw"], }) 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 } 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}}) case path == "/search.json" && r.Method == "GET": term := strings.ToLower(r.URL.Query().Get("term")) var topics, posts []map[string]any for _, t := range s.topics { if term != "" && strings.Contains(strings.ToLower(str(t["title"])), term) { topics = append(topics, t) posts = append(posts, map[string]any{ "id": 900 + t["id"].(int), "topic_id": t["id"], "username": "smoker", "blurb": "match on " + str(t["title"]), "topic_title_headline": t["title"], }) } } reply(w, 200, map[string]any{"posts": posts, "topics": topics, "users": []any{}, "categories": []any{}}) case path == "/__pms.json" && r.Method == "GET": reply(w, 200, map[string]any{"private_messages": s.pms}) 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 } // str renders any as a display string (missing keys -> ""). func str(v any) string { s, _ := v.(string) return s } // hasAnyKey reports whether any of the keys holds a non-empty string. func hasAnyKey(m map[string]any, keys ...string) bool { for _, k := range keys { if str(m[k]) != "" { return true } } return false }