verstak/cmd/verstak-gui/vault_layout_test.go

1474 lines
44 KiB
Go
Raw Permalink 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 (
"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")
}
}
func TestVaultLayout_MoveNodeIntoDescendantRejected(t *testing.T) {
app, _ := setupTestApp(t)
parent, _ := app.CreateNodeFromTemplate("", "Parent", "folder.default")
child, _ := app.CreateNodeFromTemplate(parent.ID, "Child", "folder.default")
grandchild, _ := app.CreateNodeFromTemplate(child.ID, "Grandchild", "folder.default")
// Try to move parent into grandchild
if err := app.MoveNode(parent.ID, grandchild.ID); err == nil {
t.Error("expected error when moving parent into descendant")
}
// Try to move child into its own descendant
if err := app.MoveNode(child.ID, grandchild.ID); err == nil {
t.Error("expected error when moving node into own descendant")
}
// Verify nothing changed
n, _ := app.nodes.GetActive(parent.ID)
if n.ParentID != nil {
t.Error("expected parent to remain root")
}
movedChild, _ := app.nodes.GetActive(child.ID)
if movedChild.ParentID == nil || *movedChild.ParentID != parent.ID {
t.Error("expected child to remain under parent")
}
}
func TestVaultLayout_TemplateDefaultFoldersCreatedAsNodes(t *testing.T) {
app, vault := setupTestApp(t)
// The project.default template has DefaultFolders: ["Documents", "Notes", "Files"]
proj, err := app.CreateNodeFromTemplate("", "TestProject", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Verify children nodes exist for each default folder
children, err := app.nodes.ListChildren(proj.ID, false)
if err != nil {
t.Fatalf("list children: %v", err)
}
expected := map[string]string{
"Documents": "folder",
"Notes": "folder",
"Files": "folder",
"Overview": "note",
}
for _, child := range children {
expectedType, ok := expected[child.Title]
if !ok {
t.Errorf("unexpected child %q (type=%q)", child.Title, child.Type)
continue
}
if child.Type != expectedType {
t.Errorf("child %q expected type %q, got %q", child.Title, expectedType, child.Type)
}
if child.FsPath == "" && child.Type == "folder" {
t.Errorf("child %q has empty fs_path", child.Title)
}
if child.Type == "folder" {
physPath := filepath.Join(vault, child.FsPath)
if info, err := os.Stat(physPath); err != nil || !info.IsDir() {
t.Errorf("expected physical folder at %s", physPath)
}
}
}
if len(children) < 4 {
t.Errorf("expected at least 4 children (3 folders + 1 note), got %d", len(children))
}
}
func TestVaultLayout_TemplateDefaultFileCreatedAsNodeWithFileRecord(t *testing.T) {
app, vault := setupTestApp(t)
// The project.default template has DefaultFiles: [{"path": "Overview.md"}]
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Find the Overview note child
children, err := app.nodes.ListChildren(proj.ID, false)
if err != nil {
t.Fatalf("list children: %v", err)
}
var overview *nodes.Node
for i := range children {
if children[i].Title == "Overview" {
overview = &children[i]
break
}
}
if overview == nil {
t.Fatal("expected 'Overview' child node from template")
}
if overview.Type != "note" {
t.Errorf("expected type 'note', got %q", overview.Type)
}
// Verify file record exists
records, err := app.files.ListByNode(overview.ID)
if err != nil {
t.Fatalf("list file records: %v", err)
}
if len(records) == 0 {
t.Fatal("expected file record for Overview")
}
rec := records[0]
if rec.Filename != "Overview.md" {
t.Errorf("expected filename 'Overview.md', got %q", rec.Filename)
}
if rec.StorageMode != "vault" {
t.Errorf("expected storage mode 'vault', got %q", rec.StorageMode)
}
// Verify physical file exists
physPath := filepath.Join(vault, rec.Path)
if _, err := os.Stat(physPath); os.IsNotExist(err) {
t.Errorf("expected physical file at %s", physPath)
}
// Verify notes record exists
var format string
err = app.db.QueryRow("SELECT format FROM notes WHERE node_id=?", overview.ID).Scan(&format)
if err != nil {
t.Errorf("expected notes record: %v", err)
}
if format != "markdown" {
t.Errorf("expected format 'markdown', got %q", format)
}
// Verify sync ops were recorded for child
ops, err := app.sync.GetUnpushedOps()
if err != nil {
t.Fatalf("get ops: %v", err)
}
foundNoteOp := false
for _, op := range ops {
if op.EntityID == overview.ID && op.OpType == syncsvc.OpCreate {
foundNoteOp = true
break
}
}
if !foundNoteOp {
t.Error("expected sync OpCreate for Overview note child")
}
}
func TestVaultLayout_DeleteNodeWithMissingFileDoesNotCorruptDB(t *testing.T) {
app, _ := setupTestApp(t)
// Create a folder with a child note
parent, _ := app.CreateNodeFromTemplate("", "DeleteTest", "folder.default")
noteNode, fileRec, err := app.notes.Create(parent.ID, "TestNote", "")
if err != nil {
t.Fatalf("create note: %v", err)
}
// Delete the physical file to simulate a missing file
physPath := filepath.Join(app.vault, fileRec.Path)
os.Remove(physPath)
// Delete the parent (should handle missing file gracefully)
if err := app.DeleteNode(parent.ID); err != nil {
t.Fatalf("delete parent with missing file: %v", err)
}
// Verify all nodes are soft-deleted
_, err = app.nodes.GetActive(parent.ID)
if err == nil {
t.Error("expected parent to be soft-deleted")
}
_, err = app.nodes.GetActive(noteNode.ID)
if err == nil {
t.Error("expected note to be soft-deleted")
}
// VaultCheck should be healthy (no orphan references)
result, err := app.VaultCheck()
if err != nil {
t.Fatalf("vault check: %v", err)
}
if !result.Healthy {
t.Logf("vault check errors (may be acceptable): %v", result.Errors)
}
}
func TestVaultLayout_TemplateChildrenSyncRoundtrip(t *testing.T) {
app1, vault1 := setupTestApp(t)
defer app1.db.Close()
defer os.RemoveAll(vault1)
// Create a project from template on app1 (creates parent + default children)
proj, err := app1.CreateNodeFromTemplate("", "RoundtripProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Collect all sync ops from app1
ops1, err := app1.sync.GetUnpushedOps()
if err != nil {
t.Fatalf("get ops: %v", err)
}
if len(ops1) < 4 {
t.Fatalf("expected at least 4 sync ops (1 parent + 3 folders + 1 note), got %d", len(ops1))
}
// Create app2 (simulating another device)
app2, vault2 := setupTestApp(t)
defer app2.db.Close()
defer os.RemoveAll(vault2)
// Apply all ops on app2
for _, op := range ops1 {
if err := app2.applyRemoteOp(op); err != nil {
// Skip notes that reference files not in blob store
if op.EntityType == "note" && op.OpType == "create" {
continue
}
t.Fatalf("apply remote op %s/%s: %v", op.EntityType, op.OpType, err)
}
}
// Create physical files for notes on app2 (since blobs aren't shared locally)
createOps := 0
for _, op := range ops1 {
if op.EntityType == syncsvc.EntityNote && op.OpType == syncsvc.OpCreate {
var payload struct {
NodeID string `json:"node_id"`
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
continue
}
dest := filepath.Join(vault2, payload.Path)
os.MkdirAll(filepath.Dir(dest), 0o750)
os.WriteFile(dest, []byte(payload.Content), 0o640)
createOps++
}
}
// Verify app2 has the project node
proj2, err := app2.nodes.GetActive(proj.ID)
if err != nil {
t.Fatalf("app2: expected project node: %v", err)
}
if proj2.Title != "RoundtripProj" {
t.Errorf("app2: expected title %q, got %q", "RoundtripProj", proj2.Title)
}
// Verify app2 has all child nodes
children2, err := app2.nodes.ListChildren(proj.ID, false)
if err != nil {
t.Fatalf("app2: list children: %v", err)
}
expectedChildren := map[string]string{
"Documents": "folder",
"Notes": "folder",
"Files": "folder",
"Overview": "note",
}
found := make(map[string]bool)
for _, child := range children2 {
expectedType, ok := expectedChildren[child.Title]
if !ok {
t.Errorf("app2: unexpected child %q", child.Title)
continue
}
if child.Type != expectedType {
t.Errorf("app2: child %q expected type %q, got %q", child.Title, expectedType, child.Type)
}
found[child.Title] = true
}
for title := range expectedChildren {
if !found[title] {
t.Errorf("app2: missing child %q", title)
}
}
// Verify app2 has the Overview file record and note
for _, child := range children2 {
if child.Title == "Overview" {
records, err := app2.files.ListByNode(child.ID)
if err != nil {
t.Errorf("app2: list file records for Overview: %v", err)
continue
}
if len(records) == 0 {
t.Error("app2: expected file record for Overview")
}
// Verify notes record exists
var format string
err = app2.db.QueryRow("SELECT format FROM notes WHERE node_id=?", child.ID).Scan(&format)
if err != nil {
t.Errorf("app2: expected notes record: %v", err)
}
}
}
}
func TestVaultLayout_TemplateChildrenBackwardCompat(t *testing.T) {
app, vault := setupTestApp(t)
// Simulate a remote node create with template_id but without separate child ops
op := syncsvc.Op{
EntityType: syncsvc.EntityNode,
EntityID: "backward-compat-node-1",
OpType: syncsvc.OpCreate,
PayloadJSON: `{
"id": "backward-compat-node-1",
"parent_id": "",
"type": "project",
"title": "BackwardCompatProj",
"slug": "backward-compat-proj",
"template_id": "project.default",
"fs_path": "BackwardCompatProj",
"section": "",
"sort_order": 0,
"archived": false,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}`,
}
if err := app.applyRemoteOp(op); err != nil {
t.Fatalf("apply remote op: %v", err)
}
// Verify the parent was created
n, err := app.nodes.GetActive("backward-compat-node-1")
if err != nil {
t.Fatalf("get node: %v", err)
}
if n.Title != "BackwardCompatProj" {
t.Errorf("expected title 'BackwardCompatProj', got %q", n.Title)
}
// Verify template children were created by ensureTemplateChildren
children, err := app.nodes.ListChildren("backward-compat-node-1", false)
if err != nil {
t.Fatalf("list children: %v", err)
}
if len(children) < 3 {
t.Errorf("expected at least 3 template children, got %d", len(children))
}
expectedChildren := map[string]string{
"Documents": "folder",
"Notes": "folder",
"Files": "folder",
"Overview": "note",
}
for _, child := range children {
expectedType, ok := expectedChildren[child.Title]
if !ok {
t.Errorf("unexpected child %q", child.Title)
continue
}
if child.Type != expectedType {
t.Errorf("child %q expected type %q, got %q", child.Title, expectedType, child.Type)
}
if child.Type == "folder" && child.FsPath == "" {
t.Errorf("child %q has empty fs_path", child.Title)
}
// Verify physical folder/file exists
if child.Type == "folder" {
physPath := filepath.Join(vault, child.FsPath)
if _, err := os.Stat(physPath); os.IsNotExist(err) {
t.Errorf("expected physical folder at %s", physPath)
}
}
}
}
// --- 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
}