// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package caption import ( "bytes" "errors" "io" "net/http" "net/http/httptest" "os" "reflect" "testing" "github.com/eat-pray-ai/yutu/pkg" "github.com/eat-pray-ai/yutu/pkg/common" "google.golang.org/api/option" "google.golang.org/api/youtube/v3" ) func TestCaption_GetFields(t *testing.T) { c := NewCaption().(*Caption) if c.GetFields() == nil { t.Error("Caption.GetFields() is nil") } } func TestNewCaption(t *testing.T) { type args struct { opts []Option } svc := &youtube.Service{} isAutoSyncedTrue := true isAutoSyncedFalse := false isCCTrue := true isCCFalse := false isDraftTrue := true isDraftFalse := false isEasyReaderTrue := true isEasyReaderFalse := false isLargeTrue := true isLargeFalse := false tests := []struct { name string args args want ICaption[youtube.Caption] }{ { name: "with all options", args: args{ opts: []Option{ WithIds([]string{"caption1", "caption2"}), WithFile("/path/to/file.srt"), WithAudioTrackType("primary"), WithIsAutoSynced(&isAutoSyncedTrue), WithIsCC(&isCCTrue), WithIsDraft(&isDraftTrue), WithIsEasyReader(&isEasyReaderTrue), WithIsLarge(&isLargeTrue), WithLanguage("en"), WithName("English Captions"), WithTrackKind("standard"), WithOnBehalfOf("channel123"), WithOnBehalfOfContentOwner("owner123"), WithVideoId("video123"), WithTfmt("srt"), WithTlang("es"), WithService(svc), }, }, want: &Caption{ Service: svc, Ids: []string{"caption1", "caption2"}, OnBehalfOfContentOwner: "owner123", File: "/path/to/file.srt", AudioTrackType: "primary", IsAutoSynced: &isAutoSyncedTrue, IsCC: &isCCTrue, IsDraft: &isDraftTrue, IsEasyReader: &isEasyReaderTrue, IsLarge: &isLargeTrue, Language: "en", Name: "English Captions", TrackKind: "standard", OnBehalfOf: "channel123", VideoId: "video123", Tfmt: "srt", Tlang: "es", }, }, { name: "with no options", args: args{ opts: []Option{}, }, want: &Caption{Fields: common.Fields{}}, }, { name: "with nil boolean options", args: args{ opts: []Option{ WithIsAutoSynced(nil), WithIsCC(nil), WithIsDraft(nil), WithIsEasyReader(nil), WithIsLarge(nil), }, }, want: &Caption{Fields: common.Fields{}}, }, { name: "with false boolean options", args: args{ opts: []Option{ WithIsAutoSynced(&isAutoSyncedFalse), WithIsCC(&isCCFalse), WithIsDraft(&isDraftFalse), WithIsEasyReader(&isEasyReaderFalse), WithIsLarge(&isLargeFalse), }, }, want: &Caption{ Fields: common.Fields{}, IsAutoSynced: &isAutoSyncedFalse, IsCC: &isCCFalse, IsDraft: &isDraftFalse, IsEasyReader: &isEasyReaderFalse, IsLarge: &isLargeFalse, }, }, { name: "with empty string values", args: args{ opts: []Option{ WithFile(""), WithAudioTrackType(""), WithLanguage(""), WithName(""), WithTrackKind(""), WithOnBehalfOf(""), WithOnBehalfOfContentOwner(""), WithVideoId(""), WithTfmt(""), WithTlang(""), }, }, want: &Caption{ OnBehalfOfContentOwner: "", File: "", AudioTrackType: "", Language: "", Name: "", TrackKind: "", OnBehalfOf: "", VideoId: "", Tfmt: "", Tlang: "", }, }, { name: "with partial options", args: args{ opts: []Option{ WithIds([]string{"caption1"}), WithLanguage("fr"), WithVideoId("video456"), WithIsCC(&isCCTrue), }, }, want: &Caption{ Ids: []string{"caption1"}, Language: "fr", VideoId: "video456", IsCC: &isCCTrue, }, }, } for _, tt := range tests { t.Run( tt.name, func(t *testing.T) { if got := NewCaption(tt.args.opts...); !reflect.DeepEqual(got, tt.want) { t.Errorf("%s\nNewCaption() = %v\nwant %v", tt.name, got, tt.want) } }, ) } } func TestCaption_Get(t *testing.T) { tests := []struct { name string opts []Option verify func(*http.Request) wantLen int wantErr bool }{ { name: "get captions with videoId", opts: []Option{ WithVideoId("video-id"), }, verify: func(r *http.Request) { if r.URL.Query().Get("videoId") != "video-id" { t.Errorf( "expected videoId=video-id, got %s", r.URL.Query().Get("videoId"), ) } }, wantLen: 2, wantErr: false, }, { name: "get captions with ids", opts: []Option{ WithVideoId("video-id"), WithIds([]string{"id1", "id2"}), }, verify: func(r *http.Request) { ids := r.URL.Query()["id"] if len(ids) != 2 || ids[0] != "id1" || ids[1] != "id2" { t.Errorf("expected id=[id1 id2], got %v", ids) } }, wantLen: 2, wantErr: false, }, { name: "get captions with onBehalfOf", opts: []Option{ WithVideoId("video-id"), WithOnBehalfOf("channel-id"), }, verify: func(r *http.Request) { if r.URL.Query().Get("onBehalfOf") != "channel-id" { t.Errorf( "expected onBehalfOf=channel-id, got %s", r.URL.Query().Get("onBehalfOf"), ) } }, wantLen: 2, wantErr: false, }, { name: "get captions with onBehalfOfContentOwner", opts: []Option{ WithVideoId("video-id"), WithOnBehalfOfContentOwner("content-owner"), }, verify: func(r *http.Request) { if r.URL.Query().Get("onBehalfOfContentOwner") != "content-owner" { t.Errorf( "expected onBehalfOfContentOwner=content-owner, got %s", r.URL.Query().Get("onBehalfOfContentOwner"), ) } }, wantLen: 2, wantErr: false, }, } for _, tt := range tests { t.Run( tt.name, func(t *testing.T) { svc := common.NewTestService( t, http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { if tt.verify != nil { tt.verify(r) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write( []byte(`{ "items": [ {"id": "1", "snippet": {"videoId": "video-id"}}, {"id": "2", "snippet": {"videoId": "video-id"}} ] }`), ) }, ), ) opts := append([]Option{WithService(svc)}, tt.opts...) c := NewCaption(opts...) got, err := c.Get() if (err != nil) != tt.wantErr { t.Errorf("Caption.Get() error = %v, wantErr %v", err, tt.wantErr) return } if len(got) != tt.wantLen { t.Errorf( "Caption.Get() got length = %v, want %v", len(got), tt.wantLen, ) } }, ) } } func TestCaption_List(t *testing.T) { common.RunListTest( t, `{"items": [{"id": "caption-1", "snippet": {"videoId": "video-1", "name": "English", "language": "en"}}]}`, func(svc *youtube.Service, output string) func(io.Writer) error { c := NewCaption( WithService(svc), WithOutput(output), WithVideoId("video-1"), ) return c.List }, ) } func TestCaption_Insert(t *testing.T) { tests := []struct { name string opts []Option verify func(*http.Request) wantErr bool }{ { name: "insert caption", opts: []Option{ WithVideoId("video-id"), WithLanguage("en"), WithName("English"), WithFile("test_caption.srt"), }, verify: func(r *http.Request) { if r.Method != "POST" { t.Errorf("expected POST, got %s", r.Method) } }, wantErr: false, }, { name: "insert caption with onBehalfOf", opts: []Option{ WithVideoId("video-id"), WithFile("test_caption.srt"), WithOnBehalfOf("channel-id"), }, verify: func(r *http.Request) { if r.URL.Query().Get("onBehalfOf") != "channel-id" { t.Errorf( "expected onBehalfOf=channel-id, got %s", r.URL.Query().Get("onBehalfOf"), ) } }, wantErr: false, }, { name: "insert caption with onBehalfOfContentOwner", opts: []Option{ WithVideoId("video-id"), WithFile("test_caption.srt"), WithOnBehalfOfContentOwner("content-owner"), }, verify: func(r *http.Request) { if r.URL.Query().Get("onBehalfOfContentOwner") != "content-owner" { t.Errorf( "expected onBehalfOfContentOwner=content-owner, got %s", r.URL.Query().Get("onBehalfOfContentOwner"), ) } }, wantErr: false, }, } err := os.WriteFile( "test_caption.srt", []byte("1\n00:00:01,000 --> 00:00:04,000\nHello"), 0644, ) if err != nil { t.Fatalf("failed to create dummy file: %v", err) } defer func() { _ = os.Remove("test_caption.srt") }() for _, tt := range tests { t.Run( tt.name, func(t *testing.T) { svc := common.NewTestService( t, http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { if tt.verify != nil { tt.verify(r) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id": "new-caption-id", "snippet": {"videoId": "video-id"}}`)) }, ), ) opts := append([]Option{WithService(svc)}, tt.opts...) c := NewCaption(opts...) var buf bytes.Buffer if err := c.Insert(&buf); (err != nil) != tt.wantErr { t.Errorf("Caption.Insert() error = %v, wantErr %v", err, tt.wantErr) } }, ) } } func TestCaption_Update(t *testing.T) { tests := []struct { name string opts []Option verify func(*http.Request) wantErr bool }{ { name: "update caption", opts: []Option{ WithVideoId("video-id"), WithIds([]string{"caption-id"}), WithLanguage("es"), }, verify: func(r *http.Request) { switch r.Method { case "PUT": if r.URL.Query().Get("part") != "snippet" { t.Errorf("expected part=snippet, got %s", r.URL.Query().Get("part")) } case "GET": if r.URL.Query().Get("videoId") != "video-id" { t.Errorf( "expected videoId=video-id, got %s", r.URL.Query().Get("videoId"), ) } default: t.Errorf("unexpected method %s", r.Method) } }, wantErr: false, }, { name: "update caption with onBehalfOf", opts: []Option{ WithVideoId("video-id"), WithIds([]string{"caption-id"}), WithOnBehalfOf("channel-id"), }, verify: func(r *http.Request) { if r.Method == "PUT" { if r.URL.Query().Get("onBehalfOf") != "channel-id" { t.Errorf( "expected onBehalfOf=channel-id, got %s", r.URL.Query().Get("onBehalfOf"), ) } } }, wantErr: false, }, { name: "update caption with onBehalfOfContentOwner", opts: []Option{ WithVideoId("video-id"), WithIds([]string{"caption-id"}), WithOnBehalfOfContentOwner("content-owner"), }, verify: func(r *http.Request) { if r.Method == "PUT" { if r.URL.Query().Get("onBehalfOfContentOwner") != "content-owner" { t.Errorf( "expected onBehalfOfContentOwner=content-owner, got %s", r.URL.Query().Get("onBehalfOfContentOwner"), ) } } }, wantErr: false, }, } for _, tt := range tests { t.Run( tt.name, func(t *testing.T) { svc := common.NewTestService( t, http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { if tt.verify != nil { tt.verify(r) } w.Header().Set("Content-Type", "application/json") if r.Method == "GET" { _, _ = w.Write( []byte(`{ "items": [ {"id": "caption-id", "snippet": {"videoId": "video-id", "language": "en"}} ] }`), ) } else { _, _ = w.Write([]byte(`{"id": "caption-id", "snippet": {"videoId": "video-id", "language": "es"}}`)) } }, ), ) opts := append([]Option{WithService(svc)}, tt.opts...) c := NewCaption(opts...) var buf bytes.Buffer if err := c.Update(&buf); (err != nil) != tt.wantErr { t.Errorf("Caption.Update() error = %v, wantErr %v", err, tt.wantErr) } }, ) } } func TestCaption_Delete(t *testing.T) { tests := []struct { name string opts []Option verify func(*http.Request) wantErr bool }{ { name: "delete caption", opts: []Option{ WithIds([]string{"caption-id"}), }, verify: func(r *http.Request) { if r.Method != "DELETE" { t.Errorf("expected DELETE, got %s", r.Method) } }, wantErr: false, }, { name: "delete caption with onBehalfOf", opts: []Option{ WithIds([]string{"caption-id"}), WithOnBehalfOf("channel-id"), }, verify: func(r *http.Request) { if r.URL.Query().Get("onBehalfOf") != "channel-id" { t.Errorf( "expected onBehalfOf=channel-id, got %s", r.URL.Query().Get("onBehalfOf"), ) } }, wantErr: false, }, { name: "delete caption with onBehalfOfContentOwner", opts: []Option{ WithIds([]string{"caption-id"}), WithOnBehalfOfContentOwner("content-owner"), }, verify: func(r *http.Request) { if r.URL.Query().Get("onBehalfOfContentOwner") != "content-owner" { t.Errorf( "expected onBehalfOfContentOwner=content-owner, got %s", r.URL.Query().Get("onBehalfOfContentOwner"), ) } }, wantErr: false, }, } for _, tt := range tests { t.Run( tt.name, func(t *testing.T) { svc := common.NewTestService( t, http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { if tt.verify != nil { tt.verify(r) } w.WriteHeader(http.StatusNoContent) }, ), ) opts := append([]Option{WithService(svc)}, tt.opts...) c := NewCaption(opts...) var buf bytes.Buffer if err := c.Delete(&buf); (err != nil) != tt.wantErr { t.Errorf("Caption.Delete() error = %v, wantErr %v", err, tt.wantErr) } }, ) } } func TestCaption_Download(t *testing.T) { tests := []struct { name string opts []Option verify func(*http.Request) wantErr bool }{ { name: "download caption", opts: []Option{ WithIds([]string{"caption-id"}), WithFile("downloaded.srt"), }, verify: func(r *http.Request) { if r.Method != "GET" { t.Errorf("expected GET, got %s", r.Method) } }, wantErr: false, }, { name: "download caption with tfmt", opts: []Option{ WithIds([]string{"caption-id"}), WithFile("downloaded.srt"), WithTfmt("vtt"), }, verify: func(r *http.Request) { if r.URL.Query().Get("tfmt") != "vtt" { t.Errorf("expected tfmt=vtt, got %s", r.URL.Query().Get("tfmt")) } }, wantErr: false, }, { name: "download caption with tlang", opts: []Option{ WithIds([]string{"caption-id"}), WithFile("downloaded.srt"), WithTlang("es"), }, verify: func(r *http.Request) { if r.URL.Query().Get("tlang") != "es" { t.Errorf("expected tlang=es, got %s", r.URL.Query().Get("tlang")) } }, wantErr: false, }, { name: "download caption with onBehalfOf", opts: []Option{ WithIds([]string{"caption-id"}), WithFile("downloaded.srt"), WithOnBehalfOf("channel-id"), }, verify: func(r *http.Request) { if r.URL.Query().Get("onBehalfOf") != "channel-id" { t.Errorf( "expected onBehalfOf=channel-id, got %s", r.URL.Query().Get("onBehalfOf"), ) } }, wantErr: false, }, { name: "download caption with onBehalfOfContentOwner", opts: []Option{ WithIds([]string{"caption-id"}), WithFile("downloaded.srt"), WithOnBehalfOfContentOwner("content-owner"), }, verify: func(r *http.Request) { if r.URL.Query().Get("onBehalfOfContentOwner") != "content-owner" { t.Errorf( "expected onBehalfOfContentOwner=content-owner, got %s", r.URL.Query().Get("onBehalfOfContentOwner"), ) } }, wantErr: false, }, } for _, tt := range tests { t.Run( tt.name, func(t *testing.T) { svc := common.NewTestService( t, http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { if tt.verify != nil { tt.verify(r) } _, _ = w.Write([]byte("1\n00:00:01,000 --> 00:00:04,000\nDownloaded Caption")) }, ), ) opts := append([]Option{WithService(svc)}, tt.opts...) c := NewCaption(opts...) var buf bytes.Buffer if err := c.Download(&buf); (err != nil) != tt.wantErr { t.Errorf("Caption.Download() error = %v, wantErr %v", err, tt.wantErr) } defer func() { _ = os.Remove("downloaded.srt") }() }, ) } } func TestCaption_Insert_Error(t *testing.T) { // Setup pkg.Root for file tests tmpDir := t.TempDir() f, err := os.OpenRoot(tmpDir) if err != nil { t.Fatal(err) } oldRoot := pkg.Root pkg.Root = f defer func() { pkg.Root = oldRoot }() defer func(f *os.Root) { _ = f.Close() }(f) svc, _ := youtube.NewService(t.Context(), option.WithAPIKey("test")) // Test: File open error c := NewCaption(WithFile("non_existent.srt"), WithService(svc)) if err := c.Insert(&bytes.Buffer{}); err == nil { t.Error("expected error for non-existent file, got nil") } // Test: API error // Create dummy file if err := os.WriteFile( tmpDir+"/test.srt", []byte("content"), 0644, ); err != nil { t.Fatal(err) } ts := httptest.NewServer( http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }, ), ) defer ts.Close() svc, _ = youtube.NewService( t.Context(), option.WithEndpoint(ts.URL), option.WithAPIKey("test"), ) c = NewCaption(WithFile("test.srt"), WithService(svc)) if err := c.Insert(&bytes.Buffer{}); err == nil { t.Error("expected error from API, got nil") } } func TestCaption_Update_Error(t *testing.T) { // Setup pkg.Root tmpDir := t.TempDir() f, err := os.OpenRoot(tmpDir) if err != nil { t.Fatal(err) } oldRoot := pkg.Root pkg.Root = f defer func() { pkg.Root = oldRoot }() defer func(f *os.Root) { _ = f.Close() }(f) // Test: File open error (when file is specified) ts := httptest.NewServer( http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"items": [{"id": "1", "snippet": {"videoId": "v1"}}]}`)) }, ), ) defer ts.Close() svc, _ := youtube.NewService( t.Context(), option.WithEndpoint(ts.URL), option.WithAPIKey("test"), ) c := NewCaption( WithFile("non_existent.srt"), WithService(svc), WithVideoId("v1"), ) if err := c.Update(&bytes.Buffer{}); err == nil { t.Error("expected error for non-existent file, got nil") } // Test: Get error tsError := httptest.NewServer( http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }, ), ) defer tsError.Close() svcError, _ := youtube.NewService( t.Context(), option.WithEndpoint(tsError.URL), option.WithAPIKey("test"), ) c = NewCaption(WithService(svcError), WithVideoId("v1")) if err := c.Update(&bytes.Buffer{}); err == nil { t.Error("expected error from Get, got nil") } // Test: No caption found tsEmpty := httptest.NewServer( http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"items": []}`)) }, ), ) defer tsEmpty.Close() svcEmpty, _ := youtube.NewService( t.Context(), option.WithEndpoint(tsEmpty.URL), option.WithAPIKey("test"), ) c = NewCaption(WithService(svcEmpty), WithVideoId("v1")) if err := c.Update(&bytes.Buffer{}); !errors.Is(err, errGetCaption) { t.Errorf("expected errGetCaption, got %v", err) } // Test: API Update Error tsUpdateErr := httptest.NewServer( http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"items": [{"id": "1", "snippet": {"videoId": "v1"}}]}`)) return } w.WriteHeader(http.StatusInternalServerError) }, ), ) defer tsUpdateErr.Close() svcUpdateErr, _ := youtube.NewService( t.Context(), option.WithEndpoint(tsUpdateErr.URL), option.WithAPIKey("test"), ) c = NewCaption(WithService(svcUpdateErr), WithVideoId("v1")) if err := c.Update(&bytes.Buffer{}); err == nil { t.Error("expected error from API Update, got nil") } } func TestCaption_Delete_Error(t *testing.T) { ts := httptest.NewServer( http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }, ), ) defer ts.Close() svc, _ := youtube.NewService( t.Context(), option.WithEndpoint(ts.URL), option.WithAPIKey("test"), ) c := NewCaption(WithService(svc), WithIds([]string{"id1"})) if err := c.Delete(&bytes.Buffer{}); err == nil { t.Error("expected error from Delete, got nil") } } func TestCaption_Download_Error(t *testing.T) { // Test API Error ts := httptest.NewServer( http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }, ), ) defer ts.Close() svc, _ := youtube.NewService( t.Context(), option.WithEndpoint(ts.URL), option.WithAPIKey("test"), ) c := NewCaption( WithService(svc), WithIds([]string{"id1"}), WithFile("out.srt"), ) if err := c.Download(&bytes.Buffer{}); err == nil { t.Error("expected error from Download API, got nil") } // Test File Creation Error ts2 := httptest.NewServer( http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("content")) }, ), ) defer ts2.Close() svc2, _ := youtube.NewService( t.Context(), option.WithEndpoint(ts2.URL), option.WithAPIKey("test"), ) // Use a directory as file path to trigger error. tmpDir := t.TempDir() c = NewCaption(WithService(svc2), WithIds([]string{"id1"}), WithFile(tmpDir)) if err := c.Download(&bytes.Buffer{}); err == nil { t.Error("expected error for file creation, got nil") } }