diff --git a/pkg/thirdPartyLink/BUILD.bazel b/pkg/thirdPartyLink/BUILD.bazel new file mode 100644 index 0000000..8b507f1 --- /dev/null +++ b/pkg/thirdPartyLink/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "thirdPartyLink", + srcs = ["thirdPartyLink.go"], + importpath = "github.com/eat-pray-ai/yutu/pkg/thirdPartyLink", + visibility = ["//visibility:public"], + deps = [ + "//pkg/common", + "@com_github_jedib0t_go_pretty_v6//table", + "@org_golang_google_api//youtube/v3:youtube", + ], +) + +go_test( + name = "thirdPartyLink_test", + srcs = ["thirdPartyLink_test.go"], + embed = [":thirdPartyLink"], + deps = [ + "//pkg/common", + "@org_golang_google_api//youtube/v3:youtube", + ], +) diff --git a/pkg/thirdPartyLink/thirdPartyLink.go b/pkg/thirdPartyLink/thirdPartyLink.go new file mode 100644 index 0000000..5267c4b --- /dev/null +++ b/pkg/thirdPartyLink/thirdPartyLink.go @@ -0,0 +1,219 @@ +// Copyright 2026 eat-pray-ai & OpenWaygate +// SPDX-License-Identifier: Apache-2.0 + +package thirdPartyLink + +import ( + "errors" + "fmt" + "io" + + "github.com/eat-pray-ai/yutu/pkg/common" + "github.com/jedib0t/go-pretty/v6/table" + "google.golang.org/api/youtube/v3" +) + +var ( + errGetThirdPartyLink = errors.New("failed to get third party link") + errInsertThirdPartyLink = errors.New("failed to insert third party link") + errUpdateThirdPartyLink = errors.New("failed to update third party link") + errDeleteThirdPartyLink = errors.New("failed to delete third party link") +) + +type ThirdPartyLink struct { + common.Fields + LinkingToken string `yaml:"linking_token" json:"linking_token,omitempty"` + Type string `yaml:"type" json:"type,omitempty"` + LinkStatus string `yaml:"link_status" json:"link_status,omitempty"` + ExternalChannelId string `yaml:"external_channel_id" json:"external_channel_id,omitempty"` +} + +type IThirdPartyLink[T any] interface { + Get() ([]*T, error) + List(io.Writer) error + Insert(io.Writer) error + Update(io.Writer) error + Delete(io.Writer) error +} + +type Option func(*ThirdPartyLink) + +func NewThirdPartyLink(opts ...Option) IThirdPartyLink[youtube.ThirdPartyLink] { + tpl := &ThirdPartyLink{Fields: common.Fields{}} + for _, opt := range opts { + opt(tpl) + } + return tpl +} + +func (tpl *ThirdPartyLink) Get() ([]*youtube.ThirdPartyLink, error) { + if err := tpl.EnsureService(); err != nil { + return nil, err + } + call := tpl.Service.ThirdPartyLinks.List(tpl.Parts) + if tpl.LinkingToken != "" { + call = call.LinkingToken(tpl.LinkingToken) + } + if tpl.Type != "" { + call = call.Type(tpl.Type) + } + if tpl.ExternalChannelId != "" { + call = call.ExternalChannelId(tpl.ExternalChannelId) + } + + res, err := call.Do() + if err != nil { + return nil, errors.Join(errGetThirdPartyLink, err) + } + return res.Items, nil +} + +func (tpl *ThirdPartyLink) List(writer io.Writer) error { + links, err := tpl.Get() + if err != nil { + return err + } + + common.PrintList( + tpl.Output, links, writer, + table.Row{"Linking Token", "Type", "Link Status"}, + func(link *youtube.ThirdPartyLink) table.Row { + var linkStatus, linkType string + if link.Status != nil { + linkStatus = link.Status.LinkStatus + } + if link.Snippet != nil { + linkType = link.Snippet.Type + } + return table.Row{link.LinkingToken, linkType, linkStatus} + }, + ) + return nil +} + +func (tpl *ThirdPartyLink) Insert(writer io.Writer) error { + if err := tpl.EnsureService(); err != nil { + return err + } + link := &youtube.ThirdPartyLink{ + LinkingToken: tpl.LinkingToken, + Snippet: &youtube.ThirdPartyLinkSnippet{ + Type: tpl.Type, + }, + Status: &youtube.ThirdPartyLinkStatus{ + LinkStatus: tpl.LinkStatus, + }, + } + + call := tpl.Service.ThirdPartyLinks.Insert(tpl.Parts, link) + if tpl.ExternalChannelId != "" { + call = call.ExternalChannelId(tpl.ExternalChannelId) + } + + res, err := call.Do() + if err != nil { + return errors.Join(errInsertThirdPartyLink, err) + } + + common.PrintResult( + tpl.Output, res, writer, "Third party link inserted: %s\n", res.LinkingToken, + ) + return nil +} + +func (tpl *ThirdPartyLink) Update(writer io.Writer) error { + if err := tpl.EnsureService(); err != nil { + return err + } + + newType := tpl.Type + tpl.Type = "" + links, err := tpl.Get() + tpl.Type = newType + if err != nil { + return errors.Join(errUpdateThirdPartyLink, err) + } + if len(links) == 0 { + return errGetThirdPartyLink + } + + link := links[0] + if tpl.LinkStatus != "" { + if link.Status == nil { + link.Status = &youtube.ThirdPartyLinkStatus{} + } + link.Status.LinkStatus = tpl.LinkStatus + } + if tpl.Type != "" { + if link.Snippet == nil { + link.Snippet = &youtube.ThirdPartyLinkSnippet{} + } + link.Snippet.Type = tpl.Type + } + + call := tpl.Service.ThirdPartyLinks.Update(tpl.Parts, link) + if tpl.ExternalChannelId != "" { + call = call.ExternalChannelId(tpl.ExternalChannelId) + } + + res, err := call.Do() + if err != nil { + return errors.Join(errUpdateThirdPartyLink, err) + } + + common.PrintResult( + tpl.Output, res, writer, "Third party link updated: %s\n", res.LinkingToken, + ) + return nil +} + +func (tpl *ThirdPartyLink) Delete(writer io.Writer) error { + if err := tpl.EnsureService(); err != nil { + return err + } + call := tpl.Service.ThirdPartyLinks.Delete(tpl.LinkingToken, tpl.Type) + if tpl.ExternalChannelId != "" { + call = call.ExternalChannelId(tpl.ExternalChannelId) + } + if len(tpl.Parts) > 0 { + call = call.Part(tpl.Parts...) + } + + err := call.Do() + if err != nil { + return errors.Join(errDeleteThirdPartyLink, err) + } + + _, _ = fmt.Fprintf(writer, "Third party link deleted: %s\n", tpl.LinkingToken) + return nil +} + +func WithLinkingToken(linkingToken string) Option { + return func(tpl *ThirdPartyLink) { + tpl.LinkingToken = linkingToken + } +} + +func WithType(type_ string) Option { + return func(tpl *ThirdPartyLink) { + tpl.Type = type_ + } +} + +func WithLinkStatus(linkStatus string) Option { + return func(tpl *ThirdPartyLink) { + tpl.LinkStatus = linkStatus + } +} + +func WithExternalChannelId(externalChannelId string) Option { + return func(tpl *ThirdPartyLink) { + tpl.ExternalChannelId = externalChannelId + } +} + +var ( + WithParts = common.WithParts[*ThirdPartyLink] + WithOutput = common.WithOutput[*ThirdPartyLink] + WithService = common.WithService[*ThirdPartyLink] +) diff --git a/pkg/thirdPartyLink/thirdPartyLink_test.go b/pkg/thirdPartyLink/thirdPartyLink_test.go new file mode 100644 index 0000000..168a7ee --- /dev/null +++ b/pkg/thirdPartyLink/thirdPartyLink_test.go @@ -0,0 +1,401 @@ +// Copyright 2026 eat-pray-ai & OpenWaygate +// SPDX-License-Identifier: Apache-2.0 + +package thirdPartyLink + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "reflect" + "testing" + + "github.com/eat-pray-ai/yutu/pkg/common" + "google.golang.org/api/youtube/v3" +) + +func TestNewThirdPartyLink(t *testing.T) { + type args struct { + opts []Option + } + + svc := &youtube.Service{} + + tests := []struct { + name string + args args + want IThirdPartyLink[youtube.ThirdPartyLink] + }{ + { + name: "with all options", + args: args{ + opts: []Option{ + WithLinkingToken("token123"), + WithType("channelToStoreLink"), + WithLinkStatus("linked"), + WithExternalChannelId("ext-channel-123"), + WithParts([]string{"snippet", "status"}), + WithOutput("json"), + WithService(svc), + }, + }, + want: &ThirdPartyLink{ + Fields: common.Fields{ + Service: svc, + Parts: []string{"snippet", "status"}, + Output: "json", + }, + LinkingToken: "token123", + Type: "channelToStoreLink", + LinkStatus: "linked", + ExternalChannelId: "ext-channel-123", + }, + }, + { + name: "with no options", + args: args{ + opts: []Option{}, + }, + want: &ThirdPartyLink{Fields: common.Fields{}}, + }, + { + name: "with partial options", + args: args{ + opts: []Option{ + WithLinkingToken("token456"), + WithType("channelToStoreLink"), + }, + }, + want: &ThirdPartyLink{ + Fields: common.Fields{}, + LinkingToken: "token456", + Type: "channelToStoreLink", + }, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + if got := NewThirdPartyLink(tt.args.opts...); !reflect.DeepEqual( + got, tt.want, + ) { + t.Errorf("%s\nNewThirdPartyLink() = %v\nwant %v", tt.name, got, tt.want) + } + }, + ) + } +} + +func TestThirdPartyLink_Get(t *testing.T) { + tests := []struct { + name string + opts []Option + verify func(*http.Request) + wantLen int + wantErr bool + }{ + { + name: "get with linking token", + opts: []Option{ + WithLinkingToken("token123"), + WithParts([]string{"snippet", "status"}), + }, + verify: func(r *http.Request) { + if r.URL.Query().Get("linkingToken") != "token123" { + t.Errorf("expected linkingToken=token123, got %s", r.URL.Query().Get("linkingToken")) + } + }, + wantLen: 1, + wantErr: false, + }, + { + name: "get with type filter", + opts: []Option{ + WithType("channelToStoreLink"), + WithParts([]string{"snippet"}), + }, + verify: func(r *http.Request) { + if r.URL.Query().Get("type") != "channelToStoreLink" { + t.Errorf("expected type=channelToStoreLink, got %s", r.URL.Query().Get("type")) + } + }, + wantLen: 1, + wantErr: false, + }, + { + name: "get with external channel id", + opts: []Option{ + WithExternalChannelId("ext-channel-123"), + WithParts([]string{"snippet"}), + }, + verify: func(r *http.Request) { + if r.URL.Query().Get("externalChannelId") != "ext-channel-123" { + t.Errorf("expected externalChannelId=ext-channel-123, got %s", r.URL.Query().Get("externalChannelId")) + } + }, + wantLen: 1, + 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": [ + {"linkingToken": "token123", "snippet": {"type": "channelToStoreLink"}, "status": {"linkStatus": "linked"}} + ] + }`), + ) + }, + ), + ) + + opts := append([]Option{WithService(svc)}, tt.opts...) + tpl := NewThirdPartyLink(opts...) + got, err := tpl.Get() + if (err != nil) != tt.wantErr { + t.Errorf("ThirdPartyLink.Get() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != tt.wantLen { + t.Errorf("ThirdPartyLink.Get() got length = %v, want %v", len(got), tt.wantLen) + } + }, + ) + } +} + +func TestThirdPartyLink_List(t *testing.T) { + common.RunListTest(t, `{ + "items": [ + { + "linkingToken": "token123", + "snippet": {"type": "channelToStoreLink"}, + "status": {"linkStatus": "linked"} + } + ] + }`, + func(svc *youtube.Service, output string) func(io.Writer) error { + tpl := NewThirdPartyLink( + WithService(svc), + WithOutput(output), + WithParts([]string{"snippet", "status"}), + ) + return tpl.List + }, + ) +} + +func TestThirdPartyLink_Insert(t *testing.T) { + tests := []struct { + name string + opts []Option + verify func(*http.Request) + wantErr bool + }{ + { + name: "insert third party link", + opts: []Option{ + WithLinkingToken("token123"), + WithType("channelToStoreLink"), + WithLinkStatus("pending"), + WithParts([]string{"snippet", "status"}), + }, + verify: func(r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + + defer func() { _ = r.Body.Close() }() + var body struct { + LinkingToken string `json:"linkingToken"` + Snippet struct { + Type string `json:"type"` + } `json:"snippet"` + Status struct { + LinkStatus string `json:"linkStatus"` + } `json:"status"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + + if body.LinkingToken != "token123" { + t.Errorf("expected linkingToken=token123, got %s", body.LinkingToken) + } + if body.Snippet.Type != "channelToStoreLink" { + t.Errorf("expected snippet.type=channelToStoreLink, got %s", body.Snippet.Type) + } + if body.Status.LinkStatus != "pending" { + t.Errorf("expected status.linkStatus=pending, got %s", body.Status.LinkStatus) + } + }, + 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(`{"linkingToken": "token123"}`)) + }, + ), + ) + + opts := append([]Option{WithService(svc)}, tt.opts...) + tpl := NewThirdPartyLink(opts...) + var buf bytes.Buffer + if err := tpl.Insert(&buf); (err != nil) != tt.wantErr { + t.Errorf("ThirdPartyLink.Insert() error = %v, wantErr %v", err, tt.wantErr) + } + }, + ) + } +} + +func TestThirdPartyLink_Update(t *testing.T) { + tests := []struct { + name string + opts []Option + verify func(*http.Request) + wantErr bool + }{ + { + name: "update third party link", + opts: []Option{ + WithLinkingToken("token123"), + WithType("channelToStoreLink"), + WithLinkStatus("linked"), + WithParts([]string{"snippet", "status"}), + }, + verify: func(r *http.Request) { + if r.Method != "PUT" { + return + } + + defer func() { _ = r.Body.Close() }() + var body struct { + LinkingToken string `json:"linkingToken"` + Status struct { + LinkStatus string `json:"linkStatus"` + } `json:"status"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + + if body.LinkingToken != "token123" { + t.Errorf("expected linkingToken=token123, got %s", body.LinkingToken) + } + if body.Status.LinkStatus != "linked" { + t.Errorf("expected status.linkStatus=linked, got %s", body.Status.LinkStatus) + } + }, + 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) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" { + _, _ = w.Write([]byte(`{ + "items": [ + {"linkingToken": "token123", "snippet": {"type": "channelToStoreLink"}, "status": {"linkStatus": "pending"}} + ] + }`)) + return + } + if tt.verify != nil { + tt.verify(r) + } + _, _ = w.Write([]byte(`{"linkingToken": "token123"}`)) + }, + ), + ) + + opts := append([]Option{WithService(svc)}, tt.opts...) + tpl := NewThirdPartyLink(opts...) + var buf bytes.Buffer + if err := tpl.Update(&buf); (err != nil) != tt.wantErr { + t.Errorf("ThirdPartyLink.Update() error = %v, wantErr %v", err, tt.wantErr) + } + }, + ) + } +} + +func TestThirdPartyLink_Delete(t *testing.T) { + tests := []struct { + name string + opts []Option + verify func(*http.Request) + wantErr bool + }{ + { + name: "delete third party link", + opts: []Option{ + WithLinkingToken("token123"), + WithType("channelToStoreLink"), + }, + verify: func(r *http.Request) { + if r.Method != "DELETE" { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Query().Get("linkingToken") != "token123" { + t.Errorf("expected linkingToken=token123, got %s", r.URL.Query().Get("linkingToken")) + } + if r.URL.Query().Get("type") != "channelToStoreLink" { + t.Errorf("expected type=channelToStoreLink, got %s", r.URL.Query().Get("type")) + } + }, + 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...) + tpl := NewThirdPartyLink(opts...) + var buf bytes.Buffer + if err := tpl.Delete(&buf); (err != nil) != tt.wantErr { + t.Errorf("ThirdPartyLink.Delete() error = %v, wantErr %v", err, tt.wantErr) + } + }, + ) + } +}