package commands import ( "os" "path/filepath" "runtime" "sync" "sync/atomic" "testing" "time" utils2 "github.com/belphemur/CBZOptimizer/v2/internal/utils" "github.com/fsnotify/fsnotify" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestIsComicArchive(t *testing.T) { testCases := []struct { name string path string expected bool }{ {"cbz lowercase", "/a/b/chapter.cbz", true}, {"cbr lowercase", "/a/b/chapter.cbr", true}, {"cbz uppercase", "/a/b/chapter.CBZ", true}, {"other extension", "/a/b/chapter.zip", false}, {"no extension", "/a/b/chapter", false}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { assert.Equal(t, tc.expected, isComicArchive(tc.path)) }) } } func TestShouldProcessWatchEvent(t *testing.T) { testCases := []struct { name string op fsnotify.Op expected bool }{ {"create", fsnotify.Create, true}, {"write", fsnotify.Write, true}, {"rename", fsnotify.Rename, true}, {"remove", fsnotify.Remove, false}, {"chmod", fsnotify.Chmod, false}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { event := fsnotify.Event{Name: "file.cbz", Op: tc.op} assert.Equal(t, tc.expected, shouldProcessWatchEvent(event)) }) } } func TestAddRecursiveWatchSkipsUnreadableSubdirectory(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("permission bits are not reliably enforced on Windows") } root := t.TempDir() nested := filepath.Join(root, "nested") assert.NoError(t, os.MkdirAll(nested, 0o755)) assert.NoError(t, os.Chmod(nested, 0o000)) defer func() { assert.NoError(t, os.Chmod(nested, 0o755)) }() if _, err := os.ReadDir(nested); err == nil { t.Skip("cannot make nested directory unreadable in this environment") } watcher, err := fsnotify.NewWatcher() assert.NoError(t, err) defer func() { assert.NoError(t, watcher.Close()) }() // A regular, fully accessible tree should be watched without error. assert.NoError(t, addRecursiveWatch(watcher, root)) } func TestBackfillExistingArchivesFindsPreExistingArchives(t *testing.T) { root := t.TempDir() assert.NoError(t, os.WriteFile(filepath.Join(root, "chapter1.cbz"), []byte("data"), 0o644)) assert.NoError(t, os.WriteFile(filepath.Join(root, "notes.txt"), []byte("data"), 0o644)) sub := filepath.Join(root, "sub") assert.NoError(t, os.MkdirAll(sub, 0o755)) assert.NoError(t, os.WriteFile(filepath.Join(sub, "chapter2.cbr"), []byte("data"), 0o644)) var mu sync.Mutex var found []string backfillExistingArchives(root, func(path string) { mu.Lock() defer mu.Unlock() found = append(found, path) }) assert.Len(t, found, 2) } func TestEventDebouncerCoalescesBurstsIntoSingleCall(t *testing.T) { var calls int32 debouncer := newEventDebouncer(20*time.Millisecond, func(path string) { atomic.AddInt32(&calls, 1) }) defer debouncer.Stop() // Simulate a burst of events for the same path. for i := 0; i < 5; i++ { debouncer.Trigger("/tmp/chapter.cbz") } require.Eventually(t, func() bool { return atomic.LoadInt32(&calls) == 1 }, 2*time.Second, 10*time.Millisecond) } func TestEventDebouncerHandlesMultiplePaths(t *testing.T) { var mu sync.Mutex seen := make(map[string]int) debouncer := newEventDebouncer(10*time.Millisecond, func(path string) { mu.Lock() defer mu.Unlock() seen[path]++ }) defer debouncer.Stop() debouncer.Trigger("/tmp/a.cbz") debouncer.Trigger("/tmp/b.cbz") require.Eventually(t, func() bool { mu.Lock() defer mu.Unlock() return seen["/tmp/a.cbz"] == 1 && seen["/tmp/b.cbz"] == 1 }, 2*time.Second, 10*time.Millisecond) } func TestOptimizeQueueSkipsMissingPath(t *testing.T) { q := newOptimizeQueue(1, &utils2.OptimizeOptions{}) defer q.Stop() var calls int32 done := make(chan struct{}, 1) existing := filepath.Join(t.TempDir(), "existing.cbz") require.NoError(t, os.WriteFile(existing, []byte("data"), 0o644)) q.optimize = func(options *utils2.OptimizeOptions) error { if options.Path == existing { atomic.AddInt32(&calls, 1) select { case done <- struct{}{}: default: } } return nil } // Enqueue a path that doesn't exist; process should skip it without // panicking or blocking since it never reaches utils2.Optimize. q.Enqueue(filepath.Join(t.TempDir(), "missing.cbz")) q.Enqueue(existing) select { case <-done: case <-time.After(2 * time.Second): t.Fatal("timed out waiting for existing path to be processed") } assert.EqualValues(t, 1, atomic.LoadInt32(&calls)) } func TestWatchCommandBackfillFlagDefaultsToFalse(t *testing.T) { watchCmd, _, err := rootCmd.Find([]string{"watch"}) require.NoError(t, err) flag := watchCmd.Flags().Lookup("backfill") require.NotNil(t, flag, "watch command should register a --backfill flag") assert.Equal(t, "false", flag.DefValue) assert.Equal(t, "bool", flag.Value.Type()) } func TestMaybeBackfillExistingArchivesOnlyInvokedWhenRequested(t *testing.T) { root := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(root, "chapter1.cbz"), []byte("data"), 0o644)) runBackfill := func(enabled bool) []string { var found []string var mu sync.Mutex process := func(path string) { mu.Lock() defer mu.Unlock() found = append(found, path) } // This is the exact same gating call WatchCommand makes based on the // --backfill flag value. maybeBackfillExistingArchives(enabled, root, process) return found } assert.Empty(t, runBackfill(false), "no pre-existing archive should be processed when backfill is disabled") assert.Len(t, runBackfill(true), 1, "pre-existing archives should be processed when backfill is enabled") }