Big task. Five migrations, full store + web rewrite, and a model upgrade
that turns the parent_id tree into a parent_ids[] DAG.
Schema (db/migrations)
- 0006_tags_management_unify: adds tags + management text[] (GIN-indexed),
collapses the area/project distinction (kind keeps the slot but 'area'
is no longer a special value), drops the structural rules from the
path trigger so root projects + non-root projects are both legal.
- 0007_backfill_mai_projects: one-shot, idempotent — for every row in
mai.projects without a 'mai-project' item_link, create a projax.items
row under a heuristic-chosen area (mhealth→health, msports/manjin→
sports, kanzlai/hlckm/work/mworkrepo/paliad or HL/* repo→work,
mhome→home, default→dev), insert the item_link, and tag the row
management=['mai']. Also flips management='mai' on any already-linked
pre-Phase-1.5 promotions.
- 0008_mai_projects_sync: bidirectional triggers. sync_to_mai runs as
projax_admin and writes mai.projects directly (after the operator-run
grant + RLS policy widening — documented in the migration header).
sync_from_mai is SECURITY DEFINER so writes by the mai role fan out
into projax.items. pg_trigger_depth() + projax.in_sync GUC keep the
cycle suppressed. Slug stays the join key for new rows; the
item_link pointer survives renames.
- 0009_items_unified_simplify: view collapses to a thin projection over
projax.items now that mai.projects is a derived projection.
- 0010_multi_parent: parent_id → parent_ids uuid[], path → paths text[].
compute_item_paths walks via parents' precomputed paths (no recursive
CTE in the hot path; cycle detection uses one). New triggers:
items_check_slug_collision (multi-parent uniqueness),
items_after_delete (manual cascade since arrays don't carry FK).
Trigger refresh_item_paths_recursive does parent-first DFS over
descendants, guarded by projax.refreshing_paths GUC.
Go store + handlers
- Item gains ParentIDs []string + Paths []string. PrimaryPath /
OtherPaths helpers feed the detail breadcrumb. Source always
'projax' now; SourceRefDeref still surfaces the mai-id pointer.
- Update / Reparent / Create take ParentIDs []string. AddParent helper
for the multi-parent UI's "also list under" action.
- GetByPath uses '$1 = any(paths)' so /i/work.paliad and /i/dev.paliad
resolve to the same row.
- buildForest renders a multi-parent item under each of its parents
(duplicated nodes in distinct branches). Tag-filter prune is
branch-preserving.
Templates
- detail.tmpl: multi-select parents, tags + management chip inputs,
"Also at: …" breadcrumb for multi-parent items.
- new.tmpl: same multi-select + chip inputs.
- tree.tmpl: tag-filter chip bar, "×N" badge on multi-parent rows,
management chips visible on every row.
- classify.tmpl: re-parent workflow (no more promote-to-projax — the
bidirectional sync removed the dichotomy).
Tests (DB + HTTP, all skip without env)
- TestMultiParentResolvesBothPaths inserts an item with two parents,
asserts both inherited paths.
- TestSlugCollisionUnderCommonParent refuses a sibling clash.
- TestMultiParentBothPathsRouteToSameRow HTTP-level: /i/dev.X and
/i/work.X both 200, same row.
- TestReparentRoundTrip rewritten for parent_ids[] semantics.
- TestPathTriggerNestAndRename / Reparent rewritten to query paths[].
Docs (docs/design.md)
- §2 rewritten: items in a DAG, no area/project distinction.
- §3 schema: parent_ids + paths + tags + management + indices.
- §3.1 path-trigger overhaul incl. cycle detection via recursive CTE
and slug-collision-under-common-parent guard.
- §3.2 view simplified.
- §3.4 NEW: mai.projects bidirectional sync, including the manual
prereq.
- §4.1 + §4.2: classify becomes re-parent, tags+management UI section.
mai head start / mai hire / mai status / mai instruct keep working
because mai.projects retains its FK-target shape; the projax sync just
mirrors the row in lock-step.
239 lines
6.9 KiB
Go
239 lines
6.9 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/m/projax/db"
|
|
"github.com/m/projax/store"
|
|
"github.com/m/projax/web"
|
|
)
|
|
|
|
var (
|
|
migrateOnce sync.Once
|
|
migrateErr error
|
|
)
|
|
|
|
func mustServer(t *testing.T) (*web.Server, *pgxpool.Pool) {
|
|
t.Helper()
|
|
dbURL := os.Getenv("PROJAX_DB_URL")
|
|
if dbURL == "" {
|
|
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
|
|
}
|
|
if dbURL == "" {
|
|
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping HTTP integration test")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
pool, err := pgxpool.New(ctx, dbURL)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
t.Skipf("DB unreachable: %v", err)
|
|
}
|
|
if os.Getenv("PROJAX_SKIP_MIGRATE") != "1" {
|
|
migrateOnce.Do(func() { migrateErr = db.ApplyMigrations(ctx, pool) })
|
|
if migrateErr != nil {
|
|
t.Fatalf("migrate: %v", migrateErr)
|
|
}
|
|
}
|
|
srv, err := web.New(store.New(pool), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err != nil {
|
|
t.Fatalf("server: %v", err)
|
|
}
|
|
return srv, pool
|
|
}
|
|
|
|
func get(t *testing.T, h http.Handler, url string) (int, string) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
body, _ := io.ReadAll(w.Result().Body)
|
|
return w.Result().StatusCode, string(body)
|
|
}
|
|
|
|
func TestTreeRenders(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
code, body := get(t, h, "/")
|
|
if code != 200 {
|
|
t.Fatalf("GET / status %d body=%s", code, body)
|
|
}
|
|
for _, want := range []string{"<h1>Tree</h1>", "/i/dev", "/i/home", "/admin/classify"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("body missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHealthz(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
code, body := get(t, srv.Routes(), "/healthz")
|
|
if code != 200 || strings.TrimSpace(body) != "ok" {
|
|
t.Fatalf("healthz: %d %q", code, body)
|
|
}
|
|
}
|
|
|
|
func TestDetailRendersEditableForm(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
code, body := get(t, srv.Routes(), "/i/dev")
|
|
if code != 200 {
|
|
t.Fatalf("status %d body=%s", code, body)
|
|
}
|
|
if !strings.Contains(body, `form method="post" action="/i/dev"`) {
|
|
t.Errorf("edit form missing for /i/dev")
|
|
}
|
|
if !strings.Contains(body, `name="tags"`) {
|
|
t.Errorf("tags input missing")
|
|
}
|
|
if !strings.Contains(body, `name="management"`) {
|
|
t.Errorf("management input missing")
|
|
}
|
|
}
|
|
|
|
func TestDetailShowsManagementChips(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
// dev.projax is the manually-promoted item from before Phase 1.5 — should
|
|
// already carry management=['mai'] after the backfill+sync pass.
|
|
code, body := get(t, srv.Routes(), "/i/dev.projax")
|
|
if code != 200 {
|
|
t.Fatalf("status %d", code)
|
|
}
|
|
if !strings.Contains(body, "mgmt-mai") {
|
|
t.Errorf("expected mgmt-mai chip on /i/dev.projax, body did not include it")
|
|
}
|
|
}
|
|
|
|
func TestClassifyListsMaiRoots(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
code, body := get(t, srv.Routes(), "/admin/classify")
|
|
if code != 200 {
|
|
t.Fatalf("status %d", code)
|
|
}
|
|
if !strings.Contains(body, "Classify root mai-managed items") &&
|
|
!strings.Contains(body, "No unclassified roots") {
|
|
t.Errorf("classify page body unexpected: %q", body)
|
|
}
|
|
}
|
|
|
|
func TestReparentRoundTrip(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// Create a fresh root mai-managed item via the reverse-sync path so the
|
|
// test never collides with another project. The sync trigger drops the
|
|
// mirror at parent_id=NULL — exactly the case /admin/classify handles.
|
|
maiID := "phase15-test-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
|
defer func() {
|
|
_, _ = pool.Exec(context.Background(), `delete from mai.projects where id=$1`, maiID)
|
|
_, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, maiID)
|
|
}()
|
|
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into mai.projects (id, name, status) values ($1, $2, 'active')`,
|
|
maiID, "Reparent test "+maiID,
|
|
); err != nil {
|
|
t.Fatalf("seed mai.projects: %v", err)
|
|
}
|
|
|
|
var nParents int
|
|
if err := pool.QueryRow(ctx,
|
|
`select cardinality(parent_ids) from projax.items where slug=$1`, maiID,
|
|
).Scan(&nParents); err != nil {
|
|
t.Fatalf("read mirror: %v", err)
|
|
}
|
|
if nParents != 0 {
|
|
t.Fatalf("expected mirror at root (no parents), got %d parents", nParents)
|
|
}
|
|
|
|
var devID string
|
|
if err := pool.QueryRow(ctx,
|
|
`select id from projax.items where slug='dev' and cardinality(parent_ids) = 0`,
|
|
).Scan(&devID); err != nil {
|
|
t.Fatalf("dev: %v", err)
|
|
}
|
|
|
|
form := url.Values{}
|
|
form.Set("parent_id", devID)
|
|
req := httptest.NewRequest(http.MethodPost, "/i/"+maiID+"/reparent", 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 {
|
|
body, _ := io.ReadAll(w.Result().Body)
|
|
t.Fatalf("reparent status %d body=%s", w.Result().StatusCode, body)
|
|
}
|
|
if loc := w.Result().Header.Get("Location"); loc != "/i/dev."+maiID {
|
|
t.Errorf("Location = %q, want /i/dev.%s", loc, maiID)
|
|
}
|
|
|
|
var parents []string
|
|
if err := pool.QueryRow(ctx,
|
|
`select array(select unnest(parent_ids)::text) from projax.items where slug=$1`, maiID,
|
|
).Scan(&parents); err != nil {
|
|
t.Fatalf("post-reparent read: %v", err)
|
|
}
|
|
if len(parents) != 1 || parents[0] != devID {
|
|
t.Errorf("parent_ids after reparent = %v, want [%s]", parents, devID)
|
|
}
|
|
}
|
|
|
|
func TestMultiParentBothPathsRouteToSameRow(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 := "p15-multi-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
|
defer func() {
|
|
_, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, slug)
|
|
}()
|
|
|
|
var dev, work 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='work' and cardinality(parent_ids)=0`).Scan(&work); err != nil {
|
|
t.Fatalf("work: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'Multi', $1, ARRAY[$2,$3]::uuid[])`,
|
|
slug, dev, work,
|
|
); err != nil {
|
|
t.Fatalf("insert multi: %v", err)
|
|
}
|
|
|
|
for _, p := range []string{"dev." + slug, "work." + slug} {
|
|
code, body := get(t, h, "/i/"+p)
|
|
if code != 200 {
|
|
t.Fatalf("GET /i/%s → %d", p, code)
|
|
}
|
|
if !strings.Contains(body, "Multi") {
|
|
t.Errorf("body for /i/%s missing item title 'Multi'", p)
|
|
}
|
|
}
|
|
}
|