Add nested posts UI (#5)
## Summary - Cut hunt, question, submission, voting, hiding, and profile flows over to unified posts - Render nested replies with permission-aware inline Reply/Edit controls and edited markers - Add post profile queries and the author index migration they depend on Co-authored-by: codegirl-007 <s.raide@gmail.com>
This commit was merged in pull request #5.
This commit is contained in:
+40
-133
@@ -19,7 +19,6 @@ import (
|
||||
|
||||
"plumber"
|
||||
"plumber/internal/blob"
|
||||
"plumber/internal/mail"
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
@@ -417,21 +416,21 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
|
||||
alice := seedUser(t, mem, aliceName, "hunter22", store.RoleUser)
|
||||
adminCookies := loginUser(t, h, hubName, "hunter22")
|
||||
|
||||
q := &store.RankedQuestion{
|
||||
root := &store.Post{
|
||||
AuthorID: alice.ID,
|
||||
Title: "Drip",
|
||||
Body: "Under sink",
|
||||
City: "Oakland",
|
||||
}
|
||||
if err := mem.CreateQuestion(context.Background(), q); err != nil {
|
||||
if err := mem.CreatePost(context.Background(), root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ans := &store.Answer{
|
||||
QuestionID: q.ID,
|
||||
AuthorID: hub.ID,
|
||||
Body: "Replace the cartridge.",
|
||||
reply := &store.Post{
|
||||
ParentID: &root.ID,
|
||||
AuthorID: hub.ID,
|
||||
Body: "Replace the cartridge.",
|
||||
}
|
||||
if err := mem.UpsertAnswer(context.Background(), ans); err != nil {
|
||||
if err := mem.CreatePost(context.Background(), reply); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -493,14 +492,14 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
||||
adminCookies := loginUser(t, h, adminName, "hunter22")
|
||||
userCookies := loginUser(t, h, userName, "hunter22")
|
||||
|
||||
q := &store.RankedQuestion{
|
||||
q := &store.Post{
|
||||
AuthorID: user.ID,
|
||||
Title: "Pipe noise",
|
||||
Body: "Clanking",
|
||||
City: "SF",
|
||||
HuntDate: pacific.Today(),
|
||||
PostDate: pacific.Today(),
|
||||
}
|
||||
if err := mem.CreateQuestion(context.Background(), q); err != nil {
|
||||
if err := mem.CreatePost(context.Background(), q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -556,7 +555,7 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("vote htmx %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
got, err := mem.GetQuestion(context.Background(), q.ID, user.ID)
|
||||
got, err := mem.GetPostThreadForViewer(context.Background(), q.ID, user.ID)
|
||||
if err != nil || got.UserVote != 1 || got.Score != 1 {
|
||||
t.Fatalf("vote not applied: %+v %v", got, err)
|
||||
}
|
||||
@@ -581,7 +580,7 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
||||
t.Fatalf("non-admin answer want 403, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Admin answer success (HTMX)
|
||||
// Admin answer compatibility route creates a reply and redirects the thread.
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
||||
for _, c := range adminCookies {
|
||||
@@ -598,22 +597,37 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts") {
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, `class="answer-editor"`) ||
|
||||
!strings.Contains(body, "<summary>Edit answer</summary>") ||
|
||||
thread, err := mem.GetPostThread(context.Background(), q.ID)
|
||||
if err != nil || len(thread.Replies) != 1 {
|
||||
t.Fatalf("admin reply missing: %+v %v", thread, err)
|
||||
}
|
||||
adminReply := thread.Replies[0]
|
||||
if adminReply.AuthorID != admin.ID || adminReply.Body != "Tighten the nuts." {
|
||||
t.Fatalf("unexpected admin reply: %+v", adminReply)
|
||||
}
|
||||
if got := rec.Header().Get("HX-Redirect"); got != "/questions/"+q.ID+"#post-"+adminReply.ID {
|
||||
t.Fatalf("admin answer redirect = %q", got)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Tighten the nuts.") ||
|
||||
!strings.Contains(body, "<summary>Edit</summary>") ||
|
||||
!strings.Contains(body, ">Tighten the nuts.</textarea>") ||
|
||||
!strings.Contains(body, `type="reset" class="btn btn-ghost"`) ||
|
||||
!strings.Contains(body, `removeAttribute('open')`) ||
|
||||
strings.Contains(body, `<details class="answer-editor" open`) {
|
||||
t.Fatalf("admin answer editor is not collapsed and populated: %s", body)
|
||||
}
|
||||
if _, err := mem.GetAnswer(context.Background(), q.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
strings.Contains(body, `<details class="post-composer" open`) {
|
||||
t.Fatalf("admin reply editor is not collapsed and populated: %s", body)
|
||||
}
|
||||
|
||||
// The public answer is visible to its author, but editing remains admin-only.
|
||||
// The public reply is visible to the root author, but editing remains admin-only.
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
||||
for _, c := range userCookies {
|
||||
@@ -623,8 +637,8 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts.") {
|
||||
t.Fatalf("question author cannot see answer: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), `class="answer-editor"`) {
|
||||
t.Fatalf("question author can see admin answer editor: %s", rec.Body.String())
|
||||
if strings.Contains(rec.Body.String(), `/posts/`+adminReply.ID+`/edit`) {
|
||||
t.Fatalf("question author can edit admin reply: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Hide invalid id
|
||||
@@ -659,8 +673,8 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("hide %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
hidden, err := mem.GetQuestion(context.Background(), q.ID, admin.ID)
|
||||
if err != nil || !hidden.Hidden {
|
||||
hidden, err := mem.GetPost(context.Background(), q.ID)
|
||||
if err != nil || hidden.PostState != store.PostStateHidden {
|
||||
t.Fatalf("question not hidden: %+v %v", hidden, err)
|
||||
}
|
||||
}
|
||||
@@ -713,113 +727,6 @@ func TestRegisterRequiresEmail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerNotifyFirstOnly(t *testing.T) {
|
||||
recMail := &mail.Recording{}
|
||||
srv, mem := newTestServer(t, Config{Mail: recMail})
|
||||
h := srv.Handler()
|
||||
adminName := uniq("adm")
|
||||
askName := uniq("ask")
|
||||
admin := seedUser(t, mem, adminName, "hunter22", store.RoleAdmin)
|
||||
asker := seedUser(t, mem, askName, "hunter22", store.RoleUser)
|
||||
adminCookies := loginUser(t, h, adminName, "hunter22")
|
||||
|
||||
q := &store.RankedQuestion{
|
||||
AuthorID: asker.ID,
|
||||
Title: "Leaky sink",
|
||||
Body: "Drip",
|
||||
City: "Oakland",
|
||||
HuntDate: pacific.Today(),
|
||||
}
|
||||
if err := mem.CreateQuestion(context.Background(), q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
postAnswer := func(body string) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(w, req)
|
||||
csrf := csrfFrom(w.Body.String())
|
||||
form := strings.NewReader("_csrf=" + csrf + "&body=" + body)
|
||||
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("HX-Request", "true")
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("answer %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
postAnswer("First+reply")
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var msgs []mail.QuestionAnswered
|
||||
for time.Now().Before(deadline) {
|
||||
msgs = recMail.Snapshot()
|
||||
if len(msgs) > 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("first answer notifies once, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].ToEmail != asker.Email || msgs[0].QuestionID != q.ID {
|
||||
t.Fatalf("unexpected notify: %+v", msgs[0])
|
||||
}
|
||||
if msgs[0].AnswerBody != "First reply" {
|
||||
t.Fatalf("answer body %q", msgs[0].AnswerBody)
|
||||
}
|
||||
|
||||
postAnswer("Edited+reply")
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if recMail.Len() != 1 {
|
||||
t.Fatalf("edit must not notify again, got %d", recMail.Len())
|
||||
}
|
||||
|
||||
// Author without email is skipped
|
||||
recMail2 := &mail.Recording{}
|
||||
srv2, mem2 := newTestServer(t, Config{Mail: recMail2})
|
||||
h2 := srv2.Handler()
|
||||
admin2 := seedUser(t, mem2, uniq("adm2"), "hunter22", store.RoleAdmin)
|
||||
noMail := &store.User{Username: uniq("silent"), PasswordHash: admin.PasswordHash, Role: store.RoleUser, Email: ""}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("hunter22"), bcrypt.MinCost)
|
||||
noMail.PasswordHash = string(hash)
|
||||
if err := mem2.CreateUser(context.Background(), noMail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q2 := &store.RankedQuestion{AuthorID: noMail.ID, Title: "Quiet", Body: "x", HuntDate: pacific.Today()}
|
||||
if err := mem2.CreateQuestion(context.Background(), q2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookies := loginUser(t, h2, admin2.Username, "hunter22")
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/questions/"+q2.ID, nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h2.ServeHTTP(w, req)
|
||||
csrf := csrfFrom(w.Body.String())
|
||||
form := strings.NewReader("_csrf=" + csrf + "&body=Hello")
|
||||
req = httptest.NewRequest(http.MethodPost, "/questions/"+q2.ID+"/answer", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
h2.ServeHTTP(w, req)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if recMail2.Len() != 0 {
|
||||
t.Fatalf("empty email must skip notify, got %d", recMail2.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterThrottleUsesTCPPeerThroughRouter ensures forged X-Forwarded-For
|
||||
// cannot bypass rate limits when the direct peer is outside TrustedProxies.
|
||||
// This must go through Handler() so middleware ordering bugs are caught.
|
||||
|
||||
Reference in New Issue
Block a user