Generalize post reply notifications (#6)
## Summary - Notify the direct parent post author for replies throughout nested conversations - Skip root creation, self-replies, edits, disabled mail, and recipients without email - Link directly to each reply and use per-reply Resend idempotency Co-authored-by: codegirl-007 <s.raide@gmail.com>
This commit was merged in pull request #6.
This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@ SECURE_COOKIE=0
|
||||
# RESEND_API_KEY=re_xxxxxxxxx
|
||||
# RESEND_FROM=Ask a Plumber <notify@yourdomain.com>
|
||||
# Public site origin used in email links (required when Resend is enabled):
|
||||
# APP_BASE_URL=https://askaplumber.example
|
||||
# APP_BASE_URL=https://www.askaplumberfirst.com
|
||||
# DigitalOcean Spaces (profile avatars). Leave unset to disable uploads.
|
||||
# SPACES_KEY=
|
||||
# SPACES_SECRET=
|
||||
|
||||
+49
-33
@@ -5,6 +5,7 @@ import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
@@ -14,24 +15,26 @@ import (
|
||||
//go:embed mark.png
|
||||
var markPNG []byte
|
||||
|
||||
// QuestionAnswered is the payload for notifying a question author of a reply.
|
||||
type QuestionAnswered struct {
|
||||
// PostReply is the payload for notifying a post author of a direct reply.
|
||||
type PostReply struct {
|
||||
ToEmail string
|
||||
ToName string
|
||||
QuestionID string
|
||||
QuestionTitle string
|
||||
AnswerBody string
|
||||
RootID string
|
||||
RootTitle string
|
||||
ReplyID string
|
||||
ReplyBody string
|
||||
ReplyAuthorName string
|
||||
}
|
||||
|
||||
// Notifier sends transactional email about answered questions.
|
||||
// Notifier sends transactional email about post replies.
|
||||
type Notifier interface {
|
||||
NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error
|
||||
NotifyPostReply(ctx context.Context, msg PostReply) error
|
||||
}
|
||||
|
||||
// Nop is a no-op Notifier used when Resend is not configured.
|
||||
type Nop struct{}
|
||||
|
||||
func (Nop) NotifyQuestionAnswered(context.Context, QuestionAnswered) error { return nil }
|
||||
func (Nop) NotifyPostReply(context.Context, PostReply) error { return nil }
|
||||
|
||||
// Resend sends via the Resend HTTP API.
|
||||
type Resend struct {
|
||||
@@ -62,7 +65,7 @@ func FromEnv() (Notifier, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error {
|
||||
func (r *Resend) NotifyPostReply(ctx context.Context, msg PostReply) error {
|
||||
if r == nil || r.client == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -70,59 +73,72 @@ func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswere
|
||||
if to == "" {
|
||||
return nil
|
||||
}
|
||||
text, htmlBody := questionAnsweredContent(r.baseURL, msg)
|
||||
text, htmlBody := postReplyContent(r.baseURL, msg)
|
||||
params := &resend.SendEmailRequest{
|
||||
From: r.from,
|
||||
To: []string{to},
|
||||
Subject: "Your question was answered",
|
||||
Subject: "New reply to your post",
|
||||
Text: text,
|
||||
Html: htmlBody,
|
||||
Attachments: []*resend.Attachment{{
|
||||
Content: markPNG,
|
||||
Filename: "ask-a-plumber-first.png",
|
||||
ContentType: "image/png",
|
||||
ContentId: "answer-notification-mark",
|
||||
ContentId: "reply-notification-mark",
|
||||
}},
|
||||
}
|
||||
opts := &resend.SendEmailOptions{
|
||||
IdempotencyKey: "answer-notify:" + msg.QuestionID,
|
||||
IdempotencyKey: "post-reply:" + msg.ReplyID,
|
||||
}
|
||||
_, err := r.client.Emails.SendWithOptions(ctx, params, opts)
|
||||
return err
|
||||
}
|
||||
|
||||
func questionAnsweredContent(baseURL string, msg QuestionAnswered) (string, string) {
|
||||
link := strings.TrimRight(baseURL, "/") + "/questions/" + msg.QuestionID
|
||||
title := strings.TrimSpace(msg.QuestionTitle)
|
||||
if title == "" {
|
||||
title = "your question"
|
||||
func postReplyContent(baseURL string, msg PostReply) (string, string) {
|
||||
link := strings.TrimRight(baseURL, "/") +
|
||||
"/questions/" + url.PathEscape(msg.RootID) +
|
||||
"#post-" + url.PathEscape(msg.ReplyID)
|
||||
title := replyRootTitle(msg.RootTitle)
|
||||
author := strings.TrimSpace(msg.ReplyAuthorName)
|
||||
if author == "" {
|
||||
author = "Someone"
|
||||
}
|
||||
text := fmt.Sprintf(
|
||||
"Hi%s,\n\nYour question %q has an answer from a plumber:\n\n%s\n\nView it here:\n%s\n",
|
||||
"Hi%s,\n\n%s replied in %q:\n\n%s\n\nView the reply:\n%s\n",
|
||||
greetingName(msg.ToName),
|
||||
author,
|
||||
title,
|
||||
msg.AnswerBody,
|
||||
msg.ReplyBody,
|
||||
link,
|
||||
)
|
||||
htmlBody := strings.NewReplacer(
|
||||
"{{PREHEADER}}", html.EscapeString("A plumber answered "+title+"."),
|
||||
"{{PREHEADER}}", html.EscapeString(author+" replied in "+title+"."),
|
||||
"{{GREETING}}", html.EscapeString(greetingName(msg.ToName)),
|
||||
"{{TITLE}}", html.EscapeString(title),
|
||||
"{{ANSWER}}", html.EscapeString(msg.AnswerBody),
|
||||
"{{AUTHOR}}", html.EscapeString(author),
|
||||
"{{REPLY}}", html.EscapeString(msg.ReplyBody),
|
||||
"{{LINK}}", html.EscapeString(link),
|
||||
"{{MARK}}", "cid:answer-notification-mark",
|
||||
).Replace(questionAnsweredHTML)
|
||||
"{{MARK}}", "cid:reply-notification-mark",
|
||||
).Replace(postReplyHTML)
|
||||
return text, htmlBody
|
||||
}
|
||||
|
||||
const questionAnsweredHTML = `<!doctype html>
|
||||
func replyRootTitle(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return "your conversation"
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
const postReplyHTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="supported-color-schemes" content="dark">
|
||||
<title>Your question was answered</title>
|
||||
<title>New reply to your conversation</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#161719;color:#ecebe7;font-family:Arial,'Helvetica Neue',sans-serif;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">{{PREHEADER}}</div>
|
||||
@@ -147,22 +163,22 @@ const questionAnsweredHTML = `<!doctype html>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:30px 28px 32px;">
|
||||
<div style="margin:0 0 10px;color:#e96a26;font-family:'Courier New',monospace;font-size:11px;font-weight:700;line-height:1.4;letter-spacing:1.8px;text-transform:uppercase;">Shop response</div>
|
||||
<h1 style="margin:0;color:#ecebe7;font-size:28px;font-weight:600;line-height:1.2;letter-spacing:-0.4px;">Your question has an answer.</h1>
|
||||
<p style="margin:18px 0 0;color:#b8babf;font-size:16px;line-height:1.6;">Hi{{GREETING}}, a plumber replied to:</p>
|
||||
<div style="margin:0 0 10px;color:#e96a26;font-family:'Courier New',monospace;font-size:11px;font-weight:700;line-height:1.4;letter-spacing:1.8px;text-transform:uppercase;">New reply</div>
|
||||
<h1 style="margin:0;color:#ecebe7;font-size:28px;font-weight:600;line-height:1.2;letter-spacing:-0.4px;">The conversation has a new reply.</h1>
|
||||
<p style="margin:18px 0 0;color:#b8babf;font-size:16px;line-height:1.6;">Hi{{GREETING}}, {{AUTHOR}} replied in:</p>
|
||||
<p style="margin:8px 0 0;color:#ecebe7;font-size:17px;font-weight:600;line-height:1.45;">“{{TITLE}}”</p>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;margin-top:24px;background:#161719;border:1px solid #2e3136;border-radius:3px;">
|
||||
<tr>
|
||||
<td style="padding:20px 18px;">
|
||||
<div style="margin:0 0 10px;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;font-weight:700;line-height:1.4;letter-spacing:1.5px;text-transform:uppercase;">The answer</div>
|
||||
<div style="margin:0;color:#ecebe7;font-size:16px;line-height:1.65;white-space:pre-wrap;">{{ANSWER}}</div>
|
||||
<div style="margin:0 0 10px;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;font-weight:700;line-height:1.4;letter-spacing:1.5px;text-transform:uppercase;">The reply</div>
|
||||
<div style="margin:0;color:#ecebe7;font-size:16px;line-height:1.65;white-space:pre-wrap;">{{REPLY}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin-top:26px;">
|
||||
<tr>
|
||||
<td bgcolor="#e96a26" style="border-radius:3px;">
|
||||
<a href="{{LINK}}" style="display:inline-block;padding:13px 18px;color:#161719;font-family:'Courier New',monospace;font-size:12px;font-weight:700;line-height:1;text-decoration:none;letter-spacing:0.8px;text-transform:uppercase;">View the answer →</a>
|
||||
<a href="{{LINK}}" style="display:inline-block;padding:13px 18px;color:#161719;font-family:'Courier New',monospace;font-size:12px;font-weight:700;line-height:1;text-decoration:none;letter-spacing:0.8px;text-transform:uppercase;">View the reply →</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -170,7 +186,7 @@ const questionAnsweredHTML = `<!doctype html>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:18px 28px;border-top:1px solid #2e3136;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;line-height:1.6;letter-spacing:0.4px;">
|
||||
You received this because you asked a question on Ask a Plumber First.
|
||||
You received this because someone replied to your post on Ask a Plumber First.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
+20
-16
@@ -14,24 +14,27 @@ func TestEmbeddedMarkIsPNG(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuestionAnsweredContent(t *testing.T) {
|
||||
func TestPostReplyContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
text, htmlBody := questionAnsweredContent("https://plumber.example/", QuestionAnswered{
|
||||
text, htmlBody := postReplyContent("https://www.askaplumberfirst.com/", PostReply{
|
||||
ToName: `<Sam & Pat>`,
|
||||
QuestionID: "question-123",
|
||||
QuestionTitle: `<b>Leaky sink</b>`,
|
||||
AnswerBody: "Replace the cartridge.\nThen test the handle. <script>alert('x')</script>",
|
||||
RootID: "question-123",
|
||||
RootTitle: `<b>Leaky sink</b>`,
|
||||
ReplyID: "reply-456",
|
||||
ReplyBody: "Replace the cartridge.\nThen test the handle. <script>alert('x')</script>",
|
||||
ReplyAuthorName: `<Jo & Co>`,
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
"Ask a Plumber First",
|
||||
"Shop response",
|
||||
"cid:answer-notification-mark",
|
||||
"https://plumber.example/questions/question-123",
|
||||
"New reply",
|
||||
"cid:reply-notification-mark",
|
||||
"https://www.askaplumberfirst.com/questions/question-123#post-reply-456",
|
||||
"white-space:pre-wrap",
|
||||
"<Sam & Pat>",
|
||||
"<b>Leaky sink</b>",
|
||||
"<Jo & Co>",
|
||||
"<script>alert('x')</script>",
|
||||
} {
|
||||
if !strings.Contains(htmlBody, want) {
|
||||
@@ -41,6 +44,7 @@ func TestQuestionAnsweredContent(t *testing.T) {
|
||||
for _, unsafe := range []string{
|
||||
"<Sam & Pat>",
|
||||
"<b>Leaky sink</b>",
|
||||
"<Jo & Co>",
|
||||
"<script>alert('x')</script>",
|
||||
} {
|
||||
if strings.Contains(htmlBody, unsafe) {
|
||||
@@ -52,9 +56,9 @@ func TestQuestionAnsweredContent(t *testing.T) {
|
||||
}
|
||||
for _, want := range []string{
|
||||
`Hi <Sam & Pat>,`,
|
||||
`Your question "<b>Leaky sink</b>"`,
|
||||
`<Jo & Co> replied in "<b>Leaky sink</b>"`,
|
||||
"Replace the cartridge.\nThen test the handle.",
|
||||
"https://plumber.example/questions/question-123",
|
||||
"https://www.askaplumberfirst.com/questions/question-123#post-reply-456",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("text missing %q", want)
|
||||
@@ -62,15 +66,15 @@ func TestQuestionAnsweredContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuestionAnsweredContentUsesFallbackTitle(t *testing.T) {
|
||||
func TestPostReplyContentUsesFallbacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
text, htmlBody := questionAnsweredContent("https://plumber.example", QuestionAnswered{})
|
||||
if !strings.Contains(text, `"your question"`) {
|
||||
text, htmlBody := postReplyContent("https://www.askaplumberfirst.com", PostReply{})
|
||||
if !strings.Contains(text, `Someone replied in "your conversation"`) {
|
||||
t.Errorf("text missing fallback title")
|
||||
}
|
||||
if !strings.Contains(htmlBody, "a plumber replied to:</p>") ||
|
||||
!strings.Contains(htmlBody, "“your question”") {
|
||||
t.Errorf("HTML missing fallback title")
|
||||
if !strings.Contains(htmlBody, "Someone replied in:</p>") ||
|
||||
!strings.Contains(htmlBody, "“your conversation”") {
|
||||
t.Errorf("HTML missing fallbacks")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
// Recording is a test Notifier that records calls.
|
||||
type Recording struct {
|
||||
mu sync.Mutex
|
||||
Msgs []QuestionAnswered
|
||||
Msgs []PostReply
|
||||
}
|
||||
|
||||
func (r *Recording) NotifyQuestionAnswered(_ context.Context, msg QuestionAnswered) error {
|
||||
func (r *Recording) NotifyPostReply(_ context.Context, msg PostReply) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.Msgs = append(r.Msgs, msg)
|
||||
@@ -25,10 +25,10 @@ func (r *Recording) Len() int {
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of recorded messages.
|
||||
func (r *Recording) Snapshot() []QuestionAnswered {
|
||||
func (r *Recording) Snapshot() []PostReply {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]QuestionAnswered, len(r.Msgs))
|
||||
out := make([]PostReply, len(r.Msgs))
|
||||
copy(out, r.Msgs)
|
||||
return out
|
||||
}
|
||||
|
||||
+56
-2
@@ -5,12 +5,15 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"plumber/internal/mail"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
@@ -37,7 +40,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
||||
AuthorID: user.ID,
|
||||
Body: truncateRunes(body, 12000),
|
||||
}
|
||||
var root *store.Post
|
||||
var parent, root *store.Post
|
||||
if parentID == "" {
|
||||
post.Title = truncateRunes(strings.TrimSpace(r.PostFormValue("title")), 120)
|
||||
post.City = truncateRunes(strings.TrimSpace(r.PostFormValue("city")), 80)
|
||||
@@ -46,7 +49,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
parent, threadRoot, err := s.postAndRoot(r.Context(), parentID)
|
||||
loadedParent, threadRoot, err := s.postAndRoot(r.Context(), parentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.NotFound(w, r)
|
||||
@@ -63,6 +66,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
parent = loadedParent
|
||||
post.ParentID = &parent.ID
|
||||
root = threadRoot
|
||||
}
|
||||
@@ -78,6 +82,9 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
||||
if root == nil {
|
||||
root = post
|
||||
}
|
||||
if parent != nil {
|
||||
s.notifyPostReply(parent, root, post, user)
|
||||
}
|
||||
http.Redirect(
|
||||
w,
|
||||
r,
|
||||
@@ -86,6 +93,53 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// notifyPostReply asynchronously emails the direct parent post's author.
|
||||
func (s *Server) notifyPostReply(
|
||||
parent *store.Post,
|
||||
root *store.Post,
|
||||
reply *store.Post,
|
||||
replyAuthor *store.User,
|
||||
) {
|
||||
if parent == nil ||
|
||||
root == nil ||
|
||||
reply == nil ||
|
||||
replyAuthor == nil ||
|
||||
s.cfg.Mail == nil ||
|
||||
parent.AuthorID == replyAuthor.ID {
|
||||
return
|
||||
}
|
||||
if _, disabled := s.cfg.Mail.(mail.Nop); disabled {
|
||||
return
|
||||
}
|
||||
msg := mail.PostReply{
|
||||
RootID: root.ID,
|
||||
RootTitle: root.Title,
|
||||
ReplyID: reply.ID,
|
||||
ReplyBody: reply.Body,
|
||||
ReplyAuthorName: replyAuthor.Name,
|
||||
}
|
||||
recipientID := parent.AuthorID
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
recipient, err := s.store.UserByID(ctx, recipientID)
|
||||
if err != nil {
|
||||
log.Printf("notify reply %s: load recipient: %v", msg.ReplyID, err)
|
||||
return
|
||||
}
|
||||
if recipient == nil || strings.TrimSpace(recipient.Email) == "" {
|
||||
return
|
||||
}
|
||||
msg.ToEmail = recipient.Email
|
||||
msg.ToName = recipient.Name
|
||||
if err := s.cfg.Mail.NotifyPostReply(ctx, msg); err != nil {
|
||||
log.Printf("notify reply %s: %v", msg.ReplyID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("notify reply %s: accepted", msg.ReplyID)
|
||||
}()
|
||||
}
|
||||
|
||||
// handleEditPost updates only a post's body after verifying that the current
|
||||
// homeowner owns it or that an admin is editing an admin-authored post.
|
||||
func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"plumber/internal/mail"
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
@@ -258,6 +260,127 @@ func TestEditPostRoutePermissions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostReplyNotifications(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
recording := &mail.Recording{}
|
||||
srv, mem := newTestServer(t, Config{Mail: recording})
|
||||
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)
|
||||
|
||||
rec := postForm(handler, "/posts", url.Values{
|
||||
"_csrf": {homeownerCSRF},
|
||||
"title": {"Leaky sink"},
|
||||
"body": {"Water under the cabinet."},
|
||||
}, homeownerCookies)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("root create status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if recording.Len() != 0 {
|
||||
t.Fatalf("root create sent %d notifications", recording.Len())
|
||||
}
|
||||
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
|
||||
if err != nil || len(roots) != 1 {
|
||||
t.Fatalf("created roots = %+v, %v", roots, err)
|
||||
}
|
||||
root := roots[0]
|
||||
|
||||
rec = postForm(handler, "/posts", url.Values{
|
||||
"_csrf": {adminCSRF},
|
||||
"parent_id": {root.ID},
|
||||
"body": {"Replace the cartridge."},
|
||||
}, adminCookies)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("admin reply status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
thread, err := mem.GetPostThread(context.Background(), root.ID)
|
||||
if err != nil || len(thread.Replies) != 1 {
|
||||
t.Fatalf("admin reply thread = %+v, %v", thread, err)
|
||||
}
|
||||
adminReply := thread.Replies[0]
|
||||
msgs := waitForMail(t, recording, 1)
|
||||
if msg := msgs[0]; msg.ToEmail != homeowner.Email ||
|
||||
msg.RootID != root.ID ||
|
||||
msg.RootTitle != root.Title ||
|
||||
msg.ReplyID != adminReply.ID ||
|
||||
msg.ReplyBody != adminReply.Body ||
|
||||
msg.ReplyAuthorName != admin.Name {
|
||||
t.Fatalf("admin reply notification = %+v", msg)
|
||||
}
|
||||
|
||||
rec = postForm(handler, "/posts", url.Values{
|
||||
"_csrf": {homeownerCSRF},
|
||||
"parent_id": {adminReply.ID},
|
||||
"body": {"That fixed the drip."},
|
||||
}, homeownerCookies)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("homeowner reply status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
thread, err = mem.GetPostThread(context.Background(), root.ID)
|
||||
if err != nil || len(thread.Replies[0].Replies) != 1 {
|
||||
t.Fatalf("homeowner nested reply thread = %+v, %v", thread, err)
|
||||
}
|
||||
homeownerReply := thread.Replies[0].Replies[0]
|
||||
msgs = waitForMail(t, recording, 2)
|
||||
if msg := msgs[1]; msg.ToEmail != admin.Email ||
|
||||
msg.RootID != root.ID ||
|
||||
msg.ReplyID != homeownerReply.ID ||
|
||||
msg.ReplyAuthorName != homeowner.Name {
|
||||
t.Fatalf("homeowner reply notification = %+v", msg)
|
||||
}
|
||||
|
||||
rec = postForm(handler, "/posts", url.Values{
|
||||
"_csrf": {homeownerCSRF},
|
||||
"parent_id": {root.ID},
|
||||
"body": {"A note to myself."},
|
||||
}, homeownerCookies)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("self reply status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = postForm(handler, "/posts/"+adminReply.ID+"/edit", url.Values{
|
||||
"_csrf": {adminCSRF},
|
||||
"body": {"Replace the ceramic cartridge."},
|
||||
}, adminCookies)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("edit status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
noEmail := &store.User{
|
||||
Username: uniq("no-email"),
|
||||
PasswordHash: homeowner.PasswordHash,
|
||||
Role: store.RoleUser,
|
||||
}
|
||||
if err := mem.CreateUser(context.Background(), noEmail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noEmailRoot := &store.Post{
|
||||
AuthorID: noEmail.ID,
|
||||
Title: "Quiet thread",
|
||||
Body: "No email configured.",
|
||||
PostDate: pacific.Today(),
|
||||
}
|
||||
if err := mem.CreatePost(context.Background(), noEmailRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = postForm(handler, "/posts", url.Values{
|
||||
"_csrf": {adminCSRF},
|
||||
"parent_id": {noEmailRoot.ID},
|
||||
"body": {"This should not send."},
|
||||
}, adminCookies)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("no-email reply status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if recording.Len() != 2 {
|
||||
t.Fatalf("self, edit, or no-email action sent a notification: %+v", recording.Snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuestionPageRendersNestedPostControls(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -345,6 +468,19 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func waitForMail(t *testing.T, recording *mail.Recording, want int) []mail.PostReply {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if recording.Len() >= want {
|
||||
return recording.Snapshot()
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("recorded %d notifications, want %d", recording.Len(), want)
|
||||
return nil
|
||||
}
|
||||
|
||||
func csrfForCookies(t *testing.T, handler http.Handler, cookies []*http.Cookie) string {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
|
||||
|
||||
@@ -510,6 +510,7 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "could not save answer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.notifyPostReply(root, root, reply, u)
|
||||
location := "/questions/" + url.PathEscape(id) + "#post-" + url.PathEscape(reply.ID)
|
||||
if isHTMX(r) {
|
||||
w.Header().Set("HX-Redirect", location)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"plumber"
|
||||
"plumber/internal/blob"
|
||||
"plumber/internal/mail"
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
@@ -483,7 +484,8 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
||||
srv, mem := newTestServer(t, Config{})
|
||||
recording := &mail.Recording{}
|
||||
srv, mem := newTestServer(t, Config{Mail: recording})
|
||||
h := srv.Handler()
|
||||
adminName := uniq("admin")
|
||||
userName := uniq("user")
|
||||
@@ -611,6 +613,13 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
||||
if got := rec.Header().Get("HX-Redirect"); got != "/questions/"+q.ID+"#post-"+adminReply.ID {
|
||||
t.Fatalf("admin answer redirect = %q", got)
|
||||
}
|
||||
msgs := waitForMail(t, recording, 1)
|
||||
if msg := msgs[0]; msg.ToEmail != user.Email ||
|
||||
msg.RootID != q.ID ||
|
||||
msg.ReplyID != adminReply.ID ||
|
||||
msg.ReplyBody != adminReply.Body {
|
||||
t.Fatalf("compatibility reply notification = %+v", msg)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
||||
|
||||
Reference in New Issue
Block a user