package metadata import ( "bytes" "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "sync" "sync/atomic" "testing" ) // fakeTVDB — стенд v4: /login выдаёт токен, остальное требует Bearer. func fakeTVDB(t *testing.T, logins *atomic.Int32) *httptest.Server { t.Helper() mux := http.NewServeMux() mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { var body map[string]string _ = json.NewDecoder(r.Body).Decode(&body) if body["apikey"] != "k" { w.WriteHeader(http.StatusUnauthorized) return } if logins != nil { logins.Add(1) } _, _ = w.Write([]byte(`{"status":"success","data":{"token":"tok"}}`)) }) authed := func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer tok" { w.WriteHeader(http.StatusUnauthorized) return } next(w, r) } } mux.HandleFunc("/search", authed(func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("type") != "series" || r.URL.Query().Get("query") != "Fargo" { t.Errorf("query = %v", r.URL.Query()) } _, _ = w.Write([]byte(`{"data":[{"tvdb_id":"269613","name":"Fargo","year":"2014", "translations":{"rus":"Фарго","eng":"Fargo"}}]}`)) })) mux.HandleFunc("/series/269613/extended", authed(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"data":{"episodes":[ {"seasonNumber":1},{"seasonNumber":1},{"seasonNumber":2} ]}}`)) })) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv } func newTVDB(t *testing.T, url string) *TVDB { t.Helper() return newTVDBLang(t, url, "") } func newTVDBLang(t *testing.T, url, lang string) *TVDB { t.Helper() c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url, Language: lang}, nil) if err != nil { t.Fatalf("NewTVDB: %v", err) } return c } // searchStand — стенд с одной записью поиска: тело ответа задаётся тестом, // строка запроса и число обращений к /search записываются для проверок. type searchStand struct { srv *httptest.Server mu sync.Mutex queries []string searches atomic.Int32 } func (s *searchStand) recorded() []string { s.mu.Lock() defer s.mu.Unlock() return append([]string(nil), s.queries...) } func newSearchStand(t *testing.T, body string) *searchStand { t.Helper() s := &searchStand{} mux := http.NewServeMux() mux.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"data":{"token":"tok"}}`)) }) mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { s.searches.Add(1) s.mu.Lock() s.queries = append(s.queries, r.URL.RawQuery) s.mu.Unlock() _, _ = w.Write([]byte(body)) }) s.srv = httptest.NewServer(mux) t.Cleanup(s.srv.Close) return s } // Локализованное название кандидата: перевод, фолбэк во всех его формах и // OriginalTitle, который равен primary name всегда. func TestTVDB_SearchTranslations(t *testing.T) { const primary = "哪吒之魔童降世" cases := []struct { name string lang string record string wantTitle string }{ {"перевод есть", "ru", `"translations":{"rus":"Нэчжа","eng":"Ne Zha"}`, "Нэчжа"}, {"перевода на язык нет", "ru", `"translations":{"eng":"Ne Zha"}`, primary}, {"перевод из пробелов", "ru", `"translations":{"rus":" "}`, primary}, {"ключ в другом регистре", "ru", `"translations":{"RUS":"Нэчжа"}`, "Нэчжа"}, {"блока переводов нет", "ru", `"year":"2019"`, primary}, {"блок пустой", "ru", `"translations":{}`, primary}, {"блок не карта", "ru", `"translations":["Нэчжа"]`, primary}, {"блок null", "ru", `"translations":null`, primary}, {"язык по умолчанию — eng", "", `"translations":{"rus":"Нэчжа","eng":"Ne Zha"}`, "Ne Zha"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { body := `{"data":[{"tvdb_id":"131155","name":"` + primary + `","year":"2019",` + tc.record + `}]}` stand := newSearchStand(t, body) got, err := newTVDBLang(t, stand.srv.URL, tc.lang). Search(context.Background(), Query{Type: Movie, Title: "Ne Zha"}) if err != nil { t.Fatalf("Search: %v", err) } if len(got) != 1 { t.Fatalf("кандидатов = %d, want 1 (негодный перевод не должен ронять выдачу)", len(got)) } if got[0].Title != tc.wantTitle { t.Errorf("Title = %q, want %q", got[0].Title, tc.wantTitle) } if got[0].OriginalTitle != primary { t.Errorf("OriginalTitle = %q, want primary name %q", got[0].OriginalTitle, primary) } if n := stand.searches.Load(); n != 1 { t.Errorf("обращений к /search = %d, want 1 (лимит ключа не растёт)", n) } }) } } // Сигнал «форма ответа не та, что записана в разведке». Он единственный, кто // отличает неверное предположение о чужом API от штатного «перевода нет», — // поэтому проверяется поимённо, а не через покрытие. func TestTVDB_SignalOnUnexpectedTranslationForm(t *testing.T) { cases := []struct { name string record string wantSignal bool }{ {"двухбуквенные коды", `"translations":{"ru":"Дюна","en":"Dune"}`, true}, {"блок null", `"translations":null`, true}, {"блок пустой", `"translations":{}`, true}, {"блока нет", `"year":"2021"`, true}, {"блок не карта", `"translations":["Дюна"]`, true}, {"вложенные объекты", `"translations":{"rus":{"name":"Дюна"}}`, true}, // Штатный случай: коды трёхбуквенные, нужного среди них нет — не сигнал. {"перевода на язык нет", `"translations":{"eng":"Dune"}`, false}, {"перевод есть", `"translations":{"rus":"Дюна","eng":"Dune"}`, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { stand := newSearchStand(t, `{"data":[{"tvdb_id":"1","name":"Dune","year":"2021",`+tc.record+`}]}`) var buf bytes.Buffer log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})) c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: stand.srv.URL, Language: "ru"}, log) if err != nil { t.Fatalf("NewTVDB: %v", err) } got, err := c.Search(context.Background(), Query{Type: Movie, Title: "Dune"}) if err != nil { t.Fatalf("Search: %v", err) } if len(got) != 1 { t.Fatalf("кандидатов = %d, want 1", len(got)) } signal := strings.Contains(buf.String(), "no language-coded translations") if signal != tc.wantSignal { t.Errorf("сигнал = %v, want %v; лог: %q", signal, tc.wantSignal, buf.String()) } }) } } // Выбор среди EqualFold-совпавших ключей детерминирован: иначе один и тот же // ответ давал бы разное имя папки от прогона к прогону. func TestTVDB_TranslationKeyPickIsDeterministic(t *testing.T) { cases := []struct{ name, block, want string }{ {"точное совпадение сильнее регистра", `{"rus":"Дюна","RUS":"HACK"}`, "Дюна"}, {"без точного — лексикографически меньший", `{"RUS":"A","Rus":"B"}`, "A"}, {"юникод-эквивалент case-folding", `{"rus":"Дюна","ruſ":"HACK"}`, "Дюна"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { for i := 0; i < 200; i++ { got, _ := translatedName(json.RawMessage(tc.block), "rus") if got != tc.want { t.Fatalf("прогон %d: got %q, want %q", i, got, tc.want) } } }) } } // Локаль работает только на разборе ответа: параметр языка в запрос не уходит, // и строка запроса не зависит от настройки. func TestTVDB_SearchQueryHasNoLanguage(t *testing.T) { const body = `{"data":[{"tvdb_id":"1","name":"X","year":"2000"}]}` var got []string for _, lang := range []string{"ru", "en", ""} { stand := newSearchStand(t, body) if _, err := newTVDBLang(t, stand.srv.URL, lang). Search(context.Background(), Query{Type: Movie, Title: "X", Year: 2000}); err != nil { t.Fatalf("Search(%q): %v", lang, err) } recorded := stand.recorded() if len(recorded) != 1 { t.Fatalf("запросов = %d", len(recorded)) } q, err := url.ParseQuery(recorded[0]) if err != nil { t.Fatalf("ParseQuery: %v", err) } if _, ok := q["language"]; ok { t.Errorf("language=%q в запросе при lang=%q: параметр сужает выдачу, слать его нельзя", q.Get("language"), lang) } got = append(got, recorded[0]) } if got[0] != got[1] || got[1] != got[2] { t.Errorf("строка запроса зависит от языка: %q", got) } } func TestTVDB_SearchAndLoginCached(t *testing.T) { var logins atomic.Int32 srv := fakeTVDB(t, &logins) c := newTVDB(t, srv.URL) got, err := c.Search(context.Background(), Query{Type: Series, Title: "Fargo", Year: 2014}) if err != nil { t.Fatalf("Search: %v", err) } if len(got) != 1 || got[0].ID != "269613" || got[0].Provider != "tvdb" || got[0].Year != 2014 { t.Fatalf("candidate = %+v", got) } if got[0].URL != "https://www.thetvdb.com/dereferrer/series/269613" { t.Fatalf("URL = %q", got[0].URL) } // Второй запрос переиспользует токен — повторного логина нет. if _, err := c.Search(context.Background(), Query{Type: Series, Title: "Fargo"}); err != nil { t.Fatal(err) } if logins.Load() != 1 { t.Errorf("logins = %d, want 1 (token cached)", logins.Load()) } } // Для фильма ссылка-dereferrer должна вести на /movie/, а не /series/. func TestTVDB_MovieDereferrerURL(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"data":{"token":"tok"}}`)) }) mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("type") != "movie" { t.Errorf("type = %q, want movie", r.URL.Query().Get("type")) } _, _ = w.Write([]byte(`{"data":[{"tvdb_id":"3015","name":"The Last Unicorn","year":"1982"}]}`)) }) srv := httptest.NewServer(mux) defer srv.Close() got, err := newTVDB(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "The Last Unicorn"}) if err != nil { t.Fatalf("Search: %v", err) } if len(got) != 1 || got[0].URL != "https://www.thetvdb.com/dereferrer/movie/3015" { t.Fatalf("candidate = %+v", got) } } func TestTVDB_SeasonEpisodeCounts(t *testing.T) { srv := fakeTVDB(t, nil) counts, err := newTVDB(t, srv.URL).SeasonEpisodeCounts(context.Background(), "269613") if err != nil { t.Fatalf("SeasonEpisodeCounts: %v", err) } if counts[1] != 2 || counts[2] != 1 { t.Errorf("counts = %v", counts) } } func TestTVDB_ReloginOn401(t *testing.T) { var logins atomic.Int32 var token atomic.Value token.Store("tok") mux := http.NewServeMux() mux.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) { logins.Add(1) _, _ = w.Write([]byte(`{"data":{"token":"tok"}}`)) }) var firstCall atomic.Bool mux.HandleFunc("/search", func(w http.ResponseWriter, _ *http.Request) { // Первый авторизованный запрос отдаёт 401 (токен «протух»). if firstCall.CompareAndSwap(false, true) { w.WriteHeader(http.StatusUnauthorized) return } _, _ = w.Write([]byte(`{"data":[{"tvdb_id":"1","name":"X","year":"2000"}]}`)) }) srv := httptest.NewServer(mux) defer srv.Close() c := newTVDB(t, srv.URL) got, err := c.Search(context.Background(), Query{Type: Series, Title: "X"}) if err != nil { t.Fatalf("Search: %v", err) } if len(got) != 1 { t.Fatalf("got %d", len(got)) } if logins.Load() != 2 { t.Errorf("logins = %d, want 2 (initial + relogin)", logins.Load()) } } func TestNewTVDB_RequiresKey(t *testing.T) { if _, err := NewTVDB(TVDBConfig{}, nil); err == nil { t.Fatal("want error without api_key") } }