package commands import ( "context" "fmt" "log" "os" "path/filepath" "strings" "testing" "time" "github.com/belphemur/CBZOptimizer/v2/internal/cbz" "github.com/belphemur/CBZOptimizer/v2/internal/manga" "github.com/belphemur/CBZOptimizer/v2/internal/utils/errs" "github.com/belphemur/CBZOptimizer/v2/pkg/converter" "github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant" "github.com/spf13/cobra" ) // MockConverter is a mock implementation of the Converter interface type MockConverter struct{} func (m *MockConverter) ConvertChapter(ctx context.Context, chapter *manga.Chapter, quality uint8, split bool, progress func(message string, current uint32, total uint32)) (*manga.Chapter, error) { chapter.IsConverted = true chapter.ConvertedTime = time.Now() return chapter, nil } func (m *MockConverter) Format() constant.ConversionFormat { return constant.WebP } func (m *MockConverter) PrepareConverter() error { return nil } func TestConvertCbzCommand(t *testing.T) { // Create a temporary directory for testing tempDir, err := os.MkdirTemp("", "test_cbz") if err != nil { log.Fatal(err) } defer errs.CaptureGeneric(&err, os.RemoveAll, tempDir, "failed to remove temporary directory") // Locate the testdata directory testdataDir := filepath.Join("../../../testdata") if _, err := os.Stat(testdataDir); os.IsNotExist(err) { t.Fatalf("testdata directory not found") } // Copy sample CBZ/CBR files from testdata to the temporary directory err = filepath.Walk(testdataDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { fileName := strings.ToLower(info.Name()) if strings.HasSuffix(fileName, ".cbz") || strings.HasSuffix(fileName, ".cbr") { destPath := filepath.Join(tempDir, info.Name()) data, err := os.ReadFile(path) if err != nil { return err } return os.WriteFile(destPath, data, info.Mode()) } } return nil }) if err != nil { t.Fatalf("Failed to copy sample files: %v", err) } // Mock the converter.Get function originalGet := converter.Get converter.Get = func(format constant.ConversionFormat) (converter.Converter, error) { return &MockConverter{}, nil } defer func() { converter.Get = originalGet }() // Set up the command cmd := &cobra.Command{ Use: "optimize", } cmd.Flags().Uint8P("quality", "q", 85, "Quality for conversion (0-100)") cmd.Flags().IntP("parallelism", "n", 2, "Number of chapters to convert in parallel") cmd.Flags().BoolP("override", "o", false, "Override the original CBZ/CBR files") cmd.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks") cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter (e.g., 30s, 5m, 1h). 0 means no timeout") // Execute the command err = ConvertCbzCommand(cmd, []string{tempDir}) if err != nil { t.Fatalf("Command execution failed: %v", err) } // Track expected converted files for verification expectedFiles := make(map[string]bool) convertedFiles := make(map[string]bool) // First pass: identify original files and expected converted filenames err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } fileName := strings.ToLower(info.Name()) if strings.HasSuffix(fileName, ".cbz") || strings.HasSuffix(fileName, ".cbr") { if !strings.Contains(fileName, "_converted") { // This is an original file, determine expected converted filename baseName := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name())) expectedConverted := baseName + "_converted.cbz" expectedFiles[expectedConverted] = false // false means not yet found } } return nil }) if err != nil { t.Fatalf("Error identifying original files: %v", err) } // Second pass: verify converted files exist and are properly converted err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } fileName := info.Name() // Check if this is a converted file (should only be .cbz, never .cbr) if strings.HasSuffix(fileName, "_converted.cbz") { convertedFiles[fileName] = true expectedFiles[fileName] = true // Mark as found t.Logf("Archive file found: %s", path) // Load the converted chapter chapter, err := cbz.LoadChapter(path) if err != nil { return err } // Check if the chapter is marked as converted if !chapter.IsConverted { t.Errorf("Chapter is not marked as converted: %s", path) } // Check if the ConvertedTime is set if chapter.ConvertedTime.IsZero() { t.Errorf("ConvertedTime is not set for chapter: %s", path) } t.Logf("Archive file [%s] is converted: %s", path, chapter.ConvertedTime) } else if strings.HasSuffix(fileName, "_converted.cbr") { t.Errorf("Found incorrectly named converted file: %s (should be .cbz, not .cbr)", fileName) } return nil }) if err != nil { t.Fatalf("Error verifying converted files: %v", err) } // Verify all expected files were found for expectedFile, found := range expectedFiles { if !found { t.Errorf("Expected converted file not found: %s", expectedFile) } } // Log summary t.Logf("Found %d converted files", len(convertedFiles)) } // setupTestCommand creates a test command with all required flags for testing. // It mocks the converter.Get function and sets up a complete command with all flags. // // Returns: // - *cobra.Command: A configured command ready for testing // - func(): A cleanup function that must be deferred to restore the original converter.Get func setupTestCommand(t *testing.T) (*cobra.Command, func()) { t.Helper() // Mock the converter.Get function originalGet := converter.Get converter.Get = func(format constant.ConversionFormat) (converter.Converter, error) { return &MockConverter{}, nil } cleanup := func() { converter.Get = originalGet } // Set up the command cmd := &cobra.Command{ Use: "optimize", } cmd.Flags().Uint8P("quality", "q", 85, "Quality for conversion (0-100)") cmd.Flags().IntP("parallelism", "n", 1, "Number of chapters to convert in parallel") cmd.Flags().BoolP("override", "o", false, "Override the original CBZ/CBR files") cmd.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks") cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter") // Reset converterType to default before test for consistency converterType = constant.DefaultConversion setupFormatFlag(cmd, &converterType, false) return cmd, cleanup } // TestFormatFlagWithSpace tests that the format flag works with space-separated values func TestFormatFlagWithSpace(t *testing.T) { // Create a temporary directory for testing tempDir, err := os.MkdirTemp("", "test_format_space") if err != nil { t.Fatalf("Failed to create temp directory: %v", err) } defer os.RemoveAll(tempDir) cmd, cleanup := setupTestCommand(t) defer cleanup() // Test with space-separated format flag (--format webp) cmd.ParseFlags([]string{"--format", "webp"}) // Execute the command err = ConvertCbzCommand(cmd, []string{tempDir}) if err != nil { t.Fatalf("Command execution failed with --format webp: %v", err) } // Verify the format was set correctly if converterType != constant.WebP { t.Errorf("Expected format to be WebP, got %v", converterType) } } // TestFormatFlagWithShortForm tests that the short form of format flag works with space-separated values func TestFormatFlagWithShortForm(t *testing.T) { // Create a temporary directory for testing tempDir, err := os.MkdirTemp("", "test_format_short") if err != nil { t.Fatalf("Failed to create temp directory: %v", err) } defer os.RemoveAll(tempDir) cmd, cleanup := setupTestCommand(t) defer cleanup() // Test with short form and space (-f webp) cmd.ParseFlags([]string{"-f", "webp"}) // Execute the command err = ConvertCbzCommand(cmd, []string{tempDir}) if err != nil { t.Fatalf("Command execution failed with -f webp: %v", err) } // Verify the format was set correctly if converterType != constant.WebP { t.Errorf("Expected format to be WebP, got %v", converterType) } } // TestFormatFlagWithEquals tests that the format flag works with equals syntax func TestFormatFlagWithEquals(t *testing.T) { // Create a temporary directory for testing tempDir, err := os.MkdirTemp("", "test_format_equals") if err != nil { t.Fatalf("Failed to create temp directory: %v", err) } defer os.RemoveAll(tempDir) cmd, cleanup := setupTestCommand(t) defer cleanup() // Test with equals syntax (--format=webp) cmd.ParseFlags([]string{"--format=webp"}) // Execute the command err = ConvertCbzCommand(cmd, []string{tempDir}) if err != nil { t.Fatalf("Command execution failed with --format=webp: %v", err) } // Verify the format was set correctly if converterType != constant.WebP { t.Errorf("Expected format to be WebP, got %v", converterType) } } // TestFormatFlagDefaultValue tests that the default format is used when flag is not provided func TestFormatFlagDefaultValue(t *testing.T) { // Create a temporary directory for testing tempDir, err := os.MkdirTemp("", "test_format_default") if err != nil { t.Fatalf("Failed to create temp directory: %v", err) } defer os.RemoveAll(tempDir) cmd, cleanup := setupTestCommand(t) defer cleanup() // Don't set format flag - should use default cmd.ParseFlags([]string{}) // Execute the command err = ConvertCbzCommand(cmd, []string{tempDir}) if err != nil { t.Fatalf("Command execution failed with default format: %v", err) } // Verify the default format is used if converterType != constant.DefaultConversion { t.Errorf("Expected format to be default (%v), got %v", constant.DefaultConversion, converterType) } } // TestFormatFlagCaseInsensitive tests that the format flag is case-insensitive func TestFormatFlagCaseInsensitive(t *testing.T) { // Create a temporary directory for testing tempDir, err := os.MkdirTemp("", "test_format_case") if err != nil { t.Fatalf("Failed to create temp directory: %v", err) } defer os.RemoveAll(tempDir) testCases := []string{"webp", "WEBP", "WebP", "WeBp"} for _, formatValue := range testCases { t.Run(formatValue, func(t *testing.T) { cmd, cleanup := setupTestCommand(t) defer cleanup() // Test with different case variations cmd.ParseFlags([]string{"--format", formatValue}) // Execute the command err = ConvertCbzCommand(cmd, []string{tempDir}) if err != nil { t.Fatalf("Command execution failed with format '%s': %v", formatValue, err) } // Verify the format was set correctly if converterType != constant.WebP { t.Errorf("Expected format to be WebP for input '%s', got %v", formatValue, converterType) } }) } } // TestConvertCbzCommand_ManyFiles_NoDeadlock tests that processing many files in parallel // does not cause a deadlock. This reproduces the scenario where processing // recursive folders of CBZ files with parallelism > 1 could cause a "all goroutines are asleep - deadlock!" error. func TestConvertCbzCommand_ManyFiles_NoDeadlock(t *testing.T) { // Create a temporary directory for testing tempDir, err := os.MkdirTemp("", "test_cbz_many_files") if err != nil { log.Fatal(err) } defer errs.CaptureGeneric(&err, os.RemoveAll, tempDir, "failed to remove temporary directory") // Locate the testdata directory testdataDir := filepath.Join("../../../testdata") if _, err := os.Stat(testdataDir); os.IsNotExist(err) { t.Fatalf("testdata directory not found") } // Create subdirectories to simulate the recursive folder structure from the bug report subdirs := []string{"author1/book1", "author2/book2", "author3/book3", "author4/book4"} for _, subdir := range subdirs { err := os.MkdirAll(filepath.Join(tempDir, subdir), 0755) if err != nil { t.Fatalf("Failed to create subdirectory: %v", err) } } // Find a sample CBZ file to copy var sampleCBZ string err = filepath.Walk(testdataDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".cbz") && !strings.Contains(info.Name(), "converted") { sampleCBZ = path return filepath.SkipDir } return nil }) if err != nil || sampleCBZ == "" { t.Fatalf("Failed to find sample CBZ file: %v", err) } // Copy the sample file to multiple locations (simulating many files to process) numFilesPerDir := 5 totalFiles := 0 for _, subdir := range subdirs { for i := 0; i < numFilesPerDir; i++ { destPath := filepath.Join(tempDir, subdir, fmt.Sprintf("Chapter_%d.cbz", i+1)) data, err := os.ReadFile(sampleCBZ) if err != nil { t.Fatalf("Failed to read sample file: %v", err) } err = os.WriteFile(destPath, data, 0644) if err != nil { t.Fatalf("Failed to write test file: %v", err) } totalFiles++ } } t.Logf("Created %d test files across %d directories", totalFiles, len(subdirs)) // Mock the converter.Get function originalGet := converter.Get converter.Get = func(format constant.ConversionFormat) (converter.Converter, error) { return &MockConverter{}, nil } defer func() { converter.Get = originalGet }() // Set up the command with parallelism = 2 (same as the bug report) cmd := &cobra.Command{ Use: "optimize", } cmd.Flags().Uint8P("quality", "q", 85, "Quality for conversion (0-100)") cmd.Flags().IntP("parallelism", "n", 2, "Number of chapters to convert in parallel") cmd.Flags().BoolP("override", "o", false, "Override the original CBZ/CBR files") cmd.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks") cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter") converterType = constant.DefaultConversion setupFormatFlag(cmd, &converterType, false) // Run the command with a timeout to detect deadlocks done := make(chan error, 1) go func() { done <- ConvertCbzCommand(cmd, []string{tempDir}) }() select { case err := <-done: if err != nil { t.Fatalf("Command execution failed: %v", err) } t.Logf("Command completed successfully without deadlock") case <-time.After(60 * time.Second): t.Fatal("Deadlock detected: Command did not complete within 60 seconds") } // Verify that converted files were created var convertedCount int err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() && strings.HasSuffix(info.Name(), "_converted.cbz") { convertedCount++ } return nil }) if err != nil { t.Fatalf("Error counting converted files: %v", err) } if convertedCount != totalFiles { t.Errorf("Expected %d converted files, found %d", totalFiles, convertedCount) } t.Logf("Found %d converted files as expected", convertedCount) } // TestConvertCbzCommand_HighParallelism_NoDeadlock tests processing with high parallelism setting. func TestConvertCbzCommand_HighParallelism_NoDeadlock(t *testing.T) { // Create a temporary directory tempDir, err := os.MkdirTemp("", "test_cbz_high_parallel") if err != nil { log.Fatal(err) } defer errs.CaptureGeneric(&err, os.RemoveAll, tempDir, "failed to remove temporary directory") // Locate the testdata directory testdataDir := filepath.Join("../../../testdata") if _, err := os.Stat(testdataDir); os.IsNotExist(err) { t.Fatalf("testdata directory not found") } // Find and copy sample CBZ files var sampleCBZ string err = filepath.Walk(testdataDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".cbz") && !strings.Contains(info.Name(), "converted") { sampleCBZ = path return filepath.SkipDir } return nil }) if err != nil || sampleCBZ == "" { t.Fatalf("Failed to find sample CBZ file: %v", err) } // Create many test files numFiles := 15 for i := 0; i < numFiles; i++ { destPath := filepath.Join(tempDir, fmt.Sprintf("test_file_%d.cbz", i+1)) data, err := os.ReadFile(sampleCBZ) if err != nil { t.Fatalf("Failed to read sample file: %v", err) } err = os.WriteFile(destPath, data, 0644) if err != nil { t.Fatalf("Failed to write test file: %v", err) } } // Mock the converter originalGet := converter.Get converter.Get = func(format constant.ConversionFormat) (converter.Converter, error) { return &MockConverter{}, nil } defer func() { converter.Get = originalGet }() // Test with high parallelism (8) cmd := &cobra.Command{ Use: "optimize", } cmd.Flags().Uint8P("quality", "q", 85, "Quality for conversion (0-100)") cmd.Flags().IntP("parallelism", "n", 8, "Number of chapters to convert in parallel") cmd.Flags().BoolP("override", "o", false, "Override the original CBZ/CBR files") cmd.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks") cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter") converterType = constant.DefaultConversion setupFormatFlag(cmd, &converterType, false) done := make(chan error, 1) go func() { done <- ConvertCbzCommand(cmd, []string{tempDir}) }() select { case err := <-done: if err != nil { t.Fatalf("Command execution failed: %v", err) } case <-time.After(60 * time.Second): t.Fatal("Deadlock detected with high parallelism") } }