From 522a99ddf8cf0b55794d35c73272a51181111f8e Mon Sep 17 00:00:00 2001 From: mkelvers Date: Mon, 6 Jul 2026 07:36:54 +0200 Subject: [PATCH] test: add episode title enrichment tests --- internal/episodes/service/titles_test.go | 145 +++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 internal/episodes/service/titles_test.go diff --git a/internal/episodes/service/titles_test.go b/internal/episodes/service/titles_test.go new file mode 100644 index 00000000..da0512ff --- /dev/null +++ b/internal/episodes/service/titles_test.go @@ -0,0 +1,145 @@ +package service + +import ( + "context" + "database/sql" + "encoding/json" + "testing" + + "mal/integrations/jikan" + "mal/internal/db" + "mal/internal/domain" + + _ "github.com/mattn/go-sqlite3" +) + +type titleProviderStub struct { + loads int + resolves int +} + +func (p *titleProviderStub) Name() string { return "TVmaze" } + +func (p *titleProviderStub) ResolveEpisodeProviderID(context.Context, int, []string) (string, error) { + p.resolves++ + return "80712", nil +} + +func (p *titleProviderStub) GetEpisodeTitlesByProviderID(context.Context, string) (map[int]string, error) { + p.loads++ + return map[int]string{1: "TVmaze title one", 2: "TVmaze title two"}, nil +} + +func TestMergeMissingTitlesPreservesAllAnimeTitles(t *testing.T) { + episodes := []domain.CanonicalEpisode{ + {Number: 1, Title: "AllAnime title"}, + {Number: 2, Title: "Episode 2"}, + {Number: 3, Title: "Episode 3"}, + } + + changed := mergeMissingTitles(episodes, map[int]string{ + 1: "TVmaze title one", + 2: "TVmaze title two", + }) + + if !changed { + t.Fatal("expected missing title to be enriched") + } + if episodes[0].Title != "AllAnime title" { + t.Fatalf("AllAnime title was overwritten: %q", episodes[0].Title) + } + if episodes[1].Title != "TVmaze title two" { + t.Fatalf("episode 2 title = %q", episodes[1].Title) + } + if episodes[2].Title != "Episode 3" { + t.Fatalf("episode 3 title = %q", episodes[2].Title) + } +} + +func TestEnrichEpisodeTitlesCachesTVmazeTitles(t *testing.T) { + ctx := context.Background() + sqlDB := newTitleTestDB(ctx, t) + provider := &titleProviderStub{} + svc := &EpisodeService{queries: db.New(sqlDB), titles: provider, clock: realClock{}} + anime := domain.Anime{Anime: jikan.Anime{ + MalID: 59846, + Title: "Saigo ni Hitotsu dake Onegai shitemo Yoroshii deshou ka", + TitleEnglish: "May I Ask for One Final Thing?", + }} + + for range 2 { + assertEnrichedTitleList(t, svc, anime) + } + if provider.resolves != 1 || provider.loads != 1 { + t.Fatalf("provider calls = resolves:%d loads:%d, want 1 each", provider.resolves, provider.loads) + } + assertCachedTitles(ctx, t, sqlDB) +} + +func newTitleTestDB(ctx context.Context, t *testing.T) *sql.DB { + t.Helper() + sqlDB, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := sqlDB.Close(); err != nil { + t.Errorf("close sqlite: %v", err) + } + }) + sqlDB.SetMaxOpenConns(1) + + for _, statement := range []string{ + `CREATE TABLE episode_availability_cache ( + anime_id INTEGER PRIMARY KEY, data TEXT NOT NULL, next_refresh_at DATETIME, + retry_until_at DATETIME, last_attempt_at DATETIME, last_success_at DATETIME, + failure_count INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE episode_provider_mapping ( + anime_id INTEGER NOT NULL, provider TEXT NOT NULL, provider_show_id TEXT NOT NULL, + failed_until DATETIME, last_error TEXT NOT NULL DEFAULT '', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (anime_id, provider) + )`, + `INSERT INTO episode_availability_cache ( + anime_id, data, next_refresh_at, failure_count, last_error + ) VALUES ( + 59846, + '{"anime_id":59846,"source":"AllAnime","availability_version":4,"episodes":[{"number":1,"title":"AllAnime title","has_sub":true},{"number":2,"title":"Episode 2","has_sub":true}]}', + '2999-01-01T00:00:00Z', 2, 'preserve me' + )`, + } { + if _, err := sqlDB.ExecContext(ctx, statement); err != nil { + t.Fatal(err) + } + } + return sqlDB +} + +func assertEnrichedTitleList(t *testing.T, svc *EpisodeService, anime domain.Anime) { + t.Helper() + list, err := svc.EnrichEpisodeTitles(context.Background(), anime) + if err != nil { + t.Fatal(err) + } + if list.Episodes[0].Title != "AllAnime title" || list.Episodes[1].Title != "TVmaze title two" { + t.Fatalf("episodes = %#v", list.Episodes) + } +} + +func assertCachedTitles(ctx context.Context, t *testing.T, sqlDB *sql.DB) { + t.Helper() + var raw, lastError string + var failureCount int + if err := sqlDB.QueryRowContext(ctx, `SELECT data, failure_count, last_error FROM episode_availability_cache WHERE anime_id = 59846`).Scan(&raw, &failureCount, &lastError); err != nil { + t.Fatal(err) + } + var cached domain.CanonicalEpisodeList + if err := json.Unmarshal([]byte(raw), &cached); err != nil { + t.Fatal(err) + } + if cached.Episodes[1].Title != "TVmaze title two" || failureCount != 2 || lastError != "preserve me" { + t.Fatalf("cached = %#v failure_count=%d last_error=%q", cached.Episodes, failureCount, lastError) + } +}