package moviefeed import ( "encoding/xml" "errors" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "olexsmir.xyz/rss-tools/app/atom" "olexsmir.xyz/x/is" ) type fakeEpisodeAPI struct { episodes map[string][]TMDBEpisode errs map[string]error searches map[string]tmdbShow } func (f fakeEpisodeAPI) FetchEpisodesForShow(showID string) ([]TMDBEpisode, error) { if err, ok := f.errs[showID]; ok { return nil, err } if episodes, ok := f.episodes[showID]; ok { return episodes, nil } return nil, nil } func (f fakeEpisodeAPI) SearchShow(query string) (*tmdbShow, error) { if f.searches == nil { return nil, fmt.Errorf("no search results for %q", query) } s, ok := f.searches[query] if !ok { return nil, fmt.Errorf("no search results for %q", query) } return &s, nil } func TestHandleMoviesRendersFeedFromConfiguredShows(t *testing.T) { episodes := []TMDBEpisode{ { ID: 1001, Name: "Episode 1", Overview: "E1", AirDate: "2026-04-20", EpisodeNumber: 1, SeasonNumber: 1, StillPath: "/e1.jpg", ShowName: "Test Show", ShowID: "101", }, { ID: 1002, Name: "Episode 2", Overview: "E2", AirDate: "2026-04-21", EpisodeNumber: 2, SeasonNumber: 1, StillPath: "", ShowName: "Test Show", ShowID: "101", }, } mf := &moviefeed{ api: fakeEpisodeAPI{ episodes: map[string][]TMDBEpisode{ "tt123": episodes, }, }, shows: []string{"tt123"}, nameCache: map[string]string{}, } mux := http.NewServeMux() mux.HandleFunc("GET /movies", mf.handleMovies) req := httptest.NewRequest(http.MethodGet, "/movies", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) is.Equal(t, rr.Code, http.StatusOK) if got := rr.Header().Get("Content-Type"); !strings.Contains(got, "application/atom+xml") { t.Fatalf("expected atom response content-type, got %q", got) } var feed atom.Feed is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil) is.Equal(t, feed.Title, "moviefeed") is.Equal(t, len(feed.Entry), 2) is.Equal(t, strings.Contains(feed.Entry[0].Title, "S1E2"), true) is.Equal(t, feed.Entry[0].Content.Type, "text") is.Equal(t, len(feed.Entry[1].Link), 2) is.Equal(t, feed.Entry[1].Link[1].Rel, "enclosure") is.Equal(t, feed.Entry[1].Link[1].Type, "image/jpeg") is.Equal(t, feed.Entry[1].Link[1].Length, uint(0)) is.Equal(t, feed.Entry[1].Link[1].Href, "https://image.tmdb.org/t/p/w500/e1.jpg") is.Equal(t, feed.Entry[1].Content.Type, "xhtml") } func TestHandleMoviesContinuesWhenOneShowFails(t *testing.T) { episodes := []TMDBEpisode{ { ID: 1001, Name: "Episode 1", Overview: "E1", AirDate: "2026-04-20", EpisodeNumber: 1, SeasonNumber: 1, StillPath: "/e1.jpg", ShowName: "Test Show", ShowID: "101", }, { ID: 1002, Name: "Episode 2", Overview: "E2", AirDate: "2026-04-21", EpisodeNumber: 2, SeasonNumber: 1, StillPath: "", ShowName: "Test Show", ShowID: "101", }, } mf := &moviefeed{ api: fakeEpisodeAPI{ episodes: map[string][]TMDBEpisode{ "tt123": episodes, }, errs: map[string]error{ "bad-show": errors.New("boom"), }, }, shows: []string{"bad-show", "tt123"}, nameCache: map[string]string{}, } mux := http.NewServeMux() mux.HandleFunc("GET /movies", mf.handleMovies) req := httptest.NewRequest(http.MethodGet, "/movies", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) is.Equal(t, rr.Code, http.StatusOK) var feed atom.Feed is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil) is.Equal(t, len(feed.Entry), 2) } func TestFilterRecentEpisodes(t *testing.T) { now := time.Now() recent := now.AddDate(0, 0, -5).Format(dateFormat) old := now.AddDate(0, 0, -40).Format(dateFormat) episodes := filterRecentEpisodes([]TMDBEpisode{ {AirDate: recent, Name: "recent"}, {AirDate: old, Name: "old"}, {AirDate: "", Name: "missing"}, }) is.Equal(t, 1, len(episodes)) is.Equal(t, "recent", episodes[0].Name) } func TestEpisodeContentIncludesImageInBody(t *testing.T) { content, contentType := episodeContent(TMDBEpisode{ Name: "Episode 1", Overview: "E1", StillPath: "/e1.jpg", }) is.Equal(t, contentType, "xhtml") is.Equal(t, strings.Contains(content, ""), true) is.Equal(t, strings.Contains(content, `Episode 1"), true) } func TestIsDirectID(t *testing.T) { is.Equal(t, true, isDirectID("tt1190634")) is.Equal(t, true, isDirectID("101")) is.Equal(t, true, isDirectID("1")) is.Equal(t, false, isDirectID("")) is.Equal(t, false, isDirectID("The Boys")) is.Equal(t, false, isDirectID("101a")) } func TestResolveShowIDDirectPassthrough(t *testing.T) { mf := &moviefeed{nameCache: map[string]string{}, api: fakeEpisodeAPI{}} id, err := mf.resolveShowID("tt123") is.Err(t, err, nil) is.Equal(t, "tt123", id) id, err = mf.resolveShowID("42") is.Err(t, err, nil) is.Equal(t, "42", id) } func TestResolveShowIDNameIDSkipsSearch(t *testing.T) { mf := &moviefeed{nameCache: map[string]string{}, api: fakeEpisodeAPI{}} id, err := mf.resolveShowID("The Boys::101") is.Err(t, err, nil) is.Equal(t, "101", id) } func TestResolveShowIDNameIDSkipsSearchWithIMDB(t *testing.T) { mf := &moviefeed{nameCache: map[string]string{}, api: fakeEpisodeAPI{}} id, err := mf.resolveShowID("The Boys::tt1190634") is.Err(t, err, nil) is.Equal(t, "tt1190634", id) } func TestResolveShowIDSearchesAndCaches(t *testing.T) { mf := &moviefeed{ nameCache: map[string]string{}, api: fakeEpisodeAPI{ searches: map[string]tmdbShow{ "The Boys": {ID: 1398, Name: "The Boys"}, }, }, } id, err := mf.resolveShowID("The Boys") is.Err(t, err, nil) is.Equal(t, "1398", id) cached, ok := mf.nameCache["The Boys"] is.Equal(t, true, ok) is.Equal(t, "1398", cached) } func TestResolveShowIDCacheHit(t *testing.T) { mf := &moviefeed{ nameCache: map[string]string{"The Boys": "1398"}, api: fakeEpisodeAPI{}, // empty — would fail if search called } id, err := mf.resolveShowID("The Boys") is.Err(t, err, nil) is.Equal(t, "1398", id) } func TestResolveShowIDSearchFails(t *testing.T) { mf := &moviefeed{ nameCache: map[string]string{}, api: fakeEpisodeAPI{}, // no searches registered } _, err := mf.resolveShowID("Nonexistent Show") if err == nil { t.Fatal("expected error for unresolvable name") } } func TestFetchNewEpisodesResolvesNames(t *testing.T) { episodes := []TMDBEpisode{ { ID: 1001, Name: "E1", AirDate: time.Now().AddDate(0, 0, -2).Format(dateFormat), EpisodeNumber: 1, SeasonNumber: 1, ShowName: "The Boys", ShowID: "1398", }, } mf := &moviefeed{ nameCache: map[string]string{}, api: fakeEpisodeAPI{ searches: map[string]tmdbShow{ "The Boys": {ID: 1398, Name: "The Boys"}, }, episodes: map[string][]TMDBEpisode{ "1398": episodes, }, }, shows: []string{"The Boys"}, } got, err := mf.fetchNewEpisodes() is.Err(t, err, nil) is.Equal(t, 1, len(got)) is.Equal(t, 1001, got[0].ID) } func TestFetchNewEpisodesNameIDFormat(t *testing.T) { episodes := []TMDBEpisode{ { ID: 1001, Name: "E1", AirDate: time.Now().AddDate(0, 0, -2).Format(dateFormat), EpisodeNumber: 1, SeasonNumber: 1, ShowName: "The Boys", ShowID: "1398", }, } mf := &moviefeed{ nameCache: map[string]string{}, api: fakeEpisodeAPI{ episodes: map[string][]TMDBEpisode{ "1398": episodes, }, }, shows: []string{"The Boys::1398"}, } got, err := mf.fetchNewEpisodes() is.Err(t, err, nil) is.Equal(t, 1, len(got)) is.Equal(t, 1001, got[0].ID) } func TestFetchNewEpisodesContinuesOnResolveError(t *testing.T) { mf := &moviefeed{ nameCache: map[string]string{}, api: fakeEpisodeAPI{ episodes: map[string][]TMDBEpisode{}, }, shows: []string{"Does Not Exist", "tt1190634"}, } got, err := mf.fetchNewEpisodes() is.Err(t, err, nil) is.Equal(t, 0, len(got)) }