package youtube import ( "context" "fmt" "net/http" "net/http/httptest" "testing" "gitea.d-ma.be/mathias/tapir/internal/domain" ) // --- fakes ------------------------------------------------------------------ // fakeSecrets records the references it was asked to resolve, proving the // adapter resolves OAuth material by reference (never holding the token itself). type fakeSecrets struct { byRef map[string]string requested []string } func (f *fakeSecrets) Get(_ context.Context, ref string) (string, error) { f.requested = append(f.requested, ref) v, ok := f.byRef[ref] if !ok { return "", fmt.Errorf("no secret for ref %q", ref) } return v, nil } // newTestAdapter wires an adapter against an httptest server: BaseURL points at // the server and the server's transport is injected so the live OAuth2 exchange // is skipped while SecretStore resolution still runs. func newTestAdapter(t *testing.T, handler http.HandlerFunc) (*Adapter, *fakeSecrets) { t.Helper() srv := httptest.NewServer(handler) t.Cleanup(srv.Close) secrets := &fakeSecrets{byRef: map[string]string{ "op://HomeLab/tapir-youtube#refresh": "super-secret-refresh-token", }} a := New(Config{ ClientID: "cid", ClientSecret: "csecret", TokenSecretRef: "op://HomeLab/tapir-youtube#refresh", ConnectionID: "conn-1", PreferredLanguages: []string{"en"}, BaseURL: srv.URL, PlayerBaseURL: srv.URL, }, secrets) a.transport = srv.Client().Transport return a, secrets } // --- subscriptions ---------------------------------------------------------- func TestListSubscriptions(t *testing.T) { a, secrets := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/subscriptions" { t.Errorf("unexpected path %q", r.URL.Path) } if got := r.URL.Query().Get("mine"); got != "true" { t.Errorf("expected mine=true, got %q", got) } _, _ = w.Write([]byte(`{ "items": [ {"id": "sub-a", "snippet": {"title": "Acme Talks", "resourceId": {"channelId": "UC_acme"}}}, {"id": "sub-b", "snippet": {"title": "Beta Lab", "resourceId": {"channelId": "UC_beta"}}} ] }`)) }) subs, err := a.ListSubscriptions(context.Background(), "u1") if err != nil { t.Fatalf("ListSubscriptions: %v", err) } if len(subs) != 2 { t.Fatalf("expected 2 subscriptions, got %d", len(subs)) } if subs[0].ChannelID != "UC_acme" || subs[0].ChannelTitle != "Acme Talks" { t.Errorf("unexpected first subscription: %+v", subs[0]) } if subs[0].UserID != "u1" || subs[0].ConnectionID != "conn-1" || !subs[0].Active { t.Errorf("subscription not wired to user/connection: %+v", subs[0]) } // The refresh token was resolved by reference, never read from config directly. if len(secrets.requested) == 0 || secrets.requested[0] != "op://HomeLab/tapir-youtube#refresh" { t.Errorf("expected token resolved by ref, got %v", secrets.requested) } } func TestListSubscriptionsPaginates(t *testing.T) { a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Query().Get("pageToken") { case "": _, _ = w.Write([]byte(`{"nextPageToken":"p2","items":[{"id":"s1","snippet":{"title":"One","resourceId":{"channelId":"c1"}}}]}`)) case "p2": _, _ = w.Write([]byte(`{"items":[{"id":"s2","snippet":{"title":"Two","resourceId":{"channelId":"c2"}}}]}`)) default: t.Errorf("unexpected pageToken %q", r.URL.Query().Get("pageToken")) } }) subs, err := a.ListSubscriptions(context.Background(), "u1") if err != nil { t.Fatalf("ListSubscriptions: %v", err) } if len(subs) != 2 { t.Fatalf("expected 2 subscriptions across pages, got %d", len(subs)) } } // --- new videos ------------------------------------------------------------- // TestNewVideos: discovery uses playlistItems.list (1 quota unit) against the // uploads playlist derived from the channel id (UC_acme -> UU_acme, ADR via // Worker H mission), NOT search.list (100 units). Items map newest-first. func TestNewVideos(t *testing.T) { a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/playlistItems" { t.Errorf("unexpected path %q (must use playlistItems, not search)", r.URL.Path) } if got := r.URL.Query().Get("playlistId"); got != "UU_acme" { t.Errorf("expected playlistId=UU_acme (uploads playlist), got %q", got) } _, _ = w.Write([]byte(`{ "items": [ {"snippet": {"title": "Designing for Attention", "publishedAt": "2026-06-01T10:00:00Z", "resourceId": {"videoId": "vid1"}}}, {"snippet": {"title": "Second", "publishedAt": "2026-05-31T10:00:00Z", "resourceId": {"videoId": "vid2"}}} ] }`)) }) sub := domain.Subscription{ID: "s1", UserID: "u1", ChannelID: "UC_acme"} vids, err := a.NewVideos(context.Background(), sub) if err != nil { t.Fatalf("NewVideos: %v", err) } if len(vids) != 2 { t.Fatalf("expected 2 videos, got %d", len(vids)) } v := vids[0] if v.ProviderVideoID != "vid1" || v.Title != "Designing for Attention" { t.Errorf("unexpected video: %+v", v) } if v.Provider != domain.ProviderYouTube || v.URL != "https://www.youtube.com/watch?v=vid1" { t.Errorf("video not wired correctly: %+v", v) } if v.UserID != "u1" || v.SubscriptionID != "s1" { t.Errorf("video not scoped to user/subscription: %+v", v) } if v.PublishedAt.IsZero() { t.Errorf("expected publishedAt parsed, got zero") } // Newest-first ordering preserved from the playlist response. if vids[1].ProviderVideoID != "vid2" { t.Errorf("expected newest-first ordering, got second=%q", vids[1].ProviderVideoID) } } // TestNewVideosCapsAtMax: MaxVideosPerSubscription bounds the playlistItems // page size (maxResults) and the number of returned videos. func TestNewVideosCapsAtMax(t *testing.T) { a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { if got := r.URL.Query().Get("maxResults"); got != "2" { t.Errorf("expected maxResults=2 from cap, got %q", got) } _, _ = w.Write([]byte(`{ "items": [ {"snippet": {"title": "A", "publishedAt": "2026-06-03T10:00:00Z", "resourceId": {"videoId": "a"}}}, {"snippet": {"title": "B", "publishedAt": "2026-06-02T10:00:00Z", "resourceId": {"videoId": "b"}}} ] }`)) }) a.cfg.MaxVideosPerSubscription = 2 vids, err := a.NewVideos(context.Background(), domain.Subscription{ID: "s1", UserID: "u1", ChannelID: "UC_acme"}) if err != nil { t.Fatalf("NewVideos: %v", err) } if len(vids) != 2 { t.Fatalf("expected cap of 2 videos, got %d", len(vids)) } } // TestUploadsPlaylistID covers the zero-cost UC->UU derivation, including // non-standard ids that must fall through unchanged (handled via fallback). func TestUploadsPlaylistID(t *testing.T) { cases := []struct { channel string want string derived bool }{ {"UC_acme", "UU_acme", true}, {"UCabcdef123456", "UUabcdef123456", true}, {"HC_handle_style", "", false}, {"", "", false}, } for _, c := range cases { got, ok := uploadsPlaylistID(c.channel) if ok != c.derived { t.Errorf("uploadsPlaylistID(%q) derived=%v, want %v", c.channel, ok, c.derived) } if got != c.want { t.Errorf("uploadsPlaylistID(%q)=%q, want %q", c.channel, got, c.want) } } } // TestNewVideosFallbackToChannelsList: a non-standard channel id can't be // mapped UC->UU, so the adapter reads contentDetails.relatedPlaylists.uploads // via channels.list (1 unit) and then fetches that playlist. func TestNewVideosFallbackToChannelsList(t *testing.T) { a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/channels": if got := r.URL.Query().Get("id"); got != "HC_weird" { t.Errorf("expected channels id=HC_weird, got %q", got) } _, _ = w.Write([]byte(`{"items":[{"contentDetails":{"relatedPlaylists":{"uploads":"UU_resolved"}}}]}`)) case "/playlistItems": if got := r.URL.Query().Get("playlistId"); got != "UU_resolved" { t.Errorf("expected playlistId=UU_resolved, got %q", got) } _, _ = w.Write([]byte(`{"items":[{"snippet":{"title":"X","publishedAt":"2026-06-01T10:00:00Z","resourceId":{"videoId":"x"}}}]}`)) default: t.Errorf("unexpected path %q", r.URL.Path) } }) vids, err := a.NewVideos(context.Background(), domain.Subscription{ID: "s1", UserID: "u1", ChannelID: "HC_weird"}) if err != nil { t.Fatalf("NewVideos fallback: %v", err) } if len(vids) != 1 || vids[0].ProviderVideoID != "x" { t.Fatalf("expected 1 video via fallback, got %+v", vids) } } // --- transcript: captions present (player response + timedtext baseUrl) ------ // captionTracksJSON builds a player-response body whose captionTracks point their // baseUrls back at the test server (via the request Host), so the unauthenticated // baseUrl GET lands on the same httptest handler. func playerBody(host string) string { base := "http://" + host return `{"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[ {"baseUrl":"` + base + `/api/timedtext?lang=sv","languageCode":"sv"}, {"baseUrl":"` + base + `/api/timedtext?lang=en&kind=asr","languageCode":"en","kind":"asr"}, {"baseUrl":"` + base + `/api/timedtext?lang=en","languageCode":"en"} ]}}}` } // TestFetchTranscriptWithCaptions: ANDROID player response yields tracks; the en // non-asr track is preferred over both the sv track and the en asr track; its // srv3 timedtext XML is fetched unauthenticated and reduced to plain text. func TestFetchTranscriptWithCaptions(t *testing.T) { a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/youtubei/v1/player": if r.Method != http.MethodPost { t.Errorf("player must be POST, got %s", r.Method) } if h := r.Header.Get("Authorization"); h != "" { t.Errorf("player request must not be authenticated, got Authorization=%q", h) } _, _ = w.Write([]byte(playerBody(r.Host))) case "/api/timedtext": if h := r.Header.Get("Authorization"); h != "" { t.Errorf("timedtext request must not be authenticated, got Authorization=%q", h) } switch r.URL.Query().Get("kind") { case "asr": _, _ = w.Write([]byte(`

WRONG asr track

`)) default: if got := r.URL.Query().Get("lang"); got != "en" { t.Errorf("expected en track selected, got lang=%q", got) } _, _ = w.Write([]byte(`` + `

Hello world

` + `

Hello world

` + `

Second line

` + `
`)) } default: t.Errorf("unexpected path %q", r.URL.Path) } }) v := domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid1"} tr, err := a.FetchTranscript(context.Background(), v) if err != nil { t.Fatalf("FetchTranscript: %v", err) } if tr.Source != domain.SourceCaptions { t.Fatalf("expected SourceCaptions, got %q", tr.Source) } if tr.Language != "en" { t.Errorf("expected preferred language en, got %q", tr.Language) } // Consecutive duplicate cue collapsed; asr track not chosen. want := "Hello world\nSecond line" if tr.Content != want { t.Errorf("transcript text mismatch:\n got %q\nwant %q", tr.Content, want) } if !tr.HasText() { t.Error("expected HasText() true") } } // TestFetchTranscriptJSON3: a json3 timedtext body parses to clean text. func TestFetchTranscriptJSON3(t *testing.T) { a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/youtubei/v1/player": base := "http://" + r.Host _, _ = w.Write([]byte(`{"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[` + `{"baseUrl":"` + base + `/api/timedtext?lang=en","languageCode":"en"}]}}}`)) case "/api/timedtext": _, _ = w.Write([]byte(`{"events":[` + `{"segs":[{"utf8":"Hello "},{"utf8":"world"}]},` + `{"segs":[{"utf8":"\n"}]},` + `{"segs":[{"utf8":"Second line"}]}]}`)) default: t.Errorf("unexpected path %q", r.URL.Path) } }) tr, err := a.FetchTranscript(context.Background(), domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid1"}) if err != nil { t.Fatalf("FetchTranscript: %v", err) } if tr.Source != domain.SourceCaptions { t.Fatalf("expected SourceCaptions, got %q", tr.Source) } if want := "Hello world\nSecond line"; tr.Content != want { t.Errorf("json3 text mismatch:\n got %q\nwant %q", tr.Content, want) } } // --- transcript: no captions => SourceNone, no error ------------------------ func TestFetchTranscriptNoCaptionsReturnsSourceNone(t *testing.T) { a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/timedtext" { t.Errorf("timedtext must not be fetched when no track exists") } // Player response with no captions block at all. _, _ = w.Write([]byte(`{"videoDetails":{"videoId":"vid-nocaps"}}`)) }) v := domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid-nocaps"} tr, err := a.FetchTranscript(context.Background(), v) if err != nil { t.Fatalf("expected no error for missing captions, got %v", err) } if tr.Source != domain.SourceNone { t.Fatalf("expected SourceNone, got %q", tr.Source) } if tr.HasText() { t.Error("expected HasText() false for SourceNone") } if tr.Content != "" { t.Errorf("expected empty content, got %q", tr.Content) } } // A 403/empty baseUrl fetch degrades to SourceNone, never an error (the explicit // quick-fix: a finicky unofficial endpoint must not produce an error spew). func TestFetchTranscriptBaseURLForbiddenDegrades(t *testing.T) { a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/youtubei/v1/player": base := "http://" + r.Host _, _ = w.Write([]byte(`{"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[` + `{"baseUrl":"` + base + `/api/timedtext?lang=en","languageCode":"en"}]}}}`)) case "/api/timedtext": w.WriteHeader(http.StatusForbidden) } }) tr, err := a.FetchTranscript(context.Background(), domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid1"}) if err != nil { t.Fatalf("403 on baseUrl must degrade, not error: %v", err) } if tr.Source != domain.SourceNone { t.Fatalf("expected SourceNone on 403, got %q", tr.Source) } } // A 429 on the baseUrl fetch is the IP being rate-limited, NOT a permanent // absence of captions: it returns SourceRateLimited (no error, no text) so the // runner can record it and retry after a backoff window rather than recording a // false "no transcript". func TestFetchTranscriptRateLimitedReturnsSourceRateLimited(t *testing.T) { a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/youtubei/v1/player": base := "http://" + r.Host _, _ = w.Write([]byte(`{"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[` + `{"baseUrl":"` + base + `/api/timedtext?lang=en","languageCode":"en"}]}}}`)) case "/api/timedtext": w.WriteHeader(http.StatusTooManyRequests) } }) tr, err := a.FetchTranscript(context.Background(), domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid1"}) if err != nil { t.Fatalf("429 on baseUrl must degrade, not error: %v", err) } if tr.Source != domain.SourceRateLimited { t.Fatalf("expected SourceRateLimited on 429, got %q", tr.Source) } if tr.HasText() { t.Error("expected HasText() false for SourceRateLimited") } if tr.Content != "" { t.Errorf("expected empty content on 429, got %q", tr.Content) } } // An empty baseUrl on the selected track degrades to SourceNone, never an error. func TestFetchTranscriptEmptyBaseURLDegrades(t *testing.T) { a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/timedtext" { t.Errorf("must not fetch an empty baseUrl") } _, _ = w.Write([]byte(`{"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[` + `{"baseUrl":"","languageCode":"en"}]}}}`)) }) tr, err := a.FetchTranscript(context.Background(), domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid1"}) if err != nil { t.Fatalf("empty baseUrl must degrade, not error: %v", err) } if tr.Source != domain.SourceNone { t.Fatalf("expected SourceNone on empty baseUrl, got %q", tr.Source) } }