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: /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 (/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 }