feat(itemwrite): introduce internal/itemwrite/ validator
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
This commit is contained in:
131
docs/plans/itemwrite-validation.md
Normal file
131
docs/plans/itemwrite-validation.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# itemwrite-validation — Phase 5c
|
||||||
|
|
||||||
|
**Task:** `t-projax-5c-itemwrite`
|
||||||
|
**Status:** in progress (Slice A)
|
||||||
|
**Date:** 2026-05-22
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Six Go-side write paths (`web/server.go:handleDetailWrite/handleReparent/handleNewSubmit`, `web/bulk.go:handleBulkApply/handleBulkChip`, `mcp/tools.go:createItemTool/updateItemTool`) currently hand inputs straight to `store.Create/Update/Reparent`. Validation lives only in Postgres triggers. When the trigger raises, callers get an opaque pgErr string and have to substring-match to render anything useful. The bulk-apply path wants to *pre-flight* every row outside the txn — impossible without a Go-side validator.
|
||||||
|
|
||||||
|
Phase 5c lifts the structural rules out of the trigger into `internal/itemwrite/` so:
|
||||||
|
|
||||||
|
1. Callers fail fast with typed `ValidationError{Kind, Path, Detail}` instead of raw pgErr.
|
||||||
|
2. The bulk-apply path validates every row outside the txn; the txn only opens for inputs that already passed validation.
|
||||||
|
3. Handlers render human-readable banners keyed on `Kind`, no substring matching.
|
||||||
|
|
||||||
|
The Postgres trigger stays as **defence in depth**. If the trigger rejects something the Go validator allowed, that's a validator bug and the raw pgErr surfaces unchanged so the gap is visible.
|
||||||
|
|
||||||
|
## Rule enumeration (read from db/migrations/0001 + 0010)
|
||||||
|
|
||||||
|
Every structural rule the live triggers enforce against `projax.items` writes, with the `ValidationError.Kind` the Go validator will report for the same case.
|
||||||
|
|
||||||
|
| # | Where (SQL) | Rule | `Kind` | Trigger raises errcode |
|
||||||
|
|---|----------------------------------------------------------|-------------------------------------------------------------------|-----------------------|------------------------|
|
||||||
|
| 1 | `0001_init.sql` `items_slug_no_dots` | slug must not contain `.` | `invalid-slug-format` | check_violation 23514 |
|
||||||
|
| 2 | `0001_init.sql` `items_status_valid` | status ∈ {active, done, archived} | `invalid-status` | check_violation 23514 |
|
||||||
|
| 3 | `0001_init.sql` `title not null` | title required (we additionally reject empty) | `missing-required` | not_null_violation 23502 |
|
||||||
|
| 4 | `0001_init.sql` `slug not null` | slug required (we additionally reject empty) | `missing-required` | not_null_violation 23502 |
|
||||||
|
| 5 | `0010_multi_parent.sql` `items_before_write` self-parent | `new.parent_ids` must not contain `new.id` | `self-parent` | check_violation 23514 |
|
||||||
|
| 6 | `0010_multi_parent.sql` `compute_item_paths` direct cycle| `p_self_id = ANY(p_parent_ids)` (transitive variant: closure walk)| `cycle` | check_violation 23514 |
|
||||||
|
| 7 | `0010_multi_parent.sql` `compute_item_paths` transitive | A parent's ancestor closure contains self | `cycle` | check_violation 23514 |
|
||||||
|
| 8 | `0010_multi_parent.sql` `items_check_slug_collision` | No two items share a slug under any common parent | `slug-collision` | unique_violation 23505 |
|
||||||
|
| 9 | `0010_multi_parent.sql` `items_root_slug_uniq` (partial idx) | No two root items share a slug | `slug-collision` | unique_violation 23505 |
|
||||||
|
| 10| Implicit: any `parent_ids[i]` must resolve to a live item | `compute_item_paths` swallows pathless parents (returns `[slug]`); the SQL FK is gone with the array model. We add this Go-side. | `unknown-parent` | (n/a — added in Go) |
|
||||||
|
| 11| Implicit: Reparent target path resolution | A path arg in handleReparent / MCP reparent must resolve | `unresolvable-path` | (n/a — added in Go) |
|
||||||
|
|
||||||
|
Two rules in the SQL trigger that we DO NOT validate in Go:
|
||||||
|
|
||||||
|
- **Path computation cap** (`hops > 64` in `compute_item_paths`). It's a safety rail against pathological inputs; the cycle check fires earlier in practice. If the cap ever fires, we let the trigger surface the raw pgErr so we can investigate.
|
||||||
|
- **`items_after_delete` cascade** scrubs deleted ids from descendants' `parent_ids`. Not a validation rule — runs unconditionally on delete.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Package
|
||||||
|
|
||||||
|
`internal/itemwrite/` — new top-level package alongside `internal/aggregate/`, `internal/cache/`, `internal/graph/`. Nothing outside the projax binary imports it.
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ValidationError struct {
|
||||||
|
Kind string // discriminator — see table above
|
||||||
|
Path string // dot-path of the offending item ("dev.paliad", or "" if not yet a path)
|
||||||
|
Detail string // human-facing message (used as banner copy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) Error() string
|
||||||
|
|
||||||
|
type Input struct {
|
||||||
|
ID string // empty for Create; populated for Update / Reparent
|
||||||
|
Title string
|
||||||
|
Slug string
|
||||||
|
Status string
|
||||||
|
ParentIDs []string // resolved IDs (not paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Reader interface {
|
||||||
|
GetByID(ctx context.Context, id string) (*store.Item, error)
|
||||||
|
ListAll(ctx context.Context) ([]*store.Item, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Input` is the validator's input shape; callers populate it from form values / MCP args / bulk rows. `*store.Store` satisfies `Reader` by method-set; tests stub with a small fake.
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ValidateFormat(in Input) *ValidationError
|
||||||
|
func ValidateAgainstStore(ctx context.Context, r Reader, in Input) *ValidationError
|
||||||
|
```
|
||||||
|
|
||||||
|
`ValidateFormat` is pure (no DB): rules 1–4 + 5 (self-parent if Input.ID present).
|
||||||
|
`ValidateAgainstStore` adds rules 6–11.
|
||||||
|
|
||||||
|
Callers compose:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := itemwrite.ValidateFormat(in); err != nil {
|
||||||
|
// render banner via err.Kind
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := itemwrite.ValidateAgainstStore(ctx, s.Store, in); err != nil {
|
||||||
|
// render banner via err.Kind
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// safe to call store.Create / Update / Reparent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cycle detection
|
||||||
|
|
||||||
|
DFS up the ancestor closure starting from each parent id, looking for `in.ID`. Cap at 64 hops to mirror the SQL trigger. Pure-Go; uses the `Reader.ListAll` snapshot to avoid N+1 round-trips.
|
||||||
|
|
||||||
|
## Slicing
|
||||||
|
|
||||||
|
| Slice | What lands |
|
||||||
|
|-------|-----------|
|
||||||
|
| A | Plan doc + `internal/itemwrite/` + table-driven tests covering every Kind. No callers migrate. |
|
||||||
|
| B | `web/server.go` + `web/bulk.go` call the validator before the store. pgErr-string-matching deleted from web/. Bulk-apply pre-flights outside the txn. |
|
||||||
|
| C | `mcp/tools.go createItemTool` + `updateItemTool` call the validator. MCP errors carry `{kind, path, detail}` structured content. |
|
||||||
|
|
||||||
|
Standard deploy-verification triple per slice (commit SHA + container task-ID delta + artifact probe). Per-slice `mai report completed`.
|
||||||
|
|
||||||
|
## Test-modification rule (per task brief)
|
||||||
|
|
||||||
|
- **Behaviour preservation** is the contract: what HTTP status, response body shape, MCP result, accepted/rejected input set — all stay byte-identical for valid AND invalid inputs.
|
||||||
|
- Test SOURCE may change where it asserts on an implementation detail being refactored away (pgErr substring → ValidationError.Kind). Each such change is called out in the commit message.
|
||||||
|
- Test SOURCE must NOT change where it asserts on observable behaviour. If such a test breaks, it's a real behaviour drift — investigate, don't loosen.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Type-system enforcement (`ValidatedInput` newtype). Discipline for v1.
|
||||||
|
- Removing the Postgres triggers (defence in depth stays).
|
||||||
|
- Adding new rules beyond what the trigger enforces today.
|
||||||
|
- Slug rename / alias semantics.
|
||||||
|
- Auditing `if err != nil` paths in web/ beyond the write paths.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Task `t-projax-5c-itemwrite`
|
||||||
|
- Trigger source: `db/migrations/0001_init.sql`, `db/migrations/0002_path_trigger.sql`, `db/migrations/0010_multi_parent.sql`
|
||||||
|
- Sibling packages: `internal/aggregate/` (5a), `internal/cache/` (5b), `internal/graph/`
|
||||||
244
internal/itemwrite/itemwrite.go
Normal file
244
internal/itemwrite/itemwrite.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
// Package itemwrite is the projax write-side validator. Before Phase 5c
|
||||||
|
// every web/MCP write path handed raw form/MCP input straight to
|
||||||
|
// store.Create / store.Update / store.Reparent and relied on the Postgres
|
||||||
|
// triggers in db/migrations/0001 + 0010 to enforce structural rules. The
|
||||||
|
// trigger then raised an opaque pgErr that callers had to substring-match
|
||||||
|
// to render anything human-readable.
|
||||||
|
//
|
||||||
|
// This package mirrors the trigger's rules in Go so:
|
||||||
|
//
|
||||||
|
// - Handlers fail fast with a typed ValidationError before opening a
|
||||||
|
// transaction.
|
||||||
|
// - The /admin/bulk-apply path pre-flights every row outside the txn —
|
||||||
|
// impossible with SQL-only validation.
|
||||||
|
// - Callers render banner copy keyed on err.Kind, no pgErr substring
|
||||||
|
// matching anywhere in projax.
|
||||||
|
//
|
||||||
|
// The Postgres trigger stays as defence-in-depth. If the trigger ever
|
||||||
|
// rejects something this validator allowed, that's a validator bug and the
|
||||||
|
// raw pgErr surfaces unchanged so the gap is visible.
|
||||||
|
//
|
||||||
|
// See docs/plans/itemwrite-validation.md for the rule-to-Kind enumeration.
|
||||||
|
package itemwrite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/m/projax/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind values returned on ValidationError. Each kind matches a rule in
|
||||||
|
// docs/plans/itemwrite-validation.md.
|
||||||
|
const (
|
||||||
|
KindMissingRequired = "missing-required"
|
||||||
|
KindInvalidSlugFormat = "invalid-slug-format"
|
||||||
|
KindInvalidStatus = "invalid-status"
|
||||||
|
KindSlugCollision = "slug-collision"
|
||||||
|
KindCycle = "cycle"
|
||||||
|
KindSelfParent = "self-parent"
|
||||||
|
KindUnknownParent = "unknown-parent"
|
||||||
|
KindUnresolvablePath = "unresolvable-path"
|
||||||
|
)
|
||||||
|
|
||||||
|
// allowedStatuses mirrors db/migrations/0001_init.sql items_status_valid.
|
||||||
|
var allowedStatuses = map[string]struct{}{
|
||||||
|
"active": {}, "done": {}, "archived": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// slugInvalid matches the inverse of db/migrations/0001_init.sql
|
||||||
|
// items_slug_no_dots. Slugs that contain a dot are rejected; we
|
||||||
|
// additionally reject upper-case and whitespace per the convention
|
||||||
|
// documented in docs/design.md §2.2 (slugs are lower-case, no dots).
|
||||||
|
var slugInvalid = regexp.MustCompile(`[A-Z.\s]`)
|
||||||
|
|
||||||
|
// ValidationError is the typed return from ValidateFormat /
|
||||||
|
// ValidateAgainstStore. Handlers switch on Kind to render banner copy and
|
||||||
|
// pick HTTP status codes; the structured shape is what the MCP error
|
||||||
|
// surfaces back to callers via the JSON-RPC envelope.
|
||||||
|
type ValidationError struct {
|
||||||
|
Kind string
|
||||||
|
Path string // dot-path of the offending item ("dev.paliad", "" if not yet a path)
|
||||||
|
Detail string // human-facing message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) Error() string {
|
||||||
|
if e.Path == "" {
|
||||||
|
return fmt.Sprintf("itemwrite: %s: %s", e.Kind, e.Detail)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("itemwrite: %s at %s: %s", e.Kind, e.Path, e.Detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input is the validator's view of a write request. Callers populate it
|
||||||
|
// from form values / MCP args / bulk rows before calling either Validate
|
||||||
|
// method. Empty fields are treated as "not provided" — Update paths leave
|
||||||
|
// fields blank to mean "no change".
|
||||||
|
type Input struct {
|
||||||
|
ID string // empty for Create; populated for Update / Reparent
|
||||||
|
Title string
|
||||||
|
Slug string
|
||||||
|
Status string
|
||||||
|
ParentIDs []string // resolved IDs, not paths
|
||||||
|
// Path is optional context for the error: lets handlers report which
|
||||||
|
// row in a bulk-apply (or which detail page) is broken. Not used by
|
||||||
|
// the validation logic itself.
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reader is the slice of *store.Store the validator needs. Kept as an
|
||||||
|
// interface so unit tests can stub the DB calls cleanly.
|
||||||
|
type Reader interface {
|
||||||
|
GetByID(ctx context.Context, id string) (*store.Item, error)
|
||||||
|
ListAll(ctx context.Context) ([]*store.Item, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFormat runs the pure (no DB) checks: required fields, slug
|
||||||
|
// format, status whitelist, and self-parent. Cheap; safe to call inside a
|
||||||
|
// tight loop (e.g. the bulk-apply preflight).
|
||||||
|
func ValidateFormat(in Input) *ValidationError {
|
||||||
|
if strings.TrimSpace(in.Title) == "" {
|
||||||
|
return &ValidationError{Kind: KindMissingRequired, Path: in.Path, Detail: "title is required"}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.Slug) == "" {
|
||||||
|
return &ValidationError{Kind: KindMissingRequired, Path: in.Path, Detail: "slug is required"}
|
||||||
|
}
|
||||||
|
if slugInvalid.MatchString(in.Slug) {
|
||||||
|
return &ValidationError{
|
||||||
|
Kind: KindInvalidSlugFormat,
|
||||||
|
Path: in.Path,
|
||||||
|
Detail: fmt.Sprintf("slug %q must be lower-case, no dots, no whitespace", in.Slug),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Status != "" {
|
||||||
|
if _, ok := allowedStatuses[in.Status]; !ok {
|
||||||
|
return &ValidationError{
|
||||||
|
Kind: KindInvalidStatus,
|
||||||
|
Path: in.Path,
|
||||||
|
Detail: fmt.Sprintf("status %q must be one of active|done|archived", in.Status),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.ID != "" {
|
||||||
|
for _, pid := range in.ParentIDs {
|
||||||
|
if pid == in.ID {
|
||||||
|
return &ValidationError{
|
||||||
|
Kind: KindSelfParent,
|
||||||
|
Path: in.Path,
|
||||||
|
Detail: "an item cannot be its own parent",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAgainstStore adds the DB-aware checks: every parent id must
|
||||||
|
// resolve to a live item, the proposed parent_ids must not introduce a
|
||||||
|
// cycle, and no sibling under any common parent already carries this slug.
|
||||||
|
// Mirrors db/migrations/0010_multi_parent.sql trigger logic in Go.
|
||||||
|
//
|
||||||
|
// Callers should run ValidateFormat first — this function assumes the
|
||||||
|
// pure checks already passed.
|
||||||
|
func ValidateAgainstStore(ctx context.Context, r Reader, in Input) *ValidationError {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
items, err := r.ListAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// DB unreachable: surface as raw error rather than masquerading as
|
||||||
|
// validation. The caller wraps this; it's not a ValidationError.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
byID := make(map[string]*store.Item, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
byID[it.ID] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 10: every parent_id must resolve to a live item.
|
||||||
|
for _, pid := range in.ParentIDs {
|
||||||
|
if _, ok := byID[pid]; !ok {
|
||||||
|
return &ValidationError{
|
||||||
|
Kind: KindUnknownParent,
|
||||||
|
Path: in.Path,
|
||||||
|
Detail: fmt.Sprintf("parent id %q does not resolve to a live item", pid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules 6/7: cycle detection. Walk the ancestor closure starting from
|
||||||
|
// each proposed parent_id and look for in.ID. Cap at 64 hops to mirror
|
||||||
|
// the trigger's safety rail.
|
||||||
|
if in.ID != "" && len(in.ParentIDs) > 0 {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
queue := append([]string{}, in.ParentIDs...)
|
||||||
|
hops := 0
|
||||||
|
for len(queue) > 0 && hops < 64 {
|
||||||
|
next := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
if next == in.ID {
|
||||||
|
return &ValidationError{
|
||||||
|
Kind: KindCycle,
|
||||||
|
Path: in.Path,
|
||||||
|
Detail: "the proposed parent_ids would put this item in its own ancestor closure",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := seen[next]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[next] = struct{}{}
|
||||||
|
it, ok := byID[next]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
queue = append(queue, it.ParentIDs...)
|
||||||
|
hops++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules 8/9: slug uniqueness. For each parent_id, check whether any
|
||||||
|
// sibling under that parent already carries the same slug. Roots
|
||||||
|
// (parent_ids empty) check against other roots.
|
||||||
|
if len(in.ParentIDs) == 0 {
|
||||||
|
for _, it := range items {
|
||||||
|
if len(it.ParentIDs) != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.ID == in.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.Slug == in.Slug {
|
||||||
|
return &ValidationError{
|
||||||
|
Kind: KindSlugCollision,
|
||||||
|
Path: in.Path,
|
||||||
|
Detail: fmt.Sprintf("a root item with slug %q already exists", in.Slug),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parentSet := make(map[string]struct{}, len(in.ParentIDs))
|
||||||
|
for _, pid := range in.ParentIDs {
|
||||||
|
parentSet[pid] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, it := range items {
|
||||||
|
if it.ID == in.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.Slug != in.Slug {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, sibPID := range it.ParentIDs {
|
||||||
|
if _, common := parentSet[sibPID]; common {
|
||||||
|
return &ValidationError{
|
||||||
|
Kind: KindSlugCollision,
|
||||||
|
Path: in.Path,
|
||||||
|
Detail: fmt.Sprintf("slug %q already exists under parent %q", in.Slug, sibPID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
222
internal/itemwrite/itemwrite_test.go
Normal file
222
internal/itemwrite/itemwrite_test.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user