package main import ( "encoding/json" "fmt" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "path/filepath" "testing" ) // newTestServer richtet eine frische, isolierte DB (eigene Datei je Test) und // einen httptest-Server mit dem echten Routing ein. func newTestServer(t *testing.T) *httptest.Server { t.Helper() dsn := filepath.Join(t.TempDir(), "test.db") if err := initDB(dsn); err != nil { t.Fatalf("initDB: %v", err) } t.Cleanup(func() { db.Close() }) srv := httptest.NewServer(routes()) t.Cleanup(srv.Close) return srv } // newClient liefert einen HTTP-Client mit Cookie-Jar (hält Session-Cookies). func newClient(t *testing.T) *http.Client { t.Helper() jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) } return &http.Client{Jar: jar} } func postForm(t *testing.T, c *http.Client, urlStr string, form url.Values) *http.Response { t.Helper() resp, err := c.PostForm(urlStr, form) if err != nil { t.Fatalf("POST %s: %v", urlStr, err) } return resp } // registerAndLogin legt einen Nutzer an, loggt ihn ein und gibt den Client // (mit gesetztem Session-Cookie) zurück. func registerAndLogin(t *testing.T, srv *httptest.Server, username string) *http.Client { t.Helper() c := newClient(t) resp := postForm(t, c, srv.URL+"/auth/newuser", url.Values{ "user": {username}, "pass1": {"supersecret1"}, "pass2": {"supersecret1"}, }) resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Fatalf("register %s: status %d", username, resp.StatusCode) } resp = postForm(t, c, srv.URL+"/auth/login", url.Values{ "user": {username}, "pass": {"supersecret1"}, "timeout": {"86400"}, }) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("login %s: status %d", username, resp.StatusCode) } return c } // createEntry erstellt einen Beitrag und liefert dessen pid. func createEntry(t *testing.T, c *http.Client, srv *httptest.Server, content string) int64 { t.Helper() resp := postForm(t, c, srv.URL+"/entry/create", url.Values{"content": {content}}) defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Fatalf("create entry: status %d", resp.StatusCode) } var out struct { PID int64 `json:"pid"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode create: %v", err) } return out.PID } func TestRegisterLoginHeaderbar(t *testing.T) { srv := newTestServer(t) c := registerAndLogin(t, srv, "alice") resp, err := c.Get(srv.URL + "/auth/headerbar") if err != nil { t.Fatal(err) } defer resp.Body.Close() var h struct { LoggedIn bool `json:"loggedin"` } json.NewDecoder(resp.Body).Decode(&h) if !h.LoggedIn { t.Fatal("erwartete loggedin=true nach Login") } // Anonymer Client ist nicht eingeloggt. anon := newClient(t) resp2, err := anon.Get(srv.URL + "/auth/headerbar") if err != nil { t.Fatal(err) } defer resp2.Body.Close() var h2 struct { LoggedIn bool `json:"loggedin"` } json.NewDecoder(resp2.Body).Decode(&h2) if h2.LoggedIn { t.Fatal("anonymer Client sollte nicht eingeloggt sein") } } func TestFeedEmptyThenPopulated(t *testing.T) { srv := newTestServer(t) resp, err := http.Get(srv.URL + "/entry/feed/0") if err != nil { t.Fatal(err) } var empty []map[string]any json.NewDecoder(resp.Body).Decode(&empty) resp.Body.Close() if len(empty) != 0 { t.Fatalf("erwartete leeren Feed, bekam %d", len(empty)) } alice := registerAndLogin(t, srv, "alice") createEntry(t, alice, srv, "hallo welt") resp, err = http.Get(srv.URL + "/entry/feed/0") if err != nil { t.Fatal(err) } var posts []struct { Content string `json:"content"` Username string `json:"username"` } json.NewDecoder(resp.Body).Decode(&posts) resp.Body.Close() if len(posts) != 1 || posts[0].Content != "hallo welt" || posts[0].Username != "alice" { t.Fatalf("unerwarteter Feed: %+v", posts) } } func TestCreateEntryRequiresAuth(t *testing.T) { srv := newTestServer(t) anon := newClient(t) resp := postForm(t, anon, srv.URL+"/entry/create", url.Values{"content": {"x"}}) resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("create ohne Login: erwartet 401, bekam %d", resp.StatusCode) } } type tally struct { Left int `json:"left"` Right int `json:"right"` Selected string `json:"selected"` } func TestVoting(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") bob := registerAndLogin(t, srv, "bob") pid := createEntry(t, alice, srv, "vote me") vote := func(c *http.Client, mode string) tally { resp := postForm(t, c, fmt.Sprintf("%s/entry/%d/vote", srv.URL, pid), url.Values{"mode": {mode}}) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("vote %s: status %d", mode, resp.StatusCode) } var tl tally json.NewDecoder(resp.Body).Decode(&tl) return tl } if got := vote(alice, "left"); got.Left != 1 || got.Selected != "left" { t.Fatalf("alice left: %+v", got) } if got := vote(bob, "right"); got.Left != 1 || got.Right != 1 { t.Fatalf("bob right: %+v", got) } if got := vote(alice, "left"); got.Left != 0 || got.Selected != "none" { t.Fatalf("alice toggle off: %+v", got) } if got := vote(alice, "right"); got.Right != 2 || got.Selected != "right" { t.Fatalf("alice switch: %+v", got) } } func TestVoteRequiresAuthAndValidMode(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") pid := createEntry(t, alice, srv, "x") voteURL := fmt.Sprintf("%s/entry/%d/vote", srv.URL, pid) anon := newClient(t) resp := postForm(t, anon, voteURL, url.Values{"mode": {"left"}}) resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("anon vote: erwartet 401, bekam %d", resp.StatusCode) } resp = postForm(t, alice, voteURL, url.Values{"mode": {"up"}}) resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("ungültiger Modus: erwartet 400, bekam %d", resp.StatusCode) } } func TestBump(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") bob := registerAndLogin(t, srv, "bob") pid := createEntry(t, alice, srv, "bump me") bumpURL := fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid) // Anonym: 401. resp := postForm(t, newClient(t), bumpURL, url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("anon bump: erwartet 401, bekam %d", resp.StatusCode) } // Erster Bump (bump_count 0 -> kein Cooldown): 200, danach bump_count 1. resp = postForm(t, alice, bumpURL, url.Values{}) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("erster bump: erwartet 200, bekam %d", resp.StatusCode) } var out struct { BumpCount int64 `json:"bump_count"` RetryAfter int64 `json:"retry_after"` } json.NewDecoder(resp.Body).Decode(&out) if out.BumpCount != 1 || out.RetryAfter != 86400 { t.Fatalf("nach erstem bump: %+v", out) } // Cooldown gilt pro Beitrag (global): auch ein anderer Nutzer wird sofort // abgewiesen. resp = postForm(t, bob, bumpURL, url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusTooManyRequests { t.Fatalf("bump im cooldown: erwartet 429, bekam %d", resp.StatusCode) } // Gelöschter Beitrag lässt sich nicht bumpen. del := createEntry(t, alice, srv, "weg gleich") resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/delete", srv.URL, del), url.Values{}) resp.Body.Close() resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, del), url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("bump gelöscht: erwartet 404, bekam %d", resp.StatusCode) } } // 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"` Deleted int64 `json:"deleted"` Content string `json:"content"` Username string `json:"username"` } 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 TestEditEntry(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") bob := registerAndLogin(t, srv, "bob") pid := createEntry(t, alice, srv, "original") editURL := fmt.Sprintf("%s/entry/%d/edit", srv.URL, pid) // Eigentümer bearbeitet -> 200, neuer Inhalt sichtbar. resp := postForm(t, alice, editURL, url.Values{"content": {"korrigiert"}}) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("edit: erwartet 200, bekam %d", resp.StatusCode) } if tv := getThread(t, srv, pid); tv.Entry.Content != "korrigiert" { t.Fatalf("inhalt nach edit: %q", tv.Entry.Content) } // Leerer Inhalt -> 400. resp = postForm(t, alice, editURL, url.Values{"content": {" "}}) resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("leerer edit: erwartet 400, bekam %d", resp.StatusCode) } // Fremder -> 403. resp = postForm(t, bob, editURL, url.Values{"content": {"hijack"}}) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("fremd-edit: erwartet 403, bekam %d", resp.StatusCode) } // Unbekannte pid -> 404. resp = postForm(t, alice, srv.URL+"/entry/999999/edit", url.Values{"content": {"x"}}) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("edit unbekannt: erwartet 404, bekam %d", resp.StatusCode) } // Nach Soft-Delete ist der Beitrag nicht mehr bearbeitbar (uid=0) -> 403. postForm(t, alice, fmt.Sprintf("%s/entry/%d/delete", srv.URL, pid), url.Values{}).Body.Close() resp = postForm(t, alice, editURL, url.Values{"content": {"wieder da"}}) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("edit nach delete: erwartet 403, bekam %d", resp.StatusCode) } } 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") registerAndLogin(t, srv, "bob") // Zu kurz -> 400. resp := postForm(t, alice, srv.URL+"/user/rename", url.Values{"user": {"ab"}}) resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("zu kurz: erwartet 400, bekam %d", resp.StatusCode) } // Bereits vergeben -> 409. resp = postForm(t, alice, srv.URL+"/user/rename", url.Values{"user": {"bob"}}) resp.Body.Close() if resp.StatusCode != http.StatusConflict { t.Fatalf("vergeben: erwartet 409, bekam %d", resp.StatusCode) } // Gültig -> 200 + neuer Name. resp = postForm(t, alice, srv.URL+"/user/rename", url.Values{"user": {"alice2"}}) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("rename: erwartet 200, bekam %d", resp.StatusCode) } var out struct { Username string `json:"username"` } json.NewDecoder(resp.Body).Decode(&out) if out.Username != "alice2" { t.Fatalf("erwarteter Name alice2, bekam %q", out.Username) } // Profil unter dem neuen Namen erreichbar, alter Name weg. r2, _ := http.Get(srv.URL + "/u/alice2/info") r2.Body.Close() if r2.StatusCode != http.StatusOK { t.Fatalf("/u/alice2/info: erwartet 200, bekam %d", r2.StatusCode) } r3, _ := http.Get(srv.URL + "/u/alice/info") r3.Body.Close() if r3.StatusCode != http.StatusNotFound { t.Fatalf("/u/alice/info: erwartet 404, bekam %d", r3.StatusCode) } } 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"}}) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("falsches Passwort: erwartet 403, bekam %d", resp.StatusCode) } // Richtiges Passwort -> 200. resp = postForm(t, alice, srv.URL+"/user/delete", url.Values{"pass1": {"supersecret1"}}) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("delete: erwartet 200, bekam %d", resp.StatusCode) } // Login danach schlägt fehl. fresh := newClient(t) resp = postForm(t, fresh, srv.URL+"/auth/login", url.Values{ "user": {"alice"}, "pass": {"supersecret1"}, "timeout": {"86400"}, }) resp.Body.Close() 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) } }