Files
projax/db/unified_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

137 lines
4.6 KiB
Go

package db_test
import (
"context"
"fmt"
"testing"
"time"
)
func TestItemsUnifiedShape(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// After Phase 1.5 the view collapses to a thin projection over projax.items.
// Source is always 'projax'; there are no 'mai.projects' rows in the view.
var total, projaxN, otherN int
if err := pool.QueryRow(ctx, `select count(*) from projax.items_unified`).Scan(&total); err != nil {
t.Fatalf("total: %v", err)
}
if err := pool.QueryRow(ctx, `select count(*) from projax.items_unified where source = 'projax'`).Scan(&projaxN); err != nil {
t.Fatalf("projax count: %v", err)
}
if err := pool.QueryRow(ctx, `select count(*) from projax.items_unified where source <> 'projax'`).Scan(&otherN); err != nil {
t.Fatalf("other count: %v", err)
}
if projaxN != total {
t.Fatalf("expected all %d rows to have source='projax', got %d projax + %d other", total, projaxN, otherN)
}
if total < 7 {
t.Fatalf("expected at least the 7 seeded roots in items_unified, got %d", total)
}
}
func TestItemsUnifiedSurfacesMaiPointer(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Any item with a 'mai-project' item_link should surface source_ref_id.
var linked, withRef int
if err := pool.QueryRow(ctx,
`select count(*) from projax.item_links where ref_type='mai-project'`,
).Scan(&linked); err != nil {
t.Fatalf("count links: %v", err)
}
if err := pool.QueryRow(ctx,
`select count(*) from projax.items_unified where source_ref_id is not null`,
).Scan(&withRef); err != nil {
t.Fatalf("count source_ref_id: %v", err)
}
if linked != withRef {
t.Fatalf("source_ref_id surfacing diverged from item_links: %d link rows vs %d view rows with ref", linked, withRef)
}
}
// TestSoftDeleteCascadesToItemLinks proves migration 0013's trigger: setting
// deleted_at on a projax.items row deletes every item_links row that pointed
// at it in the same statement. Without this, the source_ref_id count in
// items_unified diverges from the raw link count (and external pointers to
// dead items linger, which silent-corrupts downstream integrations).
func TestSoftDeleteCascadesToItemLinks(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback(ctx)
// Insert a fresh root item that is NOT 'mai' managed so the sync_to_mai
// trigger does not own this link's life cycle — we want to test the new
// cascade trigger in isolation.
var id string
slug := fmt.Sprintf("linkcascade-%d", time.Now().UnixNano())
if err := tx.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, management)
values (array['project']::text[], 'cascade test', $1, '{}'::uuid[], '{}')
returning id`,
slug,
).Scan(&id); err != nil {
t.Fatalf("insert item: %v", err)
}
if _, err := tx.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
values ($1, 'gitea-repo', 'mgit.msbls.de/test', 'tracks')`,
id,
); err != nil {
t.Fatalf("insert link: %v", err)
}
var linksBefore int
if err := tx.QueryRow(ctx, `select count(*) from projax.item_links where item_id=$1`, id).Scan(&linksBefore); err != nil {
t.Fatalf("count before: %v", err)
}
if linksBefore != 1 {
t.Fatalf("expected 1 link before soft-delete, got %d", linksBefore)
}
if _, err := tx.Exec(ctx, `update projax.items set deleted_at = now() where id=$1`, id); err != nil {
t.Fatalf("soft-delete: %v", err)
}
var linksAfter int
if err := tx.QueryRow(ctx, `select count(*) from projax.item_links where item_id=$1`, id).Scan(&linksAfter); err != nil {
t.Fatalf("count after: %v", err)
}
if linksAfter != 0 {
t.Fatalf("expected 0 links after soft-delete (cascade trigger should have removed them), got %d", linksAfter)
}
}
func TestPhase15ColumnsPresent(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Sanity-check tags + management + the multi-parent paths/parent_ids columns.
row := pool.QueryRow(ctx,
`select tags, management, paths, parent_ids from projax.items_unified
where cardinality(parent_ids) = 0 limit 1`)
var tags, mgmt, paths []string
var parentIDs []string
if err := row.Scan(&tags, &mgmt, &paths, &parentIDs); err != nil {
t.Fatalf("scan tags/management/paths/parent_ids: %v", err)
}
if len(paths) == 0 {
t.Fatalf("expected at least one path on a root item")
}
}