verstak/cmd/verstak-gui/vault_layout_test.go

557 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"os"
"path/filepath"
"testing"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
"verstak/internal/core/notes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/templates"
"verstak/internal/core/worklog"
)
// setupTestApp creates a full App with a temp vault directory for testing.
func setupTestApp(t *testing.T) (*App, string) {
t.Helper()
vaultRoot, err := os.MkdirTemp("", "verstak-test-*")
if err != nil {
t.Fatalf("mkdir temp: %v", err)
}
// Init vault structure
if err := os.MkdirAll(filepath.Join(vaultRoot, ".verstak"), 0o750); err != nil {
t.Fatalf("mkdir .verstak: %v", err)
}
if err := os.MkdirAll(filepath.Join(vaultRoot, ".verstak", "trash"), 0o750); err != nil {
t.Fatalf("mkdir trash: %v", err)
}
dbPath := filepath.Join(vaultRoot, ".verstak", "vault.db")
db, err := storage.Open(dbPath)
if err != nil {
t.Fatalf("open db: %v", err)
}
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, vaultRoot, nodeRepo)
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
activitySvc := activity.NewService(db)
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
pm := plugins.NewManager(vaultRoot)
pm.Discover()
templatesReg := templates.NewRegistry()
if err := templatesReg.LoadSystem(); err != nil {
t.Fatalf("load templates: %v", err)
}
syncSvc := syncsvc.NewService(db, "test-device")
app := &App{
db: db,
nodes: nodeRepo,
files: fileSvc,
notes: noteSvc,
activity: activitySvc,
actions: actionSvc,
worklog: worklogSvc,
search: searchSvc,
plugins: pm,
sync: syncSvc,
templates: templatesReg,
vault: vaultRoot,
}
t.Cleanup(func() {
db.Close()
os.RemoveAll(vaultRoot)
})
return app, vaultRoot
}
func TestVaultLayout_CreateProjectTree(t *testing.T) {
app, vault := setupTestApp(t)
// 1. Create root "Проекты" from folder template
proj, err := app.CreateNodeFromTemplate("", "Проекты", "folder.default")
if err != nil {
t.Fatalf("create Проекты: %v", err)
}
if proj.FsPath != "Проекты" {
t.Errorf("expected fs_path 'Проекты', got %q", proj.FsPath)
}
if _, err := os.Stat(filepath.Join(vault, "Проекты")); os.IsNotExist(err) {
t.Error("expected folder 'Проекты' to exist on disk")
}
// 2. Create child "Рабочие" inside Проекты
work, err := app.CreateNodeFromTemplate(proj.ID, "Рабочие", "folder.default")
if err != nil {
t.Fatalf("create Рабочие: %v", err)
}
expectedWorkPath := "Проекты/Рабочие"
if work.FsPath != expectedWorkPath {
t.Errorf("expected fs_path %q, got %q", expectedWorkPath, work.FsPath)
}
if _, err := os.Stat(filepath.Join(vault, expectedWorkPath)); os.IsNotExist(err) {
t.Error("expected folder 'Проекты/Рабочие' to exist on disk")
}
// 3. Create project "Разработка серверной" from project template
server, err := app.CreateNodeFromTemplate(work.ID, "Разработка серверной", "project.default")
if err != nil {
t.Fatalf("create Разработка серверной: %v", err)
}
expectedServerPath := "Проекты/Рабочие/Разработка серверной"
if server.FsPath != expectedServerPath {
t.Errorf("expected fs_path %q, got %q", expectedServerPath, server.FsPath)
}
serverFolder := filepath.Join(vault, expectedServerPath)
if _, err := os.Stat(serverFolder); os.IsNotExist(err) {
t.Error("expected project folder on disk")
}
// 4. Verify template created Overview.md
overviewPath := filepath.Join(serverFolder, "Overview.md")
if _, err := os.Stat(overviewPath); os.IsNotExist(err) {
t.Log("note: Overview.md from template not created (may not be implemented)")
}
}
func TestVaultLayout_CreateNoteInsideProject(t *testing.T) {
app, vault := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "Тестовый проект", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Create a note inside the project
noteNode, fileRec, err := app.notes.Create(proj.ID, "Моя заметка", "")
if err != nil {
t.Fatalf("create note: %v", err)
}
if noteNode == nil || fileRec == nil {
t.Fatal("expected non-nil node and file record")
}
// Verify the note .md file is inside the project folder
expectedPath := filepath.Join(vault, proj.FsPath, "Моя заметка.md")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
// Try the safe-display-name variant
expectedPath2 := filepath.Join(vault, proj.FsPath, "Моя_заметка.md")
if _, err2 := os.Stat(expectedPath2); os.IsNotExist(err2) {
// Show what actually exists
entries, _ := os.ReadDir(filepath.Join(vault, proj.FsPath))
t.Errorf("expected note file in project folder, found: %v", listNames(entries))
}
}
}
func TestVaultLayout_CopyFileIntoProject(t *testing.T) {
app, vault := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "Проект", "folder.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Create a temp source file
srcFile := filepath.Join(vault, "source.txt")
if err := os.WriteFile(srcFile, []byte("hello"), 0o640); err != nil {
t.Fatalf("write source: %v", err)
}
// Create a file node inside the project
fileNode, err := app.nodes.Create(&proj.ID, "file", "test.txt", 0, "", "")
if err != nil {
t.Fatalf("create file node: %v", err)
}
// Copy the file into vault (using parent's fs_path)
rec, err := app.files.CopyIntoVault(fileNode.ID, srcFile, proj.FsPath)
if err != nil {
t.Fatalf("copy into vault: %v", err)
}
// Verify file lands in project folder
expectedPath := filepath.Join(vault, proj.FsPath, "source.txt")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
t.Errorf("expected file at %s, record path = %s", expectedPath, rec.Path)
}
}
func TestVaultLayout_RenameParentUpdatesDescendants(t *testing.T) {
app, vault := setupTestApp(t)
root, _ := app.CreateNodeFromTemplate("", "Root", "folder.default")
child, _ := app.CreateNodeFromTemplate(root.ID, "Child", "folder.default")
grandchild, _ := app.CreateNodeFromTemplate(child.ID, "Grandchild", "folder.default")
// Rename root
if err := app.RenameNode(root.ID, "RenamedRoot"); err != nil {
t.Fatalf("rename root: %v", err)
}
// Verify child fs_path updated
childUpdated, _ := app.nodes.GetActive(child.ID)
expectedChildPath := "RenamedRoot/Child"
if childUpdated.FsPath != expectedChildPath {
t.Errorf("expected child fs_path %q, got %q", expectedChildPath, childUpdated.FsPath)
}
// Verify grandchild fs_path updated
gcUpdated, _ := app.nodes.GetActive(grandchild.ID)
expectedGCPath := "RenamedRoot/Child/Grandchild"
if gcUpdated.FsPath != expectedGCPath {
t.Errorf("expected grandchild fs_path %q, got %q", expectedGCPath, gcUpdated.FsPath)
}
// Verify physical folders
if _, err := os.Stat(filepath.Join(vault, expectedChildPath)); os.IsNotExist(err) {
t.Error("expected child folder on disk after rename")
}
if _, err := os.Stat(filepath.Join(vault, expectedGCPath)); os.IsNotExist(err) {
t.Error("expected grandchild folder on disk after rename")
}
// Verify old path no longer exists
if _, err := os.Stat(filepath.Join(vault, "Root")); !os.IsNotExist(err) {
t.Error("expected old root path to not exist")
}
}
func TestVaultLayout_MoveNode(t *testing.T) {
app, vault := setupTestApp(t)
folder1, _ := app.CreateNodeFromTemplate("", "Folder1", "folder.default")
folder2, _ := app.CreateNodeFromTemplate("", "Folder2", "folder.default")
child, _ := app.CreateNodeFromTemplate(folder1.ID, "Child", "folder.default")
// Move child from Folder1 to Folder2
if err := app.MoveNode(child.ID, folder2.ID); err != nil {
t.Fatalf("move node: %v", err)
}
moved, _ := app.nodes.GetActive(child.ID)
expectedPath := "Folder2/Child"
if moved.FsPath != expectedPath {
t.Errorf("expected fs_path %q, got %q", expectedPath, moved.FsPath)
}
if _, err := os.Stat(filepath.Join(vault, expectedPath)); os.IsNotExist(err) {
t.Error("expected child folder at new location on disk")
}
}
func TestVaultLayout_DeleteMovesToTrash(t *testing.T) {
app, vault := setupTestApp(t)
node, _ := app.CreateNodeFromTemplate("", "ToDelete", "folder.default")
nodePath := filepath.Join(vault, "ToDelete")
if _, err := os.Stat(nodePath); os.IsNotExist(err) {
t.Fatal("expected folder to exist before delete")
}
// Delete the node
if err := app.DeleteNode(node.ID); err != nil {
t.Fatalf("delete node: %v", err)
}
// Verify node folder is no longer in original location
if _, err := os.Stat(nodePath); !os.IsNotExist(err) {
t.Error("expected node folder to be removed from original location")
}
// Verify trash has the folder
trashDir := filepath.Join(vault, ".verstak", "trash")
entries, _ := os.ReadDir(trashDir)
found := false
for _, e := range entries {
if e.IsDir() && contains(e.Name(), "ToDelete") {
found = true
break
}
}
if !found {
t.Errorf("expected deleted folder in trash, found: %v", listNames(entries))
}
// Verify node is soft-deleted
_, err := app.nodes.GetActive(node.ID)
if err == nil {
t.Error("expected node to be soft-deleted")
}
}
func TestVaultLayout_NameConflict(t *testing.T) {
app, vault := setupTestApp(t)
node1, err := app.CreateNodeFromTemplate("", "SameName", "folder.default")
if err != nil {
t.Fatalf("create first: %v", err)
}
if node1.FsPath != "SameName" {
t.Errorf("expected fs_path 'SameName', got %q", node1.FsPath)
}
node2, err := app.CreateNodeFromTemplate("", "SameName", "folder.default")
if err != nil {
t.Fatalf("create second: %v", err)
}
if node2.FsPath == "SameName" {
t.Errorf("expected unique fs_path for second node, got same %q", node2.FsPath)
}
if node2.FsPath == node1.FsPath {
t.Error("expected different fs_path for conflicting name")
}
// Both folders should exist on disk
if _, err := os.Stat(filepath.Join(vault, node1.FsPath)); os.IsNotExist(err) {
t.Errorf("expected first folder at %s", node1.FsPath)
}
if _, err := os.Stat(filepath.Join(vault, node2.FsPath)); os.IsNotExist(err) {
t.Errorf("expected second folder at %s", node2.FsPath)
}
}
func TestVaultLayout_VaultCheck(t *testing.T) {
app, _ := setupTestApp(t)
// Create a healthy vault structure
app.CreateNodeFromTemplate("", "Healthy", "folder.default")
child, _ := app.CreateNodeFromTemplate("", "Child", "folder.default")
app.CreateNodeFromTemplate(child.ID, "Grandchild", "folder.default")
// Run vault check
result, err := app.VaultCheck()
if err != nil {
t.Fatalf("vault check: %v", err)
}
if !result.Healthy {
t.Errorf("expected healthy vault, got errors: %v", result.Errors)
}
if result.TotalNodes == 0 {
t.Error("expected at least 1 node in check")
}
}
func TestVaultLayout_RenameFileNodeUsesEntityFile(t *testing.T) {
app, vault := setupTestApp(t)
parent, _ := app.CreateNodeFromTemplate("", "Parent", "folder.default")
// Create a file node with file record
fileNode, err := app.nodes.Create(&parent.ID, "file", "test.txt", 0, "", "")
if err != nil {
t.Fatalf("create file node: %v", err)
}
src := filepath.Join(vault, "src.txt")
os.WriteFile(src, []byte("data"), 0o640)
_, err = app.files.CopyIntoVault(fileNode.ID, src, parent.FsPath)
if err != nil {
t.Fatalf("copy into vault: %v", err)
}
// Rename the file node
if err := app.RenameNode(fileNode.ID, "renamed.txt"); err != nil {
t.Fatalf("rename file node: %v", err)
}
// Check the recorded sync op
ops, err := app.sync.GetUnpushedOps()
if err != nil {
t.Fatalf("get ops: %v", err)
}
var found bool
for _, op := range ops {
if op.EntityID == fileNode.ID && op.OpType == syncsvc.OpUpdate {
if op.EntityType != syncsvc.EntityFile {
t.Errorf("expected EntityFile, got %q", op.EntityType)
}
found = true
break
}
}
if !found {
t.Error("expected sync OpUpdate for file node")
}
// Verify physical file was renamed on disk
records, _ := app.files.ListByNode(fileNode.ID)
if len(records) > 0 && records[0].Filename != "renamed.txt" {
t.Errorf("expected filename 'renamed.txt', got %q", records[0].Filename)
}
oldPath := filepath.Join(vault, parent.FsPath, "test.txt")
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
t.Error("expected old file name to not exist")
}
}
func TestVaultLayout_MoveNoteToRoot(t *testing.T) {
app, vault := setupTestApp(t)
parent, _ := app.CreateNodeFromTemplate("", "Parent", "folder.default")
noteNode, _, err := app.notes.Create(parent.ID, "MyNote", "")
if err != nil {
t.Fatalf("create note: %v", err)
}
// Verify note file exists in parent folder
notePath := filepath.Join(vault, "Parent", "MyNote.md")
altPath := filepath.Join(vault, "Parent", "Моя_заметка.md")
if _, err := os.Stat(notePath); os.IsNotExist(err) {
if _, err2 := os.Stat(altPath); os.IsNotExist(err2) {
// Try other variants
entries, _ := os.ReadDir(filepath.Join(vault, "Parent"))
t.Logf("note file candidates: %v", listNames(entries))
}
}
// Move note to root
if err := app.MoveNode(noteNode.ID, ""); err != nil {
t.Fatalf("move note to root: %v", err)
}
// Verify note node has no parent
n, err := app.nodes.GetActive(noteNode.ID)
if err != nil {
t.Fatalf("get note: %v", err)
}
if !n.IsRoot() {
t.Error("expected note to be root after move")
}
// Verify file record path was updated
records, _ := app.files.ListByNode(noteNode.ID)
if len(records) > 0 {
expectedFileBase := filepath.Base(records[0].Path)
// The file should be at vault root, not inside Parent/
if records[0].Path != expectedFileBase {
t.Errorf("expected file path at root, got %q", records[0].Path)
}
}
}
func TestVaultLayout_DeleteFolderLeavesVaultCheckHealthy(t *testing.T) {
app, vault := setupTestApp(t)
// Create folder structure with files
parent, _ := app.CreateNodeFromTemplate("", "ToDelete", "folder.default")
child, _ := app.CreateNodeFromTemplate(parent.ID, "Child", "folder.default")
// Create a file inside the folder
src := filepath.Join(vault, "test.txt")
os.WriteFile(src, []byte("data"), 0o640)
fileNode, err := app.nodes.Create(&child.ID, "file", "file.txt", 0, "", "")
if err != nil {
t.Fatalf("create file node: %v", err)
}
app.files.CopyIntoVault(fileNode.ID, src, child.FsPath)
// Create a note inside the folder
app.notes.Create(child.ID, "Note", "")
// Delete via GUI
if err := app.DeleteNode(parent.ID); err != nil {
t.Fatalf("delete: %v", err)
}
// VaultCheck should be healthy
result, err := app.VaultCheck()
if err != nil {
t.Fatalf("vault check: %v", err)
}
if !result.Healthy {
t.Errorf("expected healthy vault after delete, errors: %v", result.Errors)
}
}
func TestVaultLayout_SyncNodeCreatePreservesFields(t *testing.T) {
app, _ := setupTestApp(t)
// Simulate a remote node create op with all fields
op := syncsvc.Op{
EntityType: syncsvc.EntityNode,
EntityID: "test-remote-id-1",
OpType: syncsvc.OpCreate,
PayloadJSON: `{
"id": "test-remote-id-1",
"parent_id": "",
"type": "folder",
"title": "RemoteFolder",
"slug": "remote-folder",
"template_id": "folder.default",
"fs_path": "RemoteFolder",
"section": "",
"sort_order": 5,
"archived": false,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}`,
}
if err := app.applyRemoteNodeCreate(op); err != nil {
t.Fatalf("apply remote create: %v", err)
}
// Verify the node was created with all fields
n, err := app.nodes.Get("test-remote-id-1")
if err != nil {
t.Fatalf("get node: %v", err)
}
if n.TemplateID != "folder.default" {
t.Errorf("expected template_id 'folder.default', got %q", n.TemplateID)
}
if n.FsPath != "RemoteFolder" {
t.Errorf("expected fs_path 'RemoteFolder', got %q", n.FsPath)
}
if n.SortOrder != 5 {
t.Errorf("expected sort_order 5, got %d", n.SortOrder)
}
if n.Archived {
t.Error("expected archived false")
}
// Verify physical folder was created
physPath := filepath.Join(app.vault, "RemoteFolder")
if _, err := os.Stat(physPath); os.IsNotExist(err) {
t.Error("expected physical folder to be created")
}
}
// --- helpers ---
func listNames(entries []os.DirEntry) []string {
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
return names
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && containsStr(s, substr)
}
func containsStr(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}