package main import ( "os" "path/filepath" "strings" "testing" ) func TestListTrashShowsDeletedNodesAndPhysicalEntries(t *testing.T) { app, _ := setupTestApp(t) n, err := app.CreateNodeFromTemplate("", "Trash Me", "folder.default") if err != nil { t.Fatalf("create node: %v", err) } if err := app.DeleteNode(n.ID); err != nil { t.Fatalf("DeleteNode: %v", err) } trash, err := app.ListTrash() if err != nil { t.Fatalf("ListTrash: %v", err) } var foundNode bool for _, node := range trash.Nodes { if node.ID == n.ID && node.Title == "Trash Me" && node.DeletedAt != "" { foundNode = true break } } if !foundNode { t.Fatalf("deleted node %s missing from trash nodes: %#v", n.ID, trash.Nodes) } var foundPhysical bool for _, entry := range trash.Entries { if strings.Contains(entry.Name, n.ID) && entry.IsDir { foundPhysical = true break } } if !foundPhysical { t.Fatalf("physical trash entry for %s missing: %#v", n.ID, trash.Entries) } } func TestRestoreTrashNodeRestoresAncestorPathOnlyForSelectedChild(t *testing.T) { app, vault := setupTestApp(t) parent, err := app.CreateNodeFromTemplate("", "Documents", "folder.default") if err != nil { t.Fatalf("create parent: %v", err) } child, err := app.CreateNodeFromTemplate(parent.ID, "Specs", "folder.default") if err != nil { t.Fatalf("create child: %v", err) } other, err := app.CreateNodeFromTemplate(parent.ID, "Drafts", "folder.default") if err != nil { t.Fatalf("create other: %v", err) } if err := app.DeleteNode(parent.ID); err != nil { t.Fatalf("DeleteNode: %v", err) } if err := app.RestoreTrashNode(child.ID); err != nil { t.Fatalf("RestoreTrashNode(child): %v", err) } for _, id := range []string{parent.ID, child.ID} { if _, err := app.nodes.GetActive(id); err != nil { t.Fatalf("node %s should be active after restore: %v", id, err) } } if _, err := app.nodes.GetActive(other.ID); err == nil { t.Fatalf("unselected sibling should remain deleted") } if _, err := os.Stat(filepath.Join(vault, "Documents", "Specs")); err != nil { t.Fatalf("restored child path missing: %v", err) } } func TestRestoreTrashNodeFromNestedDeletedFolderRestoresFullPath(t *testing.T) { app, vault := setupTestApp(t) parent, err := app.CreateNodeFromTemplate("", "Verstak", "folder.default") if err != nil { t.Fatalf("create parent: %v", err) } templates, err := app.CreateNodeFromTemplate(parent.ID, "templates", "folder.default") if err != nil { t.Fatalf("create templates: %v", err) } registry, err := app.CreateNodeFromTemplate(templates.ID, "registry.go", "folder.default") if err != nil { t.Fatalf("create registry: %v", err) } other, err := app.CreateNodeFromTemplate(templates.ID, "other.go", "folder.default") if err != nil { t.Fatalf("create other: %v", err) } if err := app.DeleteNode(parent.ID); err != nil { t.Fatalf("DeleteNode: %v", err) } if err := app.RestoreTrashNode(registry.ID); err != nil { t.Fatalf("RestoreTrashNode(registry): %v", err) } for _, id := range []string{parent.ID, templates.ID, registry.ID} { if _, err := app.nodes.GetActive(id); err != nil { t.Fatalf("node %s should be active after restore: %v", id, err) } } if _, err := app.nodes.GetActive(other.ID); err == nil { t.Fatalf("unselected nested sibling should remain deleted") } if _, err := os.Stat(filepath.Join(vault, "Verstak", "templates", "registry.go")); err != nil { t.Fatalf("restored nested path missing: %v", err) } } func TestTrashCountPurgeAndEmpty(t *testing.T) { app, _ := setupTestApp(t) a, _ := app.CreateNodeFromTemplate("", "Trash A", "folder.default") b, _ := app.CreateNodeFromTemplate("", "Trash B", "folder.default") if err := app.DeleteNode(a.ID); err != nil { t.Fatalf("delete A: %v", err) } if err := app.DeleteNode(b.ID); err != nil { t.Fatalf("delete B: %v", err) } count, err := app.TrashCount() if err != nil { t.Fatalf("TrashCount: %v", err) } if count != 4 { t.Fatalf("TrashCount = %d, want 4 (2 folders + 2 Notes children)", count) } if err := app.PurgeTrashNodesJSON(`["` + a.ID + `"]`); err != nil { t.Fatalf("PurgeTrashNodesJSON: %v", err) } count, _ = app.TrashCount() if count != 2 { t.Fatalf("TrashCount after purge = %d, want 2 (1 folder + 1 Notes child)", count) } if err := app.EmptyTrash(); err != nil { t.Fatalf("EmptyTrash: %v", err) } count, _ = app.TrashCount() if count != 0 { t.Fatalf("TrashCount after empty = %d, want 0", count) } } func TestTrashTypeFilePreviewAndRestore(t *testing.T) { app, vault := setupTestApp(t) // Create a folder to hold the file. parent, err := app.CreateNodeFromTemplate("", "Documents", "folder.default") if err != nil { t.Fatalf("create parent: %v", err) } // Create a TypeFile node with a file record. fileNode, err := app.CreateEmptyFile(parent.ID, "hello.txt") if err != nil { t.Fatalf("CreateEmptyFile: %v", err) } // Write some content to the physical file via file records. recs, err := app.files.ListByNode(fileNode.ID) if err != nil || len(recs) == 0 { t.Fatalf("ListByNode: %v (len=%d)", err, len(recs)) } record := recs[0] absPath := filepath.Join(vault, record.Path) content := "Hello, World!" if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil { t.Fatalf("write file: %v", err) } // Delete the entire tree. if err := app.DeleteNode(parent.ID); err != nil { t.Fatalf("DeleteNode: %v", err) } // Verify trash listing has the file node with trashFsPath set. trash, err := app.ListTrash() if err != nil { t.Fatalf("ListTrash: %v", err) } var fileTrashNode *TrashNodeDTO for i, n := range trash.Nodes { if n.ID == fileNode.ID { fileTrashNode = &trash.Nodes[i] break } } if fileTrashNode == nil { t.Fatalf("file node not found in trash listing") } if fileTrashNode.TrashFsPath == "" { t.Fatalf("file node missing trashFsPath: %+v", fileTrashNode) } // Verify ReadTrashFile works with the precomputed path. readContent, err := app.ReadTrashFile(fileTrashNode.TrashFsPath) if err != nil { t.Fatalf("ReadTrashFile: %v", err) } if readContent != content { t.Fatalf("ReadTrashFile content = %q, want %q", readContent, content) } // Verify ReadTrashFileContent also works (fallback using file records). readContent2, err := app.ReadTrashFileContent(fileNode.ID) if err != nil { t.Fatalf("ReadTrashFileContent: %v", err) } if readContent2 != content { t.Fatalf("ReadTrashFileContent = %q, want %q", readContent2, content) } // Verify trashed file records are still in DB. trashedRecs, err := app.files.ListTrashedByNode(fileNode.ID) if err != nil { t.Fatalf("ListTrashedByNode: %v", err) } if len(trashedRecs) != 1 { t.Fatalf("ListTrashedByNode = %d records, want 1", len(trashedRecs)) } if !trashedRecs[0].Missing { t.Fatalf("expected trashed record to have Missing=true") } if trashedRecs[0].Path == "" { t.Fatalf("expected trashed record to keep original Path") } // Restore the file node. if err := app.RestoreTrashNode(fileNode.ID); err != nil { t.Fatalf("RestoreTrashNode: %v", err) } // Verify the node is active. if _, err := app.nodes.GetActive(fileNode.ID); err != nil { t.Fatalf("file node not active after restore: %v", err) } // Verify the file record is restored (missing=0). restoredRecs, err := app.files.ListByNode(fileNode.ID) if err != nil { t.Fatalf("ListByNode after restore: %v", err) } if len(restoredRecs) != 1 { t.Fatalf("ListByNode after restore = %d records, want 1", len(restoredRecs)) } if restoredRecs[0].Missing { t.Fatalf("file record should have Missing=false after restore") } // Verify the physical file content is intact. absRestored := filepath.Join(vault, restoredRecs[0].Path) restoredBytes, err := os.ReadFile(absRestored) if err != nil { t.Fatalf("read restored file: %v", err) } if string(restoredBytes) != content { t.Fatalf("restored file content = %q, want %q", string(restoredBytes), content) } // Verify the trash entry is gone (file was moved back). if _, err := os.Stat(fileTrashNode.TrashFsPath); !os.IsNotExist(err) { t.Fatalf("trash entry should be gone after restore, err=%v", err) } // Parent is also restored because RestoreTrashNode restores the entire // ancestor chain from the requested node up to the root deleted node. if _, err := app.nodes.GetActive(parent.ID); err != nil { t.Fatalf("parent should be active after child restore (ancestor chain): %v", err) } } func TestTrashTypeFileInsideFolderRestorePreservesContent(t *testing.T) { app, vault := setupTestApp(t) // Create: parent folder → child TypeFile. parent, err := app.CreateNodeFromTemplate("", "ProjectX", "folder.default") if err != nil { t.Fatalf("create parent: %v", err) } fileNode, err := app.CreateEmptyFile(parent.ID, "data.csv") if err != nil { t.Fatalf("CreateEmptyFile: %v", err) } // Write content. recs, err := app.files.ListByNode(fileNode.ID) if err != nil || len(recs) == 0 { t.Fatalf("ListByNode: %v (len=%d)", err, len(recs)) } absPath := filepath.Join(vault, recs[0].Path) content := "a,b,c\n1,2,3" if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil { t.Fatalf("write file: %v", err) } // Delete the whole tree. if err := app.DeleteNode(parent.ID); err != nil { t.Fatalf("DeleteNode: %v", err) } // Restore parent. This moves the directory back from trash but does NOT // restore child nodes (RestoreTrashNode walks ancestor chain, not children). if err := app.RestoreTrashNode(parent.ID); err != nil { t.Fatalf("RestoreTrashNode parent: %v", err) } // Only the parent should be active. if _, err := app.nodes.GetActive(parent.ID); err != nil { t.Fatalf("parent should be active: %v", err) } if _, err := app.nodes.GetActive(fileNode.ID); err == nil { t.Fatalf("child file node should remain deleted (RestoreTrashNode is ancestor-only)") } // Parent directory should exist on disk. if _, err := os.Stat(filepath.Join(vault, "ProjectX")); err != nil { t.Fatalf("parent directory should exist: %v", err) } // Now restore the child file node specifically. if err := app.RestoreTrashNode(fileNode.ID); err != nil { t.Fatalf("RestoreTrashNode file: %v", err) } if _, err := app.nodes.GetActive(fileNode.ID); err != nil { t.Fatalf("file node should be active: %v", err) } // File record should be restored. recs, err = app.files.ListByNode(fileNode.ID) if err != nil || len(recs) == 0 { t.Fatalf("ListByNode after restore: %v (len=%d)", err, len(recs)) } if recs[0].Missing { t.Fatalf("file record should not be missing after restore") } // Physical file content should be intact. absRestored := filepath.Join(vault, recs[0].Path) restoredBytes, err := os.ReadFile(absRestored) if err != nil { t.Fatalf("read restored file: %v", err) } if string(restoredBytes) != content { t.Fatalf("content = %q, want %q", string(restoredBytes), content) } } func TestTrashTypeFileMultipleRecords(t *testing.T) { app, vault := setupTestApp(t) // Create a TypeFile node with two file records. fileNode, err := app.CreateEmptyFile("", "report.txt") if err != nil { t.Fatalf("CreateEmptyFile: %v", err) } // Write content to first record. recs, err := app.files.ListByNode(fileNode.ID) if err != nil || len(recs) == 0 { t.Fatalf("ListByNode: %v (len=%d)", err, len(recs)) } absPath1 := filepath.Join(vault, recs[0].Path) content1 := "version 1" if err := os.WriteFile(absPath1, []byte(content1), 0o644); err != nil { t.Fatalf("write file 1: %v", err) } // Manually insert a second file record with its own vault file. now := nowStr() absPath2 := filepath.Join(vault, "report-v2.txt") content2 := "version 2" if err := os.WriteFile(absPath2, []byte(content2), 0o644); err != nil { t.Fatalf("write file 2: %v", err) } _, err = app.db.Exec( `INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing) VALUES (?,?,?,?,?,?,?,?,?,?,0)`, "second-record-id", fileNode.ID, "report-v2.txt", "report-v2.txt", "vault", 0, "", "text/plain", now, now) if err != nil { t.Fatalf("insert second record: %v", err) } // Delete the node. if err := app.DeleteNode(fileNode.ID); err != nil { t.Fatalf("DeleteNode: %v", err) } // Verify both records are trashed. trashedRecs, err := app.files.ListTrashedByNode(fileNode.ID) if err != nil { t.Fatalf("ListTrashedByNode: %v", err) } if len(trashedRecs) != 2 { t.Fatalf("expected 2 trashed records, got %d", len(trashedRecs)) } for _, r := range trashedRecs { if !r.Missing { t.Fatalf("record %s should have Missing=true", r.ID) } } // Verify trash listing has trashFsPath set. trash, err := app.ListTrash() if err != nil { t.Fatalf("ListTrash: %v", err) } var found bool for _, n := range trash.Nodes { if n.ID == fileNode.ID { found = true if n.TrashFsPath == "" { t.Fatalf("trashFsPath should be set for file node with multiple records") } break } } if !found { t.Fatalf("file node not found in trash listing") } // Restore. if err := app.RestoreTrashNode(fileNode.ID); err != nil { t.Fatalf("RestoreTrashNode: %v", err) } // Both records should be restored. restoredRecs, err := app.files.ListByNode(fileNode.ID) if err != nil { t.Fatalf("ListByNode after restore: %v", err) } if len(restoredRecs) != 2 { t.Fatalf("expected 2 restored records, got %d: %+v", len(restoredRecs), restoredRecs) } for _, r := range restoredRecs { if r.Missing { t.Fatalf("record %s should not be missing", r.ID) } } }