557 lines
16 KiB
Go
557 lines
16 KiB
Go
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
|
||
}
|