SSR: Repost ohne JS, return_to in Formularen, Werbebanner entfernt

Letzter vollständiger SSR-Stand vor dem Rückbau auf reine SPA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-26 21:23:09 +02:00
co-authored by Claude Opus 5.5
parent c66df283d1
commit eccddcebe6
9 changed files with 194 additions and 45 deletions
+111 -3
View File
@@ -288,6 +288,85 @@ func TestBump(t *testing.T) {
}
}
// TestRepostOhneJavaScript deckt den Repost-Weg ohne JS ab: der Knopf muss im
// Feed stehen, der Klick per 303 an den Beitrag zurückführen, danach gesperrt
// sein -- und ein erneuter Klick im Cooldown eine HTML-Seite liefern, nicht die
// JSON-Zeile, die ein Browser roh anzeigen würde.
func TestRepostOhneJavaScript(t *testing.T) {
srv := newTestServer(t)
alice := registerAndLogin(t, srv, "alice")
pid := createEntry(t, alice, srv, "hebt euch")
// Angemeldet: Repost-Formular mit Rücksprungziel, Knopf noch frei.
_, page := getHTML(t, alice, srv.URL+"/feed")
if !strings.Contains(page, fmt.Sprintf(`action="/entry/%d/bump"`, pid)) {
t.Fatalf("kein repost-formular im feed:\n%s", excerpt(page, "interactions"))
}
if !strings.Contains(page, "Repost (0)") {
t.Errorf("repost-zähler falsch:\n%s", excerpt(page, "bump"))
}
if strings.Contains(page, `class="bump" type="submit" disabled`) {
t.Errorf("repost-knopf ohne bisherigen repost gesperrt:\n%s", excerpt(page, "bump"))
}
// Reposten -> 303 zurück an den Beitrag.
resp := postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid),
url.Values{"return_to": {fmt.Sprintf("/feed#e%d", pid)}})
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("repost: erwartet 303, bekam %d", resp.StatusCode)
}
if got, want := resp.Header.Get("Location"), fmt.Sprintf("/feed#e%d", pid); got != want {
t.Fatalf("repost: Location = %q, erwartet %q", got, want)
}
// Jetzt im Cooldown: Zähler hoch, Knopf gesperrt, Wartezeit sichtbar.
_, page = getHTML(t, alice, srv.URL+"/feed")
if !strings.Contains(page, "Repost (1)") {
t.Errorf("repost-zähler nicht erhöht:\n%s", excerpt(page, "bump"))
}
if !strings.Contains(page, `class="bump" type="submit" disabled`) {
t.Errorf("repost-knopf im cooldown nicht gesperrt:\n%s", excerpt(page, "bump"))
}
// Erneuter Repost im Cooldown -> HTML-Fehlerseite, nicht rohes JSON.
resp = postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{})
cooldown := body(t, resp)
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("repost im cooldown: erwartet 429, bekam %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Fatalf("repost im cooldown: erwartet HTML, bekam %q", ct)
}
if !strings.Contains(cooldown, "erst kürzlich repostet") {
t.Errorf("keine verständliche meldung:\n%s", cooldown)
}
// Der JSON-Client bekommt weiterhin JSON mit Nutzdaten.
resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{})
jsonBody := body(t, resp)
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Fatalf("API-Client: erwartet JSON, bekam %q", ct)
}
if !strings.Contains(jsonBody, `"retry_after"`) {
t.Errorf("JSON-Antwort ohne retry_after:\n%s", jsonBody)
}
// Nicht angemeldet: kein Formular im Feed, POST -> 401 als HTML.
_, anonPage := getHTML(t, newClient(t), srv.URL+"/feed")
if strings.Contains(anonPage, fmt.Sprintf(`action="/entry/%d/bump"`, pid)) {
t.Error("repost-formular für nicht angemeldete besucher")
}
resp = postFormHTML(t, newClient(t), fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{})
page = body(t, resp)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anon repost: erwartet 401, bekam %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Fatalf("anon repost: erwartet HTML, bekam %q", ct)
}
}
// 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()
@@ -1212,6 +1291,11 @@ func TestBeitragOhneJavaScript(t *testing.T) {
if !strings.Contains(page, `type="file"`) {
t.Errorf("kein dateifeld:\n%s", excerpt(page, "compose"))
}
// Das Formular trägt den Rücksprungort mit, damit ein abgelehnter Post ihn
// nicht verliert und die neu gerenderten Knöpfe auf "/feed" zeigen statt "/".
if !strings.Contains(page, `name="return_to" value="/feed"`) {
t.Errorf("beitragsformular ohne return_to:\n%s", excerpt(page, "compose"))
}
// Nicht angemeldet: kein Formular.
_, anonPage := getHTML(t, newClient(t), srv.URL+"/feed")
@@ -1252,7 +1336,9 @@ func TestBeitragOhneJavaScript(t *testing.T) {
}
// Leerer Beitrag -> Feed mit Meldung und erhaltenem Text, kein Redirect.
resp = postFormHTML(t, alice, srv.URL+"/entry/create", url.Values{"content": {" "}})
// return_to wie vom Formular mitgeschickt.
resp = postFormHTML(t, alice, srv.URL+"/entry/create",
url.Values{"content": {" "}, "return_to": {"/feed"}, "page": {"0"}})
failPage := body(t, resp)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("leerer beitrag: erwartet 400, bekam %d", resp.StatusCode)
@@ -1264,6 +1350,14 @@ func TestBeitragOhneJavaScript(t *testing.T) {
if !strings.Contains(failPage, "mein erster ssr-beitrag") {
t.Errorf("feed nach fehler nicht mitgerendert:\n%s", excerpt(failPage, "feed"))
}
// Die neu gerenderten Interaktions-Knöpfe zeigen zurück in den Feed, nicht
// auf "/" (die noch per JS bediente Startseite).
if strings.Contains(failPage, `name="return_to" value="/#e`) {
t.Errorf("interaktions-knöpfe zeigen nach dem fehler auf \"/\":\n%s", excerpt(failPage, "vote"))
}
if !strings.Contains(failPage, `name="return_to" value="/feed#e`) {
t.Errorf("interaktions-knöpfe ohne feed-rücksprung:\n%s", excerpt(failPage, "vote"))
}
// Getippter Text überlebt einen abgelehnten Upload.
resp = postMultipartHTML(t, alice, srv.URL+"/entry/create",
@@ -1367,6 +1461,10 @@ func TestThreadOhneJavaScript(t *testing.T) {
if !strings.Contains(page, fmt.Sprintf(`name="reply_to" value="%d"`, mitte)) {
t.Fatalf("antwortformular ohne reply_to:\n%s", excerpt(page, "compose"))
}
// Das Antwortformular trägt den Rücksprung in den Thread mit.
if !strings.Contains(page, fmt.Sprintf(`name="return_to" value="/beitrag/%d"`, mitte)) {
t.Errorf("antwortformular ohne return_to:\n%s", excerpt(page, "compose"))
}
// Antworten ohne JS -> 303 in den Thread des Elternbeitrags.
resp := postFormHTML(t, alice, srv.URL+"/entry/create",
@@ -1390,9 +1488,11 @@ func TestThreadOhneJavaScript(t *testing.T) {
t.Errorf("anker %q aus dem redirect existiert nicht in der seite", anchor)
}
// Fehlerfall: leere Antwort -> Thread neu, nicht der Feed.
// Fehlerfall: leere Antwort -> Thread neu, nicht der Feed. return_to wie
// vom Formular mitgeschickt.
resp = postFormHTML(t, alice, srv.URL+"/entry/create",
url.Values{"content": {" "}, "reply_to": {fmt.Sprint(mitte)}})
url.Values{"content": {" "}, "reply_to": {fmt.Sprint(mitte)},
"return_to": {fmt.Sprintf("/beitrag/%d", mitte)}})
failPage := body(t, resp)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("leere antwort: erwartet 400, bekam %d", resp.StatusCode)
@@ -1403,6 +1503,14 @@ func TestThreadOhneJavaScript(t *testing.T) {
if !strings.Contains(failPage, "mittlere antwort") {
t.Errorf("thread nach fehler nicht mitgerendert:\n%s", excerpt(failPage, "focus"))
}
// Die neu gerenderten Interaktions-Knöpfe zeigen zurück in den Thread,
// nicht auf "/".
if strings.Contains(failPage, `name="return_to" value="/#e`) {
t.Errorf("interaktions-knöpfe zeigen nach dem fehler auf \"/\":\n%s", excerpt(failPage, "vote"))
}
if !strings.Contains(failPage, fmt.Sprintf(`name="return_to" value="/beitrag/%d#e`, mitte)) {
t.Errorf("interaktions-knöpfe ohne thread-rücksprung:\n%s", excerpt(failPage, "vote"))
}
// Unbekannter Beitrag -> 404, keine leere Seite.
if status, _ := getHTML(t, anon, srv.URL+"/beitrag/999999"); status != http.StatusNotFound {