Files
jellybit/internal/metadata/tvdb_test.go
T
av fdbc781197 metadata: TVDB отдаёт локализованное название и оригинал
- локаль из [general].language применяется при разборе ответа /search, а в
  запрос не уходит: параметр language у TVDB — фильтр выдачи, а не селектор
  перевода (ADR-2026-08-07)
- Title берётся из блока translations с тотальным фолбэком на primary name,
  OriginalTitle — из primary name; форма ответа сверена по документации и
  живым прогоном не подтверждена (docs/research)
- неожиданная форма ответа даёт WARN: признак — отсутствие во всей выдаче
  ключей языка ожидаемого вида, а не неудача разбора блока
2026-08-07 15:17:05 +03:00

344 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}