Files
projax/web/bulk_repro_test.go
mAi 838793ee69 fix(phase 3n bulk): un-nest chip-add form, inline banner for empty Apply, multi-value filter preserved
Three structural bugs from Phase 3d caught by m's "doesn't work" report:

1. The chip-add <form class="chip-add" ...> was rendered INSIDE the outer
   <form id="bulk-actions" ...> in bulk_section.tmpl. HTML forbids
   nested forms — browsers silently flatten them, so the chip-add's
   hx-trigger="submit" never fired and pressing Enter in any chip-add
   input dispatched the outer Apply form instead. Replaced the inner
   <form> with a <span class="chip-add"> wrapping an input that fires
   hx-post directly on Enter (hx-trigger="keyup[key=='Enter']") plus an
   explicit + button. No more nested forms. New TestBulkPageHasNoNested
   Forms regression-guards via a substring check on the rendered HTML.

2. handleBulkApply 400'd on empty ids OR empty action via http.Error,
   which HTMX swapped into #bulk-section as a plain-text error page —
   the page chrome vanished and the user saw "no action chosen". Now
   the handler validates inputs, sets a banner string, and falls through
   to renderBulkList (the section re-renders with the banner inline).
   Banner copy is task-specific so m can tell what he missed.

3. renderBulkList read filter values with r.FormValue, which returns
   ONLY the first value for multi-value names. Multi-select tag/mgmt/
   status filters dropped their 2nd+ values on every Apply round-trip.
   Switched to r.Form["..."] + a new normaliseFormStrings helper that
   dedupes / lowercases / trims the slice. TestBulkApplyRendersWithFilter
   Preserved regression-guards.

All 3 bugs caught by tests written first (TestBulkPageHasNoNestedForms,
TestBulkApplyEmpty{Action,Ids}RendersInlineBanner, TestBulkApplyRenders
WithFilterPreserved). Existing 4 bulk tests still green; full test suite
green.
2026-05-16 01:25:48 +02:00

208 lines
7.5 KiB
Go

package web_test
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// TestBulkPageHasNoNestedForms guards against the chip-add <form> being
// nested inside the bulk-actions <form>. HTML forbids nested forms and
// browsers silently flatten them — the chip-add's hx-trigger="submit"
// stops firing and the chip-add inputs leak into the outer Apply form.
// Phase 3n regression.
func TestBulkPageHasNoNestedForms(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/admin/bulk")
// Locate bulk-actions form and verify NO inner <form> opens before its
// matching </form>. A naive but robust check: between the opening
// <form id="bulk-actions" ...> and the next </form>, there must be no
// other <form opening.
startTag := `<form id="bulk-actions"`
open := strings.Index(body, startTag)
if open < 0 {
t.Fatalf("bulk page lacks <form id=\"bulk-actions\">; body sample:\n%s", body[:min3(800, len(body))])
}
tail := body[open:]
end := strings.Index(tail, "</form>")
if end < 0 {
t.Fatalf("bulk-actions form never closes")
}
inner := tail[len(startTag):end]
if strings.Contains(inner, "<form") {
t.Errorf("bulk-actions form contains a nested <form ...>: HTML forbids this and browsers flatten it. Slice:\n%s",
inner[:min3(1200, len(inner))])
}
}
// TestBulkApplyEmptyActionRendersInlineBanner: clicking Apply without
// filling an action field should not 400 + plain-text error page (which
// HTMX swaps as the whole #bulk-section). It should render the section
// with an inline banner. Phase 3n regression.
func TestBulkApplyEmptyActionRendersInlineBanner(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Seed one item we can tick a checkbox for.
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "bulk-empty-act-" + stamp
var dev, id string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], 'bulk-empty', $1, ARRAY[$2]::uuid[])
returning id`,
slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
form := url.Values{}
form.Set("ids", id)
// Intentionally NO add_tag / remove_tag / set_mgmt / set_status set.
req := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
code := w.Result().StatusCode
body := readBodyString(t, w.Result().Body)
if code != 200 {
t.Fatalf("expected 200 with inline banner for empty Apply, got %d body=%s", code, body)
}
if !strings.Contains(body, `id="bulk-section"`) {
t.Errorf("response should re-render the bulk section, not replace it with an error message")
}
if !strings.Contains(strings.ToLower(body), "action") {
t.Errorf("response should contain a 'no action chosen' style banner; body:\n%s", body[:min3(600, len(body))])
}
}
// TestBulkApplyEmptyIdsRendersInlineBanner: clicking Apply with no rows
// ticked should also render the section with a banner, not 400.
func TestBulkApplyEmptyIdsRendersInlineBanner(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
form := url.Values{}
form.Set("add_tag", "x") // action chosen but no ids
req := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
code := w.Result().StatusCode
body := readBodyString(t, w.Result().Body)
if code != 200 {
t.Fatalf("expected 200 with inline banner for empty ids, got %d body=%s", code, body)
}
if !strings.Contains(body, `id="bulk-section"`) {
t.Errorf("response should re-render the bulk section, not replace it with an error message")
}
}
// TestBulkApplyRendersWithFilterPreserved: after Apply, the re-rendered
// section should keep multi-value filter selections (tag=A AND tag=B, the
// browser sends two name=tag entries). Pre-fix renderBulkList used
// r.FormValue which only returns the FIRST value, dropping B.
func TestBulkApplyRendersWithFilterPreserved(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Seed two items in different areas, distinct tags so we can verify the
// filter narrows.
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
var dev, home string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
if err := pool.QueryRow(ctx, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&home); err != nil {
t.Fatalf("home: %v", err)
}
mk := func(parent, slug string, tags []string) string {
var id string
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, tags)
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[], $4)
returning id`,
"f", slug, parent, tags,
).Scan(&id); err != nil {
t.Fatalf("seed %s: %v", slug, err)
}
return id
}
tagA := "bulkfilt-a-" + stamp
tagB := "bulkfilt-b-" + stamp
devSlug := "bulkfilt-dev-" + stamp
homeSlug := "bulkfilt-home-" + stamp
devID := mk(dev, devSlug, []string{tagA, tagB})
homeID := mk(home, homeSlug, []string{tagA})
defer func() {
_, _ = pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, devID, homeID)
}()
// Apply: ids=devID, add_tag=newtag, and tag filter has BOTH tagA AND tagB
// (multi-select scenario).
form := url.Values{}
form.Add("ids", devID)
form.Set("add_tag", "newtag-"+stamp)
form.Add("tag", tagA)
form.Add("tag", tagB)
req := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != 200 {
body := readBodyString(t, w.Result().Body)
t.Fatalf("apply → %d body=%s", w.Result().StatusCode, body)
}
body := readBodyString(t, w.Result().Body)
// dev-item carries both tags → matches the AND filter → should appear in the rendered list.
if !strings.Contains(body, fmt.Sprintf("/i/dev.%s", devSlug)) {
t.Errorf("expected dev-row to remain visible after Apply (filter AND-matches both tags)")
}
// home-item carries only tagA → should NOT appear (filter requires BOTH).
if strings.Contains(body, fmt.Sprintf("/i/home.%s", homeSlug)) {
t.Errorf("home-row should be filtered out — has only tagA, filter required tagA+tagB")
}
}
// readBodyString reads an HTTP response body as a string. Helper used by
// the regression tests since httptest.NewRecorder().Result().Body is a
// ReadCloser.
func readBodyString(t *testing.T, r io.ReadCloser) string {
t.Helper()
defer r.Close()
b, _ := io.ReadAll(r)
return string(b)
}
func min3(a, b int) int {
if a < b {
return a
}
return b
}