package watcher import ( "os" "path/filepath" "testing" "time" "verstak/internal/core/activity" "verstak/internal/core/files" "verstak/internal/core/nodes" "verstak/internal/core/storage" ) func setupWatcherTest(t *testing.T) (string, *storage.DB, *nodes.Repository, *files.Service, *activity.Service, func()) { t.Helper() vaultRoot, err := os.MkdirTemp("", "verstak-watcher-*") if err != nil { t.Fatal(err) } dbDir := filepath.Join(vaultRoot, ".verstak") if err := os.MkdirAll(dbDir, 0o750); err != nil { t.Fatal(err) } db, err := storage.Open(filepath.Join(dbDir, "index.db")) if err != nil { os.RemoveAll(vaultRoot) t.Fatalf("open db: %v", err) } nodeRepo := nodes.NewRepository(db) fileSvc := files.NewService(db, vaultRoot, nodeRepo) activitySvc := activity.NewService(db) cleanup := func() { db.Close() os.RemoveAll(vaultRoot) } return vaultRoot, db, nodeRepo, fileSvc, activitySvc, cleanup } // tempFileOutsideVault creates a temp file not inside the vault for import. func tempFileOutsideVault(t *testing.T, content string) string { t.Helper() f, err := os.CreateTemp("", "verstak-import-*") if err != nil { t.Fatal(err) } if _, err := f.WriteString(content); err != nil { t.Fatal(err) } f.Close() return f.Name() } func TestScanner_NoChanges(t *testing.T) { vaultRoot, _, nodeRepo, fileSvc, activitySvc, cleanup := setupWatcherTest(t) defer cleanup() node, err := nodeRepo.Create(nil, nodes.TypeFolder, "test-folder", 0, "", "test-folder") if err != nil { t.Fatal(err) } dir := filepath.Join(vaultRoot, "test-folder") if err := os.MkdirAll(dir, 0o750); err != nil { t.Fatal(err) } // Import a file into vault (creates record + copies file). src := tempFileOutsideVault(t, "hello world") defer os.Remove(src) _, err = fileSvc.CopyIntoVault(node.ID, src, "test-folder") if err != nil { t.Fatal(err) } scanner := NewScanner(vaultRoot, nodeRepo, fileSvc, activitySvc) result, err := scanner.Run() if err != nil { t.Fatal(err) } if result.MissingFiles != 0 { t.Errorf("expected 0 missing, got %d", result.MissingFiles) } if result.RestoredFiles != 0 { t.Errorf("expected 0 restored, got %d", result.RestoredFiles) } if result.ModifiedFiles != 0 { t.Errorf("expected 0 modified, got %d", result.ModifiedFiles) } if result.NewFiles != 0 { t.Errorf("expected 0 new, got %d", result.NewFiles) } if result.NodesScanned != 1 { t.Errorf("expected 1 node scanned, got %d", result.NodesScanned) } } func TestScanner_MissingFile(t *testing.T) { vaultRoot, _, nodeRepo, fileSvc, activitySvc, cleanup := setupWatcherTest(t) defer cleanup() node, err := nodeRepo.Create(nil, nodes.TypeFolder, "test-folder", 0, "", "test-folder") if err != nil { t.Fatal(err) } dir := filepath.Join(vaultRoot, "test-folder") if err := os.MkdirAll(dir, 0o750); err != nil { t.Fatal(err) } // Import a file. src := tempFileOutsideVault(t, "bye") defer os.Remove(src) _, err = fileSvc.CopyIntoVault(node.ID, src, "test-folder") if err != nil { t.Fatal(err) } // Now remove the physical file from vault. vaultFiles, err := fileSvc.ListByNode(node.ID) if err != nil || len(vaultFiles) == 0 { t.Fatal("no vault files found") } rec := vaultFiles[0] absPath := filepath.Join(vaultRoot, rec.Path) if err := os.Remove(absPath); err != nil { t.Fatal(err) } scanner := NewScanner(vaultRoot, nodeRepo, fileSvc, activitySvc) result, err := scanner.Run() if err != nil { t.Fatal(err) } if result.MissingFiles != 1 { t.Errorf("expected 1 missing, got %d", result.MissingFiles) } // Verify the file record is now marked missing. trashed, err := fileSvc.ListTrashedByNode(node.ID) if err != nil { t.Fatal(err) } if len(trashed) != 1 { t.Errorf("expected 1 trashed record, got %d", len(trashed)) } if !trashed[0].Missing { t.Error("expected record to be marked missing") } } func TestScanner_RestoredFile(t *testing.T) { vaultRoot, _, nodeRepo, fileSvc, activitySvc, cleanup := setupWatcherTest(t) defer cleanup() node, err := nodeRepo.Create(nil, nodes.TypeFolder, "test-folder", 0, "", "test-folder") if err != nil { t.Fatal(err) } dir := filepath.Join(vaultRoot, "test-folder") if err := os.MkdirAll(dir, 0o750); err != nil { t.Fatal(err) } // Import a file. src := tempFileOutsideVault(t, "back") defer os.Remove(src) rec, err := fileSvc.CopyIntoVault(node.ID, src, "test-folder") if err != nil { t.Fatal(err) } // Mark as missing and remove from disk. if err := fileSvc.MarkMissing(rec.ID, true); err != nil { t.Fatal(err) } absPath := filepath.Join(vaultRoot, rec.Path) if err := os.Remove(absPath); err != nil { t.Fatal(err) } // Re-create the file. if err := os.WriteFile(absPath, []byte("back again"), 0o640); err != nil { t.Fatal(err) } scanner := NewScanner(vaultRoot, nodeRepo, fileSvc, activitySvc) result, err := scanner.Run() if err != nil { t.Fatal(err) } if result.RestoredFiles != 1 { t.Errorf("expected 1 restored, got %d", result.RestoredFiles) } } func TestScanner_ModifiedFile(t *testing.T) { vaultRoot, _, nodeRepo, fileSvc, activitySvc, cleanup := setupWatcherTest(t) defer cleanup() node, err := nodeRepo.Create(nil, nodes.TypeFolder, "test-folder", 0, "", "test-folder") if err != nil { t.Fatal(err) } dir := filepath.Join(vaultRoot, "test-folder") if err := os.MkdirAll(dir, 0o750); err != nil { t.Fatal(err) } // Import a file. src := tempFileOutsideVault(t, "original") defer os.Remove(src) _, err = fileSvc.CopyIntoVault(node.ID, src, "test-folder") if err != nil { t.Fatal(err) } // Get the record to know the vault path. vaultFiles, err := fileSvc.ListByNode(node.ID) if err != nil || len(vaultFiles) == 0 { t.Fatal("no vault files found") } rec := vaultFiles[0] absPath := filepath.Join(vaultRoot, rec.Path) // Modify the file content. time.Sleep(10 * time.Millisecond) if err := os.WriteFile(absPath, []byte("modified"), 0o640); err != nil { t.Fatal(err) } scanner := NewScanner(vaultRoot, nodeRepo, fileSvc, activitySvc) result, err := scanner.Run() if err != nil { t.Fatal(err) } if result.ModifiedFiles != 1 { t.Errorf("expected 1 modified, got %d", result.ModifiedFiles) } } func TestIsHiddenOrMeta(t *testing.T) { tests := []struct { path string expected bool }{ {"/vault/.verstak/config.yml", true}, {"/vault/.verstak/trash/file.txt", true}, {"/vault/my-project/file.txt", false}, {"/vault/.hidden/file.txt", true}, {"/vault/project/.hidden/file.txt", true}, {"/vault/project/file.txt", false}, } for _, tc := range tests { got := isHiddenOrMeta(tc.path) if got != tc.expected { t.Errorf("isHiddenOrMeta(%q) = %v, want %v", tc.path, got, tc.expected) } } }