Files
projax/internal/itemwrite/itemwrite_test.go
mAi 67577396a2 feat(web): Phase 6 Slice C — route every web write + validator through the adapter
Wire all web-side writes to depend on the interfaces (Server.Writes for
writes, Server.Items for the write-pre-flight reads) instead of the
concrete *Store, so PROJAX_BACKEND will flip them with the reader:

- handleDetailWrite / handleReparent / handleNewSubmit: Update / Reparent /
  Create now go through s.Writes; ValidateAgainstStore now reads s.Items
  (was s.Store) so cycle + collision detection runs against the live
  backend, not stale projax.items.
- dashboard_pin: SetPinned via s.Writes.
- links: AddLinkDated / DeleteLink via s.Writes. linkBelongsToItem now
  resolves ownership through s.Items.LinksByType — a direct
  projax.item_links query would reject every delete under the mBrian
  backend. Dropped the now-dead isNoRows + errors import.
- caldav: all four AddLink + the unlink DeleteLink via s.Writes.
- bulk applyBulk: replaced the raw single-tx multi-row UPDATE with
  interface calls — make_public/private map to SetPublic; the field
  mutations (tags/mgmt/status/timeline-exclude) are read-modify-write via
  Update. Cross-row tx atomicity is dropped (mBrian's HTTP write API has
  no multi-node tx); acceptable at m's bulk-edit scale, one write path
  across both backends. Added updateInputFromItem + appendUnique/removeValue.

- itemwrite: slug uniqueness is now per-user-global (Q6=a, matching
  mBrian's idx_nodes_slug) instead of per-parent. Strictly tighter, so
  still correct on the legacy backend. Test updated to assert the new rule.

Build green. Web suite: only the 8 pre-existing failures remain (4
project_filter + TestTimelineKindMultiValueSurvives + 3 timeline_filter,
all /timeline-301 / seeding issues on main, unrelated to slice C). No new
failures from the rewiring.
2026-06-01 12:18:03 +02:00

235 lines
8.2 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 TestValidateStoreSlugCollisionPerUser(t *testing.T) {
// Phase 6 / Q6=a: slug uniqueness is per-user-global, not per-parent.
r := &stubReader{items: []*store.Item{
mkItem("parent", "parent"),
mkItem("child1", "kid", "parent"),
mkItem("other", "other"),
}}
// New child under the 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 now also collides — the old
// per-parent escape ("two paliads under different roots") is gone.
err = ValidateAgainstStore(context.Background(), r, Input{Title: "x", Slug: "kid", ParentIDs: []string{"other"}})
if err == nil || err.Kind != KindSlugCollision {
t.Fatalf("expected per-user slug-collision regardless of parent, got %v", err)
}
// A novel slug under any parent is fine.
err = ValidateAgainstStore(context.Background(), r, Input{Title: "x", Slug: "novel", ParentIDs: []string{"other"}})
if err != nil {
t.Errorf("novel slug should pass, got %v", err)
}
// Updating the existing child to keep its own slug is not a collision.
err = ValidateAgainstStore(context.Background(), r, Input{ID: "child1", Title: "x", Slug: "kid", ParentIDs: []string{"parent"}})
if err != nil {
t.Errorf("self-update keeping own 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)
}
}