Soft-Delete: Beiträge als [deleted] erhalten statt löschen

- entry.deleted; entrySelect LEFT JOIN + COALESCE(username)
- POST /entry/{pid}/delete: deleted=1, Inhalt/Bild/Autor geleert, Zeile bleibt
- Konto-Löschung soft-deletet die eigenen Beiträge (statt hartem DELETE),
  damit fremde Antworten nicht verwaisen
- Frontend: [deleted]-Platzhalter im Feed, Löschen-Button auf Focus-Seite
- notes/migrations.md: ALTER TABLE deleted

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 13:21:42 +02:00
parent ba085eff4e
commit cb3da3bb24
8 changed files with 164 additions and 17 deletions
+69 -2
View File
@@ -239,8 +239,11 @@ func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content str
}
type threadEntry struct {
PID int64 `json:"pid"`
ReplyCount int64 `json:"reply_count"`
PID int64 `json:"pid"`
ReplyCount int64 `json:"reply_count"`
Deleted int64 `json:"deleted"`
Content string `json:"content"`
Username string `json:"username"`
}
type threadView struct {
@@ -315,6 +318,56 @@ func TestThreading(t *testing.T) {
}
}
func TestSoftDelete(t *testing.T) {
srv := newTestServer(t)
alice := registerAndLogin(t, srv, "alice")
bob := registerAndLogin(t, srv, "bob")
root := createEntry(t, alice, srv, "wurzel")
reply := createReply(t, alice, srv, "antwort", root)
delURL := fmt.Sprintf("%s/entry/%d/delete", srv.URL, root)
// Fremder darf nicht löschen.
resp := postForm(t, bob, delURL, url.Values{})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("fremd-delete: erwartet 403, bekam %d", resp.StatusCode)
}
// Eigentümer löscht den Root weich.
resp = postForm(t, alice, delURL, url.Values{})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("delete: erwartet 200, bekam %d", resp.StatusCode)
}
// Root bleibt als [deleted]-Platzhalter; die Antwort verwaist nicht.
tv := getThread(t, srv, root)
if tv.Entry.Deleted != 1 || tv.Entry.Content != "" || tv.Entry.Username != "" {
t.Fatalf("deleted entry nicht anonymisiert: %+v", tv.Entry)
}
if len(tv.Replies) != 1 || tv.Replies[0].PID != reply {
t.Fatalf("antwort verwaist: %+v", tv.Replies)
}
// Der gelöschte Root erscheint weiterhin im Hauptfeed (Thread bleibt erreichbar).
feedResp, _ := http.Get(srv.URL + "/entry/feed/0")
var feed []threadEntry
json.NewDecoder(feedResp.Body).Decode(&feed)
feedResp.Body.Close()
if len(feed) != 1 || feed[0].PID != root || feed[0].Deleted != 1 {
t.Fatalf("hauptfeed nach delete: %+v", feed)
}
// Nicht existierender Beitrag -> 404.
resp = postForm(t, alice, srv.URL+"/entry/999999/delete", url.Values{})
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("delete unbekannt: erwartet 404, bekam %d", resp.StatusCode)
}
}
func TestRenameUser(t *testing.T) {
srv := newTestServer(t)
alice := registerAndLogin(t, srv, "alice")
@@ -364,6 +417,11 @@ func TestRenameUser(t *testing.T) {
func TestDeleteUser(t *testing.T) {
srv := newTestServer(t)
alice := registerAndLogin(t, srv, "alice")
bob := registerAndLogin(t, srv, "bob")
// alice schreibt einen Beitrag, bob antwortet darauf.
root := createEntry(t, alice, srv, "alices beitrag")
reply := createReply(t, bob, srv, "bobs antwort", root)
// Falsches Passwort -> 403.
resp := postForm(t, alice, srv.URL+"/user/delete", url.Values{"pass1": {"falsch"}})
@@ -388,4 +446,13 @@ func TestDeleteUser(t *testing.T) {
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("Login nach Löschen: erwartet 401, bekam %d", resp.StatusCode)
}
// alices Beitrag bleibt als [deleted]-Platzhalter; bobs Antwort verwaist nicht.
tv := getThread(t, srv, root)
if tv.Entry.Deleted != 1 || tv.Entry.Content != "" || tv.Entry.Username != "" {
t.Fatalf("beitrag nach konto-löschung nicht soft-deleted: %+v", tv.Entry)
}
if len(tv.Replies) != 1 || tv.Replies[0].PID != reply {
t.Fatalf("bobs antwort verwaist: %+v", tv.Replies)
}
}