Files
projax/web/bulk_test.go
mAi 0e490bb600 feat(phase 3d auto-tag): backfill area tags, bulk-edit UI, soft-delete cleanup
- migration 0012: one-shot populate empty tags from each item's area-roots
  (so chips on /?tag=work etc. actually filter the 40+ mai-backfilled rows)
- migration 0013: cleanup 12 orphan item_links + BEFORE-UPDATE trigger that
  cascades soft-delete to item_links going forward — closes the data drift
  that made TestItemsUnifiedSurfacesMaiPointer fail since 3c
- /admin/bulk page: flat filter+checkbox list with one-tx Apply for add/
  remove tag, set management, set status. Per-row inline chip add/remove
  via /admin/bulk/chip. Reuses tree_filter URL params 1:1.
- design.md §3.2 + §4.1 updated; tag+management section notes 0012
- bulk + tag-backfill + soft-delete-cascade tests cover the new surface
2026-05-15 18:49:58 +02:00

226 lines
6.9 KiB
Go

package web_test
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// TestBulkPageRenders proves /admin/bulk loads, contains the filter form +
// the action bar + at least one item row.
func TestBulkPageRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/admin/bulk")
if code != 200 {
t.Fatalf("GET /admin/bulk → %d body=%s", code, body)
}
for _, want := range []string{
`id="bulk-section"`,
`name="add_tag"`,
`name="remove_tag"`,
`name="set_mgmt"`,
`name="set_status"`,
`name="ids"`,
} {
if !strings.Contains(body, want) {
t.Errorf("bulk page missing %q", want)
}
}
}
// TestBulkApplyAddsTagInOneTx seeds five distinct child items, posts an
// add_tag action with all five ids checked, asserts every row gained the tag.
// Idempotency: re-posting the same action leaves tags unchanged (no duplicates).
func TestBulkApplyAddsTagInOneTx(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slugs := make([]string, 5)
ids := make([]string, 5)
var dev 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)
}
for i := range slugs {
slugs[i] = fmt.Sprintf("bulk-fixture-%s-%d", stamp, i)
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, management)
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[], '{}')
returning id`,
"bulk-"+slugs[i], slugs[i], dev,
).Scan(&ids[i]); err != nil {
t.Fatalf("seed item %d: %v", i, err)
}
}
defer func() {
for _, s := range slugs {
_, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, s)
}
}()
apply := func() {
form := url.Values{}
for _, id := range ids {
form.Add("ids", id)
}
form.Set("add_tag", "bulktest-critical")
req := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != http.StatusSeeOther && w.Result().StatusCode != 200 {
body, _ := io.ReadAll(w.Result().Body)
t.Fatalf("apply status %d body=%s", w.Result().StatusCode, body)
}
}
apply()
apply() // second run: idempotent — tag is not duplicated.
for _, id := range ids {
var tags []string
if err := pool.QueryRow(ctx, `select tags from projax.items where id=$1`, id).Scan(&tags); err != nil {
t.Fatalf("read tags: %v", err)
}
n := 0
for _, x := range tags {
if x == "bulktest-critical" {
n++
}
}
if n != 1 {
t.Errorf("item %s: expected 'bulktest-critical' once in tags, got tags=%v", id, tags)
}
}
}
// TestBulkApplySetStatusAcrossRows seeds three items, marks them done via the
// set_status action, then sets them back to active. Asserts every row hit
// each requested status.
func TestBulkApplySetStatusAcrossRows(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
var dev 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)
}
slugs := []string{}
ids := []string{}
for i := 0; i < 3; i++ {
slug := fmt.Sprintf("bulk-status-%s-%d", stamp, i)
slugs = append(slugs, slug)
var id string
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[])
returning id`,
"S"+slug, slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed %d: %v", i, err)
}
ids = append(ids, id)
}
defer func() {
for _, s := range slugs {
_, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, s)
}
}()
post := func(field, value string) {
form := url.Values{}
for _, id := range ids {
form.Add("ids", id)
}
form.Set(field, value)
req := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode >= 400 {
body, _ := io.ReadAll(w.Result().Body)
t.Fatalf("post %s=%s → %d body=%s", field, value, w.Result().StatusCode, body)
}
}
post("set_status", "done")
for _, id := range ids {
var s string
_ = pool.QueryRow(ctx, `select status from projax.items where id=$1`, id).Scan(&s)
if s != "done" {
t.Errorf("item %s: status = %q, want 'done'", id, s)
}
}
post("set_status", "active")
for _, id := range ids {
var s string
_ = pool.QueryRow(ctx, `select status from projax.items where id=$1`, id).Scan(&s)
if s != "active" {
t.Errorf("item %s: status = %q, want 'active' after reset", id, s)
}
}
}
// TestBulkChipRoundTrip exercises POST /admin/bulk/chip — the inline per-row
// chip-add path used by the bulk page (no checkbox required).
func TestBulkChipRoundTrip(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
slug := "bulk-chip-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
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[], 'chip', $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("id", id)
form.Set("op", "add")
form.Set("kind", "tag")
form.Set("value", "chiproundtrip")
req := httptest.NewRequest(http.MethodPost, "/admin/bulk/chip", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != 200 {
body, _ := io.ReadAll(w.Result().Body)
t.Fatalf("chip add → %d body=%s", w.Result().StatusCode, body)
}
var tags []string
if err := pool.QueryRow(ctx, `select tags from projax.items where id=$1`, id).Scan(&tags); err != nil {
t.Fatalf("read: %v", err)
}
if len(tags) != 1 || tags[0] != "chiproundtrip" {
t.Errorf("after chip add: tags = %v, want [chiproundtrip]", tags)
}
}