package main import ( "os" "path/filepath" "testing" "verstak/internal/core/nodes" "verstak/internal/core/notes" ) // --- Files tab / ListItems tests for Notes folder --- // TestFileManagerListItemsShowsNotesFolder verifies that ListItems on a // container returns the Notes folder, matching what the Files tab UI shows. func TestFileManagerListItemsShowsNotesFolder(t *testing.T) { app, _ := setupTestApp(t) proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default") if err != nil { t.Fatalf("create project: %v", err) } // ListItems is what the Files tab actually calls items, err := app.ListItems(proj.ID) if err != nil { t.Fatalf("ListItems: %v", err) } var foundNotes bool for _, item := range items { if item.Name == notes.NotesFolder && item.Type == "folder" { foundNotes = true break } } if !foundNotes { t.Errorf("ListItems(%q) should contain Notes folder, got %d items", proj.ID, len(items)) for _, item := range items { t.Logf(" %s (type=%s)", item.Name, item.Type) } } } // TestFileManagerListItemsInsideNotesShowsOverview verifies that ListItems // on the Notes folder returns the Overview note. func TestFileManagerListItemsInsideNotesShowsOverview(t *testing.T) { app, vault := setupTestApp(t) proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default") if err != nil { t.Fatalf("create project: %v", err) } // Find Notes folder children, err := app.nodes.ListChildren(proj.ID, false) if err != nil { t.Fatalf("ListChildren: %v", err) } var notesFolder *nodes.Node for i := range children { if children[i].Title == notes.NotesFolder && children[i].Type == "folder" { notesFolder = &children[i] break } } if notesFolder == nil { t.Fatal("Notes folder not found") } // ListItems inside Notes folder items, err := app.ListItems(notesFolder.ID) if err != nil { t.Fatalf("ListItems(Notes folder): %v", err) } var foundOverview bool for _, item := range items { if item.Name == "Overview" && item.Type == "note" { foundOverview = true if item.FileID == "" { t.Error("Overview note has empty FileID") } if item.Mime == "" { t.Error("Overview note has empty Mime") } break } } if !foundOverview { t.Errorf("ListItems(Notes) should contain Overview note, got %d items", len(items)) for _, item := range items { t.Logf(" %s (type=%s)", item.Name, item.Type) } } // Verify no root-level Overview.md on disk rootPath := filepath.Join(vault, proj.FsPath, "Overview.md") if _, err := os.Stat(rootPath); err == nil { t.Error("Overview.md should NOT exist at root level, only in Notes/") } // Verify Notes/Overview.md exists on disk notesPath := filepath.Join(vault, proj.FsPath, notes.NotesFolder, "Overview.md") if _, err := os.Stat(notesPath); os.IsNotExist(err) { t.Error("Overview.md should exist at Notes/Overview.md") } } // TestRepairMovesDirectNoteChildrenToNotesFolder verifies that a note // created as a direct child of a container (old layout) is moved into // the Notes folder by RepairNotesLayout, and that ListItems then shows // the note under Notes/ not as a direct child. func TestRepairMovesDirectNoteChildrenToNotesFolder(t *testing.T) { app, vault := setupTestApp(t) // Create a container that supports notes parent, err := app.CreateNodeFromTemplate("", "TestCase", "project.default") if err != nil { t.Fatalf("create container: %v", err) } // Simulate old layout: create TypeNote as direct child (not under Notes/) noteNode, err := app.nodes.Create(&parent.ID, nodes.TypeNote, "LegacyNote", 0, "", "") if err != nil { t.Fatalf("create legacy note: %v", err) } // Write a physical file so repair has something to fix noteDir := filepath.Join(vault, parent.FsPath) if err := os.MkdirAll(noteDir, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } oldPath := filepath.Join(noteDir, "LegacyNote.md") if err := os.WriteFile(oldPath, []byte("# Legacy Content\n"), 0o640); err != nil { t.Fatalf("write file: %v", err) } relPath, _ := filepath.Rel(vault, oldPath) fileID := "repair-file-" + noteNode.ID _, _ = app.db.Exec( `INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing) VALUES (?,?,?,?,'vault',0,'','text/plain','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`, fileID, noteNode.ID, "LegacyNote.md", relPath) _, _ = app.db.Exec( `INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`, noteNode.ID, fileID, "markdown") // Verify the note is a direct child before repair beforeChildren, err := app.nodes.ListChildren(parent.ID, false) if err != nil { t.Fatalf("ListChildren before repair: %v", err) } var foundDirect bool for _, c := range beforeChildren { if c.ID == noteNode.ID { foundDirect = true break } } if !foundDirect { t.Fatal("legacy note should be a direct child before repair") } // Run repair result, err := app.notes.RepairNotesLayout() if err != nil { t.Fatalf("RepairNotesLayout: %v", err) } if result.RepairedNotes == 0 { t.Errorf("expected at least 1 repaired note, got 0") } // After repair: note should NOT be a direct child of parent afterChildren, err := app.nodes.ListChildren(parent.ID, false) if err != nil { t.Fatalf("ListChildren after repair: %v", err) } for _, c := range afterChildren { if c.ID == noteNode.ID { t.Errorf("note should no longer be a direct child after repair") break } } // After repair: note should be inside Notes folder // ListItems(parent.ID) should NOT show the note directly parentItems, err := app.ListItems(parent.ID) if err != nil { t.Fatalf("ListItems(parent) after repair: %v", err) } for _, item := range parentItems { if item.ID == noteNode.ID { t.Errorf("ListItems(parent) should not show note directly after repair") break } } // But ListItems(Notes) should show it var notesFolder *nodes.Node for i := range afterChildren { if afterChildren[i].Title == notes.NotesFolder && afterChildren[i].Type == "folder" { notesFolder = &afterChildren[i] break } } if notesFolder == nil { t.Fatal("Notes folder should exist after repair") } notesItems, err := app.ListItems(notesFolder.ID) if err != nil { t.Fatalf("ListItems(Notes) after repair: %v", err) } var foundInNotes bool for _, item := range notesItems { if item.ID == noteNode.ID && item.Type == "note" { foundInNotes = true break } } if !foundInNotes { t.Errorf("ListItems(Notes) should show the repaired note, got %d items", len(notesItems)) for _, item := range notesItems { t.Logf(" %s (type=%s)", item.Name, item.Type) } } // Verify node parent was updated note, err := app.nodes.Get(noteNode.ID) if err != nil { t.Fatalf("get note node after repair: %v", err) } if note.ParentID == nil || *note.ParentID != notesFolder.ID { t.Errorf("note.ParentID should be Notes folder (%s), got %v", notesFolder.ID, note.ParentID) } // Verify file path was updated recs, err := app.files.ListByNode(noteNode.ID) if err != nil { t.Fatalf("ListByNode after repair: %v", err) } if len(recs) == 0 { t.Fatal("file record should exist after repair") } expectedRelPath := filepath.Join(parent.FsPath, notes.NotesFolder, "LegacyNote.md") if recs[0].Path != expectedRelPath { t.Errorf("file path should be %q, got %q", expectedRelPath, recs[0].Path) } } // TestCheckFileAction_NoteLinked verifies that CheckFileAction returns // Action="note" for a .md file linked to a note record. func TestCheckFileAction_NoteLinked(t *testing.T) { app, _ := setupTestApp(t) proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default") if err != nil { t.Fatalf("create project: %v", err) } // Find the Overview note — it should be inside Notes folder, linked via notes record children, err := app.nodes.ListChildren(proj.ID, false) if err != nil { t.Fatalf("ListChildren: %v", err) } var notesFolder *nodes.Node for i := range children { if children[i].Title == notes.NotesFolder && children[i].Type == "folder" { notesFolder = &children[i] break } } if notesFolder == nil { t.Fatal("Notes folder not found") } notesChildren, err := app.nodes.ListChildren(notesFolder.ID, false) if err != nil { t.Fatalf("ListChildren(Notes): %v", err) } if len(notesChildren) == 0 { t.Fatal("expected at least one note inside Notes folder") } // Get file ID for the Overview note items, err := app.ListItems(notesFolder.ID) if err != nil { t.Fatalf("ListItems: %v", err) } var overviewFileID string for _, item := range items { if item.Type == "note" && item.Name == "Overview" { overviewFileID = item.FileID break } } if overviewFileID == "" { t.Fatal("Overview note has no FileID") } // CheckFileAction should return Action="note" action, err := app.CheckFileAction(overviewFileID) if err != nil { t.Fatalf("CheckFileAction: %v", err) } if action.Action != "note" { t.Errorf("expected Action=note for linked .md, got %q", action.Action) } if action.NoteID == "" { t.Error("expected non-empty NoteID for linked note") } if action.NoteTitle == "" { t.Error("expected non-empty NoteTitle") } if action.FileName == "" { t.Error("expected non-empty FileName") } } // TestCheckFileAction_ExternalForNonMD verifies that non-.md files return // Action="external" from CheckFileAction. func TestCheckFileAction_ExternalForNonMD(t *testing.T) { app, vault := setupTestApp(t) proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default") if err != nil { t.Fatalf("create project: %v", err) } // Create a file node and record for a non-.md file fileNode, err := app.nodes.Create(&proj.ID, nodes.TypeFile, "image.png", 0, "", filepath.Join(proj.FsPath, "image.png")) if err != nil { t.Fatalf("create file node: %v", err) } absPath := filepath.Join(vault, proj.FsPath, "image.png") if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(absPath, []byte("fake-png"), 0o640); err != nil { t.Fatalf("write file: %v", err) } // Insert file record directly _, err = app.db.Exec( `INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing) VALUES (?,?,?,?,'vault',0,'','image/png','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`, "file-png-"+fileNode.ID, fileNode.ID, "image.png", filepath.Join(proj.FsPath, "image.png")) if err != nil { t.Fatalf("insert file record: %v", err) } // CheckFileAction should return Action="external" action, err := app.CheckFileAction("file-png-" + fileNode.ID) if err != nil { t.Fatalf("CheckFileAction: %v", err) } if action.Action != "external" { t.Errorf("expected Action=external for .png, got %q", action.Action) } if action.FileName != "image.png" { t.Errorf("expected FileName=image.png, got %q", action.FileName) } } // TestCheckFileAction_PreviewForMDOutsideNotes verifies that .md files // outside Notes/ without a note record return Action="preview". func TestCheckFileAction_PreviewForMDOutsideNotes(t *testing.T) { app, vault := setupTestApp(t) proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default") if err != nil { t.Fatalf("create project: %v", err) } // Create a .md file directly under the project (not inside Notes/) mdNode, err := app.nodes.Create(&proj.ID, nodes.TypeFile, "readme.md", 0, "", filepath.Join(proj.FsPath, "readme.md")) if err != nil { t.Fatalf("create md node: %v", err) } absPath := filepath.Join(vault, proj.FsPath, "readme.md") if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(absPath, []byte("# Readme\n"), 0o640); err != nil { t.Fatalf("write file: %v", err) } // Insert file record directly _, err = app.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-md-"+mdNode.ID, mdNode.ID, "readme.md", filepath.Join(proj.FsPath, "readme.md")) if err != nil { t.Fatalf("insert file record: %v", err) } // Do NOT create a notes record — this .md is outside Notes/ action, err := app.CheckFileAction("file-md-" + mdNode.ID) if err != nil { t.Fatalf("CheckFileAction: %v", err) } if action.Action != "preview" { t.Errorf("expected Action=preview for .md outside Notes/, got %q", action.Action) } } func TestCheckFileAction_AutoLinkInNotes(t *testing.T) { app, vault := setupTestApp(t) proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default") if err != nil { t.Fatalf("create project: %v", err) } // Find Notes folder children, err := app.nodes.ListChildren(proj.ID, false) if err != nil { t.Fatalf("ListChildren: %v", err) } var notesFolder *nodes.Node for i := range children { if children[i].Title == notes.NotesFolder && children[i].Type == "folder" { notesFolder = &children[i] break } } if notesFolder == nil { t.Fatal("Notes folder not found") } // Create a .md file INSIDE Notes/ but WITHOUT a notes record mdNode, err := app.nodes.Create(¬esFolder.ID, nodes.TypeFile, "orphan.md", 0, "", filepath.Join(proj.FsPath, notes.NotesFolder, "orphan.md")) if err != nil { t.Fatalf("create md node: %v", err) } notesDir := filepath.Join(vault, proj.FsPath, notes.NotesFolder) if err := os.MkdirAll(notesDir, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } absPath := filepath.Join(notesDir, "orphan.md") if err := os.WriteFile(absPath, []byte("# Orphan Note\n"), 0o640); err != nil { t.Fatalf("write file: %v", err) } // Insert file record _, err = app.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-orphan-"+mdNode.ID, mdNode.ID, "orphan.md", filepath.Join(proj.FsPath, notes.NotesFolder, "orphan.md")) if err != nil { t.Fatalf("insert file record: %v", err) } // No notes record yet — just file + node // CheckFileAction should auto-link and return Action="note" action, err := app.CheckFileAction("file-orphan-" + mdNode.ID) if err != nil { t.Fatalf("CheckFileAction: %v", err) } if action.Action != "note" { t.Errorf("expected Action=note for .md inside Notes/, got %q", action.Action) } if action.NoteID == "" { t.Error("expected auto-linked NoteID") } if action.NoteTitle == "" { t.Error("expected NoteTitle for auto-linked note") } // Verify notes record was actually created noteRec, err := app.notes.FindByFileID("file-orphan-" + mdNode.ID) if err != nil { t.Fatalf("FindByFileID after auto-link: %v", err) } if noteRec == nil { t.Fatal("expected note record after auto-link") } }