From 9238fad8d592edcfa747fb5b80d8907da859a1a3 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 7 Feb 2026 17:07:50 -0500 Subject: [PATCH] test(scanner): add comprehensive comic metadata extraction tests Test coverage for multi-format comic archive metadata extraction: Format-specific tests: - TestExtractZipMetadata - .cbz (ZIP) with ComicInfo.xml - TestExtractZipMetadataWithoutComicInfo - Fallback behavior - TestExtractTarMetadata - .cbt (TAR) archives - TestExtractTarGzMetadata - .tar.gz (gzipped TAR) Integration tests: - TestExtractComicMetadata - Router function tests - TestIsImageFile - Image detection validation Helper functions: - createTestCBZ, createTestCBT, createTestTarGz - Create test archives - Uses image/png package for valid test images Tests cover: - Metadata extraction (title, series, issue, publisher, writer) - Cover image extraction with format validation - Fallback behavior when metadata missing - Error handling for invalid/corrupted archives All tests use t.TempDir() for automatic cleanup and follow project testing patterns (table-driven tests, t.Run(), etc). --- internal/services/ebook_scanner_comic_test.go | 611 ++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 internal/services/ebook_scanner_comic_test.go diff --git a/internal/services/ebook_scanner_comic_test.go b/internal/services/ebook_scanner_comic_test.go new file mode 100644 index 0000000..1b2cb9b --- /dev/null +++ b/internal/services/ebook_scanner_comic_test.go @@ -0,0 +1,611 @@ +package services + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "encoding/xml" + "fmt" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "testing" + "time" +) + +// TestExtractZipMetadata tests .cbz (ZIP) metadata extraction +func TestExtractZipMetadata(t *testing.T) { + tmpDir := t.TempDir() + cbzFile := filepath.Join(tmpDir, "test.cbz") + + // Create a test ZIP archive with ComicInfo.xml and cover image + if err := createTestCBZ(cbzFile); err != nil { + t.Fatalf("Failed to create test CBZ: %v", err) + } + + // Test metadata extraction + comicInfo, coverImage, err := extractZipMetadata(cbzFile) + if err != nil { + t.Fatalf("extractZipMetadata failed: %v", err) + } + + // Verify metadata + if comicInfo == nil { + t.Fatal("comicInfo is nil") + } + + if comicInfo.Title != "Test Comic Title" { + t.Errorf("Expected title 'Test Comic Title', got '%s'", comicInfo.Title) + } + + if comicInfo.Series != "Test Series" { + t.Errorf("Expected series 'Test Series', got '%s'", comicInfo.Series) + } + + if comicInfo.Number != 1 { + t.Errorf("Expected issue number 1, got %d", comicInfo.Number) + } + + if comicInfo.Publisher != "Test Publisher" { + t.Errorf("Expected publisher 'Test Publisher', got '%s'", comicInfo.Publisher) + } + + if comicInfo.Writer != "Test Writer" { + t.Errorf("Expected writer 'Test Writer', got '%s'", comicInfo.Writer) + } + + // Verify cover image + if len(coverImage) == 0 { + t.Error("Cover image not extracted") + // Debug: Check if the file was created properly + t.Logf("Debug: Checking created CBZ file") + r, err := zip.OpenReader(cbzFile) + if err != nil { + t.Logf("Failed to open CBZ for debug: %v", err) + return + } + defer r.Close() + for _, f := range r.File { + t.Logf("File in CBZ: %s (isImage: %v)", f.Name, isImageFile(f.Name)) + } + } else { + // Verify it's a valid image + _, format, err := image.Decode(bytes.NewReader(coverImage)) + if err != nil { + t.Errorf("Extracted cover is not a valid image: %v", err) + } + t.Logf("Cover image format: %s, size: %d bytes", format, len(coverImage)) + } + + t.Logf("ZIP metadata extraction test passed: title=%s, series=%s, issue=%d", + comicInfo.Title, comicInfo.Series, comicInfo.Number) +} + +// TestExtractZipMetadataWithoutComicInfo tests .cbz without ComicInfo.xml +func TestExtractZipMetadataWithoutComicInfo(t *testing.T) { + tmpDir := t.TempDir() + cbzFile := filepath.Join(tmpDir, "test_no_metadata.cbz") + + // Create a ZIP without ComicInfo.xml + if err := createTestCBZWithoutMetadata(cbzFile); err != nil { + t.Fatalf("Failed to create test CBZ: %v", err) + } + + // Test metadata extraction + comicInfo, coverImage, err := extractZipMetadata(cbzFile) + if err != nil { + t.Fatalf("extractZipMetadata failed: %v", err) + } + + // Should fall back to filename + if comicInfo.Title != "test_no_metadata" { + t.Errorf("Expected title 'test_no_metadata', got '%s'", comicInfo.Title) + } + + // Should still extract cover + if len(coverImage) == 0 { + t.Error("Cover image not extracted") + } + + t.Logf("ZIP without ComicInfo.xml test passed: title=%s (fallback)", comicInfo.Title) +} + +// TestExtractRarMetadata tests .cbr (RAR) metadata extraction +func TestExtractRarMetadata(t *testing.T) { + if testing.Short() { + t.Skip("Skipping RAR test in short mode (requires test archive)") + } + + // Note: Creating RAR archives requires additional libraries + // This test will use a pre-created test file if available + // For now, we'll test with a fallback + + tmpDir := t.TempDir() + cbrFile := filepath.Join(tmpDir, "test.cbr") + + // Create a mock RAR file (not a real RAR, just for error handling test) + if err := os.WriteFile(cbrFile, []byte("not a real rar"), 0644); err != nil { + t.Fatalf("Failed to create mock RAR: %v", err) + } + + // Test error handling + _, _, err := extractRarMetadata(cbrFile) + if err == nil { + t.Error("Expected error for invalid RAR file, got nil") + } + + t.Logf("RAR error handling test passed") +} + +// TestExtract7ZipMetadata tests .cb7 (7-Zip) metadata extraction +func TestExtract7ZipMetadata(t *testing.T) { + if testing.Short() { + t.Skip("Skipping 7-Zip test in short mode (requires test archive)") + } + + tmpDir := t.TempDir() + cb7File := filepath.Join(tmpDir, "test.cb7") + + // Create a mock 7-Zip file for error handling test + if err := os.WriteFile(cb7File, []byte("not a real 7z"), 0644); err != nil { + t.Fatalf("Failed to create mock 7z: %v", err) + } + + // Test error handling + _, _, err := extract7ZipMetadata(cb7File) + if err == nil { + t.Error("Expected error for invalid 7-Zip file, got nil") + } + + t.Logf("7-Zip error handling test passed") +} + +// TestExtractTarMetadata tests .cbt (TAR) metadata extraction +func TestExtractTarMetadata(t *testing.T) { + tmpDir := t.TempDir() + cbtFile := filepath.Join(tmpDir, "test.cbt") + + // Create a test TAR archive with ComicInfo.xml and cover image + if err := createTestCBT(cbtFile); err != nil { + t.Fatalf("Failed to create test CBT: %v", err) + } + + // Test metadata extraction + comicInfo, coverImage, err := extractTarMetadata(cbtFile) + if err != nil { + t.Fatalf("extractTarMetadata failed: %v", err) + } + + // Verify metadata + if comicInfo == nil { + t.Fatal("comicInfo is nil") + } + + if comicInfo.Title != "Test Tar Comic" { + t.Errorf("Expected title 'Test Tar Comic', got '%s'", comicInfo.Title) + } + + // Verify cover image + if len(coverImage) == 0 { + t.Error("Cover image not extracted") + } + + t.Logf("TAR metadata extraction test passed: title=%s", comicInfo.Title) +} + +// TestExtractTarGzMetadata tests .tar.gz metadata extraction +func TestExtractTarGzMetadata(t *testing.T) { + tmpDir := t.TempDir() + targzFile := filepath.Join(tmpDir, "test.tar.gz") + + // Create a test TAR.GZ archive + if err := createTestTarGz(targzFile); err != nil { + t.Fatalf("Failed to create test TAR.GZ: %v", err) + } + + // Test metadata extraction + comicInfo, coverImage, err := extractTarMetadata(targzFile) + if err != nil { + t.Fatalf("extractTarMetadata failed for tar.gz: %v", err) + } + + // Verify metadata + if comicInfo == nil { + t.Fatal("comicInfo is nil") + } + + if comicInfo.Title != "Test Tar Gz Comic" { + t.Errorf("Expected title 'Test Tar Gz Comic', got '%s'", comicInfo.Title) + } + + // Verify cover image + if len(coverImage) == 0 { + t.Error("Cover image not extracted") + } + + t.Logf("TAR.GZ metadata extraction test passed: title=%s", comicInfo.Title) +} + +// TestExtractComicMetadata tests the router function +func TestExtractComicMetadata(t *testing.T) { + tests := []struct { + name string + fileExt string + createFunc func(string) error + shouldFail bool + expectedTitle string + }{ + { + name: "CBZ file", + fileExt: ".cbz", + createFunc: func(path string) error { return createTestCBZ(path) }, + shouldFail: false, + expectedTitle: "Test Comic Title", + }, + { + name: "CBT file", + fileExt: ".cbt", + createFunc: func(path string) error { return createTestCBT(path) }, + shouldFail: false, + expectedTitle: "Test Tar Comic", + }, + { + name: "Unsupported format", + fileExt: ".xyz", + createFunc: func(path string) error { return os.WriteFile(path, []byte("test"), 0644) }, + shouldFail: true, + expectedTitle: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test"+tt.fileExt) + + if err := tt.createFunc(testFile); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + comicInfo, coverImage, err := extractComicMetadata(testFile) + + if tt.shouldFail { + if err == nil { + t.Error("Expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("extractComicMetadata failed: %v", err) + } + + if comicInfo == nil { + t.Fatal("comicInfo is nil") + } + + if comicInfo.Title != tt.expectedTitle { + t.Errorf("Expected title '%s', got '%s'", tt.expectedTitle, comicInfo.Title) + } + + if len(coverImage) == 0 { + t.Error("Cover image not extracted") + } + + t.Logf("Test passed: title=%s, cover=%d bytes", comicInfo.Title, len(coverImage)) + }) + } +} + +// TestIsImageFile tests image file detection +func TestIsImageFile(t *testing.T) { + tests := []struct { + name string + filename string + expected bool + }{ + {"JPG file", "cover.jpg", true}, + {"JPEG file", "cover.jpeg", true}, + {"PNG file", "cover.png", true}, + {"GIF file", "cover.gif", true}, + {"Uppercase JPG", "cover.JPG", true}, + {"Mixed case", "cover.JpG", true}, + {"XML file", "ComicInfo.xml", false}, + {"Text file", "readme.txt", false}, + {"No extension", "cover", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isImageFile(tt.filename) + if result != tt.expected { + t.Errorf("Expected %v for %s, got %v", tt.expected, tt.filename, result) + } + }) + } +} + +// BenchmarkExtractZipMetadata benchmarks ZIP extraction +func BenchmarkExtractZipMetadata(b *testing.B) { + tmpDir := b.TempDir() + cbzFile := filepath.Join(tmpDir, "bench.cbz") + + if err := createTestCBZ(cbzFile); err != nil { + b.Fatalf("Failed to create test CBZ: %v", err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, _ = extractZipMetadata(cbzFile) + } +} + +// Helper functions to create test archives + +// createTestCBZ creates a test ZIP archive with ComicInfo.xml and cover image +func createTestCBZ(path string) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + zipWriter := zip.NewWriter(file) + defer zipWriter.Close() + + // Add ComicInfo.xml + comicInfo := ComicInfo{ + Title: "Test Comic Title", + Series: "Test Series", + Number: 1, + Volume: 1, + Publisher: "Test Publisher", + Year: 2024, + Month: 1, + Day: 1, + Writer: "Test Writer", + Penciller: "Test Penciller", + Inker: "Test Inker", + Genre: "Test Genre", + } + + xmlData, err := xml.Marshal(comicInfo) + if err != nil { + return err + } + + writer, err := zipWriter.Create("ComicInfo.xml") + if err != nil { + return err + } + + if _, err := writer.Write(xmlData); err != nil { + return err + } + + // Create a simple valid PNG image (1x1 red pixel) using image/png package + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{255, 0, 0, 255}) + + var pngBuf bytes.Buffer + if err := png.Encode(&pngBuf, img); err != nil { + return err + } + coverPNG := pngBuf.Bytes() + + // Write cover with .jpg extension (even though it's PNG data, for testing extension handling) + writer, err = zipWriter.Create("cover.jpg") + if err != nil { + return err + } + + if _, err := writer.Write(coverPNG); err != nil { + return err + } + + // Add a page image + writer, err = zipWriter.Create("page001.jpg") + if err != nil { + return err + } + + if _, err := writer.Write(coverPNG); err != nil { + return err + } + + return nil +} + +// createTestCBZWithoutMetadata creates a ZIP without ComicInfo.xml +func createTestCBZWithoutMetadata(path string) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + zipWriter := zip.NewWriter(file) + defer zipWriter.Close() + + // Create a simple valid PNG image + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{0, 0, 255, 255}) + + var pngBuf bytes.Buffer + if err := png.Encode(&pngBuf, img); err != nil { + return err + } + coverPNG := pngBuf.Bytes() + + writer, err := zipWriter.Create("cover.jpg") + if err != nil { + return err + } + + if _, err := writer.Write(coverPNG); err != nil { + return err + } + + return nil +} + +// createTestCBT creates a test TAR archive +func createTestCBT(path string) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + tarWriter := tar.NewWriter(file) + defer tarWriter.Close() + + // Add ComicInfo.xml + comicInfo := ComicInfo{ + Title: "Test Tar Comic", + Series: "Test Tar Series", + Number: 5, + Publisher: "Test Publisher", + } + + xmlData, err := xml.Marshal(comicInfo) + if err != nil { + return err + } + + header := &tar.Header{ + Name: "ComicInfo.xml", + Mode: 0644, + Size: int64(len(xmlData)), + ModTime: time.Now(), + } + + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + + if _, err := tarWriter.Write(xmlData); err != nil { + return err + } + + // Create a simple valid PNG image + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{0, 255, 0, 255}) + + var pngBuf bytes.Buffer + if err := png.Encode(&pngBuf, img); err != nil { + return err + } + coverPNG := pngBuf.Bytes() + + header = &tar.Header{ + Name: "cover.jpg", + Mode: 0644, + Size: int64(len(coverPNG)), + ModTime: time.Now(), + } + + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + + if _, err := tarWriter.Write(coverPNG); err != nil { + return err + } + + return nil +} + +// createTestTarGz creates a test TAR.GZ archive +func createTestTarGz(path string) error { + // First create the TAR content in memory + var tarBuffer bytes.Buffer + tarWriter := tar.NewWriter(&tarBuffer) + + comicInfo := ComicInfo{ + Title: "Test Tar Gz Comic", + Series: "Test Tar Gz Series", + Number: 10, + } + + xmlData, err := xml.Marshal(comicInfo) + if err != nil { + return err + } + + header := &tar.Header{ + Name: "ComicInfo.xml", + Mode: 0644, + Size: int64(len(xmlData)), + ModTime: time.Now(), + } + + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + + if _, err := tarWriter.Write(xmlData); err != nil { + return err + } + + // Create a simple valid PNG image + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{255, 255, 0, 255}) + + var pngBuf bytes.Buffer + if err := png.Encode(&pngBuf, img); err != nil { + return err + } + coverPNG := pngBuf.Bytes() + + header = &tar.Header{ + Name: "cover.jpg", + Mode: 0644, + Size: int64(len(coverPNG)), + ModTime: time.Now(), + } + + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + + if _, err := tarWriter.Write(coverPNG); err != nil { + return err + } + + tarWriter.Close() + + // Now compress the TAR with gzip + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + gzWriter := gzip.NewWriter(file) + defer gzWriter.Close() + + if _, err := gzWriter.Write(tarBuffer.Bytes()); err != nil { + return err + } + + return nil +} + +// Example usage +func Example_extractComicMetadata() { + // Extract metadata from a comic archive + comicInfo, coverImage, err := extractComicMetadata("/path/to/comic.cbz") + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("Title: %s\n", comicInfo.Title) + fmt.Printf("Series: %s\n", comicInfo.Series) + fmt.Printf("Issue: %d\n", comicInfo.Number) + fmt.Printf("Cover size: %d bytes\n", len(coverImage)) +}