Phase 5c slice A. Pulls the structural rules out of the Postgres triggers into a Go-side validator. The trigger stays as defence in depth; the validator is the human-facing error path. - docs/plans/itemwrite-validation.md enumerates every rule the triggers in 0001 + 0010 enforce, with the ValidationError.Kind callers will see for each. Eleven rules total (two SQL-only safety rails kept untranslated). - internal/itemwrite/itemwrite.go: ValidationError + Input + Reader interface + ValidateFormat (pure: missing fields, slug format, status whitelist, self-parent) + ValidateAgainstStore (DB-aware: unknown-parent, slug-collision under any common parent, cycle via ancestor-closure DFS capped at 64 hops to mirror the trigger). - Eight kind constants exported: missing-required, invalid-slug-format, invalid-status, slug-collision, cycle, self-parent, unknown-parent, unresolvable-path. Tests cover every kind on both happy and reject paths: missing / whitespace fields, slug containing dot / upper / whitespace, invalid status enum, self-parent guard, unknown parent id, root slug collision, sibling slug collision under common parent, cycle on ancestor closure, and the "Reader returns ListAll error → validator returns nil" path (callers see the infra error later, validator doesn't mask it). No caller migrates yet. Same Go-linker DCE caveat as 5a/5b slice A: `strings <binary> | grep internal/itemwrite` returns 0 until slice B imports. Task: t-projax-5c-itemwrite
223 lines
7.4 KiB
Go
223 lines
7.4 KiB
Go
package itemwrite
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
// stubReader fulfils the Reader interface for table-driven tests.
|
|
type stubReader struct {
|
|
items []*store.Item
|
|
listErr error
|
|
}
|
|
|
|
func (s *stubReader) GetByID(_ context.Context, id string) (*store.Item, error) {
|
|
for _, it := range s.items {
|
|
if it.ID == id {
|
|
return it, nil
|
|
}
|
|
}
|
|
return nil, store.ErrNotFound
|
|
}
|
|
|
|
func (s *stubReader) ListAll(_ context.Context) ([]*store.Item, error) {
|
|
if s.listErr != nil {
|
|
return nil, s.listErr
|
|
}
|
|
out := make([]*store.Item, len(s.items))
|
|
copy(out, s.items)
|
|
return out, nil
|
|
}
|
|
|
|
func mkItem(id, slug string, parentIDs ...string) *store.Item {
|
|
return &store.Item{ID: id, Slug: slug, Title: slug, ParentIDs: parentIDs, Paths: []string{slug}}
|
|
}
|
|
|
|
// --- ValidateFormat ---
|
|
|
|
func TestValidateFormatMissing(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in Input
|
|
}{
|
|
{"missing title", Input{Slug: "x"}},
|
|
{"missing slug", Input{Title: "X"}},
|
|
{"whitespace title", Input{Title: " ", Slug: "x"}},
|
|
{"whitespace slug", Input{Title: "X", Slug: " "}},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
err := ValidateFormat(c.in)
|
|
if err == nil {
|
|
t.Fatalf("expected ValidationError, got nil")
|
|
}
|
|
if err.Kind != KindMissingRequired {
|
|
t.Errorf("kind = %q, want %q", err.Kind, KindMissingRequired)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateFormatSlugFormat(t *testing.T) {
|
|
cases := []struct {
|
|
slug string
|
|
bad bool
|
|
}{
|
|
{"prjx", false},
|
|
{"spring-clean", false},
|
|
{"work.paliad", true}, // dot
|
|
{"Work", true}, // upper-case
|
|
{"my slug", true}, // whitespace
|
|
{"123-numeric", false}, // numeric prefix is fine
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.slug, func(t *testing.T) {
|
|
err := ValidateFormat(Input{Title: "x", Slug: c.slug})
|
|
if c.bad && err == nil {
|
|
t.Errorf("expected reject")
|
|
}
|
|
if !c.bad && err != nil {
|
|
t.Errorf("unexpected reject: %v", err)
|
|
}
|
|
if c.bad && err != nil && err.Kind != KindInvalidSlugFormat {
|
|
t.Errorf("kind = %q, want %q", err.Kind, KindInvalidSlugFormat)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateFormatStatus(t *testing.T) {
|
|
if err := ValidateFormat(Input{Title: "x", Slug: "x", Status: "weird"}); err == nil || err.Kind != KindInvalidStatus {
|
|
t.Fatalf("expected invalid-status, got %v", err)
|
|
}
|
|
for _, ok := range []string{"active", "done", "archived", ""} {
|
|
if err := ValidateFormat(Input{Title: "x", Slug: "x", Status: ok}); err != nil {
|
|
t.Errorf("status %q should be valid, got %v", ok, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateFormatSelfParent(t *testing.T) {
|
|
err := ValidateFormat(Input{ID: "self", Title: "x", Slug: "x", ParentIDs: []string{"other", "self"}})
|
|
if err == nil || err.Kind != KindSelfParent {
|
|
t.Fatalf("expected self-parent reject, got %v", err)
|
|
}
|
|
// Create paths (ID empty) must not flag self-parent — there's no self yet.
|
|
if err := ValidateFormat(Input{Title: "x", Slug: "x", ParentIDs: []string{"any"}}); err != nil {
|
|
t.Errorf("Create with no ID should not flag self-parent, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateFormatHappyPath(t *testing.T) {
|
|
in := Input{Title: "X", Slug: "x", Status: "active", ParentIDs: []string{"pid"}}
|
|
if err := ValidateFormat(in); err != nil {
|
|
t.Fatalf("happy path rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- ValidateAgainstStore ---
|
|
|
|
func TestValidateStoreUnknownParent(t *testing.T) {
|
|
r := &stubReader{items: []*store.Item{mkItem("real", "real")}}
|
|
err := ValidateAgainstStore(context.Background(), r, Input{Title: "x", Slug: "x", ParentIDs: []string{"ghost"}})
|
|
if err == nil || err.Kind != KindUnknownParent {
|
|
t.Fatalf("expected unknown-parent, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateStoreSlugCollisionRoot(t *testing.T) {
|
|
r := &stubReader{items: []*store.Item{mkItem("a", "shared"), mkItem("b", "other")}}
|
|
// New root with same slug as 'a' — should reject.
|
|
err := ValidateAgainstStore(context.Background(), r, Input{Title: "x", Slug: "shared"})
|
|
if err == nil || err.Kind != KindSlugCollision {
|
|
t.Fatalf("expected slug-collision, got %v", err)
|
|
}
|
|
// Updating 'a' to itself isn't a collision.
|
|
err = ValidateAgainstStore(context.Background(), r, Input{ID: "a", Title: "x", Slug: "shared"})
|
|
if err != nil {
|
|
t.Fatalf("self-update should not collide, got %v", err)
|
|
}
|
|
// A different root slug is fine.
|
|
err = ValidateAgainstStore(context.Background(), r, Input{Title: "x", Slug: "novel"})
|
|
if err != nil {
|
|
t.Errorf("novel slug should pass, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateStoreSlugCollisionSibling(t *testing.T) {
|
|
r := &stubReader{items: []*store.Item{
|
|
mkItem("parent", "parent"),
|
|
mkItem("child1", "kid", "parent"),
|
|
mkItem("other", "other"),
|
|
}}
|
|
// New child under same parent with the same slug — reject.
|
|
err := ValidateAgainstStore(context.Background(), r, Input{Title: "x", Slug: "kid", ParentIDs: []string{"parent"}})
|
|
if err == nil || err.Kind != KindSlugCollision {
|
|
t.Fatalf("expected slug-collision under common parent, got %v", err)
|
|
}
|
|
// Same slug under a different parent is fine.
|
|
err = ValidateAgainstStore(context.Background(), r, Input{Title: "x", Slug: "kid", ParentIDs: []string{"other"}})
|
|
if err != nil {
|
|
t.Errorf("different-parent slug should pass, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateStoreCycle(t *testing.T) {
|
|
r := &stubReader{items: []*store.Item{
|
|
mkItem("root", "root"),
|
|
mkItem("mid", "mid", "root"),
|
|
mkItem("leaf", "leaf", "mid"),
|
|
}}
|
|
// Reparent 'root' under 'leaf' — closure of leaf reaches root. Cycle.
|
|
err := ValidateAgainstStore(context.Background(), r, Input{ID: "root", Title: "root", Slug: "root", ParentIDs: []string{"leaf"}})
|
|
if err == nil || err.Kind != KindCycle {
|
|
t.Fatalf("expected cycle, got %v", err)
|
|
}
|
|
// Reparent 'mid' under 'leaf' — closure of leaf reaches mid. Cycle.
|
|
err = ValidateAgainstStore(context.Background(), r, Input{ID: "mid", Title: "mid", Slug: "mid", ParentIDs: []string{"leaf"}})
|
|
if err == nil || err.Kind != KindCycle {
|
|
t.Fatalf("expected cycle, got %v", err)
|
|
}
|
|
// Adding 'leaf' under 'root' (its existing ancestor again — same already) is no cycle but
|
|
// would duplicate; we don't gate that. Sanity-check no false positives:
|
|
err = ValidateAgainstStore(context.Background(), r, Input{ID: "leaf", Title: "leaf", Slug: "leaf", ParentIDs: []string{"mid"}})
|
|
if err != nil {
|
|
t.Errorf("existing acyclic parentage should pass, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateStoreReadErrorIsNotValidationError(t *testing.T) {
|
|
r := &stubReader{listErr: errors.New("db down")}
|
|
// DB read failure surfaces as nil here — callers see the underlying
|
|
// error later when they attempt the store call. Validator is not in the
|
|
// business of masking infra errors.
|
|
if err := ValidateAgainstStore(context.Background(), r, Input{Title: "x", Slug: "x"}); err != nil {
|
|
t.Errorf("expected nil on read error (caller's responsibility), got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateStoreHappyPath(t *testing.T) {
|
|
r := &stubReader{items: []*store.Item{mkItem("parent", "parent")}}
|
|
if err := ValidateAgainstStore(context.Background(), r, Input{Title: "X", Slug: "novel", ParentIDs: []string{"parent"}}); err != nil {
|
|
t.Fatalf("happy path rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidationErrorString(t *testing.T) {
|
|
e := &ValidationError{Kind: KindCycle, Path: "dev.paliad", Detail: "cycle"}
|
|
got := e.Error()
|
|
want := "itemwrite: cycle at dev.paliad: cycle"
|
|
if got != want {
|
|
t.Errorf("got %q, want %q", got, want)
|
|
}
|
|
e2 := &ValidationError{Kind: KindMissingRequired, Detail: "title is required"}
|
|
got = e2.Error()
|
|
want = "itemwrite: missing-required: title is required"
|
|
if got != want {
|
|
t.Errorf("got %q, want %q", got, want)
|
|
}
|
|
}
|