Threading: Antworten (reply_to) + Focus-View

- entry: reply_to/reply_count/last_activity; reply_count bubbelt bis Root
- Hauptfeed nur Roots nach last_activity, Profil-Feed inkl. Antworten
- GET /entry/{pid}/thread (Ahnen+Antworten), GET /e/{pid} Focus-Seite
- Frontend: Antworten-Link im Feed, entry.html/entry.js Focus-Seite
- notes/migrations.md: ALTER TABLE fuer Prod

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 12:21:55 +02:00
parent 844bbb44e7
commit ba085eff4e
8 changed files with 377 additions and 37 deletions
+94
View File
@@ -221,6 +221,100 @@ func TestVoteRequiresAuthAndValidMode(t *testing.T) {
}
}
// createReply erstellt eine Antwort auf parentPID und liefert deren pid.
func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content string, parentPID int64) int64 {
t.Helper()
resp := postForm(t, c, srv.URL+"/entry/create", url.Values{
"content": {content}, "reply_to": {fmt.Sprint(parentPID)},
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create reply: status %d", resp.StatusCode)
}
var out struct {
PID int64 `json:"pid"`
}
json.NewDecoder(resp.Body).Decode(&out)
return out.PID
}
type threadEntry struct {
PID int64 `json:"pid"`
ReplyCount int64 `json:"reply_count"`
}
type threadView struct {
Entry threadEntry `json:"entry"`
Ancestors []threadEntry `json:"ancestors"`
Replies []threadEntry `json:"replies"`
}
func getThread(t *testing.T, srv *httptest.Server, pid int64) threadView {
t.Helper()
resp, err := http.Get(fmt.Sprintf("%s/entry/%d/thread", srv.URL, pid))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("thread %d: status %d", pid, resp.StatusCode)
}
var tv threadView
json.NewDecoder(resp.Body).Decode(&tv)
return tv
}
func TestThreading(t *testing.T) {
srv := newTestServer(t)
alice := registerAndLogin(t, srv, "alice")
root := createEntry(t, alice, srv, "wurzel")
mid := createReply(t, alice, srv, "antwort 1", root)
createReply(t, alice, srv, "antwort 2", mid) // tiefere Antwort
// reply_count bubblet bis zum Root: Root = 2 (ganzer Teilbaum), mid = 1.
if tv := getThread(t, srv, root); tv.Entry.ReplyCount != 2 {
t.Fatalf("root reply_count: erwartet 2, bekam %d", tv.Entry.ReplyCount)
}
rootThread := getThread(t, srv, root)
if len(rootThread.Replies) != 1 || rootThread.Replies[0].PID != mid {
t.Fatalf("root replies: erwartet [%d], bekam %+v", mid, rootThread.Replies)
}
if len(rootThread.Ancestors) != 0 {
t.Fatalf("root ancestors: erwartet leer, bekam %+v", rootThread.Ancestors)
}
midThread := getThread(t, srv, mid)
if midThread.Entry.ReplyCount != 1 {
t.Fatalf("mid reply_count: erwartet 1, bekam %d", midThread.Entry.ReplyCount)
}
if len(midThread.Ancestors) != 1 || midThread.Ancestors[0].PID != root {
t.Fatalf("mid ancestors: erwartet [%d], bekam %+v", root, midThread.Ancestors)
}
// Hauptfeed: nur der Root, keine Antworten.
resp, _ := http.Get(srv.URL + "/entry/feed/0")
var feed []struct {
PID int64 `json:"pid"`
}
json.NewDecoder(resp.Body).Decode(&feed)
resp.Body.Close()
if len(feed) != 1 || feed[0].PID != root {
t.Fatalf("hauptfeed: erwartet nur Root %d, bekam %+v", root, feed)
}
// Profil-Feed: alle drei Beiträge (Root + Antworten).
resp, _ = http.Get(srv.URL + "/u/alice/feed/0")
var profile []struct {
PID int64 `json:"pid"`
}
json.NewDecoder(resp.Body).Decode(&profile)
resp.Body.Close()
if len(profile) != 3 {
t.Fatalf("profil-feed: erwartet 3 Beiträge, bekam %d", len(profile))
}
}
func TestRenameUser(t *testing.T) {
srv := newTestServer(t)
alice := registerAndLogin(t, srv, "alice")