verstak/cmd/verstak-gui/vault_layout_notes_files_te...

463 lines
15 KiB
Go

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(&notesFolder.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")
}
}