package notes import ( "os" "path/filepath" "strings" "testing" "verstak/internal/core/files" "verstak/internal/core/nodes" "verstak/internal/core/storage" ) func setupRenameService(t *testing.T) (*Service, *nodes.Repository, string) { t.Helper() dir := t.TempDir() db, err := storage.Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { db.Close() }) nodeRepo := nodes.NewRepository(db) fileSvc := files.NewService(db, dir, nodeRepo) svc := NewService(db, dir, nodeRepo, fileSvc) return svc, nodeRepo, dir } func TestRenameNote(t *testing.T) { svc, nodeRepo, _ := setupRenameService(t) node, _, err := svc.Create("", "Original Title", "") if err != nil { t.Fatalf("Create: %v", err) } // Rename err = svc.Rename(node.ID, "New Title") if err != nil { t.Fatalf("Rename: %v", err) } // Verify node title updated updated, err := nodeRepo.GetActive(node.ID) if err != nil { t.Fatalf("GetActive: %v", err) } if updated.Title != "New Title" { t.Errorf("title = %q, want %q", updated.Title, "New Title") } // Verify slug updated if !strings.Contains(updated.Slug, "new") { t.Errorf("slug = %q, want containing 'new'", updated.Slug) } } func TestRenameNoteRenamesFile(t *testing.T) { svc, nodeRepo, vaultRoot := setupRenameService(t) node, _, err := svc.Create("", "Original Title", "") if err != nil { t.Fatalf("Create: %v", err) } // Write content. content := "# Original Title\n\nSome content." if err := svc.Save(node.ID, content); err != nil { t.Fatalf("Save: %v", err) } // Rename should rename the file on disk. if err := svc.Rename(node.ID, "Renamed Title"); err != nil { t.Fatalf("Rename: %v", err) } // Verify node title updated. updated, err := nodeRepo.GetActive(node.ID) if err != nil { t.Fatalf("GetActive: %v", err) } if updated.Title != "Renamed Title" { t.Errorf("title = %q, want %q", updated.Title, "Renamed Title") } // Verify old file no longer exists. oldPath := filepath.Join(vaultRoot, "Notes", "Original Title.md") if _, err := os.Stat(oldPath); !os.IsNotExist(err) { t.Error("old file should not exist after rename") } // Verify new file exists with correct content. newPath := filepath.Join(vaultRoot, "Notes", "Renamed Title.md") data, err := os.ReadFile(newPath) if err != nil { t.Fatalf("ReadFile: %v", err) } if string(data) != content { t.Errorf("content = %q, want %q", string(data), content) } } func TestRenameNoteCollisionRejected(t *testing.T) { svc, _, _ := setupRenameService(t) // Create two notes. node1, _, err := svc.Create("", "Note Alpha", "") if err != nil { t.Fatalf("Create note1: %v", err) } _, _, err = svc.Create("", "Note Beta", "") if err != nil { t.Fatalf("Create note2: %v", err) } // Renaming note1 to "Note Beta" should fail — file already exists. err = svc.Rename(node1.ID, "Note Beta") if err == nil { t.Error("expected error for rename collision, got nil") } if !strings.Contains(err.Error(), "already exists") { t.Errorf("expected 'already exists' error, got: %v", err) } } func TestRenameNoteEmptyTitle(t *testing.T) { svc, _, _ := setupRenameService(t) node, _, err := svc.Create("", "Valid Title", "") if err != nil { t.Fatalf("Create: %v", err) } // Try to rename with empty title — should fail err = svc.Rename(node.ID, "") if err == nil { t.Error("expected error for empty title") } } func TestDeleteNoteSoftDeletesNode(t *testing.T) { svc, nodeRepo, _ := setupRenameService(t) node, _, err := svc.Create("", "To Delete", "") if err != nil { t.Fatalf("Create: %v", err) } // Delete if err := svc.Delete(node.ID); err != nil { t.Fatalf("Delete: %v", err) } // Verify node is soft-deleted _, err = nodeRepo.GetActive(node.ID) if err == nil { t.Error("expected deleted node to be inactive") } } func TestDeleteNoteDoesNotAffectOtherNotes(t *testing.T) { svc, _, vaultRoot := setupRenameService(t) // Create two notes node1, _, err := svc.Create("", "Note One", "") if err != nil { t.Fatalf("Create note1: %v", err) } node2, _, err := svc.Create("", "Note Two", "") if err != nil { t.Fatalf("Create note2: %v", err) } // Save content to both svc.Save(node1.ID, "content one") svc.Save(node2.ID, "content two") // Delete note1 if err := svc.Delete(node1.ID); err != nil { t.Fatalf("Delete: %v", err) } // Verify note2 content is still readable content, err := svc.Read(node2.ID) if err != nil { t.Fatalf("Read note2: %v", err) } if content != "content two" { t.Errorf("note2 content = %q, want %q", content, "content two") } _ = vaultRoot } func TestPathTraversalBlocked(t *testing.T) { svc, _, vaultRoot := setupRenameService(t) // Try to create a note with path traversal in title node, _, err := svc.Create("", "../../../etc/passwd", "") if err != nil { t.Fatalf("Create: %v", err) } // Verify the file was created with sanitized name, not traversing content, err := svc.Read(node.ID) if err != nil { t.Fatalf("Read: %v", err) } _ = content // Check that no file exists outside vault suspicious := filepath.Join(vaultRoot, "..", "..", "..", "etc", "passwd.md") if _, err := os.Stat(suspicious); err == nil { t.Error("path traversal succeeded — file created outside vault") } } func TestDeleteNoteMovesFileToSharedTrash(t *testing.T) { svc, _, vaultRoot := setupRenameService(t) node, _, err := svc.Create("", "To Delete", "") if err != nil { t.Fatalf("Create: %v", err) } // Write content content := "# To Delete\n\nThis content should survive deletion." if err := svc.Save(node.ID, content); err != nil { t.Fatalf("Save: %v", err) } // Get the file record to know the trash file name. rec, err := svc.Load(node.ID) if err != nil { t.Fatalf("Load: %v", err) } fileRec, err := svc.files.Get(rec.FileID) if err != nil { t.Fatalf("Get file: %v", err) } // Delete (soft-delete + move to shared trash) if err := svc.Delete(node.ID); err != nil { t.Fatalf("Delete: %v", err) } // Verify original file no longer exists at original location origPath := filepath.Join(vaultRoot, "Notes", "To Delete.md") if _, err := os.Stat(origPath); !os.IsNotExist(err) { t.Error("original file should not exist at original location after delete") } // Verify file exists in shared trash (not in trash/notes/) trashDir := filepath.Join(vaultRoot, ".verstak", "trash") trashFile := filepath.Join(trashDir, fileRec.ID+"_"+fileRec.Filename) data, err := os.ReadFile(trashFile) if err != nil { t.Fatalf("ReadFile trash: %v", err) } if string(data) != content { t.Errorf("trash content = %q, want %q", string(data), content) } // Verify file record is marked missing=1 updatedRec, err := svc.files.Get(rec.FileID) if err != nil { t.Fatalf("Get file after delete: %v", err) } if !updatedRec.Missing { t.Error("file record should be marked missing=1 after delete") } } func TestDeleteNoteNoActiveNodeForOrphan(t *testing.T) { svc, nodeRepo, vaultRoot := setupRenameService(t) node, _, err := svc.Create("", "Orphan Test", "") if err != nil { t.Fatalf("Create: %v", err) } // Delete if err := svc.Delete(node.ID); err != nil { t.Fatalf("Delete: %v", err) } // Node should not be active _, err = nodeRepo.GetActive(node.ID) if err == nil { t.Error("deleted node should not be returned by GetActive") } // File should be in shared trash, not in Notes/ notesPath := filepath.Join(vaultRoot, "Notes", "Orphan Test.md") if _, err := os.Stat(notesPath); !os.IsNotExist(err) { t.Error("file should not remain in Notes/ after delete") } // Verify file is in shared trash (not trash/notes/) trashDir := filepath.Join(vaultRoot, ".verstak", "trash") entries, _ := os.ReadDir(trashDir) found := false for _, e := range entries { if e.Name() != "" { found = true break } } if !found { t.Error("file should exist in shared trash/") } }