1095 lines
34 KiB
Go
1095 lines
34 KiB
Go
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
|
||
}
|