458 lines
13 KiB
Go
458 lines
13 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
|