verstak/internal/core/notes/note_rename_test.go

312 lines
7.8 KiB
Go

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/")
}
}