This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"plumber/internal/events"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) publishPostCreated(post, root *store.Post, author *store.User) {
|
||||
s.publishPost(events.PostCreated{PostEvent: s.postEvent(post, root, author)}, root)
|
||||
}
|
||||
|
||||
func (s *Server) publishPostUpdated(post, root *store.Post, author *store.User) {
|
||||
s.publishPost(events.PostUpdated{PostEvent: s.postEvent(post, root, author)}, root)
|
||||
}
|
||||
|
||||
func (s *Server) publishPost(ev any, root *store.Post) {
|
||||
if root != nil && root.PostState == store.PostStateHidden {
|
||||
return
|
||||
}
|
||||
s.cfg.Events.Publish(context.Background(), ev)
|
||||
}
|
||||
|
||||
func (s *Server) postEvent(post, root *store.Post, author *store.User) events.PostEvent {
|
||||
if post == nil {
|
||||
return events.PostEvent{}
|
||||
}
|
||||
rootID := post.ID
|
||||
if root != nil {
|
||||
rootID = root.ID
|
||||
}
|
||||
ev := events.PostEvent{
|
||||
PostID: post.ID,
|
||||
RootID: rootID,
|
||||
Title: post.Title,
|
||||
Body: post.Body,
|
||||
City: post.City,
|
||||
AuthorID: post.AuthorID,
|
||||
AuthorName: post.AuthorName,
|
||||
AuthorRole: string(post.AuthorRole),
|
||||
Permalink: events.Permalink(s.cfg.BaseURL, rootID, post.ID),
|
||||
}
|
||||
if post.ParentID != nil {
|
||||
ev.ParentID = *post.ParentID
|
||||
}
|
||||
if author != nil {
|
||||
if ev.AuthorName == "" {
|
||||
ev.AuthorName = author.Name
|
||||
}
|
||||
if ev.AuthorRole == "" {
|
||||
ev.AuthorRole = string(author.Role)
|
||||
}
|
||||
}
|
||||
if n := len(post.Images); n > 0 {
|
||||
ev.Images = make([]events.Image, 0, n)
|
||||
for _, img := range post.Images {
|
||||
ev.Images = append(ev.Images, events.Image{
|
||||
URL: img.PublicURL,
|
||||
Description: img.Description,
|
||||
})
|
||||
}
|
||||
}
|
||||
return ev
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"plumber/internal/events"
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
func TestPostHandlersPublishEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := &events.Recording{}
|
||||
srv, mem := newTestServer(t, Config{
|
||||
Events: rec,
|
||||
BaseURL: "https://www.askaplumberfirst.com",
|
||||
})
|
||||
handler := srv.Handler()
|
||||
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
|
||||
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
|
||||
homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22")
|
||||
adminCookies := loginUser(t, handler, admin.Username, "hunter22")
|
||||
homeownerCSRF := csrfForCookies(t, handler, homeownerCookies)
|
||||
adminCSRF := csrfForCookies(t, handler, adminCookies)
|
||||
|
||||
submit := postForm(handler, "/submit", url.Values{
|
||||
"_csrf": {homeownerCSRF},
|
||||
"title": {"Leaky sink"},
|
||||
"body": {"Water under the cabinet."},
|
||||
"city": {"Oakland"},
|
||||
}, homeownerCookies)
|
||||
if submit.Code != http.StatusSeeOther {
|
||||
t.Fatalf("submit status = %d: %s", submit.Code, submit.Body.String())
|
||||
}
|
||||
|
||||
create := postForm(handler, "/posts", url.Values{
|
||||
"_csrf": {homeownerCSRF},
|
||||
"title": {"Second question"},
|
||||
"body": {"Another leak."},
|
||||
"city": {"Berkeley"},
|
||||
}, homeownerCookies)
|
||||
if create.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create status = %d: %s", create.Code, create.Body.String())
|
||||
}
|
||||
|
||||
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
|
||||
if err != nil || len(roots) != 2 {
|
||||
t.Fatalf("roots = %+v, %v", roots, err)
|
||||
}
|
||||
var submitRoot, createRoot store.Post
|
||||
for _, root := range roots {
|
||||
switch root.Title {
|
||||
case "Leaky sink":
|
||||
submitRoot = root
|
||||
case "Second question":
|
||||
createRoot = root
|
||||
}
|
||||
}
|
||||
if submitRoot.ID == "" || createRoot.ID == "" {
|
||||
t.Fatalf("missing created roots: %+v", roots)
|
||||
}
|
||||
|
||||
reply := postForm(handler, "/posts", url.Values{
|
||||
"_csrf": {adminCSRF},
|
||||
"parent_id": {createRoot.ID},
|
||||
"body": {"Replace the cartridge."},
|
||||
}, adminCookies)
|
||||
if reply.Code != http.StatusSeeOther {
|
||||
t.Fatalf("reply status = %d: %s", reply.Code, reply.Body.String())
|
||||
}
|
||||
thread, err := mem.GetPostThread(context.Background(), createRoot.ID)
|
||||
if err != nil || len(thread.Replies) != 1 {
|
||||
t.Fatalf("thread = %+v, %v", thread, err)
|
||||
}
|
||||
adminReply := thread.Replies[0]
|
||||
|
||||
edit := postForm(handler, "/posts/"+createRoot.ID+"/edit", url.Values{
|
||||
"_csrf": {homeownerCSRF},
|
||||
"body": {"Updated leak description."},
|
||||
}, homeownerCookies)
|
||||
if edit.Code != http.StatusSeeOther {
|
||||
t.Fatalf("edit status = %d: %s", edit.Code, edit.Body.String())
|
||||
}
|
||||
|
||||
empty := postForm(handler, "/posts", url.Values{
|
||||
"_csrf": {homeownerCSRF},
|
||||
"title": {"Missing body"},
|
||||
}, homeownerCookies)
|
||||
if empty.Code != http.StatusBadRequest {
|
||||
t.Fatalf("empty body status = %d, want 400", empty.Code)
|
||||
}
|
||||
|
||||
hidden := &store.Post{
|
||||
AuthorID: homeowner.ID,
|
||||
Title: "Hidden thread",
|
||||
Body: "Not public.",
|
||||
PostDate: pacific.Today(),
|
||||
PostState: store.PostStateHidden,
|
||||
}
|
||||
if err := mem.CreatePost(context.Background(), hidden); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hiddenReply := postForm(handler, "/posts", url.Values{
|
||||
"_csrf": {homeownerCSRF},
|
||||
"parent_id": {hidden.ID},
|
||||
"body": {"Should not publish."},
|
||||
}, homeownerCookies)
|
||||
if hiddenReply.Code != http.StatusNotFound {
|
||||
t.Fatalf("hidden reply status = %d, want 404", hiddenReply.Code)
|
||||
}
|
||||
hiddenEdit := postForm(handler, "/posts/"+hidden.ID+"/edit", url.Values{
|
||||
"_csrf": {homeownerCSRF},
|
||||
"body": {"Still hidden."},
|
||||
}, homeownerCookies)
|
||||
if hiddenEdit.Code != http.StatusSeeOther {
|
||||
t.Fatalf("hidden edit status = %d: %s", hiddenEdit.Code, hiddenEdit.Body.String())
|
||||
}
|
||||
|
||||
got := rec.Snapshot()
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("published %d events, want 4: %#v", len(got), got)
|
||||
}
|
||||
|
||||
submitEv, ok := got[0].(events.PostCreated)
|
||||
if !ok {
|
||||
t.Fatalf("first event %T, want PostCreated", got[0])
|
||||
}
|
||||
assertPostEvent(t, submitEv.PostEvent, events.PostEvent{
|
||||
PostID: submitRoot.ID,
|
||||
RootID: submitRoot.ID,
|
||||
Title: "Leaky sink",
|
||||
Body: "Water under the cabinet.",
|
||||
City: "Oakland",
|
||||
AuthorID: homeowner.ID,
|
||||
AuthorName: homeowner.Name,
|
||||
AuthorRole: string(store.RoleUser),
|
||||
Permalink: "https://www.askaplumberfirst.com/questions/" + submitRoot.ID + "#post-" + submitRoot.ID,
|
||||
})
|
||||
|
||||
createEv, ok := got[1].(events.PostCreated)
|
||||
if !ok {
|
||||
t.Fatalf("second event %T, want PostCreated", got[1])
|
||||
}
|
||||
assertPostEvent(t, createEv.PostEvent, events.PostEvent{
|
||||
PostID: createRoot.ID,
|
||||
RootID: createRoot.ID,
|
||||
Title: "Second question",
|
||||
Body: "Another leak.",
|
||||
City: "Berkeley",
|
||||
AuthorID: homeowner.ID,
|
||||
AuthorName: homeowner.Name,
|
||||
AuthorRole: string(store.RoleUser),
|
||||
Permalink: "https://www.askaplumberfirst.com/questions/" + createRoot.ID + "#post-" + createRoot.ID,
|
||||
})
|
||||
|
||||
replyEv, ok := got[2].(events.PostCreated)
|
||||
if !ok {
|
||||
t.Fatalf("third event %T, want PostCreated", got[2])
|
||||
}
|
||||
assertPostEvent(t, replyEv.PostEvent, events.PostEvent{
|
||||
PostID: adminReply.ID,
|
||||
RootID: createRoot.ID,
|
||||
ParentID: createRoot.ID,
|
||||
Body: "Replace the cartridge.",
|
||||
AuthorID: admin.ID,
|
||||
AuthorName: admin.Name,
|
||||
AuthorRole: string(store.RoleAdmin),
|
||||
Permalink: "https://www.askaplumberfirst.com/questions/" + createRoot.ID + "#post-" + adminReply.ID,
|
||||
})
|
||||
|
||||
editEv, ok := got[3].(events.PostUpdated)
|
||||
if !ok {
|
||||
t.Fatalf("fourth event %T, want PostUpdated", got[3])
|
||||
}
|
||||
assertPostEvent(t, editEv.PostEvent, events.PostEvent{
|
||||
PostID: createRoot.ID,
|
||||
RootID: createRoot.ID,
|
||||
Title: "Second question",
|
||||
Body: "Updated leak description.",
|
||||
City: "Berkeley",
|
||||
AuthorID: homeowner.ID,
|
||||
AuthorName: homeowner.Name,
|
||||
AuthorRole: string(store.RoleUser),
|
||||
Permalink: "https://www.askaplumberfirst.com/questions/" + createRoot.ID + "#post-" + createRoot.ID,
|
||||
})
|
||||
|
||||
for i, ev := range got {
|
||||
raw, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(string(raw)), "discord") {
|
||||
t.Fatalf("event %d contains discord fields: %s", i, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCreateDoesNotPublish(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := &events.Recording{}
|
||||
_, mem := newTestServer(t, Config{Events: rec})
|
||||
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
|
||||
if err := mem.CreatePost(context.Background(), &store.Post{
|
||||
AuthorID: homeowner.ID,
|
||||
Title: "Direct write",
|
||||
Body: "No handler.",
|
||||
PostDate: pacific.Today(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Len() != 0 {
|
||||
t.Fatalf("store.CreatePost published %d events", rec.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func assertPostEvent(t *testing.T, got, want events.PostEvent) {
|
||||
t.Helper()
|
||||
if got.PostID != want.PostID ||
|
||||
got.RootID != want.RootID ||
|
||||
got.ParentID != want.ParentID ||
|
||||
got.Title != want.Title ||
|
||||
got.Body != want.Body ||
|
||||
got.City != want.City ||
|
||||
got.AuthorID != want.AuthorID ||
|
||||
got.AuthorName != want.AuthorName ||
|
||||
got.AuthorRole != want.AuthorRole ||
|
||||
got.Permalink != want.Permalink ||
|
||||
len(got.Images) != 0 {
|
||||
t.Fatalf("event = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
||||
if parent != nil {
|
||||
s.notifyPostReply(parent, root, post, user)
|
||||
}
|
||||
s.publishPostCreated(post, root, user)
|
||||
http.Redirect(
|
||||
w,
|
||||
r,
|
||||
@@ -190,6 +191,7 @@ func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "could not save post", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.publishPostUpdated(post, root, nil)
|
||||
http.Redirect(
|
||||
w,
|
||||
r,
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"plumber/internal/blob"
|
||||
"plumber/internal/events"
|
||||
"plumber/internal/geo"
|
||||
"plumber/internal/mail"
|
||||
"plumber/internal/pacific"
|
||||
@@ -35,6 +36,8 @@ type Config struct {
|
||||
TrustedProxies []*net.IPNet
|
||||
Blob blob.Uploader
|
||||
Mail mail.Notifier
|
||||
Events events.Publisher
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -110,6 +113,9 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
|
||||
if cfg.Mail == nil {
|
||||
cfg.Mail = mail.Nop{}
|
||||
}
|
||||
if cfg.Events == nil {
|
||||
cfg.Events = events.Nop{}
|
||||
}
|
||||
funcMap := template.FuncMap{
|
||||
"voteCtx": func(user *store.User, csrf, view, date string, post *store.Post) voteCtx {
|
||||
return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Post: post}
|
||||
@@ -371,6 +377,7 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "could not save question", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.publishPostCreated(post, post, u)
|
||||
http.Redirect(w, r, "/questions/"+url.PathEscape(post.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user