1434 lines
44 KiB
Go
1434 lines
44 KiB
Go
package notes
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"verstak/internal/core/files"
|
|
"verstak/internal/core/nodes"
|
|
"verstak/internal/core/storage"
|
|
)
|
|
|
|
// setupRepairTest creates a test vault with a case node and returns the
|
|
// full set of services for repair tests.
|
|
func setupRepairTest(t *testing.T) (*Service, *nodes.Repository, *files.Service, 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, fileSvc, dir
|
|
}
|
|
|
|
// createCaseNode is a helper to create a root-level case node with a given fs_path.
|
|
func createCaseNode(t *testing.T, nodeRepo *nodes.Repository, title, fsPath string) *nodes.Node {
|
|
t.Helper()
|
|
parentID := (*string)(nil)
|
|
caseNode, err := nodeRepo.Create(parentID, nodes.TypeCase, title, 0, "", fsPath)
|
|
if err != nil {
|
|
t.Fatalf("create case: %v", err)
|
|
}
|
|
// Create the case directory on disk
|
|
if fsPath != "" {
|
|
if err := os.MkdirAll(filepath.Dir(filepath.Join(t.TempDir(), fsPath)), 0o750); err != nil {
|
|
// This is just a helper — we use the vault root separately
|
|
_ = err
|
|
}
|
|
}
|
|
return caseNode
|
|
}
|
|
|
|
// setupOldLayout creates a case with an Overview.md in the old location
|
|
// (case root) and a note node directly under the case — no Notes folder.
|
|
func setupOldLayout(t *testing.T, svc *Service, nodeRepo *nodes.Repository, fileSvc *files.Service, vaultRoot string) (*nodes.Node, *nodes.Node) {
|
|
t.Helper()
|
|
|
|
// 1. Create case node at root level with fs_path
|
|
caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "Old Case", 0, "", "Cases/OldCase")
|
|
if err != nil {
|
|
t.Fatalf("create case: %v", err)
|
|
}
|
|
|
|
// Create the case directory
|
|
caseDir := filepath.Join(vaultRoot, "Cases", "OldCase")
|
|
if err := os.MkdirAll(caseDir, 0o750); err != nil {
|
|
t.Fatalf("mkdir case: %v", err)
|
|
}
|
|
|
|
// 2. Create note node as direct child of case (old layout)
|
|
noteNode, err := nodeRepo.Create(strPtr(caseNode.ID), nodes.TypeNote, "Overview", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create note: %v", err)
|
|
}
|
|
|
|
// 3. Create the .md file at the OLD location: <case>/Overview.md
|
|
oldFilePath := filepath.Join(caseDir, "Overview.md")
|
|
oldContent := "# Overview\n\nThis is the old layout content."
|
|
if err := os.WriteFile(oldFilePath, []byte(oldContent), 0o640); err != nil {
|
|
t.Fatalf("write file: %v", err)
|
|
}
|
|
|
|
// 4. Insert file record pointing to old path
|
|
filename := "Overview.md"
|
|
oldRelPath := "Cases/OldCase/Overview.md"
|
|
fileRec, err := insertFileRecord(svc.db, noteNode.ID, filename, oldRelPath, "vault", int64(len(oldContent)))
|
|
if err != nil {
|
|
t.Fatalf("insert file record: %v", err)
|
|
}
|
|
|
|
// 5. Insert notes record linking note node → file record
|
|
_, err = svc.db.Exec(
|
|
`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
|
noteNode.ID, fileRec.ID, "markdown")
|
|
if err != nil {
|
|
t.Fatalf("insert notes record: %v", err)
|
|
}
|
|
|
|
return caseNode, noteNode
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestRepairOldLayout verifies that a note under a case with a file at the
|
|
// old location (<case>/Overview.md) gets migrated to the canonical layout.
|
|
func TestRepairOldLayout(t *testing.T) {
|
|
svc, nodeRepo, _, vaultRoot := setupRepairTest(t)
|
|
caseNode, oldNote := setupOldLayout(t, svc, nodeRepo, nil, vaultRoot)
|
|
|
|
// Before repair: note is direct child of case, file at old path
|
|
if *oldNote.ParentID != caseNode.ID {
|
|
t.Fatalf("expected note parent to be case, got %q", *oldNote.ParentID)
|
|
}
|
|
oldFileRec, _ := svc.Load(oldNote.ID)
|
|
fileRec, _ := svc.files.Get(oldFileRec.FileID)
|
|
if !strings.Contains(fileRec.Path, "Cases/OldCase/Overview.md") {
|
|
t.Fatalf("expected old file path, got %q", fileRec.Path)
|
|
}
|
|
|
|
// Verify old file exists on disk
|
|
oldAbs := filepath.Join(vaultRoot, "Cases", "OldCase", "Overview.md")
|
|
if _, err := os.Stat(oldAbs); err != nil {
|
|
t.Fatalf("old file should exist: %v", err)
|
|
}
|
|
|
|
// Run repair
|
|
result, err := svc.RepairNotesLayout()
|
|
if err != nil {
|
|
t.Fatalf("RepairNotesLayout: %v", err)
|
|
}
|
|
|
|
// Verify repair counts
|
|
if result.RepairedNotes != 1 {
|
|
t.Errorf("RepairedNotes = %d, want 1", result.RepairedNotes)
|
|
}
|
|
if result.CreatedFolders != 1 {
|
|
t.Errorf("CreatedFolders = %d, want 1", result.CreatedFolders)
|
|
}
|
|
if result.MovedFiles != 1 {
|
|
t.Errorf("MovedFiles = %d, want 1", result.MovedFiles)
|
|
}
|
|
if len(result.Conflicts) != 0 {
|
|
t.Errorf("unexpected conflicts: %+v", result.Conflicts)
|
|
}
|
|
if len(result.Errors) != 0 {
|
|
t.Errorf("unexpected errors: %+v", result.Errors)
|
|
}
|
|
|
|
// Verify note is now under Notes folder
|
|
updatedNote, _ := nodeRepo.Get(oldNote.ID)
|
|
notesFolder := svc.FindNotesFolder(caseNode.ID)
|
|
if notesFolder == nil {
|
|
t.Fatal("Notes folder should exist")
|
|
}
|
|
if *updatedNote.ParentID != notesFolder.ID {
|
|
t.Errorf("note parent after repair = %q, want Notes folder %q", *updatedNote.ParentID, notesFolder.ID)
|
|
}
|
|
|
|
// Verify Notes folder is under the case
|
|
if *notesFolder.ParentID != caseNode.ID {
|
|
t.Errorf("Notes folder parent = %q, want case %q", *notesFolder.ParentID, caseNode.ID)
|
|
}
|
|
|
|
// Verify file was moved to new location
|
|
newAbs := filepath.Join(vaultRoot, "Cases", "OldCase", "Notes", "Overview.md")
|
|
if _, err := os.Stat(newAbs); err != nil {
|
|
t.Errorf("file should exist at new location: %v", err)
|
|
}
|
|
if _, err := os.Stat(oldAbs); !os.IsNotExist(err) {
|
|
t.Error("file should NOT exist at old location after repair")
|
|
}
|
|
|
|
// Verify file record path was updated
|
|
updatedRec, _ := svc.Load(oldNote.ID)
|
|
updatedFileRec, _ := svc.files.Get(updatedRec.FileID)
|
|
if updatedFileRec.Path != "Cases/OldCase/Notes/Overview.md" {
|
|
t.Errorf("file path = %q, want %q", updatedFileRec.Path, "Cases/OldCase/Notes/Overview.md")
|
|
}
|
|
|
|
// Verify content was preserved
|
|
content, err := svc.Read(oldNote.ID)
|
|
if err != nil {
|
|
t.Fatalf("Read after repair: %v", err)
|
|
}
|
|
if !strings.Contains(content, "old layout content") {
|
|
t.Errorf("content = %q, want to contain 'old layout content'", content)
|
|
}
|
|
}
|
|
|
|
// TestRepairManualMove verifies repair when the file was already manually
|
|
// moved to Notes/ but the DB path still points to the old location.
|
|
func TestRepairManualMove(t *testing.T) {
|
|
svc, nodeRepo, _, vaultRoot := setupRepairTest(t)
|
|
_, oldNote := setupOldLayout(t, svc, nodeRepo, nil, vaultRoot)
|
|
|
|
// Manually move the file to the canonical location (as if user did it)
|
|
oldAbs := filepath.Join(vaultRoot, "Cases", "OldCase", "Overview.md")
|
|
notesDir := filepath.Join(vaultRoot, "Cases", "OldCase", "Notes")
|
|
canonicalAbs := filepath.Join(notesDir, "Overview.md")
|
|
if err := os.MkdirAll(notesDir, 0o750); err != nil {
|
|
t.Fatalf("mkdir Notes: %v", err)
|
|
}
|
|
if err := os.Rename(oldAbs, canonicalAbs); err != nil {
|
|
t.Fatalf("move file: %v", err)
|
|
}
|
|
|
|
// Verify file is now at canonical location, DB still points to old
|
|
if _, err := os.Stat(canonicalAbs); err != nil {
|
|
t.Fatalf("canonical file should exist: %v", err)
|
|
}
|
|
|
|
// Run repair
|
|
result, err := svc.RepairNotesLayout()
|
|
if err != nil {
|
|
t.Fatalf("RepairNotesLayout: %v", err)
|
|
}
|
|
|
|
// Should have repaired the note and updated file path without moving
|
|
if result.RepairedNotes != 1 {
|
|
t.Errorf("RepairedNotes = %d, want 1", result.RepairedNotes)
|
|
}
|
|
if result.UpdatedFilePaths != 1 {
|
|
t.Errorf("UpdatedFilePaths = %d, want 1", result.UpdatedFilePaths)
|
|
}
|
|
if result.MovedFiles != 0 {
|
|
t.Errorf("MovedFiles = %d, want 0 (file was already moved)", result.MovedFiles)
|
|
}
|
|
|
|
// Verify DB path now points to canonical location
|
|
rec, _ := svc.Load(oldNote.ID)
|
|
fileRec, _ := svc.files.Get(rec.FileID)
|
|
if fileRec.Path != "Cases/OldCase/Notes/Overview.md" {
|
|
t.Errorf("file path = %q, want %q", fileRec.Path, "Cases/OldCase/Notes/Overview.md")
|
|
}
|
|
|
|
// Verify content is readable
|
|
content, err := svc.Read(oldNote.ID)
|
|
if err != nil {
|
|
t.Fatalf("Read after repair: %v", err)
|
|
}
|
|
if !strings.Contains(content, "old layout content") {
|
|
t.Errorf("content = %q, want 'old layout content'", content)
|
|
}
|
|
}
|
|
|
|
// TestRepairAlreadyCorrect verifies that repair does nothing when
|
|
// notes are already in the canonical layout.
|
|
func TestRepairAlreadyCorrect(t *testing.T) {
|
|
svc, nodeRepo, _, vaultRoot := setupRepairTest(t)
|
|
|
|
// Create a case node
|
|
caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "Correct Case", 0, "", "Cases/CorrectCase")
|
|
if err != nil {
|
|
t.Fatalf("create case: %v", err)
|
|
}
|
|
caseDir := filepath.Join(vaultRoot, "Cases", "CorrectCase")
|
|
if err := os.MkdirAll(caseDir, 0o750); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
// Create a note using the NEW Create (which creates Notes folder)
|
|
noteNode, _, err := svc.Create(caseNode.ID, "Overview", "")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// Verify note is under Notes folder
|
|
notesFolder := svc.FindNotesFolder(caseNode.ID)
|
|
if notesFolder == nil {
|
|
t.Fatal("Notes folder should exist")
|
|
}
|
|
if *noteNode.ParentID != notesFolder.ID {
|
|
t.Fatalf("note parent = %q, want Notes folder %q", *noteNode.ParentID, notesFolder.ID)
|
|
}
|
|
|
|
// Run repair
|
|
result, err := svc.RepairNotesLayout()
|
|
if err != nil {
|
|
t.Fatalf("RepairNotesLayout: %v", err)
|
|
}
|
|
|
|
// Should be no changes
|
|
if result.RepairedNotes != 0 {
|
|
t.Errorf("RepairedNotes = %d, want 0", result.RepairedNotes)
|
|
}
|
|
if result.CreatedFolders != 0 {
|
|
t.Errorf("CreatedFolders = %d, want 0", result.CreatedFolders)
|
|
}
|
|
if result.MovedFiles != 0 && result.UpdatedFilePaths != 0 {
|
|
t.Errorf("expected no file changes, got MovedFiles=%d UpdatedFilePaths=%d",
|
|
result.MovedFiles, result.UpdatedFilePaths)
|
|
}
|
|
if result.AlreadyCorrect < 1 {
|
|
t.Error("AlreadyCorrect should be >= 1")
|
|
}
|
|
}
|
|
|
|
// TestRepairConflict verifies that when both old and canonical files exist,
|
|
// the repair reports a conflict and does not overwrite.
|
|
func TestRepairConflict(t *testing.T) {
|
|
svc, nodeRepo, _, vaultRoot := setupRepairTest(t)
|
|
_, _ = setupOldLayout(t, svc, nodeRepo, nil, vaultRoot)
|
|
|
|
// Also create the canonical file (as if someone already manually created it)
|
|
notesDir := filepath.Join(vaultRoot, "Cases", "OldCase", "Notes")
|
|
canonicalAbs := filepath.Join(notesDir, "Overview.md")
|
|
if err := os.MkdirAll(notesDir, 0o750); err != nil {
|
|
t.Fatalf("mkdir Notes: %v", err)
|
|
}
|
|
if err := os.WriteFile(canonicalAbs, []byte("conflicting content"), 0o640); err != nil {
|
|
t.Fatalf("write canonical: %v", err)
|
|
}
|
|
|
|
// Both files exist now
|
|
result, err := svc.RepairNotesLayout()
|
|
if err != nil {
|
|
t.Fatalf("RepairNotesLayout: %v", err)
|
|
}
|
|
|
|
// Should report conflict, not move or update
|
|
if len(result.Conflicts) != 1 {
|
|
t.Fatalf("expected 1 conflict, got %d: %+v", len(result.Conflicts), result.Conflicts)
|
|
}
|
|
|
|
// Both files should still exist
|
|
oldAbs := filepath.Join(vaultRoot, "Cases", "OldCase", "Overview.md")
|
|
if _, err := os.Stat(oldAbs); err != nil {
|
|
t.Error("old file should still exist after conflict")
|
|
}
|
|
if _, err := os.Stat(canonicalAbs); err != nil {
|
|
t.Error("canonical file should still exist after conflict")
|
|
}
|
|
}
|
|
|
|
// TestRepairMultipleNotes verifies repair handles several notes under one case.
|
|
func TestRepairMultipleNotes(t *testing.T) {
|
|
svc, nodeRepo, _, vaultRoot := setupRepairTest(t)
|
|
|
|
caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "Multi Case", 0, "", "Cases/MultiCase")
|
|
if err != nil {
|
|
t.Fatalf("create case: %v", err)
|
|
}
|
|
caseDir := filepath.Join(vaultRoot, "Cases", "MultiCase")
|
|
if err := os.MkdirAll(caseDir, 0o750); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
// Create two old-style notes
|
|
notes := []struct {
|
|
title string
|
|
filename string
|
|
content string
|
|
}{
|
|
{"Overview", "Overview.md", "# Overview\n\nMain note."},
|
|
{"Notes", "Notes.md", "# Notes\n\nSecondary note."},
|
|
}
|
|
|
|
var noteNodes []*nodes.Node
|
|
for _, n := range notes {
|
|
noteNode, err := nodeRepo.Create(strPtr(caseNode.ID), nodes.TypeNote, n.title, 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create note %q: %v", n.title, err)
|
|
}
|
|
|
|
filePath := filepath.Join(caseDir, n.filename)
|
|
if err := os.WriteFile(filePath, []byte(n.content), 0o640); err != nil {
|
|
t.Fatalf("write file: %v", err)
|
|
}
|
|
|
|
relPath := "Cases/MultiCase/" + n.filename
|
|
fileRec, err := insertFileRecord(svc.db, noteNode.ID, n.filename, relPath, "vault", int64(len(n.content)))
|
|
if err != nil {
|
|
t.Fatalf("insert file record: %v", err)
|
|
}
|
|
|
|
_, err = svc.db.Exec(
|
|
`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
|
noteNode.ID, fileRec.ID, "markdown")
|
|
if err != nil {
|
|
t.Fatalf("insert notes record: %v", err)
|
|
}
|
|
noteNodes = append(noteNodes, noteNode)
|
|
}
|
|
|
|
// Run repair
|
|
result, err := svc.RepairNotesLayout()
|
|
if err != nil {
|
|
t.Fatalf("RepairNotesLayout: %v", err)
|
|
}
|
|
|
|
if result.RepairedNotes != 2 {
|
|
t.Errorf("RepairedNotes = %d, want 2", result.RepairedNotes)
|
|
}
|
|
|
|
// Verify Notes folder exists with both notes as children
|
|
notesFolder := svc.FindNotesFolder(caseNode.ID)
|
|
if notesFolder == nil {
|
|
t.Fatal("Notes folder should exist")
|
|
}
|
|
|
|
children, err := nodeRepo.ListChildren(notesFolder.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("ListChildren: %v", err)
|
|
}
|
|
if len(children) != 2 {
|
|
t.Errorf("Notes folder has %d children, want 2", len(children))
|
|
}
|
|
|
|
// Verify files were moved and content preserved
|
|
for _, n := range noteNodes {
|
|
content, err := svc.Read(n.ID)
|
|
if err != nil {
|
|
t.Fatalf("Read %q: %v", n.Title, err)
|
|
}
|
|
if len(content) == 0 {
|
|
t.Errorf("note %q has empty content after repair", n.Title)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRepairIdempotent verifies that running repair twice produces the same
|
|
// result (no duplicates, no errors).
|
|
func TestRepairIdempotent(t *testing.T) {
|
|
svc, nodeRepo, _, vaultRoot := setupRepairTest(t)
|
|
_, _ = setupOldLayout(t, svc, nodeRepo, nil, vaultRoot)
|
|
|
|
// First repair
|
|
result1, err := svc.RepairNotesLayout()
|
|
if err != nil {
|
|
t.Fatalf("first repair: %v", err)
|
|
}
|
|
if result1.RepairedNotes != 1 {
|
|
t.Fatalf("expected 1 repaired note, got %d", result1.RepairedNotes)
|
|
}
|
|
|
|
// Second repair — should be a no-op
|
|
result2, err := svc.RepairNotesLayout()
|
|
if err != nil {
|
|
t.Fatalf("second repair: %v", err)
|
|
}
|
|
if result2.RepairedNotes != 0 {
|
|
t.Errorf("second repair changed %d notes, want 0", result2.RepairedNotes)
|
|
}
|
|
if result2.CreatedFolders != 0 {
|
|
t.Errorf("second repair created %d folders, want 0", result2.CreatedFolders)
|
|
}
|
|
if len(result2.Conflicts) != 0 {
|
|
t.Errorf("second repair has conflicts: %+v", result2.Conflicts)
|
|
}
|
|
if len(result2.Errors) != 0 {
|
|
t.Errorf("second repair has errors: %+v", result2.Errors)
|
|
}
|
|
if result2.AlreadyCorrect < 1 {
|
|
t.Errorf("second repair AlreadyCorrect = %d, want >= 1", result2.AlreadyCorrect)
|
|
}
|
|
}
|
|
|
|
// TestRepairDoesNotLoseFileID verifies that notes.file_id stays consistent.
|
|
func TestRepairDoesNotLoseFileID(t *testing.T) {
|
|
svc, nodeRepo, _, vaultRoot := setupRepairTest(t)
|
|
_, oldNote := setupOldLayout(t, svc, nodeRepo, nil, vaultRoot)
|
|
|
|
// Capture file ID before repair
|
|
recBefore, err := svc.Load(oldNote.ID)
|
|
if err != nil {
|
|
t.Fatalf("Load before: %v", err)
|
|
}
|
|
fileIDBefore := recBefore.FileID
|
|
|
|
// Run repair
|
|
_, err = svc.RepairNotesLayout()
|
|
if err != nil {
|
|
t.Fatalf("RepairNotesLayout: %v", err)
|
|
}
|
|
|
|
// File ID should be unchanged
|
|
recAfter, err := svc.Load(oldNote.ID)
|
|
if err != nil {
|
|
t.Fatalf("Load after: %v", err)
|
|
}
|
|
if recAfter.FileID != fileIDBefore {
|
|
t.Errorf("file_id changed: before=%q after=%q", fileIDBefore, recAfter.FileID)
|
|
}
|
|
if recAfter.Format != "markdown" {
|
|
t.Errorf("format changed: %q", recAfter.Format)
|
|
}
|
|
}
|
|
|
|
// TestRepairContentPreserved verifies that note content is not lost during repair.
|
|
func TestRepairContentPreserved(t *testing.T) {
|
|
svc, nodeRepo, _, vaultRoot := setupRepairTest(t)
|
|
|
|
// Create a case and a note using setupOldLayout
|
|
caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "Content Case", 0, "", "Cases/ContentCase")
|
|
if err != nil {
|
|
t.Fatalf("create case: %v", err)
|
|
}
|
|
caseDir := filepath.Join(vaultRoot, "Cases", "ContentCase")
|
|
if err := os.MkdirAll(caseDir, 0o750); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
noteNode, err := nodeRepo.Create(strPtr(caseNode.ID), nodes.TypeNote, "My Overview", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create note: %v", err)
|
|
}
|
|
|
|
originalContent := "# My Overview\n\nThis content MUST survive repair.\n\n## Section 1\n\nSome important notes here."
|
|
oldFilePath := filepath.Join(caseDir, "My Overview.md")
|
|
if err := os.WriteFile(oldFilePath, []byte(originalContent), 0o640); err != nil {
|
|
t.Fatalf("write file: %v", err)
|
|
}
|
|
|
|
relPath := "Cases/ContentCase/My Overview.md"
|
|
fileRec, err := insertFileRecord(svc.db, noteNode.ID, "My Overview.md", relPath, "vault", int64(len(originalContent)))
|
|
if err != nil {
|
|
t.Fatalf("insert file record: %v", err)
|
|
}
|
|
_, err = svc.db.Exec(
|
|
`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
|
noteNode.ID, fileRec.ID, "markdown")
|
|
if err != nil {
|
|
t.Fatalf("insert notes record: %v", err)
|
|
}
|
|
|
|
// Run repair
|
|
_, err = svc.RepairNotesLayout()
|
|
if err != nil {
|
|
t.Fatalf("RepairNotesLayout: %v", err)
|
|
}
|
|
|
|
// Content must be preserved
|
|
content, err := svc.Read(noteNode.ID)
|
|
if err != nil {
|
|
t.Fatalf("Read after repair: %v", err)
|
|
}
|
|
if content != originalContent {
|
|
t.Errorf("content changed:\n got: %q\n want: %q", content, originalContent)
|
|
}
|
|
}
|
|
|
|
// TestCreateNoteCreatesNotesFolder verifies that Create() sets up the
|
|
// canonical layout automatically.
|
|
func TestCreateNoteCreatesNotesFolder(t *testing.T) {
|
|
svc, nodeRepo, _, vaultRoot := setupRepairTest(t)
|
|
|
|
// Create a case
|
|
caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "New Case", 0, "", "Cases/NewCase")
|
|
if err != nil {
|
|
t.Fatalf("create case: %v", err)
|
|
}
|
|
caseDir := filepath.Join(vaultRoot, "Cases", "NewCase")
|
|
if err := os.MkdirAll(caseDir, 0o750); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
// Create note under the case using the new Create
|
|
noteNode, _, err := svc.Create(caseNode.ID, "My Note", "")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// Notes folder should exist
|
|
notesFolder := svc.FindNotesFolder(caseNode.ID)
|
|
if notesFolder == nil {
|
|
t.Fatal("Notes folder should exist after Create")
|
|
}
|
|
|
|
// Note should be child of Notes folder, not case
|
|
if *noteNode.ParentID != notesFolder.ID {
|
|
t.Errorf("note parent = %q, want Notes folder %q", *noteNode.ParentID, notesFolder.ID)
|
|
}
|
|
|
|
// File should be at canonical path
|
|
rec, _ := svc.Load(noteNode.ID)
|
|
fileRec, _ := svc.files.Get(rec.FileID)
|
|
expectedPath := "Cases/NewCase/Notes/My Note.md"
|
|
if fileRec.Path != expectedPath {
|
|
t.Errorf("file path = %q, want %q", fileRec.Path, expectedPath)
|
|
}
|
|
|
|
// File should exist on disk
|
|
if _, err := os.Stat(filepath.Join(vaultRoot, expectedPath)); err != nil {
|
|
t.Errorf("file should exist at %q: %v", expectedPath, err)
|
|
}
|
|
}
|
|
|
|
// TestListNotesUsesNotesFolder verifies that ListNotes (via FindNotesFolder)
|
|
// correctly returns notes from the Notes folder.
|
|
func TestListNotesUsesNotesFolder(t *testing.T) {
|
|
svc, nodeRepo, _, vaultRoot := setupRepairTest(t)
|
|
|
|
// Create a case
|
|
caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "List Test", 0, "", "Cases/ListTest")
|
|
if err != nil {
|
|
t.Fatalf("create case: %v", err)
|
|
}
|
|
caseDir := filepath.Join(vaultRoot, "Cases", "ListTest")
|
|
if err := os.MkdirAll(caseDir, 0o750); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
// Create a note under the Notes folder (new layout)
|
|
note1, _, err := svc.Create(caseNode.ID, "Note A", "")
|
|
if err != nil {
|
|
t.Fatalf("Create Note A: %v", err)
|
|
}
|
|
note2, _, err := svc.Create(caseNode.ID, "Note B", "")
|
|
if err != nil {
|
|
t.Fatalf("Create Note B: %v", err)
|
|
}
|
|
|
|
// FindNotesFolder should return the Notes folder
|
|
notesFolder := svc.FindNotesFolder(caseNode.ID)
|
|
if notesFolder == nil {
|
|
t.Fatal("FindNotesFolder returned nil")
|
|
}
|
|
|
|
// Children of Notes folder should be the two notes
|
|
children, err := nodeRepo.ListChildren(notesFolder.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("ListChildren: %v", err)
|
|
}
|
|
noteIDs := make(map[string]bool)
|
|
for _, c := range children {
|
|
if c.Type == nodes.TypeNote {
|
|
noteIDs[c.ID] = true
|
|
}
|
|
}
|
|
if !noteIDs[note1.ID] {
|
|
t.Error("Note A not found in Notes folder children")
|
|
}
|
|
if !noteIDs[note2.ID] {
|
|
t.Error("Note B not found in Notes folder children")
|
|
}
|
|
}
|
|
|
|
// TestRepairForOldLayoutDoesNotAffectNewNotes verifies that repair
|
|
// does not touch notes already in the correct layout.
|
|
func TestRepairForOldLayoutDoesNotAffectNewNotes(t *testing.T) {
|
|
svc, nodeRepo, _, vaultRoot := setupRepairTest(t)
|
|
|
|
// Create a case
|
|
caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "Mixed Case", 0, "", "Cases/MixedCase")
|
|
if err != nil {
|
|
t.Fatalf("create case: %v", err)
|
|
}
|
|
caseDir := filepath.Join(vaultRoot, "Cases", "MixedCase")
|
|
if err := os.MkdirAll(caseDir, 0o750); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
// Add a note using the new Create (already correct layout)
|
|
noteNew, _, err := svc.Create(caseNode.ID, "New Note", "")
|
|
if err != nil {
|
|
t.Fatalf("Create new note: %v", err)
|
|
}
|
|
|
|
// Add a note using the old layout (direct child)
|
|
noteOld, err := nodeRepo.Create(strPtr(caseNode.ID), nodes.TypeNote, "Old Note", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create old note: %v", err)
|
|
}
|
|
oldFilePath := filepath.Join(caseDir, "Old Note.md")
|
|
oldContent := "# Old Note\n\nOld style content."
|
|
if err := os.WriteFile(oldFilePath, []byte(oldContent), 0o640); err != nil {
|
|
t.Fatalf("write old file: %v", err)
|
|
}
|
|
fileRec, err := insertFileRecord(svc.db, noteOld.ID, "Old Note.md", "Cases/MixedCase/Old Note.md", "vault", int64(len(oldContent)))
|
|
if err != nil {
|
|
t.Fatalf("insert file record: %v", err)
|
|
}
|
|
_, err = svc.db.Exec(
|
|
`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
|
noteOld.ID, fileRec.ID, "markdown")
|
|
if err != nil {
|
|
t.Fatalf("insert notes record: %v", err)
|
|
}
|
|
|
|
// Run repair
|
|
result, err := svc.RepairNotesLayout()
|
|
if err != nil {
|
|
t.Fatalf("RepairNotesLayout: %v", err)
|
|
}
|
|
|
|
// Old note should be repaired
|
|
if result.RepairedNotes != 1 {
|
|
t.Errorf("RepairedNotes = %d, want 1 (only the old note)", result.RepairedNotes)
|
|
}
|
|
|
|
// New note should still be under Notes folder
|
|
notesFolder := svc.FindNotesFolder(caseNode.ID)
|
|
children, _ := nodeRepo.ListChildren(notesFolder.ID, false)
|
|
newNoteFound := false
|
|
for _, c := range children {
|
|
if c.ID == noteNew.ID {
|
|
newNoteFound = true
|
|
break
|
|
}
|
|
}
|
|
if !newNoteFound {
|
|
t.Error("new note should still be in Notes folder after repair")
|
|
}
|
|
|
|
// Old note should also now be in Notes folder
|
|
oldNoteUpdated, _ := nodeRepo.Get(noteOld.ID)
|
|
if *oldNoteUpdated.ParentID != notesFolder.ID {
|
|
t.Errorf("old note parent = %q, want Notes folder %q", *oldNoteUpdated.ParentID, notesFolder.ID)
|
|
}
|
|
}
|
|
|
|
// TestFindNotesFolder verifies the simple lookup works.
|
|
func TestFindNotesFolder(t *testing.T) {
|
|
svc, nodeRepo, _, _ := setupRepairTest(t)
|
|
|
|
caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "Test Case", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create case: %v", err)
|
|
}
|
|
|
|
// Before EnsureNotesFolder, FindNotesFolder should return nil
|
|
if n := svc.FindNotesFolder(caseNode.ID); n != nil {
|
|
t.Error("FindNotesFolder should return nil before EnsureNotesFolder")
|
|
}
|
|
|
|
// Create Notes folder
|
|
folder, err := svc.EnsureNotesFolder(caseNode.ID)
|
|
if err != nil {
|
|
t.Fatalf("EnsureNotesFolder: %v", err)
|
|
}
|
|
if folder == nil {
|
|
t.Fatal("folder is nil")
|
|
}
|
|
|
|
// Now FindNotesFolder should return it
|
|
found := svc.FindNotesFolder(caseNode.ID)
|
|
if found == nil {
|
|
t.Fatal("FindNotesFolder returned nil after EnsureNotesFolder")
|
|
}
|
|
if found.ID != folder.ID {
|
|
t.Errorf("found folder ID = %q, want %q", found.ID, folder.ID)
|
|
}
|
|
|
|
// Should be idempotent
|
|
found2 := svc.FindNotesFolder(caseNode.ID)
|
|
if found2.ID != folder.ID {
|
|
t.Errorf("second call returned different folder: %q vs %q", found2.ID, folder.ID)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SupportsNotes tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSupportsNotesForContainerTypes(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
nodeRepo := nodes.NewRepository(db)
|
|
fileSvc := files.NewService(db, dir, nodeRepo)
|
|
svc := NewService(db, dir, nodeRepo, fileSvc)
|
|
|
|
// Every root-level container type should support notes.
|
|
containerTypes := []string{
|
|
nodes.TypeFolder, nodes.TypeProject, nodes.TypeClient,
|
|
nodes.TypeDocument, nodes.TypeRecipe, nodes.TypeSpace, nodes.TypeCase,
|
|
}
|
|
for _, typ := range containerTypes {
|
|
n, err := nodeRepo.Create(nil, typ, typ, 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create %s node: %v", typ, err)
|
|
}
|
|
if !svc.SupportsNotes(n.ID) {
|
|
t.Errorf("SupportsNotes(%s) = false, want true", typ)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSupportsNotesForNonContainerTypes(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
nodeRepo := nodes.NewRepository(db)
|
|
fileSvc := files.NewService(db, dir, nodeRepo)
|
|
svc := NewService(db, dir, nodeRepo, fileSvc)
|
|
|
|
parent, err := nodeRepo.Create(nil, nodes.TypeFolder, "Parent", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create parent: %v", err)
|
|
}
|
|
|
|
// Non-container types should NOT support notes.
|
|
nonContainerTypes := []string{
|
|
nodes.TypeNote, nodes.TypeFile, nodes.TypeAction,
|
|
nodes.TypeSecret, nodes.TypeWorklog, nodes.TypeLink,
|
|
}
|
|
for _, typ := range nonContainerTypes {
|
|
n, err := nodeRepo.Create(strPtr(parent.ID), typ, typ, 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create %s node: %v", typ, err)
|
|
}
|
|
if svc.SupportsNotes(n.ID) {
|
|
t.Errorf("SupportsNotes(%s) = true, want false", typ)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSupportsNotesForNotesFolder(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
nodeRepo := nodes.NewRepository(db)
|
|
fileSvc := files.NewService(db, dir, nodeRepo)
|
|
svc := NewService(db, dir, nodeRepo, fileSvc)
|
|
|
|
// A folder named "Notes" should return false (prevents Notes/Notes nesting).
|
|
parent, err := nodeRepo.Create(nil, nodes.TypeFolder, "Parent", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create parent: %v", err)
|
|
}
|
|
notesFolder, err := nodeRepo.Create(strPtr(parent.ID), nodes.TypeFolder, "Notes", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create Notes folder: %v", err)
|
|
}
|
|
if svc.SupportsNotes(notesFolder.ID) {
|
|
t.Errorf("SupportsNotes(Notes folder) = true, want false")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// EnsureNotesFolder rejection tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestEnsureNotesFolderRejectsNonContainer(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
nodeRepo := nodes.NewRepository(db)
|
|
fileSvc := files.NewService(db, dir, nodeRepo)
|
|
svc := NewService(db, dir, nodeRepo, fileSvc)
|
|
|
|
fileNode, err := nodeRepo.Create(nil, nodes.TypeFile, "document.pdf", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create file node: %v", err)
|
|
}
|
|
|
|
_, err = svc.EnsureNotesFolder(fileNode.ID)
|
|
if err == nil {
|
|
t.Fatal("EnsureNotesFolder should error for non-container types")
|
|
}
|
|
}
|
|
|
|
func TestEnsureNotesFolderRejectsNotesFolder(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
nodeRepo := nodes.NewRepository(db)
|
|
fileSvc := files.NewService(db, dir, nodeRepo)
|
|
svc := NewService(db, dir, nodeRepo, fileSvc)
|
|
|
|
parent, err := nodeRepo.Create(nil, nodes.TypeFolder, "Parent", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create parent: %v", err)
|
|
}
|
|
|
|
// Create a Notes folder
|
|
notesFolder, err := nodeRepo.Create(strPtr(parent.ID), nodes.TypeFolder, "Notes", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create Notes folder: %v", err)
|
|
}
|
|
|
|
// EnsureNotesFolder on the Notes folder should error
|
|
_, err = svc.EnsureNotesFolder(notesFolder.ID)
|
|
if err == nil {
|
|
t.Fatal("EnsureNotesFolder on Notes folder should error (prevents nesting)")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Create with non-notes parent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCreateErrorsForFileParent(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
nodeRepo := nodes.NewRepository(db)
|
|
fileSvc := files.NewService(db, dir, nodeRepo)
|
|
svc := NewService(db, dir, nodeRepo, fileSvc)
|
|
|
|
// Create a file node
|
|
fileNode, err := nodeRepo.Create(nil, nodes.TypeFile, "doc.pdf", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create file node: %v", err)
|
|
}
|
|
|
|
// Create under a file node should FAIL
|
|
_, _, err = svc.Create(fileNode.ID, "Under File", "")
|
|
if err == nil {
|
|
t.Fatal("Create under file node should error, but succeeded")
|
|
}
|
|
|
|
// Verify no note node, no file record, no notes record were created
|
|
allNotes, _ := nodeRepo.ListByType(nodes.TypeNote)
|
|
for _, n := range allNotes {
|
|
if n.Title == "Under File" {
|
|
t.Errorf("note node should not exist for unsupported parent")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateErrorsForNoteParent(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
nodeRepo := nodes.NewRepository(db)
|
|
fileSvc := files.NewService(db, dir, nodeRepo)
|
|
svc := NewService(db, dir, nodeRepo, fileSvc)
|
|
|
|
parent, err := nodeRepo.Create(nil, nodes.TypeFolder, "Container", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create container: %v", err)
|
|
}
|
|
|
|
// Create a note under the container — works
|
|
existingNote, _, err := svc.Create(parent.ID, "Existing Note", "")
|
|
if err != nil {
|
|
t.Fatalf("Create first note: %v", err)
|
|
}
|
|
|
|
// Create under a NOTE node should FAIL (note type does not support notes)
|
|
_, _, err = svc.Create(existingNote.ID, "Sub Note", "")
|
|
if err == nil {
|
|
t.Fatal("Create under note node should error, but succeeded")
|
|
}
|
|
}
|
|
|
|
func TestCreateForUnsupportedParentDoesNotCreateAnyState(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
nodeRepo := nodes.NewRepository(db)
|
|
fileSvc := files.NewService(db, dir, nodeRepo)
|
|
svc := NewService(db, dir, nodeRepo, fileSvc)
|
|
|
|
fileNode, err := nodeRepo.Create(nil, nodes.TypeFile, "data.pdf", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create file node: %v", err)
|
|
}
|
|
|
|
// Attempt create — should fail
|
|
_, _, err = svc.Create(fileNode.ID, "ShouldNotExist", "")
|
|
if err == nil {
|
|
t.Fatal("Create should have errored")
|
|
}
|
|
|
|
// Verify no note node was created with that title
|
|
allNotes, err := nodeRepo.ListByType(nodes.TypeNote)
|
|
if err != nil {
|
|
t.Fatalf("list nodes: %v", err)
|
|
}
|
|
for _, n := range allNotes {
|
|
if n.Title == "ShouldNotExist" {
|
|
t.Error("note node should not exist for unsupported parent")
|
|
}
|
|
}
|
|
|
|
// Verify no Notes folder was created under file node
|
|
notesFolder := svc.FindNotesFolder(fileNode.ID)
|
|
if notesFolder != nil {
|
|
t.Error("Notes folder should not exist under file node")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Repair skips notes under non-container parents
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestRepairSkipsNotesUnderFileParent(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
nodeRepo := nodes.NewRepository(db)
|
|
fileSvc := files.NewService(db, dir, nodeRepo)
|
|
svc := NewService(db, dir, nodeRepo, fileSvc)
|
|
|
|
// Create a file node
|
|
fileNode, err := nodeRepo.Create(nil, nodes.TypeFile, "readme.pdf", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create file node: %v", err)
|
|
}
|
|
|
|
// Create a note node as direct child of file node (old layout for non-container)
|
|
noteNode, err := nodeRepo.Create(strPtr(fileNode.ID), nodes.TypeNote, "My Note", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create note: %v", err)
|
|
}
|
|
|
|
// Create file record and notes record manually
|
|
fileRec, err := insertFileRecord(svc.db, noteNode.ID, "mynote.md", "some/path/mynote.md", "vault", 10)
|
|
if err != nil {
|
|
t.Fatalf("insert file record: %v", err)
|
|
}
|
|
_, err = svc.db.Exec(
|
|
`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
|
noteNode.ID, fileRec.ID, "markdown")
|
|
if err != nil {
|
|
t.Fatalf("insert notes record: %v", err)
|
|
}
|
|
|
|
// Repair should skip this note (parent is file type)
|
|
result, err := svc.RepairNotesLayout()
|
|
if err != nil {
|
|
t.Fatalf("RepairNotesLayout: %v", err)
|
|
}
|
|
if result.SkippedNotes != 1 {
|
|
t.Errorf("SkippedNotes = %d, want 1", result.SkippedNotes)
|
|
}
|
|
if result.RepairedNotes != 0 {
|
|
t.Errorf("RepairedNotes = %d, want 0", result.RepairedNotes)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ListNotes returns empty for non-notes parents
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestListNotesBindingEmptyForNonNotesParent(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
nodeRepo := nodes.NewRepository(db)
|
|
fileSvc := files.NewService(db, dir, nodeRepo)
|
|
svc := NewService(db, dir, nodeRepo, fileSvc)
|
|
|
|
fileNode, err := nodeRepo.Create(nil, nodes.TypeFile, "file.txt", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create file node: %v", err)
|
|
}
|
|
|
|
// ListNotes should return empty for non-notes-capable parents
|
|
notesFolder := svc.FindNotesFolder(fileNode.ID)
|
|
if notesFolder != nil {
|
|
t.Error("FindNotesFolder should return nil for file node")
|
|
}
|
|
}
|
|
|
|
// TestRepairDoesNotCreateNotesFolderForOldNoteUnderNonContainer ensures
|
|
// that repair does NOT create a Notes folder when migrating notes that were
|
|
// accidentally placed under non-container types.
|
|
func TestRepairDoesNotCreateNotesFolderForNonContainerNote(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
nodeRepo := nodes.NewRepository(db)
|
|
fileSvc := files.NewService(db, dir, nodeRepo)
|
|
svc := NewService(db, dir, nodeRepo, fileSvc)
|
|
|
|
// Create a folder (container)
|
|
container, err := nodeRepo.Create(nil, nodes.TypeFolder, "My Project", 0, "", "MyProject")
|
|
if err != nil {
|
|
t.Fatalf("create container: %v", err)
|
|
}
|
|
|
|
// Create a note node directly under the container (old layout)
|
|
noteNode, err := nodeRepo.Create(strPtr(container.ID), nodes.TypeNote, "Overview", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create note: %v", err)
|
|
}
|
|
|
|
// Create physical file
|
|
containerDir := filepath.Join(dir, "MyProject")
|
|
if err := os.MkdirAll(containerDir, 0o750); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
oldFilePath := filepath.Join(containerDir, "Overview.md")
|
|
oldContent := "# Overview\n\nContent"
|
|
if err := os.WriteFile(oldFilePath, []byte(oldContent), 0o640); err != nil {
|
|
t.Fatalf("write file: %v", err)
|
|
}
|
|
|
|
// Insert file record with old path
|
|
fileRec, err := insertFileRecord(svc.db, noteNode.ID, "Overview.md", "MyProject/Overview.md", "vault", int64(len(oldContent)))
|
|
if err != nil {
|
|
t.Fatalf("insert file record: %v", err)
|
|
}
|
|
_, err = svc.db.Exec(
|
|
`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
|
noteNode.ID, fileRec.ID, "markdown")
|
|
if err != nil {
|
|
t.Fatalf("insert notes record: %v", err)
|
|
}
|
|
|
|
// Repair should work for this note (folder type supports notes)
|
|
result, err := svc.RepairNotesLayout()
|
|
if err != nil {
|
|
t.Fatalf("RepairNotesLayout: %v", err)
|
|
}
|
|
if result.RepairedNotes != 1 {
|
|
t.Errorf("RepairedNotes = %d, want 1 (folder supports notes)", result.RepairedNotes)
|
|
}
|
|
if result.SkippedNotes != 0 {
|
|
t.Errorf("SkippedNotes = %d, want 0", result.SkippedNotes)
|
|
}
|
|
|
|
// Notes folder should exist
|
|
notesFolder := svc.FindNotesFolder(container.ID)
|
|
if notesFolder == nil {
|
|
t.Fatal("Notes folder should have been created under container")
|
|
}
|
|
|
|
// File should be in Notes/
|
|
canonicalPath := filepath.Join(dir, "MyProject", "Notes", "Overview.md")
|
|
if _, err := os.Stat(canonicalPath); err != nil {
|
|
t.Errorf("file should exist at %s: %v", canonicalPath, err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Files tab verification — full chain tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestFilesTabShowsNotesFolder verifies that after creating a note via
|
|
// the service, the case's children (ListChildren) contain a Notes folder
|
|
// and the Notes folder contains the note node.
|
|
func TestFilesTabShowsNotesFolder(t *testing.T) {
|
|
t.Parallel()
|
|
svc, nodeRepo, _, vaultRoot := setupRepairTest(t)
|
|
|
|
caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "MyCase", 0, "", "Cases/MyCase")
|
|
if err != nil {
|
|
t.Fatalf("create case: %v", err)
|
|
}
|
|
caseDir := filepath.Join(vaultRoot, "Cases", "MyCase")
|
|
if err := os.MkdirAll(caseDir, 0o750); err != nil {
|
|
t.Fatalf("mkdir case: %v", err)
|
|
}
|
|
|
|
// Create a note via the service
|
|
noteNode, _, err := svc.Create(caseNode.ID, "TestNote", "")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// Files tab list: case's children should contain Notes folder
|
|
children, err := nodeRepo.ListChildren(caseNode.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("ListChildren case: %v", err)
|
|
}
|
|
var notesFolder *nodes.Node
|
|
for _, c := range children {
|
|
if c.Type == nodes.TypeFolder && c.Title == "Notes" {
|
|
notesFolder = &c
|
|
break
|
|
}
|
|
}
|
|
if notesFolder == nil {
|
|
t.Fatal("Files tab should show Notes folder under case, but it's missing")
|
|
}
|
|
|
|
// Files tab drill-down: Notes folder should contain the note
|
|
notesChildren, err := nodeRepo.ListChildren(notesFolder.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("ListChildren Notes: %v", err)
|
|
}
|
|
found := false
|
|
for _, c := range notesChildren {
|
|
if c.ID == noteNode.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("Files tab should show the note inside Notes folder, but it's missing")
|
|
}
|
|
|
|
// Physical file should exist
|
|
expectedPath := filepath.Join(caseDir, "Notes", "TestNote.md")
|
|
if _, err := os.Stat(expectedPath); err != nil {
|
|
t.Errorf("physical file should exist at %s: %v", expectedPath, err)
|
|
}
|
|
}
|
|
|
|
// TestFilesTabRepairAndReadNoteChain verifies that after repairing an old
|
|
// layout (Overview.md at case root), the Files tab and Notes tab both work:
|
|
// - Notes folder is visible via ListChildren
|
|
// - Overview.md is visible inside Notes folder
|
|
// - ReadNote returns content
|
|
func TestFilesTabRepairAndReadNoteChain(t *testing.T) {
|
|
t.Parallel()
|
|
svc, nodeRepo, _, vaultRoot := setupRepairTest(t)
|
|
|
|
// 1. Old layout: note node as direct child of case, file at case root
|
|
caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "MyCase", 0, "", "Cases/MyCase")
|
|
if err != nil {
|
|
t.Fatalf("create case: %v", err)
|
|
}
|
|
caseDir := filepath.Join(vaultRoot, "Cases", "MyCase")
|
|
if err := os.MkdirAll(caseDir, 0o750); err != nil {
|
|
t.Fatalf("mkdir case: %v", err)
|
|
}
|
|
|
|
// Old note node
|
|
noteNode, err := nodeRepo.Create(strPtr(caseNode.ID), nodes.TypeNote, "Overview", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create note: %v", err)
|
|
}
|
|
|
|
// File at old location
|
|
oldFilePath := filepath.Join(caseDir, "Overview.md")
|
|
content := "# Overview\n\nContent"
|
|
if err := os.WriteFile(oldFilePath, []byte(content), 0o640); err != nil {
|
|
t.Fatalf("write file: %v", err)
|
|
}
|
|
|
|
// File record pointing to old path
|
|
fileRec, err := insertFileRecord(svc.db, noteNode.ID, "Overview.md", "Cases/MyCase/Overview.md", "vault", int64(len(content)))
|
|
if err != nil {
|
|
t.Fatalf("insert file record: %v", err)
|
|
}
|
|
_, err = svc.db.Exec(
|
|
`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
|
noteNode.ID, fileRec.ID, "markdown")
|
|
if err != nil {
|
|
t.Fatalf("insert notes record: %v", err)
|
|
}
|
|
|
|
// 2. Run repair
|
|
result, err := svc.RepairNotesLayout()
|
|
if err != nil {
|
|
t.Fatalf("RepairNotesLayout: %v", err)
|
|
}
|
|
if result.RepairedNotes != 1 {
|
|
t.Fatalf("expected 1 repaired note, got %d", result.RepairedNotes)
|
|
}
|
|
|
|
// 3. Files tab: case children should include Notes folder
|
|
children, err := nodeRepo.ListChildren(caseNode.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("ListChildren case: %v", err)
|
|
}
|
|
var notesFolder *nodes.Node
|
|
for _, c := range children {
|
|
if c.Type == nodes.TypeFolder && c.Title == "Notes" {
|
|
notesFolder = &c
|
|
break
|
|
}
|
|
}
|
|
if notesFolder == nil {
|
|
t.Fatal("Files tab should show Notes folder after repair")
|
|
}
|
|
|
|
// 4. Files tab: Notes folder should contain Overview note
|
|
notesChildren, err := nodeRepo.ListChildren(notesFolder.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("ListChildren Notes: %v", err)
|
|
}
|
|
noteFound := false
|
|
for _, c := range notesChildren {
|
|
if c.ID == noteNode.ID {
|
|
noteFound = true
|
|
break
|
|
}
|
|
}
|
|
if !noteFound {
|
|
t.Error("Files tab should show Overview note inside Notes folder after repair")
|
|
}
|
|
|
|
// 5. Notes tab: ReadNote should return the content
|
|
readContent, err := svc.Read(noteNode.ID)
|
|
if err != nil {
|
|
t.Fatalf("ReadNote after repair: %v", err)
|
|
}
|
|
if readContent != content {
|
|
t.Errorf("ReadNote content = %q, want %q", readContent, content)
|
|
}
|
|
|
|
// 6. Physical file should be at canonical location
|
|
canonicalPath := filepath.Join(caseDir, "Notes", "Overview.md")
|
|
if _, err := os.Stat(canonicalPath); err != nil {
|
|
t.Errorf("file should exist at canonical path %s: %v", canonicalPath, err)
|
|
}
|
|
// Old file should be gone
|
|
if _, err := os.Stat(oldFilePath); !os.IsNotExist(err) {
|
|
t.Error("old file should not exist after repair")
|
|
}
|
|
}
|
|
|
|
// TestFilesTabManualMoveRepair verifies: file manually moved to Notes/,
|
|
// DB path stale → repair updates DB, ReadNote returns content, Files tab
|
|
// shows the note.
|
|
func TestFilesTabManualMoveRepair(t *testing.T) {
|
|
t.Parallel()
|
|
svc, nodeRepo, _, vaultRoot := setupRepairTest(t)
|
|
|
|
// Old layout
|
|
caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "MyCase", 0, "", "Cases/MyCase")
|
|
if err != nil {
|
|
t.Fatalf("create case: %v", err)
|
|
}
|
|
caseDir := filepath.Join(vaultRoot, "Cases", "MyCase")
|
|
if err := os.MkdirAll(caseDir, 0o750); err != nil {
|
|
t.Fatalf("mkdir case: %v", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(caseDir, "Notes"), 0o750); err != nil {
|
|
t.Fatalf("mkdir Notes: %v", err)
|
|
}
|
|
|
|
// Old note node
|
|
noteNode, err := nodeRepo.Create(strPtr(caseNode.ID), nodes.TypeNote, "Overview", 0, "", "")
|
|
if err != nil {
|
|
t.Fatalf("create note: %v", err)
|
|
}
|
|
|
|
// File manually placed at canonical location (user moved it)
|
|
canonicalPath := filepath.Join(caseDir, "Notes", "Overview.md")
|
|
content := "# Overview\n\nManual move content"
|
|
if err := os.WriteFile(canonicalPath, []byte(content), 0o640); err != nil {
|
|
t.Fatalf("write file at canonical: %v", err)
|
|
}
|
|
|
|
// File record pointing to OLD path (stale)
|
|
fileRec, err := insertFileRecord(svc.db, noteNode.ID, "Overview.md", "Cases/MyCase/Overview.md", "vault", int64(len(content)))
|
|
if err != nil {
|
|
t.Fatalf("insert file record: %v", err)
|
|
}
|
|
_, err = svc.db.Exec(
|
|
`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
|
noteNode.ID, fileRec.ID, "markdown")
|
|
if err != nil {
|
|
t.Fatalf("insert notes record: %v", err)
|
|
}
|
|
|
|
// Run repair
|
|
result, err := svc.RepairNotesLayout()
|
|
if err != nil {
|
|
t.Fatalf("RepairNotesLayout: %v", err)
|
|
}
|
|
if result.UpdatedFilePaths != 1 {
|
|
t.Fatalf("expected 1 UpdatedFilePaths, got %d (MovedFiles=%d RepairedNotes=%d)",
|
|
result.UpdatedFilePaths, result.MovedFiles, result.RepairedNotes)
|
|
}
|
|
|
|
// 1. ReadNote should return content (path was fixed)
|
|
readContent, err := svc.Read(noteNode.ID)
|
|
if err != nil {
|
|
t.Fatalf("ReadNote after repair: %v", err)
|
|
}
|
|
if readContent != content {
|
|
t.Errorf("ReadNote content = %q, want %q", readContent, content)
|
|
}
|
|
|
|
// 2. File record path should be canonical
|
|
updatedRec, _ := svc.Load(noteNode.ID)
|
|
updatedFileRec, _ := svc.files.Get(updatedRec.FileID)
|
|
if updatedFileRec.Path != "Cases/MyCase/Notes/Overview.md" {
|
|
t.Errorf("file path = %q, want %q", updatedFileRec.Path, "Cases/MyCase/Notes/Overview.md")
|
|
}
|
|
|
|
// 3. Files tab: case children should include Notes folder
|
|
children, err := nodeRepo.ListChildren(caseNode.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("ListChildren case: %v", err)
|
|
}
|
|
var notesFolder *nodes.Node
|
|
for _, c := range children {
|
|
if c.Type == nodes.TypeFolder && c.Title == "Notes" {
|
|
notesFolder = &c
|
|
break
|
|
}
|
|
}
|
|
if notesFolder == nil {
|
|
t.Fatal("Files tab should show Notes folder after repair")
|
|
}
|
|
|
|
// 4. Files tab: Notes folder should contain Overview
|
|
notesChildren, err := nodeRepo.ListChildren(notesFolder.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("ListChildren Notes: %v", err)
|
|
}
|
|
found := false
|
|
for _, c := range notesChildren {
|
|
if c.ID == noteNode.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("Files tab should show Overview note inside Notes folder after repair")
|
|
}
|
|
}
|