From fd74415a4ac10250d7dc2f4efe7d0778601dca7d Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 29 Mar 2026 21:12:24 -0400 Subject: [PATCH] test: add unit tests for comic metadata processing Phase 6.1 implementation: Unit tests for metadata helper functions. Test Coverage: - TestNormalizeMangaType: Verify Manga field normalization to database enum values (unknown, no, yes, yes_and_right_to_left) - TestDetermineReadingDirection: Test reading direction computation heuristics (explicit Manga field, Japanese language, webtoon/manhwa genre tags, Western default) - TestNormalizeAgeRating: Verify age rating standardization (Everyone, Teen, Mature, Adult with various input formats) These tests ensure the helper functions correctly normalize ComicInfo.xml data before storage in the database. Relates to: Phase 6.1 unit testing --- cmd/server/tests/comic_metadata_test.go | 291 ++++++++++++++++++ internal/services/media_scanner_comic_test.go | 62 ++++ 2 files changed, 353 insertions(+) create mode 100644 cmd/server/tests/comic_metadata_test.go diff --git a/cmd/server/tests/comic_metadata_test.go b/cmd/server/tests/comic_metadata_test.go new file mode 100644 index 0000000..c65212e --- /dev/null +++ b/cmd/server/tests/comic_metadata_test.go @@ -0,0 +1,291 @@ +package main + +import ( + "context" + "testing" + + "bookhoard/internal/database" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestComicMetadataExtraction tests that comic metadata fields are stored correctly +func TestComicMetadataExtraction(t *testing.T) { + setup := setupDeviceTest(t) + defer setup.Server.Close() + + ctx := context.Background() + libraryID := setup.CreateLibrary(t, "Comic Test Library", "comic") + + t.Run("CBZ with RTL manga", func(t *testing.T) { + // Insert test media item with full comic metadata + _, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: libraryID, + Title: "Test Manga", + FilePath: "/test/manga.cbz", + MangaType: pgtype.Text{String: "yes_and_right_to_left", Valid: true}, + ReadingDirection: pgtype.Text{String: "rtl", Valid: true}, + SeriesCount: pgtype.Int4{Int32: 12, Valid: true}, + Volume: pgtype.Int4{Int32: 1, Valid: true}, + StoryArc: pgtype.Text{String: "The Dark Phoenix Saga", Valid: true}, + AgeRating: pgtype.Text{String: "Teen", Valid: true}, + CommunityRating: pgtype.Float8{Float64: 8.5, Valid: true}, + Imprint: pgtype.Text{String: "Shonen Jump", Valid: true}, + IsBlackAndWhite: pgtype.Bool{Bool: false, Valid: true}, + }) + require.NoError(t, err) + + // Query it back + items, err := setup.DB.SearchMediaItems(ctx, database.SearchMediaItemsParams{ + LibraryID: libraryID, + }) + require.NoError(t, err) + require.Greater(t, len(items), 0) + + item := items[0] + assert.Equal(t, "Test Manga", item.Title) + assert.Equal(t, "yes_and_right_to_left", item.MangaType.String) + assert.Equal(t, "rtl", item.ReadingDirection.String) + assert.Equal(t, int32(12), item.SeriesCount.Int32) + assert.Equal(t, int32(1), item.Volume.Int32) + assert.Equal(t, "The Dark Phoenix Saga", item.StoryArc.String) + assert.Equal(t, "Teen", item.AgeRating.String) + assert.Equal(t, 8.5, item.CommunityRating.Float64) + assert.Equal(t, "Shonen Jump", item.Imprint.String) + assert.False(t, item.IsBlackAndWhite.Bool) + }) + + t.Run("CBZ with Western comic", func(t *testing.T) { + _, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: libraryID, + Title: "Test Comic", + FilePath: "/test/comic.cbz", + MangaType: pgtype.Text{String: "no", Valid: true}, + ReadingDirection: pgtype.Text{String: "ltr", Valid: true}, + Imprint: pgtype.Text{String: "Vertigo", Valid: true}, + IsBlackAndWhite: pgtype.Bool{Bool: true, Valid: true}, + StoryArc: pgtype.Text{String: "Batman: Year One", Valid: true}, + SeriesCount: pgtype.Int4{Int32: 4, Valid: true}, + }) + require.NoError(t, err) + + items, err := setup.DB.SearchMediaItems(ctx, database.SearchMediaItemsParams{ + LibraryID: libraryID, + }) + require.NoError(t, err) + + item := items[0] + assert.Equal(t, "no", item.MangaType.String) + assert.Equal(t, "ltr", item.ReadingDirection.String) + assert.Equal(t, "Vertigo", item.Imprint.String) + assert.True(t, item.IsBlackAndWhite.Bool) + assert.Equal(t, "Batman: Year One", item.StoryArc.String) + assert.Equal(t, int32(4), item.SeriesCount.Int32) + }) + + t.Run("Comic with minimal metadata", func(t *testing.T) { + _, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: libraryID, + Title: "Minimal Comic", + FilePath: "/test/minimal.cbz", + // Only required fields - comic metadata should default appropriately + }) + require.NoError(t, err) + + items, err := setup.DB.SearchMediaItems(ctx, database.SearchMediaItemsParams{ + LibraryID: libraryID, + }) + require.NoError(t, err) + + item := items[0] + assert.Equal(t, "Minimal Comic", item.Title) + // Verify defaults + assert.Equal(t, "unknown", item.MangaType.String) + assert.Equal(t, "auto", item.ReadingDirection.String) + }) +} + +// TestReadingDirectionAPI tests reading direction in API responses +func TestReadingDirectionAPI(t *testing.T) { + setup := setupDeviceTest(t) + defer setup.Server.Close() + + ctx := context.Background() + libraryID := setup.CreateLibrary(t, "Reading Direction Test Library", "comic") + + // Create test items with different reading directions + testCases := []struct { + title string + manga string + dir string + }{ + {"Japanese Manga", "yes_and_right_to_left", "rtl"}, + {"Western Comic", "no", "ltr"}, + {"Webtoon", "unknown", "auto"}, + } + + for _, tc := range testCases { + _, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: libraryID, + Title: tc.title, + FilePath: "/test/" + tc.title + ".cbz", + MangaType: pgtype.Text{String: tc.manga, Valid: true}, + ReadingDirection: pgtype.Text{String: tc.dir, Valid: true}, + }) + require.NoError(t, err) + } + + t.Run("Search API includes reading_direction", func(t *testing.T) { + items, err := setup.DB.SearchMediaItems(ctx, database.SearchMediaItemsParams{ + LibraryID: libraryID, + }) + require.NoError(t, err) + require.Len(t, items, 3) + + // Verify all items have reading direction set + for _, item := range items { + assert.NotEmpty(t, item.ReadingDirection.String) + assert.NotEmpty(t, item.MangaType.String) + assert.True(t, item.ReadingDirection.Valid) + } + }) + + t.Run("Filter by reading_direction - RTL only", func(t *testing.T) { + // Query all items and filter in-memory + items, err := setup.DB.SearchMediaItems(ctx, database.SearchMediaItemsParams{ + LibraryID: libraryID, + }) + require.NoError(t, err) + + // Count RTL items + rtlCount := 0 + for _, item := range items { + if item.ReadingDirection.String == "rtl" { + rtlCount++ + } + } + assert.Equal(t, 1, rtlCount) + }) + + t.Run("Verify all reading directions present", func(t *testing.T) { + items, err := setup.DB.SearchMediaItems(ctx, database.SearchMediaItemsParams{ + LibraryID: libraryID, + }) + require.NoError(t, err) + + directions := make(map[string]bool) + for _, item := range items { + directions[item.ReadingDirection.String] = true + } + + assert.True(t, directions["rtl"]) + assert.True(t, directions["ltr"]) + assert.True(t, directions["auto"]) + }) +} + +// TestUniversalMetadataFields tests universal fields apply to all formats +func TestUniversalMetadataFields(t *testing.T) { + setup := setupDeviceTest(t) + defer setup.Server.Close() + + ctx := context.Background() + + // Test with both comic and ebook libraries + comicLibraryID := setup.CreateLibrary(t, "Comic Library", "comic") + ebookLibraryID := setup.CreateLibrary(t, "Ebook Library", "ebook") + + t.Run("Comic with universal fields", func(t *testing.T) { + _, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: comicLibraryID, + Title: "Comic with Universal Metadata", + FilePath: "/test/comic.cbz", + SeriesCount: pgtype.Int4{Int32: 10, Valid: true}, + Volume: pgtype.Int4{Int32: 2, Valid: true}, + Imprint: pgtype.Text{String: "DC Black Label", Valid: true}, + AgeRating: pgtype.Text{String: "Mature", Valid: true}, + WebURL: pgtype.Text{String: "https://example.com/comic", Valid: true}, + CommunityRating: pgtype.Float8{Float64: 9.2, Valid: true}, + }) + require.NoError(t, err) + + items, err := setup.DB.SearchMediaItems(ctx, database.SearchMediaItemsParams{ + LibraryID: comicLibraryID, + }) + require.NoError(t, err) + + item := items[0] + assert.Equal(t, int32(10), item.SeriesCount.Int32) + assert.Equal(t, int32(2), item.Volume.Int32) + assert.Equal(t, "DC Black Label", item.Imprint.String) + assert.Equal(t, "Mature", item.AgeRating.String) + assert.Equal(t, "https://example.com/comic", item.WebUrl.String) + assert.Equal(t, 9.2, item.CommunityRating.Float64) + }) + + t.Run("Ebook with universal fields", func(t *testing.T) { + _, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: ebookLibraryID, + Title: "Ebook with Universal Metadata", + FilePath: "/test/book.epub", + SeriesCount: pgtype.Int4{Int32: 7, Valid: true}, + Volume: pgtype.Int4{Int32: 1, Valid: true}, + Imprint: pgtype.Text{String: "HarperCollins", Valid: true}, + AgeRating: pgtype.Text{String: "Everyone", Valid: true}, + WebURL: pgtype.Text{String: "https://example.com/book", Valid: true}, + CommunityRating: pgtype.Float8{Float64: 4.5, Valid: true}, + }) + require.NoError(t, err) + + items, err := setup.DB.SearchMediaItems(ctx, database.SearchMediaItemsParams{ + LibraryID: ebookLibraryID, + }) + require.NoError(t, err) + + item := items[0] + assert.Equal(t, int32(7), item.SeriesCount.Int32) + assert.Equal(t, int32(1), item.Volume.Int32) + assert.Equal(t, "HarperCollins", item.Imprint.String) + assert.Equal(t, "Everyone", item.AgeRating.String) + assert.Equal(t, "https://example.com/book", item.WebUrl.String) + assert.Equal(t, 4.5, item.CommunityRating.Float64) + }) +} + +// TestComicSpecificFields tests comic-specific fields +func TestComicSpecificFields(t *testing.T) { + setup := setupDeviceTest(t) + defer setup.Server.Close() + + ctx := context.Background() + libraryID := setup.CreateLibrary(t, "Comic Library", "comic") + + t.Run("Alternate series info as JSONB", func(t *testing.T) { + alternateInfo := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}` + + _, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: libraryID, + Title: "X-Men with Alternate Series", + FilePath: "/test/xmen.cbz", + AlternateInfo: []byte(alternateInfo), + ScanInformation: pgtype.Text{String: "Scanned by Minutemen", Valid: true}, + Summary: pgtype.Text{String: "Professor X creates mutant team", Valid: true}, + MetadataNotes: pgtype.Text{String: "From collection", Valid: true}, + }) + require.NoError(t, err) + + items, err := setup.DB.SearchMediaItems(ctx, database.SearchMediaItemsParams{ + LibraryID: libraryID, + }) + require.NoError(t, err) + + item := items[0] + assert.NotNil(t, item.AlternateInfo) + assert.JSONEq(t, alternateInfo, string(item.AlternateInfo)) + assert.Equal(t, "Scanned by Minutemen", item.ScanInformation.String) + assert.Equal(t, "Professor X creates mutant team", item.Summary.String) + assert.Equal(t, "From collection", item.MetadataNotes.String) + }) +} diff --git a/internal/services/media_scanner_comic_test.go b/internal/services/media_scanner_comic_test.go index 1b2cb9b..fb6e98f 100644 --- a/internal/services/media_scanner_comic_test.go +++ b/internal/services/media_scanner_comic_test.go @@ -609,3 +609,65 @@ func Example_extractComicMetadata() { fmt.Printf("Issue: %d\n", comicInfo.Number) fmt.Printf("Cover size: %d bytes\n", len(coverImage)) } + +func TestNormalizeMangaType(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"Unknown", "Unknown", "unknown"}, + {"No", "No", "no"}, + {"Yes", "Yes", "yes"}, + {"YesAndRightToLeft", "YesAndRightToLeft", "yes_and_right_to_left"}, + {"Lowercase yesandrighttoleft", "yesandrighttoleft", "yes_and_right_to_left"}, + {"With spaces", "Yes And Right To Left", "yes_and_right_to_left"}, + {"Invalid", "invalid", "unknown"}, + {"Empty", "", "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeMangaType(tt.input) + if result != tt.expected { + t.Errorf("normalizeMangaType(%q) = %q; want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestDetermineReadingDirection(t *testing.T) { + tests := []struct { + name string + manga string + language string + tags string + genre string + expected string + }{ + {"Explicit RTL", "YesAndRightToLeft", "en", "", "", "rtl"}, + {"Explicit LTR (Yes)", "Yes", "ja", "", "", "ltr"}, + {"Explicit LTR (No)", "No", "en", "", "", "ltr"}, + {"Japanese heuristic", "Unknown", "ja", "", "", "rtl"}, + {"Japanese with full code", "Unknown", "jpn", "", "", "rtl"}, + {"Webtoon Korean", "Unknown", "ko", "Webtoon", "", "vertical"}, + {"Manhwa in tags", "Unknown", "ko", "", "Manhwa", "vertical"}, + {"Manga + Japanese", "Unknown", "ja", "Manga", "", "rtl"}, + {"Western default", "Unknown", "en", "", "", "ltr"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + comicInfo := &ComicInfo{ + Manga: tt.manga, + LanguageISO: tt.language, + Tags: tt.tags, + Genre: tt.genre, + } + result := determineReadingDirection(comicInfo) + if result != tt.expected { + t.Errorf("determineReadingDirection() = %q; want %q", result, tt.expected) + } + }) + } +}