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
This commit is contained in:
@@ -298,6 +298,97 @@ func TestMultiParentResolvesBothPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillTagsFromArea verifies migration 0012's logic both directly (the
|
||||
// query as written, applied to a transaction-local fixture) and in the
|
||||
// applied state (every live child item now carries its area slug). We
|
||||
// re-execute the migration body inside a transaction so we can assert on a
|
||||
// freshly inserted untagged row without polluting the live DB.
|
||||
func TestBackfillTagsFromArea(t *testing.T) {
|
||||
pool := connect(t)
|
||||
defer pool.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// First: every existing live child item already carries area tags after
|
||||
// 0012 was applied on boot. This catches regressions where a future
|
||||
// migration clears the column or the backfill stops covering some path.
|
||||
var untagged int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`select count(*) from projax.items
|
||||
where deleted_at is null
|
||||
and cardinality(parent_ids) > 0
|
||||
and tags = '{}'`).Scan(&untagged); err != nil {
|
||||
t.Fatalf("count untagged children: %v", err)
|
||||
}
|
||||
if untagged != 0 {
|
||||
t.Fatalf("expected every live child to have area tags after 0012, got %d still empty", untagged)
|
||||
}
|
||||
|
||||
// Second: idempotency + multi-parent. Insert a fresh multi-parent row
|
||||
// with empty tags, re-run the backfill query, observe both area slugs in
|
||||
// tags. Re-run a second time and check nothing changes.
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var dev, work string
|
||||
if err := tx.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 := tx.QueryRow(ctx, `select id from projax.items where slug='work' and cardinality(parent_ids)=0`).Scan(&work); err != nil {
|
||||
t.Fatalf("work: %v", err)
|
||||
}
|
||||
slug := fmt.Sprintf("tag-backfill-%d", time.Now().UnixNano())
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids, tags)
|
||||
values (array['project']::text[], 'TagBackfill', $1, ARRAY[$2,$3]::uuid[], '{}')`,
|
||||
slug, dev, work,
|
||||
); err != nil {
|
||||
t.Fatalf("insert multi-parent: %v", err)
|
||||
}
|
||||
|
||||
backfill := `UPDATE projax.items i
|
||||
SET tags = sub.area_tags
|
||||
FROM (
|
||||
SELECT id, ARRAY(SELECT DISTINCT split_part(p, '.', 1)
|
||||
FROM unnest(paths) p ORDER BY 1) AS area_tags
|
||||
FROM projax.items
|
||||
WHERE deleted_at IS NULL AND tags = '{}'
|
||||
AND cardinality(parent_ids) > 0
|
||||
) sub
|
||||
WHERE i.id = sub.id AND i.tags = '{}'
|
||||
AND cardinality(sub.area_tags) > 0`
|
||||
if _, err := tx.Exec(ctx, backfill); err != nil {
|
||||
t.Fatalf("first backfill apply: %v", err)
|
||||
}
|
||||
|
||||
var tags []string
|
||||
if err := tx.QueryRow(ctx, `select tags from projax.items where slug=$1`, slug).Scan(&tags); err != nil {
|
||||
t.Fatalf("read tags after backfill: %v", err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, t := range tags {
|
||||
got[t] = true
|
||||
}
|
||||
if !got["dev"] || !got["work"] || len(tags) != 2 {
|
||||
t.Fatalf("expected tags = {dev, work}, got %v", tags)
|
||||
}
|
||||
|
||||
// Idempotent: second run is a no-op on this row (tags no longer = '{}').
|
||||
if _, err := tx.Exec(ctx, backfill); err != nil {
|
||||
t.Fatalf("second backfill apply: %v", err)
|
||||
}
|
||||
var tags2 []string
|
||||
if err := tx.QueryRow(ctx, `select tags from projax.items where slug=$1`, slug).Scan(&tags2); err != nil {
|
||||
t.Fatalf("read tags after second backfill: %v", err)
|
||||
}
|
||||
if len(tags2) != 2 {
|
||||
t.Fatalf("expected idempotent backfill, got %v after second run", tags2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugCollisionUnderCommonParent(t *testing.T) {
|
||||
pool := connect(t)
|
||||
defer pool.Close()
|
||||
|
||||
Reference in New Issue
Block a user