package main import ( "encoding/json" "fmt" "os" "path/filepath" "testing" "time" "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") } } func TestVaultLayout_ImportEmptyDirCreatesPhysicalFolder(t *testing.T) { app, vault := setupTestApp(t) // Create a parent folder parent, err := app.CreateNodeFromTemplate("", "Parent", "folder.default") if err != nil { t.Fatalf("create parent: %v", err) } parentFsPath := parent.FsPath // Create an empty temp directory emptyDir := filepath.Join(vault, "emptydir") if err := os.MkdirAll(emptyDir, 0o750); err != nil { t.Fatalf("mkdir empty dir: %v", err) } // Import the empty directory nodes, err := app.files.AddPathCopy(parent.ID, emptyDir) if err != nil { t.Fatalf("import empty dir: %v", err) } if len(nodes) == 0 { t.Fatal("expected at least one node from import") } // The first node should be the imported folder folderNode := &nodes[0] if folderNode.Type != "folder" { t.Errorf("expected folder type, got %q", folderNode.Type) } // Re-read from DB to get updated FsPath (UpdateFsPath is called after Create) folderFromDB, err := app.nodes.Get(folderNode.ID) if err != nil { t.Fatalf("get folder from db: %v", err) } if folderFromDB.FsPath == "" { t.Error("expected non-empty fs_path for imported folder") } // Verify physical folder exists on disk expectedPath := filepath.Join(vault, parentFsPath, "emptydir") if _, err := os.Stat(expectedPath); os.IsNotExist(err) { t.Errorf("expected folder at %s", expectedPath) } if folderFromDB.FsPath != "" { folderPhysPath := filepath.Join(vault, folderFromDB.FsPath) if _, err := os.Stat(folderPhysPath); os.IsNotExist(err) { t.Errorf("expected physical folder at %s", folderPhysPath) } } } func TestVaultLayout_RemoteNoteMoveMovesFile(t *testing.T) { app, vault := setupTestApp(t) // Create source parent folder srcParent, err := app.CreateNodeFromTemplate("", "SrcParent", "folder.default") if err != nil { t.Fatalf("create src parent: %v", err) } // Create dest parent folder dstParent, err := app.CreateNodeFromTemplate("", "DstParent", "folder.default") if err != nil { t.Fatalf("create dst parent: %v", err) } // Create a note in src parent directly (simulating remote create) noteNode, fileRec, err := app.notes.Create(srcParent.ID, "RemoteNote", "") if err != nil { t.Fatalf("create note: %v", err) } // Verify note file exists in source srcFilePath := filepath.Join(vault, fileRec.Path) if _, err := os.Stat(srcFilePath); os.IsNotExist(err) { t.Fatalf("expected note file at %s", srcFilePath) } // Simulate remote move op for EntityNote op := syncsvc.Op{ EntityType: syncsvc.EntityNote, EntityID: noteNode.ID, OpType: syncsvc.OpMove, PayloadJSON: fmt.Sprintf(`{"parent_id":"%s","updated_at":"%s"}`, dstParent.ID, time.Now().UTC().Format(time.RFC3339)), } if err := app.applyRemoteOp(op); err != nil { t.Fatalf("apply remote note move: %v", err) } // Verify node parent_id changed n, err := app.nodes.GetActive(noteNode.ID) if err != nil { t.Fatalf("get moved note: %v", err) } if n.ParentID == nil || *n.ParentID != dstParent.ID { t.Errorf("expected parent_id %s, got %v", dstParent.ID, n.ParentID) } // Verify file was moved to destination folder records, _ := app.files.ListByNode(noteNode.ID) if len(records) > 0 { expectedNewPath := filepath.Join(dstParent.FsPath, filepath.Base(records[0].Path)) if records[0].Path != expectedNewPath { t.Errorf("expected file path %q, got %q", expectedNewPath, records[0].Path) } destFilePath := filepath.Join(vault, records[0].Path) if _, err := os.Stat(destFilePath); os.IsNotExist(err) { t.Errorf("expected file at destination %s", destFilePath) } } // Verify old file no longer exists at source if _, err := os.Stat(srcFilePath); !os.IsNotExist(err) { t.Error("expected old file to not exist after move") } } func TestVaultLayout_RemoteFileMoveMovesPhysicalFile(t *testing.T) { app, vault := setupTestApp(t) // Create source and destination folders srcParent, err := app.CreateNodeFromTemplate("", "SrcParent", "folder.default") if err != nil { t.Fatalf("create src parent: %v", err) } dstParent, err := app.CreateNodeFromTemplate("", "DstParent", "folder.default") if err != nil { t.Fatalf("create dst parent: %v", err) } // Create a file node in source parent fileNode, err := app.nodes.Create(&srcParent.ID, "file", "testfile.txt", 0, "", "") if err != nil { t.Fatalf("create file node: %v", err) } srcFile := filepath.Join(vault, "src.txt") if err := os.WriteFile(srcFile, []byte("test data"), 0o640); err != nil { t.Fatalf("write source: %v", err) } if _, err := app.files.CopyIntoVault(fileNode.ID, srcFile, srcParent.FsPath); err != nil { t.Fatalf("copy into vault: %v", err) } // Verify file exists in source records, _ := app.files.ListByNode(fileNode.ID) if len(records) == 0 { t.Fatal("expected file records") } oldFilePath := filepath.Join(vault, records[0].Path) if _, err := os.Stat(oldFilePath); os.IsNotExist(err) { t.Fatalf("expected file at %s", oldFilePath) } // Simulate remote move op for EntityFile op := syncsvc.Op{ EntityType: syncsvc.EntityFile, EntityID: fileNode.ID, OpType: syncsvc.OpMove, PayloadJSON: fmt.Sprintf(`{"parent_id":"%s","updated_at":"%s"}`, dstParent.ID, time.Now().UTC().Format(time.RFC3339)), } if err := app.applyRemoteOp(op); err != nil { t.Fatalf("apply remote file move: %v", err) } // Verify node parent_id changed n, err := app.nodes.GetActive(fileNode.ID) if err != nil { t.Fatalf("get moved file: %v", err) } if n.ParentID == nil || *n.ParentID != dstParent.ID { t.Errorf("expected parent_id %s, got %v", dstParent.ID, n.ParentID) } // Verify file was moved to destination records2, _ := app.files.ListByNode(fileNode.ID) if len(records2) > 0 { expectedNewPath := filepath.Join(dstParent.FsPath, filepath.Base(records2[0].Path)) if records2[0].Path != expectedNewPath { t.Errorf("expected file path %q, got %q", expectedNewPath, records2[0].Path) } destFilePath := filepath.Join(vault, records2[0].Path) if _, err := os.Stat(destFilePath); os.IsNotExist(err) { t.Errorf("expected file at destination %s", destFilePath) } } // Verify old file no longer exists if _, err := os.Stat(oldFilePath); !os.IsNotExist(err) { t.Error("expected old file to not exist after move") } } func TestVaultLayout_LocalNoteMoveSyncPayloadCanBeAppliedRemotely(t *testing.T) { app, vault := setupTestApp(t) // Create source and destination parents srcParent, err := app.CreateNodeFromTemplate("", "SrcParent", "folder.default") if err != nil { t.Fatalf("create src parent: %v", err) } dstParent, err := app.CreateNodeFromTemplate("", "DstParent", "folder.default") if err != nil { t.Fatalf("create dst parent: %v", err) } // Create a note noteNode, _, err := app.notes.Create(srcParent.ID, "MovedNote", "") if err != nil { t.Fatalf("create note: %v", err) } // Move the note via MoveNode if err := app.MoveNode(noteNode.ID, dstParent.ID); err != nil { t.Fatalf("move note: %v", err) } // Verify local move produced correct sync op ops, err := app.sync.GetUnpushedOps() if err != nil { t.Fatalf("get ops: %v", err) } var moveOp *syncsvc.Op for i := range ops { if ops[i].EntityID == noteNode.ID && ops[i].OpType == syncsvc.OpMove { moveOp = &ops[i] break } } if moveOp == nil { t.Fatal("expected move sync op for note") } if moveOp.EntityType != syncsvc.EntityNote { t.Errorf("expected entity type 'note', got %q", moveOp.EntityType) } // Verify payload has parent_id but no fs_path var payload map[string]interface{} if err := json.Unmarshal([]byte(moveOp.PayloadJSON), &payload); err != nil { t.Fatalf("unmarshal payload: %v", err) } if pid, ok := payload["parent_id"]; !ok || pid != dstParent.ID { t.Errorf("expected parent_id %q in payload, got %v", dstParent.ID, pid) } if _, ok := payload["fs_path"]; ok { t.Error("expected no fs_path in note move payload") } // Verify note file is at destination noteAtDst := filepath.Join(vault, dstParent.FsPath, "MovedNote.md") if _, err := os.Stat(noteAtDst); os.IsNotExist(err) { t.Errorf("expected note at %s", noteAtDst) } // Now simulate receiving this same op on another device app2, _ := setupTestApp(t) defer app2.db.Close() defer os.RemoveAll(app2.vault) // First create the same nodes on app2 (simulating prior sync) // Create src and dst parents _, err = app2.CreateNodeFromTemplate("", "SrcParent", "folder.default") if err != nil { t.Fatalf("app2 create src: %v", err) } _, err = app2.CreateNodeFromTemplate("", "DstParent", "folder.default") if err != nil { t.Fatalf("app2 create dst: %v", err) } // Create the same note via remote create createOp := syncsvc.Op{ EntityType: syncsvc.EntityNote, EntityID: noteNode.ID, OpType: syncsvc.OpCreate, PayloadJSON: moveOp.PayloadJSON, // approximate } // We need proper create payload app2NoteCreatePayload := fmt.Sprintf( `{"node_id":"%s","file_id":"%s","format":"markdown","content":"# MovedNote\n\n","filename":"MovedNote.md","path":"%s","created_at":"%s","updated_at":"%s"}`, noteNode.ID, "test-file-id-1", filepath.Join(srcParent.FsPath, "MovedNote.md"), time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339)) createOp.PayloadJSON = app2NoteCreatePayload if err := app2.applyRemoteOp(createOp); err != nil { t.Fatalf("app2 apply remote create: %v", err) } // Now apply the move op on app2 if err := app2.applyRemoteOp(*moveOp); err != nil { t.Fatalf("app2 apply remote move: %v", err) } // Verify node moved on app2 app2Node, err := app2.nodes.GetActive(noteNode.ID) if err != nil { t.Fatalf("app2 get moved node: %v", err) } if app2Node.ParentID == nil || *app2Node.ParentID != dstParent.ID { t.Errorf("app2: expected parent_id %s, got %v", dstParent.ID, app2Node.ParentID) } // Verify file moved on app2 app2Records, _ := app2.files.ListByNode(noteNode.ID) if len(app2Records) > 0 { destPath := filepath.Join(app2.vault, app2Records[0].Path) if _, err := os.Stat(destPath); os.IsNotExist(err) { t.Errorf("app2: expected file at %s", destPath) } } } func TestVaultLayout_LocalFileMoveSyncPayloadCanBeAppliedRemotely(t *testing.T) { app, vault := setupTestApp(t) // Create source and destination parents srcParent, err := app.CreateNodeFromTemplate("", "SrcParent", "folder.default") if err != nil { t.Fatalf("create src parent: %v", err) } dstParent, err := app.CreateNodeFromTemplate("", "DstParent", "folder.default") if err != nil { t.Fatalf("create dst parent: %v", err) } // Create a file node fileNode, err := app.nodes.Create(&srcParent.ID, "file", "movable.txt", 0, "", "") if err != nil { t.Fatalf("create file node: %v", err) } srcFile := filepath.Join(vault, "src_movable.txt") if err := os.WriteFile(srcFile, []byte("movable data"), 0o640); err != nil { t.Fatalf("write source: %v", err) } if _, err := app.files.CopyIntoVault(fileNode.ID, srcFile, srcParent.FsPath); err != nil { t.Fatalf("copy into vault: %v", err) } // Move the file via MoveNode if err := app.MoveNode(fileNode.ID, dstParent.ID); err != nil { t.Fatalf("move file: %v", err) } // Verify local move produced correct sync op ops, err := app.sync.GetUnpushedOps() if err != nil { t.Fatalf("get ops: %v", err) } var moveOp *syncsvc.Op for i := range ops { if ops[i].EntityID == fileNode.ID && ops[i].OpType == syncsvc.OpMove { moveOp = &ops[i] break } } if moveOp == nil { t.Fatal("expected move sync op for file") } if moveOp.EntityType != syncsvc.EntityFile { t.Errorf("expected entity type 'file', got %q", moveOp.EntityType) } // Verify payload var payload map[string]interface{} if err := json.Unmarshal([]byte(moveOp.PayloadJSON), &payload); err != nil { t.Fatalf("unmarshal payload: %v", err) } if _, ok := payload["fs_path"]; ok { t.Error("expected no fs_path in file move payload") } // Now simulate receiving this op on another device app2, vault2 := setupTestApp(t) defer app2.db.Close() defer os.RemoveAll(vault2) // Create same nodes on app2 _, err = app2.CreateNodeFromTemplate("", "SrcParent", "folder.default") if err != nil { t.Fatalf("app2 create src: %v", err) } _, err = app2.CreateNodeFromTemplate("", "DstParent", "folder.default") if err != nil { t.Fatalf("app2 create dst: %v", err) } // Create the file node via remote create app2CreatePayload := fmt.Sprintf( `{"node_id":"%s","type":"file","title":"movable.txt","slug":"movable-txt","parent_id":"%s","filename":"movable.txt","path":"%s","storage_mode":"vault","size":12,"file_id":"test-file-id-2","created_at":"%s","updated_at":"%s"}`, fileNode.ID, srcParent.ID, filepath.Join(srcParent.FsPath, "movable.txt"), time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339)) createOp := syncsvc.Op{ EntityType: syncsvc.EntityFile, EntityID: fileNode.ID, OpType: syncsvc.OpCreate, PayloadJSON: app2CreatePayload, } if err := app2.applyRemoteOp(createOp); err != nil { t.Fatalf("app2 apply remote create: %v", err) } // Create the physical file on app2 os.MkdirAll(filepath.Join(vault2, srcParent.FsPath), 0o750) os.WriteFile(filepath.Join(vault2, srcParent.FsPath, "movable.txt"), []byte("movable data"), 0o640) // Apply move op on app2 if err := app2.applyRemoteOp(*moveOp); err != nil { t.Fatalf("app2 apply remote move: %v", err) } // Verify node moved on app2 app2Node, err := app2.nodes.GetActive(fileNode.ID) if err != nil { t.Fatalf("app2 get moved node: %v", err) } if app2Node.ParentID == nil || *app2Node.ParentID != dstParent.ID { t.Errorf("app2: expected parent_id %s, got %v", dstParent.ID, app2Node.ParentID) } // Verify file moved on app2 app2Records, _ := app2.files.ListByNode(fileNode.ID) if len(app2Records) > 0 { destPath := filepath.Join(vault2, app2Records[0].Path) if _, err := os.Stat(destPath); os.IsNotExist(err) { t.Errorf("app2: expected file at %s", destPath) } } } func TestVaultLayout_RenameNoteFileReturnsErrorIfPhysicalFileMissing(t *testing.T) { app, _ := setupTestApp(t) parent, err := app.CreateNodeFromTemplate("", "Parent", "folder.default") if err != nil { t.Fatalf("create parent: %v", err) } // Create a note noteNode, fileRec, err := app.notes.Create(parent.ID, "TestNote", "") if err != nil { t.Fatalf("create note: %v", err) } // Delete the physical file physPath := filepath.Join(app.vault, fileRec.Path) if err := os.Remove(physPath); err != nil { t.Fatalf("remove note file: %v", err) } // Rename should fail because physical file is missing if err := app.RenameNode(noteNode.ID, "RenamedNote"); err == nil { t.Error("expected error when renaming note with missing physical file") } // Create a file node fileNode, err := app.nodes.Create(&parent.ID, "file", "testfile.txt", 0, "", "") if err != nil { t.Fatalf("create file node: %v", err) } srcFile := filepath.Join(app.vault, "src_for_test.txt") if err := os.WriteFile(srcFile, []byte("data"), 0o640); err != nil { t.Fatalf("write source: %v", err) } if _, err := app.files.CopyIntoVault(fileNode.ID, srcFile, parent.FsPath); err != nil { t.Fatalf("copy into vault: %v", err) } // Delete the physical file fileRecs, _ := app.files.ListByNode(fileNode.ID) if len(fileRecs) > 0 { filePhysPath := filepath.Join(app.vault, fileRecs[0].Path) if err := os.Remove(filePhysPath); err != nil { t.Fatalf("remove file: %v", err) } } // Rename should fail if err := app.RenameNode(fileNode.ID, "renamed.txt"); err == nil { t.Error("expected error when renaming file with missing physical file") } } func TestVaultLayout_FolderRenameDoesNotUpdateDBIfOsRenameFails(t *testing.T) { app, vault := setupTestApp(t) // Create a parent folder that we'll make read-only parent, err := app.CreateNodeFromTemplate("", "TestRoot", "folder.default") if err != nil { t.Fatalf("create parent: %v", err) } // Create a child folder inside TestRoot child, err := app.CreateNodeFromTemplate(parent.ID, "OriginalName", "folder.default") if err != nil { t.Fatalf("create child: %v", err) } oldTitle := child.Title oldFsPath := child.FsPath // Verify physical folder exists if _, err := os.Stat(filepath.Join(vault, oldFsPath)); os.IsNotExist(err) { t.Fatalf("expected child folder at %s", oldFsPath) } // Make TestRoot read-only to cause os.Rename to fail parentPhysPath := filepath.Join(vault, parent.FsPath) if err := os.Chmod(parentPhysPath, 0o555); err != nil { t.Fatalf("chmod parent: %v", err) } // Restore permissions on cleanup defer os.Chmod(parentPhysPath, 0o755) // Rename should fail because parent is read-only if err := app.RenameNode(child.ID, "RenamedName"); err == nil { t.Error("expected error when renaming folder in read-only directory") } // Verify DB was NOT updated n, err := app.nodes.GetActive(child.ID) if err != nil { t.Fatalf("get child: %v", err) } if n.Title != oldTitle { t.Errorf("expected title %q unchanged, got %q", oldTitle, n.Title) } if n.FsPath != oldFsPath { t.Errorf("expected fs_path %q unchanged, got %q", oldFsPath, n.FsPath) } // Verify physical folder still at old location if _, err := os.Stat(filepath.Join(vault, oldFsPath)); os.IsNotExist(err) { t.Error("expected original folder to still exist") } } // --- 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 }