Files
projax/db/migrate_test.go
mAi b8d3418876 feat(db): projax schema, path trigger, seed areas
- 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).
2026-05-15 13:16:24 +02:00

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")
}
}