- 0001_init.sql: projax.items + projax.item_links tables with indices, partial-unique root slug, updated_at trigger, schema grants to the application role. - 0002_path_trigger.sql: BEFORE-write trigger maintains items.path via recursive parent walk; rejects cycles and structural-rule violations (areas at root, projects not at root). AFTER trigger rewrites descendant paths on slug rename or re-parent. - 0003_seed_areas.sql: dev, sports, home, work, health, finances, social. - db/migrate.go: embed.FS-backed sequential runner. - db/migrate_test.go: integration suite covering idempotency, nest, rename propagation, re-parent propagation, cycle rejection, and structural rules. Skips when no DB env var is set. Also ignores .m/events.log and .m/locks (per-worker scratch).
241 lines
7.4 KiB
Go
241 lines
7.4 KiB
Go
package db_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/m/projax/db"
|
|
)
|
|
|
|
// connect returns a pool or skips the test if no DB is configured.
|
|
// Honours PROJAX_DB_URL first, then SUPABASE_DATABASE_URL.
|
|
func connect(t *testing.T) *pgxpool.Pool {
|
|
t.Helper()
|
|
url := os.Getenv("PROJAX_DB_URL")
|
|
if url == "" {
|
|
url = os.Getenv("SUPABASE_DATABASE_URL")
|
|
}
|
|
if url == "" {
|
|
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping integration test")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
pool, err := pgxpool.New(ctx, url)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
t.Skipf("DB unreachable: %v", err)
|
|
}
|
|
return pool
|
|
}
|
|
|
|
func TestMigrationsAreIdempotent(t *testing.T) {
|
|
pool := connect(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
|
|
// Apply twice; second run must not fail.
|
|
if err := db.ApplyMigrations(ctx, pool); err != nil {
|
|
t.Fatalf("first apply: %v", err)
|
|
}
|
|
if err := db.ApplyMigrations(ctx, pool); err != nil {
|
|
t.Fatalf("second apply: %v", err)
|
|
}
|
|
|
|
var n int
|
|
if err := pool.QueryRow(ctx, `select count(*) from projax.items where 'area' = any(kind) and parent_id is null`).Scan(&n); err != nil {
|
|
t.Fatalf("count areas: %v", err)
|
|
}
|
|
if n < 7 {
|
|
t.Fatalf("expected at least 7 seeded areas, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestPathTriggerNestAndRename(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)
|
|
|
|
// Get the 'home' area id.
|
|
var homeID string
|
|
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
|
|
t.Fatalf("read home: %v", err)
|
|
}
|
|
|
|
// Insert child project under home.
|
|
var parentPath string
|
|
if err := tx.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], $1, $2, $3) returning path`,
|
|
"Spring clean", "spring-clean", homeID,
|
|
).Scan(&parentPath); err != nil {
|
|
t.Fatalf("insert spring-clean: %v", err)
|
|
}
|
|
if parentPath != "home.spring-clean" {
|
|
t.Fatalf("expected path 'home.spring-clean', got %q", parentPath)
|
|
}
|
|
|
|
// Insert grandchild.
|
|
var childPath string
|
|
if err := tx.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_id)
|
|
select array['project']::text[], 'Bathroom', 'bathroom', id from projax.items where path='home.spring-clean'
|
|
returning path`).Scan(&childPath); err != nil {
|
|
t.Fatalf("insert bathroom: %v", err)
|
|
}
|
|
if childPath != "home.spring-clean.bathroom" {
|
|
t.Fatalf("expected path 'home.spring-clean.bathroom', got %q", childPath)
|
|
}
|
|
|
|
// Rename middle: descendants must be rewritten.
|
|
if _, err := tx.Exec(ctx, `update projax.items set slug='big-clean' where path='home.spring-clean'`); err != nil {
|
|
t.Fatalf("rename: %v", err)
|
|
}
|
|
var renamedChild string
|
|
if err := tx.QueryRow(ctx, `select path from projax.items where slug='bathroom' and parent_id=(select id from projax.items where slug='big-clean')`).Scan(&renamedChild); err != nil {
|
|
t.Fatalf("read child after rename: %v", err)
|
|
}
|
|
if renamedChild != "home.big-clean.bathroom" {
|
|
t.Fatalf("expected child path 'home.big-clean.bathroom', got %q", renamedChild)
|
|
}
|
|
}
|
|
|
|
func TestPathTriggerReparent(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)
|
|
|
|
var homeID, devID string
|
|
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
|
|
t.Fatalf("home: %v", err)
|
|
}
|
|
if err := tx.QueryRow(ctx, `select id from projax.items where slug='dev' and parent_id is null`).Scan(&devID); err != nil {
|
|
t.Fatalf("dev: %v", err)
|
|
}
|
|
|
|
// Create project under home, then move it to dev.
|
|
var pid string
|
|
if err := tx.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'X', 'mover', $1) returning id`, homeID,
|
|
).Scan(&pid); err != nil {
|
|
t.Fatalf("insert mover: %v", err)
|
|
}
|
|
// Child of mover.
|
|
if _, err := tx.Exec(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'X.child', 'child', $1)`, pid,
|
|
); err != nil {
|
|
t.Fatalf("insert child: %v", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$2`, devID, pid); err != nil {
|
|
t.Fatalf("reparent: %v", err)
|
|
}
|
|
|
|
var p1, p2 string
|
|
if err := tx.QueryRow(ctx, `select path from projax.items where id=$1`, pid).Scan(&p1); err != nil {
|
|
t.Fatalf("read mover path: %v", err)
|
|
}
|
|
if p1 != "dev.mover" {
|
|
t.Fatalf("mover path = %q, want dev.mover", p1)
|
|
}
|
|
if err := tx.QueryRow(ctx, `select path from projax.items where parent_id=$1`, pid).Scan(&p2); err != nil {
|
|
t.Fatalf("read child path: %v", err)
|
|
}
|
|
if p2 != "dev.mover.child" {
|
|
t.Fatalf("child path = %q, want dev.mover.child", p2)
|
|
}
|
|
}
|
|
|
|
func TestStructuralRules(t *testing.T) {
|
|
pool := connect(t)
|
|
defer pool.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
sql string
|
|
args []any
|
|
}{
|
|
{
|
|
name: "area with parent rejected",
|
|
sql: `insert into projax.items (kind, title, slug, parent_id) values (array['area']::text[], 'bad', $1, (select id from projax.items where slug='home' and parent_id is null))`,
|
|
args: []any{fmt.Sprintf("bad-area-%d", time.Now().UnixNano())},
|
|
},
|
|
{
|
|
name: "project at root rejected",
|
|
sql: `insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'orphan', $1, null)`,
|
|
args: []any{fmt.Sprintf("orphan-%d", time.Now().UnixNano())},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
if _, err := tx.Exec(ctx, tc.sql, tc.args...); err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCycleRejected(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)
|
|
|
|
var homeID string
|
|
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
|
|
t.Fatalf("home: %v", err)
|
|
}
|
|
var aID, bID string
|
|
if err := tx.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'A', 'cyc-a', $1) returning id`, homeID,
|
|
).Scan(&aID); err != nil {
|
|
t.Fatalf("a: %v", err)
|
|
}
|
|
if err := tx.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'B', 'cyc-b', $1) returning id`, aID,
|
|
).Scan(&bID); err != nil {
|
|
t.Fatalf("b: %v", err)
|
|
}
|
|
// Now try to make A a child of B -> cycle.
|
|
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$2`, bID, aID); err == nil {
|
|
t.Fatalf("expected cycle rejection, got nil error")
|
|
}
|
|
|
|
// Also: self-parent.
|
|
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$1`, aID); err == nil {
|
|
t.Fatalf("expected self-parent rejection, got nil error")
|
|
}
|
|
}
|