verstak/internal/core/notes/note_repair_test.go

1526 lines
47 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")
}
}
func TestFindByFileID_Success(t *testing.T) {
svc, nodeRepo, _, vaultDir := setupRepairTest(t)
caseNode := createCaseNode(t, nodeRepo, "TestCase", "test_case")
// Create a note via the full Create path...
noteNode, fileRec, err := svc.Create(caseNode.ID, "TestNote", "")
if err != nil {
t.Fatalf("create note: %v", err)
}
// Verify FindByFileID works
rec, err := svc.FindByFileID(fileRec.ID)
if err != nil {
t.Fatalf("FindByFileID: %v", err)
}
if rec == nil {
t.Fatal("expected non-nil record")
}
if rec.NodeID != noteNode.ID {
t.Errorf("expected NodeID=%s, got %s", noteNode.ID, rec.NodeID)
}
if rec.FileID != fileRec.ID {
t.Errorf("expected FileID=%s, got %s", fileRec.ID, rec.FileID)
}
if rec.Format != "markdown" {
t.Errorf("expected Format=markdown, got %s", rec.Format)
}
_ = vaultDir
}
func TestFindByFileID_NotFound(t *testing.T) {
svc, _, _, _ := setupRepairTest(t)
rec, err := svc.FindByFileID("nonexistent-file-id")
if err == nil {
t.Error("expected error for nonexistent file ID")
}
if rec != nil {
t.Error("expected nil record")
}
}
func TestFindByFileID_AfterLinkFile(t *testing.T) {
svc, nodeRepo, _, vaultDir := setupRepairTest(t)
caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "TestCase", 0, "", "test_case_link")
if err != nil {
t.Fatalf("create case node: %v", err)
}
// Create directory on disk
caseDir := filepath.Join(vaultDir, "test_case_link")
if err := os.MkdirAll(caseDir, 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Write a physical .md file
mdPath := filepath.Join(caseDir, "mynote.md")
if err := os.WriteFile(mdPath, []byte("# mynote"), 0o640); err != nil {
t.Fatalf("write file: %v", err)
}
// Create a file node
fileNode, err := nodeRepo.Create(&caseNode.ID, nodes.TypeFile, "mynote.md", 0, "", "test_case_link/mynote.md")
if err != nil {
t.Fatalf("create file node: %v", err)
}
// Insert file record directly (insertRecord is private, so we use DB())
db := svc.DB()
if db == nil {
t.Fatal("DB() returned nil")
}
_, err = db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',0,'','text/markdown','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
"file-"+fileNode.ID, fileNode.ID, "mynote.md", "test_case_link/mynote.md")
if err != nil {
t.Fatalf("insert file record: %v", err)
}
// Link note record via LinkFile
if err := svc.LinkFile(fileNode.ID, "file-"+fileNode.ID, "markdown"); err != nil {
t.Fatalf("LinkFile: %v", err)
}
// Verify
rec, err := svc.FindByFileID("file-" + fileNode.ID)
if err != nil {
t.Fatalf("FindByFileID: %v", err)
}
if rec == nil {
t.Fatal("expected non-nil record")
}
if rec.NodeID != fileNode.ID {
t.Errorf("expected NodeID=%s, got %s", fileNode.ID, rec.NodeID)
}
_ = vaultDir
}