package web_test import ( "context" "fmt" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) // TestBulkPageHasNoNestedForms guards against the chip-add
being // nested inside the bulk-actions . 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 opens before its // matching
. A naive but robust check: between the opening //
and the next
, there must be no // other
; body sample:\n%s", body[:min3(800, len(body))]) } tail := body[open:] end := strings.Index(tail, "
") if end < 0 { t.Fatalf("bulk-actions form never closes") } inner := tail[len(startTag):end] if strings.Contains(inner, ": 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 }