- 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
226 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|