package main import ( "strings" "testing" "verstak/internal/core/nodes" ) // setupTree creates a simple tree: A -> B -> C for MoveNode testing. func setupTree(t *testing.T) (*App, *NodeDTO, *NodeDTO, *NodeDTO) { t.Helper() app, _ := setupTestApp(t) a, err := app.CreateNodeFromTemplate("", "A", "folder.default") if err != nil { t.Fatalf("create A: %v", err) } b, err := app.CreateNodeFromTemplate(a.ID, "B", "folder.default") if err != nil { t.Fatalf("create B: %v", err) } c, err := app.CreateNodeFromTemplate(b.ID, "C", "folder.default") if err != nil { t.Fatalf("create C: %v", err) } return app, a, b, c } // listTree returns a flat mapping of all visible node IDs in the workspace. func listTreeIDs(t *testing.T, app *App) map[string]int { t.Helper() roots, err := app.ListWorkspaceTree() if err != nil { t.Fatalf("ListWorkspaceTree: %v", err) } ids := make(map[string]int) var walk func(parentID string) walk = func(parentID string) { var nodes []NodeDTO var err error if parentID == "" { nodes = roots } else { nodes, err = app.ListWorkspaceChildren(parentID) if err != nil { return } } for _, n := range nodes { ids[n.ID]++ if n.HasChildren { walk(n.ID) } } } walk("") return ids } func TestMoveNode_DescendantToAncestor(t *testing.T) { app, a, b, c := setupTree(t) // Move C into A — must succeed err := app.MoveNode(c.ID, a.ID) if err != nil { t.Fatalf("MoveNode(C, A): %v", err) } // Verify C's parent is A nc, err := app.nodes.GetActive(c.ID) if err != nil { t.Fatalf("GetActive(C): %v", err) } if nc.ParentID == nil || *nc.ParentID != a.ID { t.Errorf("C parent_id = %v, want %s", nc.ParentID, a.ID) } // C should no longer be a child of B bKids, _ := app.nodes.ListChildren(b.ID, false) for _, child := range bKids { if child.ID == c.ID { t.Error("C still appears under B after move") } } // C should be a child of A aKids, _ := app.nodes.ListChildren(a.ID, false) found := false for _, child := range aKids { if child.ID == c.ID { found = true break } } if !found { t.Error("C not found under A after move") } } func TestMoveNode_ChildToRoot(t *testing.T) { app, a, b, _ := setupTree(t) // Move B to root — must succeed err := app.MoveNode(b.ID, "") if err != nil { t.Fatalf("MoveNode(B, root): %v", err) } // Verify B has no parent nb, err := app.nodes.GetActive(b.ID) if err != nil { t.Fatalf("GetActive(B): %v", err) } if nb.ParentID != nil { t.Errorf("B parent_id = %v, want nil", nb.ParentID) } // B should NOT be under A aKids, _ := app.nodes.ListChildren(a.ID, false) for _, child := range aKids { if child.ID == b.ID { t.Error("B still appears under A after move to root") } } // B should appear in roots roots, _ := app.nodes.ListRoots(false) found := false for _, root := range roots { if root.ID == b.ID { found = true break } } if !found { t.Error("B not found in roots after move") } } func TestMoveNode_ParentIntoChild_Rejected(t *testing.T) { app, a, b, _ := setupTree(t) err := app.MoveNode(a.ID, b.ID) if err == nil { t.Fatal("MoveNode(A, B): expected error, got nil") } if !strings.Contains(err.Error(), "descendant") && !strings.Contains(err.Error(), "cycle") { t.Errorf("MoveNode(A, B): unexpected error: %v", err) } // Verify A's parent unchanged na, _ := app.nodes.GetActive(a.ID) if na.ParentID != nil { t.Errorf("A parent_id changed to %v after rejected move", *na.ParentID) } } func TestMoveNode_ParentIntoDeepDescendant_Rejected(t *testing.T) { app, a, _, c := setupTree(t) err := app.MoveNode(a.ID, c.ID) if err == nil { t.Fatal("MoveNode(A, C): expected error, got nil") } if !strings.Contains(err.Error(), "descendant") && !strings.Contains(err.Error(), "cycle") { t.Errorf("MoveNode(A, C): unexpected error: %v", err) } } func TestMoveNode_IntoSelf_Rejected(t *testing.T) { app, a, _, _ := setupTree(t) err := app.MoveNode(a.ID, a.ID) if err == nil { t.Fatal("MoveNode(A, A): expected error, got nil") } if !strings.Contains(err.Error(), "into itself") { t.Errorf("MoveNode(A, A): unexpected error: %v", err) } } func TestMoveNode_NoDuplicateIDs(t *testing.T) { app, a, b, c := setupTree(t) // Initial check: no duplicates ids := listTreeIDs(t, app) for id, count := range ids { if count > 1 { t.Errorf("Duplicate ID %q found %d times before move", id, count) } } // Move C into A if err := app.MoveNode(c.ID, a.ID); err != nil { t.Fatalf("MoveNode(C, A): %v", err) } ids = listTreeIDs(t, app) for id, count := range ids { if count > 1 { t.Errorf("Duplicate ID %q found %d times after C→A move", id, count) } } // Move B to root if err := app.MoveNode(b.ID, ""); err != nil { t.Fatalf("MoveNode(B, root): %v", err) } ids = listTreeIDs(t, app) for id, count := range ids { if count > 1 { t.Errorf("Duplicate ID %q found %d times after B→root move", id, count) } } } func TestMoveNode_FsPathUpdated(t *testing.T) { app, _, _, c := setupTree(t) ncBefore, _ := app.nodes.GetActive(c.ID) origPath := ncBefore.FsPath // Move C to root — fs_path should change if err := app.MoveNode(c.ID, ""); err != nil { t.Fatalf("MoveNode(C, root): %v", err) } nc, _ := app.nodes.GetActive(c.ID) if nc.FsPath == origPath { t.Errorf("C fs_path unchanged after move to root: %q", nc.FsPath) } } func TestMoveNode_NonContainerRejected(t *testing.T) { app, a, _, _ := setupTree(t) // Create a root-level note (not inside A's subtree) note, err := app.nodes.Create(nil, nodes.TypeNote, "Test Note", 0, "", "") if err != nil { t.Fatalf("create note: %v", err) } err = app.MoveNode(a.ID, note.ID) if err == nil { t.Fatal("MoveNode(A, note): expected error for non-container, got nil") } if !strings.Contains(err.Error(), "not a container") { t.Errorf("MoveNode(A, note): unexpected error: %v", err) } }