Files
bookhoard/internal/services/ebook_scanner_comic_test.go
T
john-okeefe 9238fad8d5 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).
2026-02-07 17:07:50 -05:00

612 lines
14 KiB
Go

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))
}