1280 lines
34 KiB
Go
1280 lines
34 KiB
Go
// Copyright 2025 eat-pray-ai & OpenWaygate
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package video
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json/v2"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/eat-pray-ai/yutu/pkg"
|
|
"github.com/eat-pray-ai/yutu/pkg/common"
|
|
"google.golang.org/api/youtube/v3"
|
|
)
|
|
|
|
func TestVideo_UpdateClearableMetadata(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
input string
|
|
privacy string
|
|
wantDescription string
|
|
wantLanguage string
|
|
wantPublishAt string
|
|
wantRecordingDate string
|
|
recordingPart bool
|
|
}{
|
|
{name: "omitted preserves", input: `{}`, privacy: "private", wantDescription: "Original", wantLanguage: "en", wantPublishAt: "2027-01-01T00:00:00Z"},
|
|
{name: "empty clears", input: `{"description":"","language":"","publish_at":"","recording_date":""}`, privacy: "private", recordingPart: true},
|
|
{name: "clear does not require private", input: `{"publish_at":""}`, privacy: "public", wantDescription: "Original", wantLanguage: "en"},
|
|
{name: "nonempty replaces", input: `{"description":"New","language":"ja","publish_at":"2028-01-01T00:00:00Z","recording_date":"2020-01-01T00:00:00Z"}`, privacy: "private", wantDescription: "New", wantLanguage: "ja", wantPublishAt: "2028-01-01T00:00:00Z", wantRecordingDate: "2020-01-01T00:00:00Z", recordingPart: true},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var body map[string]map[string]any
|
|
var parts string
|
|
svc := common.NewTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.Method == http.MethodGet {
|
|
_, _ = io.WriteString(w, `{"items":[{"id":"video-id","snippet":{"title":"Title","categoryId":"22","description":"Original","defaultLanguage":"en"},"status":{"privacyStatus":"`+tt.privacy+`","publishAt":"2027-01-01T00:00:00Z"}}]}`)
|
|
return
|
|
}
|
|
parts = r.URL.Query().Get("part")
|
|
var raw struct {
|
|
Snippet map[string]any `json:"snippet"`
|
|
Status map[string]any `json:"status"`
|
|
RecordingDetails map[string]any `json:"recordingDetails"`
|
|
}
|
|
if err := json.UnmarshalRead(r.Body, &raw); err != nil {
|
|
t.Error(err)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
body = map[string]map[string]any{"snippet": raw.Snippet, "status": raw.Status, "recordingDetails": raw.RecordingDetails}
|
|
_, _ = io.WriteString(w, `{"id":"video-id"}`)
|
|
}))
|
|
var input Video
|
|
if err := json.Unmarshal([]byte(tt.input), &input); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
input.Service, input.Ids, input.MaxResults, input.Output = svc, []string{"video-id"}, 1, "silent"
|
|
if err := input.Update(io.Discard); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body == nil {
|
|
t.Fatal("missing update request")
|
|
}
|
|
for _, field := range []struct{ part, key, want string }{
|
|
{"snippet", "description", tt.wantDescription},
|
|
{"snippet", "defaultLanguage", tt.wantLanguage},
|
|
{"status", "publishAt", tt.wantPublishAt},
|
|
{"recordingDetails", "recordingDate", tt.wantRecordingDate},
|
|
} {
|
|
got, present := body[field.part][field.key]
|
|
if field.want == "" {
|
|
if present {
|
|
t.Errorf("%s.%s should be omitted for clearing, got %v", field.part, field.key, got)
|
|
}
|
|
} else if got != field.want {
|
|
t.Errorf("%s.%s = %v, want %q", field.part, field.key, got, field.want)
|
|
}
|
|
}
|
|
if strings.Contains(parts, "recordingDetails") != tt.recordingPart {
|
|
t.Errorf("unexpected update parts: %s", parts)
|
|
}
|
|
if tt.recordingPart && body["recordingDetails"] == nil {
|
|
t.Error("recordingDetails object missing")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVideo_Get(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
opts []Option
|
|
verify func(*http.Request)
|
|
wantLen int
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "get videos by id",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithMaxResults(1),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.URL.Query().Get("id") != "video-id" {
|
|
t.Errorf("expected id=video-id, got %s", r.URL.Query().Get("id"))
|
|
}
|
|
},
|
|
wantLen: 1,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "get videos myRating",
|
|
opts: []Option{
|
|
WithRating("like"),
|
|
WithMaxResults(1),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.URL.Query().Get("myRating") != "like" {
|
|
t.Errorf(
|
|
"expected myRating=like, got %s", r.URL.Query().Get("myRating"),
|
|
)
|
|
}
|
|
},
|
|
wantLen: 1,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "get videos chart",
|
|
opts: []Option{
|
|
WithChart("mostPopular"),
|
|
WithMaxResults(1),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.URL.Query().Get("chart") != "mostPopular" {
|
|
t.Errorf(
|
|
"expected chart=mostPopular, got %s", r.URL.Query().Get("chart"),
|
|
)
|
|
}
|
|
},
|
|
wantLen: 1,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "get videos with regionCode, maxHeight, maxWidth, onBehalfOfContentOwner",
|
|
opts: []Option{
|
|
WithRegionCode("US"),
|
|
WithMaxHeight(1080),
|
|
WithMaxWidth(1920),
|
|
WithOnBehalfOfContentOwner("owner-id"),
|
|
WithMaxResults(1),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.URL.Query().Get("regionCode") != "US" {
|
|
t.Errorf(
|
|
"expected regionCode=US, got %s", r.URL.Query().Get("regionCode"),
|
|
)
|
|
}
|
|
if r.URL.Query().Get("maxHeight") != "1080" {
|
|
t.Errorf(
|
|
"expected maxHeight=1080, got %s", r.URL.Query().Get("maxHeight"),
|
|
)
|
|
}
|
|
if r.URL.Query().Get("maxWidth") != "1920" {
|
|
t.Errorf(
|
|
"expected maxWidth=1920, got %s", r.URL.Query().Get("maxWidth"),
|
|
)
|
|
}
|
|
if r.URL.Query().Get("onBehalfOfContentOwner") != "owner-id" {
|
|
t.Errorf(
|
|
"expected onBehalfOfContentOwner=owner-id, got %s",
|
|
r.URL.Query().Get("onBehalfOfContentOwner"),
|
|
)
|
|
}
|
|
},
|
|
wantLen: 1,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "get videos with hl, locale, categoryId",
|
|
opts: []Option{
|
|
WithHl("en"),
|
|
WithLocale("en_US"),
|
|
WithCategory("10"),
|
|
WithMaxResults(1),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.URL.Query().Get("hl") != "en" {
|
|
t.Errorf("expected hl=en, got %s", r.URL.Query().Get("hl"))
|
|
}
|
|
if r.URL.Query().Get("locale") != "en_US" {
|
|
t.Errorf("expected locale=en_US, got %s", r.URL.Query().Get("locale"))
|
|
}
|
|
if r.URL.Query().Get("videoCategoryId") != "10" {
|
|
t.Errorf(
|
|
"expected videoCategoryId=10, got %s",
|
|
r.URL.Query().Get("videoCategoryId"),
|
|
)
|
|
}
|
|
},
|
|
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": [
|
|
{"id": "video-1", "snippet": {"title": "Video 1"}}
|
|
]
|
|
}`),
|
|
)
|
|
},
|
|
),
|
|
)
|
|
|
|
opts := append([]Option{WithService(svc)}, tt.opts...)
|
|
v := NewVideo(opts...)
|
|
got, err := v.Get()
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("Video.Get() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if len(got) != tt.wantLen {
|
|
t.Errorf("Video.Get() got length = %v, want %v", len(got), tt.wantLen)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestVideo_Get_Pagination(t *testing.T) {
|
|
svc := common.NewTestService(t, common.PaginationHandler("video"))
|
|
|
|
v := NewVideo(
|
|
WithService(svc),
|
|
WithMaxResults(22),
|
|
)
|
|
got, err := v.Get()
|
|
if err != nil {
|
|
t.Errorf("Video.Get() error = %v", err)
|
|
}
|
|
if len(got) != 22 {
|
|
t.Errorf("Video.Get() got length = %v, want 22", len(got))
|
|
}
|
|
}
|
|
|
|
func TestVideo_List(t *testing.T) {
|
|
mockResponse := `{
|
|
"items": [
|
|
{
|
|
"id": "video-1",
|
|
"snippet": {
|
|
"channelId": "channel-1",
|
|
"title": "Video 1"
|
|
},
|
|
"statistics": {
|
|
"viewCount": "100"
|
|
}
|
|
}
|
|
]
|
|
}`
|
|
|
|
common.RunListTest(
|
|
t, mockResponse,
|
|
func(svc *youtube.Service, output string) func(io.Writer) error {
|
|
v := NewVideo(
|
|
WithService(svc),
|
|
WithOutput(output),
|
|
WithIds([]string{"video-1"}),
|
|
WithMaxResults(5),
|
|
)
|
|
return v.List
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestVideo_List_NilFields(t *testing.T) {
|
|
mockResponse := `{
|
|
"items": [
|
|
{"id": "video-1"},
|
|
{"id": "video-2", "snippet": {"title": "Video 2", "channelId": "ch-2"}},
|
|
{"id": "video-3", "statistics": {"viewCount": "500"}}
|
|
]
|
|
}`
|
|
|
|
svc := common.NewTestService(
|
|
t, http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(mockResponse))
|
|
},
|
|
),
|
|
)
|
|
|
|
v := NewVideo(
|
|
WithService(svc),
|
|
WithOutput("table"),
|
|
WithIds([]string{"video-1"}),
|
|
WithMaxResults(5),
|
|
)
|
|
var buf bytes.Buffer
|
|
if err := v.List(&buf); err != nil {
|
|
t.Errorf("List() error = %v", err)
|
|
}
|
|
if buf.Len() == 0 {
|
|
t.Error("List() output is empty")
|
|
}
|
|
}
|
|
|
|
func TestVideo_Insert(t *testing.T) {
|
|
autoLevelsTrue := true
|
|
notifySubscribersTrue := true
|
|
stabilizeTrue := true
|
|
forKidsTrue := true
|
|
embeddableTrue := true
|
|
containsSyntheticMediaTrue := true
|
|
publicStatsViewableTrue := true
|
|
|
|
decodeMultipartVideo := func(t *testing.T, r *http.Request) *youtube.Video {
|
|
t.Helper()
|
|
ct := r.Header.Get("Content-Type")
|
|
mediaType, params, err := mime.ParseMediaType(ct)
|
|
if err != nil {
|
|
t.Fatalf("failed to parse Content-Type %q: %v", ct, err)
|
|
}
|
|
if !strings.HasPrefix(mediaType, "multipart/") {
|
|
t.Fatalf("expected multipart content type, got %s", mediaType)
|
|
}
|
|
boundary, ok := params["boundary"]
|
|
if !ok || boundary == "" {
|
|
t.Fatalf("missing multipart boundary in Content-Type %q", ct)
|
|
}
|
|
|
|
mr := multipart.NewReader(r.Body, boundary)
|
|
part, err := mr.NextPart()
|
|
if err != nil {
|
|
t.Fatalf("failed to read first multipart part: %v", err)
|
|
}
|
|
defer func() { _ = part.Close() }()
|
|
|
|
var body youtube.Video
|
|
if err := json.UnmarshalRead(part, &body); err != nil {
|
|
t.Fatalf("failed to decode video from request body: %v", err)
|
|
}
|
|
return &body
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
opts []Option
|
|
verify func(*http.Request)
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "insert explicit false status fields",
|
|
opts: []Option{
|
|
WithFile("test_video.mp4"),
|
|
WithEmbeddable(new(false)),
|
|
WithPublicStatsViewable(new(false)),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
_, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
part, err := multipart.NewReader(r.Body, params["boundary"]).NextPart()
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
defer func() { _ = part.Close() }()
|
|
var body struct {
|
|
Status map[string]any `json:"status"`
|
|
}
|
|
if err := json.UnmarshalRead(part, &body); err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
for _, field := range []string{"embeddable", "publicStatsViewable"} {
|
|
if value, present := body.Status[field]; !present || value != false {
|
|
t.Errorf("status.%s must be present and false, got %v (present=%v)", field, value, present)
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "insert video",
|
|
opts: []Option{
|
|
WithFile("test_video.mp4"),
|
|
WithTitle("New Video"),
|
|
WithPrivacy("public"),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
body := decodeMultipartVideo(t, r)
|
|
if body.Snippet == nil || body.Snippet.Title != "New Video" {
|
|
t.Errorf("expected snippet.title=New Video, got %+v", body.Snippet)
|
|
}
|
|
if body.Status == nil || body.Status.PrivacyStatus != "public" {
|
|
t.Errorf("expected status.privacyStatus=public, got %+v", body.Status)
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "insert video with options",
|
|
opts: []Option{
|
|
WithFile("test_video.mp4"),
|
|
WithTitle("New Video"),
|
|
WithPrivacy("public"),
|
|
WithAutoLevels(&autoLevelsTrue),
|
|
WithNotifySubscribers(¬ifySubscribersTrue),
|
|
WithStabilize(&stabilizeTrue),
|
|
WithOnBehalfOfContentOwner("owner-id"),
|
|
WithOnBehalfOfContentOwnerChannel("channel-id"),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if r.URL.Query().Get("autoLevels") != "true" {
|
|
t.Errorf(
|
|
"expected autoLevels=true, got %s", r.URL.Query().Get("autoLevels"),
|
|
)
|
|
}
|
|
if r.URL.Query().Get("notifySubscribers") != "true" {
|
|
t.Errorf(
|
|
"expected notifySubscribers=true, got %s",
|
|
r.URL.Query().Get("notifySubscribers"),
|
|
)
|
|
}
|
|
if r.URL.Query().Get("stabilize") != "true" {
|
|
t.Errorf(
|
|
"expected stabilize=true, got %s", r.URL.Query().Get("stabilize"),
|
|
)
|
|
}
|
|
if r.URL.Query().Get("onBehalfOfContentOwner") != "owner-id" {
|
|
t.Errorf(
|
|
"expected onBehalfOfContentOwner=owner-id, got %s",
|
|
r.URL.Query().Get("onBehalfOfContentOwner"),
|
|
)
|
|
}
|
|
if r.URL.Query().Get("onBehalfOfContentOwnerChannel") != "channel-id" {
|
|
t.Errorf(
|
|
"expected onBehalfOfContentOwnerChannel=channel-id, got %s",
|
|
r.URL.Query().Get("onBehalfOfContentOwnerChannel"),
|
|
)
|
|
}
|
|
body := decodeMultipartVideo(t, r)
|
|
if body.Snippet == nil || body.Snippet.Title != "New Video" {
|
|
t.Errorf("expected snippet.title=New Video, got %+v", body.Snippet)
|
|
}
|
|
if body.Status == nil || body.Status.PrivacyStatus != "public" {
|
|
t.Errorf("expected status.privacyStatus=public, got %+v", body.Status)
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "insert video with recording date and boolean options",
|
|
opts: []Option{
|
|
WithFile("test_video.mp4"),
|
|
WithTitle("Dated Video"),
|
|
WithPrivacy("private"),
|
|
WithRecordingDate(new("2024-06-15T10:00:00Z")),
|
|
WithForKids(&forKidsTrue),
|
|
WithEmbeddable(&embeddableTrue),
|
|
WithContainsSyntheticMedia(&containsSyntheticMediaTrue),
|
|
WithPublicStatsViewable(&publicStatsViewableTrue),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if !strings.Contains(r.URL.Query().Get("part"), "recordingDetails") {
|
|
t.Errorf(
|
|
"expected part to contain recordingDetails, got %s",
|
|
r.URL.Query().Get("part"),
|
|
)
|
|
}
|
|
body := decodeMultipartVideo(t, r)
|
|
if body.RecordingDetails == nil || body.RecordingDetails.RecordingDate != "2024-06-15T10:00:00Z" {
|
|
t.Errorf(
|
|
"expected recordingDetails.recordingDate=2024-06-15T10:00:00Z, got %+v",
|
|
body.RecordingDetails,
|
|
)
|
|
}
|
|
if body.Status == nil || !body.Status.SelfDeclaredMadeForKids {
|
|
t.Errorf("expected status.selfDeclaredMadeForKids=true")
|
|
}
|
|
if body.Status == nil || !body.Status.Embeddable {
|
|
t.Errorf("expected status.embeddable=true")
|
|
}
|
|
if body.Status == nil || !body.Status.ContainsSyntheticMedia {
|
|
t.Errorf("expected status.containsSyntheticMedia=true")
|
|
}
|
|
if body.Status == nil || !body.Status.PublicStatsViewable {
|
|
t.Errorf("expected status.publicStatsViewable=true")
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "insert video with empty title uses filename",
|
|
opts: []Option{
|
|
WithFile("test_video.mp4"),
|
|
WithPrivacy("public"),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.Method != "POST" {
|
|
return
|
|
}
|
|
body := decodeMultipartVideo(t, r)
|
|
if body.Snippet == nil || body.Snippet.Title != "test_video" {
|
|
t.Errorf(
|
|
"expected snippet.title=test_video (from filename), got %+v",
|
|
body.Snippet,
|
|
)
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "insert video with existing yutu tag",
|
|
opts: []Option{
|
|
WithFile("test_video.mp4"),
|
|
WithTitle("Tagged Video"),
|
|
WithTags([]string{"yutu🐰", "existing"}),
|
|
WithPrivacy("public"),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.Method != "POST" {
|
|
return
|
|
}
|
|
body := decodeMultipartVideo(t, r)
|
|
count := 0
|
|
for _, tag := range body.Snippet.Tags {
|
|
if tag == "yutu🐰" {
|
|
count++
|
|
}
|
|
}
|
|
if count != 1 {
|
|
t.Errorf(
|
|
"expected exactly 1 yutu🐰 tag, got %d in %v", count,
|
|
body.Snippet.Tags,
|
|
)
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
root, err := os.OpenRoot(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("failed to open root: %v", err)
|
|
}
|
|
oldRoot := pkg.Root
|
|
pkg.Root = root
|
|
defer func() { pkg.Root = oldRoot }()
|
|
defer func() { _ = root.Close() }()
|
|
|
|
err = os.WriteFile(
|
|
tmpDir+"/test_video.mp4", []byte("dummy video content"), 0644,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("failed to create dummy file: %v", err)
|
|
}
|
|
|
|
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-video-id", "snippet": {"title": "New Video"}, "status": {"privacyStatus": "public"}}`))
|
|
},
|
|
),
|
|
)
|
|
|
|
opts := append([]Option{WithService(svc)}, tt.opts...)
|
|
v := NewVideo(opts...)
|
|
var buf bytes.Buffer
|
|
if err := v.Insert(&buf); (err != nil) != tt.wantErr {
|
|
t.Errorf("Video.Insert() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestVideo_Insert_FileError(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
root, err := os.OpenRoot(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("failed to open root: %v", err)
|
|
}
|
|
oldRoot := pkg.Root
|
|
pkg.Root = root
|
|
defer func() { pkg.Root = oldRoot }()
|
|
defer func() { _ = root.Close() }()
|
|
|
|
svc := common.NewTestService(
|
|
t, http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
),
|
|
)
|
|
|
|
v := NewVideo(
|
|
WithService(svc),
|
|
WithFile("non_existent.mp4"),
|
|
)
|
|
var buf bytes.Buffer
|
|
if err := v.Insert(&buf); err == nil {
|
|
t.Error("expected error for non-existent file, got nil")
|
|
}
|
|
}
|
|
|
|
func TestVideo_Update(t *testing.T) {
|
|
embeddableTrue := true
|
|
containsSyntheticMediaTrue := true
|
|
|
|
tests := []struct {
|
|
name string
|
|
opts []Option
|
|
getResponse string
|
|
verify func(*http.Request)
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "update video",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithTitle("Updated Title"),
|
|
WithDescription(new("Updated Description")),
|
|
WithMaxResults(1),
|
|
},
|
|
getResponse: `{"items": [{"id": "video-id", "snippet": {"title": "Old Title"}}]}`,
|
|
verify: func(r *http.Request) {
|
|
if r.Method == "PUT" {
|
|
if r.URL.Query().Get("part") != "snippet,status" {
|
|
t.Errorf(
|
|
"expected part=snippet,status, got %s", r.URL.Query().Get("part"),
|
|
)
|
|
}
|
|
|
|
var body youtube.Video
|
|
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
|
t.Fatalf("failed to decode update body: %v", err)
|
|
}
|
|
if body.Id != "video-id" {
|
|
t.Errorf("expected id=video-id, got %s", body.Id)
|
|
}
|
|
if body.Snippet == nil || body.Snippet.Title != "Updated Title" {
|
|
t.Errorf(
|
|
"expected snippet.title=Updated Title, got %+v", body.Snippet,
|
|
)
|
|
}
|
|
if body.Snippet.Description != "Updated Description" {
|
|
t.Errorf(
|
|
"expected snippet.description=Updated Description, got %s",
|
|
body.Snippet.Description,
|
|
)
|
|
}
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "update video with onBehalfOfContentOwner",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithTitle("Updated Title"),
|
|
WithMaxResults(1),
|
|
WithOnBehalfOfContentOwner("owner-id"),
|
|
},
|
|
getResponse: `{"items": [{"id": "video-id", "snippet": {"title": "Old Title"}}]}`,
|
|
verify: func(r *http.Request) {
|
|
if r.Method == "PUT" {
|
|
if r.URL.Query().Get("onBehalfOfContentOwner") != "owner-id" {
|
|
t.Errorf(
|
|
"expected onBehalfOfContentOwner=owner-id, got %s",
|
|
r.URL.Query().Get("onBehalfOfContentOwner"),
|
|
)
|
|
}
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "update video with tags, language, license, categoryId, privacy",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithTags([]string{"new-tag"}),
|
|
WithLanguage(new("ja")),
|
|
WithLicense("creativeCommon"),
|
|
WithCategory("22"),
|
|
WithPrivacy("unlisted"),
|
|
WithMaxResults(1),
|
|
},
|
|
getResponse: `{"items": [{"id": "video-id", "snippet": {"title": "Old Title"}, "status": {"privacyStatus": "public", "license": "youtube", "embeddable": true, "publicStatsViewable": true}}]}`,
|
|
verify: func(r *http.Request) {
|
|
if r.Method == "PUT" {
|
|
var body youtube.Video
|
|
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
|
t.Fatalf("failed to decode update body: %v", err)
|
|
}
|
|
if body.Snippet.DefaultLanguage != "ja" {
|
|
t.Errorf(
|
|
"expected defaultLanguage=ja, got %s", body.Snippet.DefaultLanguage,
|
|
)
|
|
}
|
|
if body.Status.License != "creativeCommon" {
|
|
t.Errorf(
|
|
"expected license=creativeCommon, got %s", body.Status.License,
|
|
)
|
|
}
|
|
if body.Snippet.CategoryId != "22" {
|
|
t.Errorf("expected categoryId=22, got %s", body.Snippet.CategoryId)
|
|
}
|
|
if body.Status.PrivacyStatus != "unlisted" {
|
|
t.Errorf(
|
|
"expected privacyStatus=unlisted, got %s",
|
|
body.Status.PrivacyStatus,
|
|
)
|
|
}
|
|
found := false
|
|
for _, tag := range body.Snippet.Tags {
|
|
if tag == "yutu🐰" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected yutu🐰 tag in %v", body.Snippet.Tags)
|
|
}
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "update video with publishAt on private video",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithPublishAt(new("2026-08-18T15:00:00Z")),
|
|
WithMaxResults(1),
|
|
},
|
|
getResponse: `{"items": [{"id": "video-id", "snippet": {"title": "Old Title"}, "status": {"privacyStatus": "private"}}]}`,
|
|
verify: func(r *http.Request) {
|
|
if r.Method == "PUT" {
|
|
var body youtube.Video
|
|
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
|
t.Fatalf("failed to decode update body: %v", err)
|
|
}
|
|
if body.Status.PublishAt != "2026-08-18T15:00:00Z" {
|
|
t.Errorf(
|
|
"expected publishAt=2026-08-18T15:00:00Z, got %s",
|
|
body.Status.PublishAt,
|
|
)
|
|
}
|
|
if body.Status.PrivacyStatus != "private" {
|
|
t.Errorf(
|
|
"expected privacyStatus=private when scheduling, got %s",
|
|
body.Status.PrivacyStatus,
|
|
)
|
|
}
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "update video with publishAt and explicit private on public video",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithPrivacy("private"),
|
|
WithPublishAt(new("2026-08-18T15:00:00Z")),
|
|
WithMaxResults(1),
|
|
},
|
|
getResponse: `{"items": [{"id": "video-id", "snippet": {"title": "Old Title"}, "status": {"privacyStatus": "public"}}]}`,
|
|
verify: func(r *http.Request) {
|
|
if r.Method == "PUT" {
|
|
var body youtube.Video
|
|
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
|
t.Fatalf("failed to decode update body: %v", err)
|
|
}
|
|
if body.Status.PublishAt != "2026-08-18T15:00:00Z" {
|
|
t.Errorf(
|
|
"expected publishAt=2026-08-18T15:00:00Z, got %s",
|
|
body.Status.PublishAt,
|
|
)
|
|
}
|
|
if body.Status.PrivacyStatus != "private" {
|
|
t.Errorf(
|
|
"expected privacyStatus=private when scheduling, got %s",
|
|
body.Status.PrivacyStatus,
|
|
)
|
|
}
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "update video with publishAt rejects public without explicit private",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithPublishAt(new("2026-08-18T15:00:00Z")),
|
|
WithMaxResults(1),
|
|
},
|
|
getResponse: `{"items": [{"id": "video-id", "snippet": {"title": "Old Title"}, "status": {"privacyStatus": "public"}}]}`,
|
|
verify: func(r *http.Request) {
|
|
if r.Method == "PUT" {
|
|
t.Error("expected no update request when scheduling a public video")
|
|
}
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "update video with publishAt rejects unlisted without explicit private",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithPublishAt(new("2026-08-18T15:00:00Z")),
|
|
WithMaxResults(1),
|
|
},
|
|
getResponse: `{"items": [{"id": "video-id", "snippet": {"title": "Old Title"}, "status": {"privacyStatus": "unlisted"}}]}`,
|
|
verify: func(r *http.Request) {
|
|
if r.Method == "PUT" {
|
|
t.Error("expected no update request when scheduling an unlisted video")
|
|
}
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "update video with embeddable, containsSyntheticMedia, recordingDate",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithEmbeddable(&embeddableTrue),
|
|
WithContainsSyntheticMedia(&containsSyntheticMediaTrue),
|
|
WithRecordingDate(new("2024-01-01T00:00:00Z")),
|
|
WithMaxResults(1),
|
|
},
|
|
getResponse: `{"items": [{"id": "video-id", "snippet": {"title": "Old Title"}, "status": {"privacyStatus": "public"}}]}`,
|
|
verify: func(r *http.Request) {
|
|
if r.Method == "PUT" {
|
|
if !strings.Contains(r.URL.Query().Get("part"), "recordingDetails") {
|
|
t.Errorf(
|
|
"expected part to contain recordingDetails, got %s",
|
|
r.URL.Query().Get("part"),
|
|
)
|
|
}
|
|
var body youtube.Video
|
|
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
|
t.Fatalf("failed to decode update body: %v", err)
|
|
}
|
|
if !body.Status.Embeddable {
|
|
t.Errorf("expected embeddable=true")
|
|
}
|
|
if !body.Status.ContainsSyntheticMedia {
|
|
t.Errorf("expected containsSyntheticMedia=true")
|
|
}
|
|
if body.RecordingDetails == nil || body.RecordingDetails.RecordingDate != "2024-01-01T00:00:00Z" {
|
|
t.Errorf(
|
|
"expected recordingDate=2024-01-01T00:00:00Z, got %+v",
|
|
body.RecordingDetails,
|
|
)
|
|
}
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "update video not found",
|
|
opts: []Option{
|
|
WithIds([]string{"missing"}),
|
|
WithTitle("X"),
|
|
WithMaxResults(1),
|
|
},
|
|
getResponse: `{"items": []}`,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
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(tt.getResponse))
|
|
} else {
|
|
_, _ = w.Write([]byte(`{"id": "video-id", "snippet": {"title": "Updated Title"}, "status": {"privacyStatus": "public"}}`))
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
opts := append([]Option{WithService(svc)}, tt.opts...)
|
|
v := NewVideo(opts...)
|
|
var buf bytes.Buffer
|
|
if err := v.Update(&buf); (err != nil) != tt.wantErr {
|
|
t.Errorf("Video.Update() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestVideo_UpdateFalseStatusFields(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
status string
|
|
opts []Option
|
|
}{
|
|
{
|
|
name: "preserve existing false values",
|
|
status: `{"embeddable":false,"publicStatsViewable":false,"selfDeclaredMadeForKids":false,"containsSyntheticMedia":false}`,
|
|
},
|
|
{
|
|
name: "explicit false overrides true",
|
|
status: `{"embeddable":true,"publicStatsViewable":false,"selfDeclaredMadeForKids":false,"containsSyntheticMedia":true}`,
|
|
opts: []Option{WithEmbeddable(new(false)), WithContainsSyntheticMedia(new(false))},
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var body struct {
|
|
Status map[string]any `json:"status"`
|
|
}
|
|
svc := common.NewTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.Method == http.MethodGet {
|
|
_, _ = io.WriteString(w, `{"items":[{"id":"video-id","snippet":{"title":"Old title","categoryId":"22"},"status":`+tt.status+`}]}`)
|
|
return
|
|
}
|
|
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
|
t.Error(err)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, `{"id":"video-id"}`)
|
|
}))
|
|
opts := append([]Option{WithService(svc), WithIds([]string{"video-id"}), WithMaxResults(1), WithTitle("New title"), WithOutput("silent")}, tt.opts...)
|
|
if err := NewVideo(opts...).Update(io.Discard); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, field := range []string{"embeddable", "publicStatsViewable", "selfDeclaredMadeForKids", "containsSyntheticMedia"} {
|
|
if value, present := body.Status[field]; !present || value != false {
|
|
t.Errorf("status.%s must be present and false, got %v (present=%v)", field, value, present)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVideo_Rate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
opts []Option
|
|
verify func(*http.Request)
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "rate video",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithRating("like"),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if r.URL.Query().Get("rating") != "like" {
|
|
t.Errorf("expected rating=like, got %s", r.URL.Query().Get("rating"))
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "rate multiple videos",
|
|
opts: []Option{
|
|
WithIds([]string{"video-1", "video-2"}),
|
|
WithRating("dislike"),
|
|
},
|
|
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...)
|
|
v := NewVideo(opts...)
|
|
var buf bytes.Buffer
|
|
if err := v.Rate(&buf); (err != nil) != tt.wantErr {
|
|
t.Errorf("Video.Rate() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestVideo_GetRating(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
opts []Option
|
|
verify func(*http.Request)
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "get rating table",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithOutput("table"),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.URL.Query().Get("id") != "video-id" {
|
|
t.Errorf("expected id=video-id, got %s", r.URL.Query().Get("id"))
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "get rating json",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithOutput("json"),
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "get rating yaml",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithOutput("yaml"),
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "get rating with onBehalfOfContentOwner",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithOnBehalfOfContentOwner("owner-id"),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.URL.Query().Get("onBehalfOfContentOwner") != "owner-id" {
|
|
t.Errorf(
|
|
"expected onBehalfOfContentOwner=owner-id, 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")
|
|
_, _ = w.Write(
|
|
[]byte(`{
|
|
"items": [
|
|
{"videoId": "video-id", "rating": "like"}
|
|
]
|
|
}`),
|
|
)
|
|
},
|
|
),
|
|
)
|
|
|
|
opts := append([]Option{WithService(svc)}, tt.opts...)
|
|
v := NewVideo(opts...)
|
|
var buf bytes.Buffer
|
|
if err := v.GetRating(&buf); (err != nil) != tt.wantErr {
|
|
t.Errorf("Video.GetRating() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
if buf.Len() == 0 {
|
|
t.Errorf("GetRating(%s) output is empty", tt.name)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestVideo_Delete(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
opts []Option
|
|
verify func(*http.Request)
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "delete video",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.Method != "DELETE" {
|
|
t.Errorf("expected DELETE, got %s", r.Method)
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "delete video with onBehalfOfContentOwner",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithOnBehalfOfContentOwner("owner-id"),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.Method != "DELETE" {
|
|
t.Errorf("expected DELETE, got %s", r.Method)
|
|
}
|
|
if r.URL.Query().Get("onBehalfOfContentOwner") != "owner-id" {
|
|
t.Errorf(
|
|
"expected onBehalfOfContentOwner=owner-id, got %s",
|
|
r.URL.Query().Get("onBehalfOfContentOwner"),
|
|
)
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "delete multiple videos",
|
|
opts: []Option{
|
|
WithIds([]string{"video-1", "video-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.WriteHeader(http.StatusNoContent)
|
|
},
|
|
),
|
|
)
|
|
|
|
opts := append([]Option{WithService(svc)}, tt.opts...)
|
|
v := NewVideo(opts...)
|
|
var buf bytes.Buffer
|
|
if err := v.Delete(&buf); (err != nil) != tt.wantErr {
|
|
t.Errorf("Video.Delete() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestVideo_ReportAbuse(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
opts []Option
|
|
verify func(*http.Request)
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "report abuse",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithReasonId("reason-id"),
|
|
WithComments("spam"),
|
|
WithLanguage(new("en")),
|
|
WithSecondaryReasonId("secondary-reason"),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "report abuse with onBehalfOfContentOwner",
|
|
opts: []Option{
|
|
WithIds([]string{"video-id"}),
|
|
WithReasonId("reason-id"),
|
|
WithOnBehalfOfContentOwner("owner-id"),
|
|
},
|
|
verify: func(r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if r.URL.Query().Get("onBehalfOfContentOwner") != "owner-id" {
|
|
t.Errorf(
|
|
"expected onBehalfOfContentOwner=owner-id, got %s",
|
|
r.URL.Query().Get("onBehalfOfContentOwner"),
|
|
)
|
|
}
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "report abuse multiple videos",
|
|
opts: []Option{
|
|
WithIds([]string{"video-1", "video-2"}),
|
|
WithReasonId("reason-id"),
|
|
},
|
|
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...)
|
|
v := NewVideo(opts...)
|
|
var buf bytes.Buffer
|
|
if err := v.ReportAbuse(&buf); (err != nil) != tt.wantErr {
|
|
t.Errorf("Video.ReportAbuse() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|