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.
208 lines
7.5 KiB
Go
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
|
|
}
|