package files import ( "os" "path/filepath" "testing" "verstak/internal/core/nodes" "verstak/internal/core/storage" ) func openTestDB(t *testing.T) *storage.DB { 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() }) return db } func TestAddExternal(t *testing.T) { db := openTestDB(t) // Run migration 002 manually since storage.Open already applied it. // We can verify the table exists by inserting. filesSvc := NewService(db, t.TempDir(), nodes.NewRepository(db)) // Create a real temp file to register. tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.txt") if err := os.WriteFile(tmpFile, []byte("hello world"), 0o640); err != nil { t.Fatal(err) } rec, err := filesSvc.AddExternal("node-1", tmpFile) if err != nil { t.Fatalf("AddExternal: %v", err) } if rec.ID == "" { t.Fatal("empty id") } if rec.Filename != "test.txt" { t.Errorf("filename = %q", rec.Filename) } if rec.StorageMode != "external" { t.Errorf("mode = %q", rec.StorageMode) } if rec.Size != 11 { t.Errorf("size = %d, want 11", rec.Size) } // Verify stored. got, err := filesSvc.Get(rec.ID) if err != nil { t.Fatal(err) } if got.Filename != "test.txt" { t.Errorf("got filename = %q", got.Filename) } } func TestCopyIntoVault(t *testing.T) { db := openTestDB(t) vaultRoot := t.TempDir() svc := NewService(db, vaultRoot, nodes.NewRepository(db)) // Source file. srcDir := t.TempDir() srcFile := filepath.Join(srcDir, "doc.pdf") os.WriteFile(srcFile, []byte("PDF content here"), 0o640) rec, err := svc.CopyIntoVault("node-1", srcFile, "") if err != nil { t.Fatalf("CopyIntoVault: %v", err) } if rec.SHA256 == "" { t.Error("expected sha256") } if rec.StorageMode != "vault" { t.Errorf("mode = %q", rec.StorageMode) } // Verify file on disk. if _, err := os.Stat(filepath.Join(vaultRoot, rec.Path)); err != nil { t.Errorf("file on disk: %v", err) } } func TestListByNode(t *testing.T) { db := openTestDB(t) svc := NewService(db, t.TempDir(), nodes.NewRepository(db)) os.WriteFile(filepath.Join(t.TempDir(), "a.txt"), []byte("a"), 0o640) f1 := filepath.Join(t.TempDir(), "a1.txt") f2 := filepath.Join(t.TempDir(), "a2.txt") os.WriteFile(f1, []byte("a"), 0o640) os.WriteFile(f2, []byte("bb"), 0o640) svc.AddExternal("node-a", f1) svc.AddExternal("node-a", f2) list, err := svc.ListByNode("node-a") if err != nil { t.Fatal(err) } if len(list) != 2 { t.Errorf("list len = %d, want 2", len(list)) } } func TestDeleteToTrash(t *testing.T) { db := openTestDB(t) vaultRoot := t.TempDir() svc := NewService(db, vaultRoot, nodes.NewRepository(db)) src := filepath.Join(t.TempDir(), "important.pdf") os.WriteFile(src, []byte("important data"), 0o640) rec, _ := svc.CopyIntoVault("node-x", src, "") if err := svc.DeleteToTrash(rec.ID); err != nil { t.Fatalf("DeleteToTrash: %v", err) } // File record should be gone. if _, err := svc.Get(rec.ID); err == nil { t.Error("expected error after trash") } // Original file should not exist anymore (moved to trash). if _, err := os.Stat(filepath.Join(vaultRoot, rec.Path)); !os.IsNotExist(err) { t.Error("expected file to be moved from original location") } // Trash dir should have it. trashDir := filepath.Join(vaultRoot, ".verstak", "trash") entries, _ := os.ReadDir(trashDir) if len(entries) != 1 { t.Errorf("trash entries = %d, want 1", len(entries)) } } func TestAddPathCopySingleFile(t *testing.T) { db := openTestDB(t) vaultRoot := t.TempDir() nodeRepo := nodes.NewRepository(db) svc := NewService(db, vaultRoot, nodeRepo) parent, _ := nodeRepo.Create(nil, "case", "Test Case", 0, "", "") src := filepath.Join(t.TempDir(), "doc.pdf") os.WriteFile(src, []byte("file content"), 0o640) nodes, err := svc.AddPathCopy(parent.ID, src) if err != nil { t.Fatalf("AddPathCopy: %v", err) } if len(nodes) != 1 { t.Fatalf("got %d nodes, want 1", len(nodes)) } if nodes[0].Type != "file" { t.Errorf("type = %q", nodes[0].Type) } // Source intact. if _, err := os.Stat(src); err != nil { t.Error("source should remain intact") } // File record created. records, _ := svc.ListByNode(nodes[0].ID) if len(records) != 1 { t.Errorf("file records = %d", len(records)) } } func TestAddPathLinkSingleFile(t *testing.T) { db := openTestDB(t) vaultRoot := t.TempDir() nodeRepo := nodes.NewRepository(db) svc := NewService(db, vaultRoot, nodeRepo) parent, _ := nodeRepo.Create(nil, "case", "Test Case", 0, "", "") src := filepath.Join(t.TempDir(), "linked.pdf") os.WriteFile(src, []byte("linked"), 0o640) nodes, err := svc.AddPathLink(parent.ID, src) if err != nil { t.Fatalf("AddPathLink: %v", err) } if len(nodes) != 1 { t.Fatalf("got %d nodes, want 1", len(nodes)) } // File record should have external storage mode. records, _ := svc.ListByNode(nodes[0].ID) if len(records) != 1 { t.Fatalf("file records = %d", len(records)) } if records[0].StorageMode != "external" { t.Errorf("storage mode = %q, want external", records[0].StorageMode) } } func TestAddPathCopyDirectory(t *testing.T) { db := openTestDB(t) vaultRoot := t.TempDir() nodeRepo := nodes.NewRepository(db) svc := NewService(db, vaultRoot, nodeRepo) parent, _ := nodeRepo.Create(nil, "case", "Test Case", 0, "", "") srcDir := t.TempDir() os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750) os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("a"), 0o640) os.WriteFile(filepath.Join(srcDir, "sub", "b.txt"), []byte("bb"), 0o640) nodes, err := svc.AddPathCopy(parent.ID, srcDir) if err != nil { t.Fatalf("AddPathCopy dir: %v", err) } // Should create: folder node + file node + sub folder node + file node in sub. if len(nodes) < 3 { t.Errorf("expected 3+ nodes, got %d", len(nodes)) } // Verify structure: root folder + children. var folders, files int for i := range nodes { if nodes[i].Type == "folder" { folders++ } else { files++ } } if folders < 1 { t.Error("expected at least 1 folder") } if files < 1 { t.Error("expected at least 1 file") } } func TestDeleteNodeAndChildren(t *testing.T) { db := openTestDB(t) vaultRoot := t.TempDir() nodeRepo := nodes.NewRepository(db) svc := NewService(db, vaultRoot, nodeRepo) parent, _ := nodeRepo.Create(nil, "case", "To Delete", 0, "", "") child, _ := nodeRepo.Create(&parent.ID, "file", "child.txt", 0, "", "") // Add file record to child. src := filepath.Join(t.TempDir(), "child.txt") os.WriteFile(src, []byte("data"), 0o640) svc.CopyIntoVault(child.ID, src, "") if err := svc.DeleteNodeAndChildren(parent.ID); err != nil { t.Fatalf("DeleteNodeAndChildren: %v", err) } // Parent should be soft-deleted. if _, err := nodeRepo.GetActive(parent.ID); err == nil { t.Error("parent should be deleted") } // Child should be soft-deleted. if _, err := nodeRepo.GetActive(child.ID); err == nil { t.Error("child should be deleted") } } func TestNameConflict(t *testing.T) { db := openTestDB(t) vaultRoot := t.TempDir() nodeRepo := nodes.NewRepository(db) svc := NewService(db, vaultRoot, nodeRepo) parent, _ := nodeRepo.Create(nil, "case", "Test", 0, "", "") src := filepath.Join(t.TempDir(), "conflict.pdf") os.WriteFile(src, []byte("data"), 0o640) // Import twice with same filename. n1, _ := svc.AddPathCopy(parent.ID, src) n2, _ := svc.AddPathCopy(parent.ID, src) if n1[0].Title == n2[0].Title { t.Error("expected unique name on conflict") } if n2[0].Title == "conflict.pdf" { t.Errorf("title unchanged = %q", n2[0].Title) } } func TestPreviewImportDir(t *testing.T) { db := openTestDB(t) vaultRoot := t.TempDir() svc := NewService(db, vaultRoot, nodes.NewRepository(db)) srcDir := t.TempDir() os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750) os.WriteFile(filepath.Join(srcDir, "f1.txt"), []byte("hello"), 0o640) os.WriteFile(filepath.Join(srcDir, "f2.txt"), []byte("world"), 0o640) sum, err := svc.PreviewImport(srcDir) if err != nil { t.Fatalf("PreviewImport: %v", err) } if sum.Files != 2 { t.Errorf("files = %d, want 2", sum.Files) } if sum.Folders != 2 { // root + sub t.Errorf("folders = %d, want 2", sum.Folders) } } func TestGuessMIME(t *testing.T) { cases := map[string]string{ "a.md": "text/plain", "a.png": "image/png", "a.pdf": "application/pdf", "a.docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "a.go": "text/plain", "a.unknown": "application/octet-stream", } for name, want := range cases { got := guessMIME(name) if got != want { t.Errorf("guessMIME(%q) = %q, want %q", name, got, want) } } }