diff --git a/.gitignore b/.gitignore index edecb13..5e11101 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ dist/ /.aswb/ /.clwb/ +/docs/superpowers # except for the following files !winres/* !server.json diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go index 21a7415..0792ae1 100644 --- a/pkg/auth/auth_test.go +++ b/pkg/auth/auth_test.go @@ -12,10 +12,12 @@ import ( "os" "strings" "testing" + "testing/iotest" "time" "github.com/eat-pray-ai/yutu/pkg" "golang.org/x/oauth2" + "google.golang.org/api/youtube/v3" ) // helper to build a minimal valid Google OAuth2 credential JSON. @@ -227,3 +229,120 @@ func TestSaveToken_InvalidPath(t *testing.T) { t.Fatalf("expected error from saveToken with invalid path, got nil") } } + +func TestStartWebServer_Success(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + addr := ln.Addr().String() + _ = ln.Close() + + redirectURL := fmt.Sprintf("http://%s", addr) + s := NewY2BService().(*svc) + s.state = "test-state" + + codeCh, err := s.startWebServer(redirectURL) + if err != nil { + t.Fatalf("startWebServer error: %v", err) + } + + // Send the HTTP request in a goroutine to avoid deadlock: + // the handler blocks on codeCh <- code until someone reads from codeCh. + go func() { + resp, err := http.Get(redirectURL + "/?state=test-state&code=auth-code-123") + if err == nil { + _ = resp.Body.Close() + } + }() + + select { + case code := <-codeCh: + if code != "auth-code-123" { + t.Errorf("expected code=auth-code-123, got %s", code) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for code") + } +} + +func TestSaveToken_Success(t *testing.T) { + tmpDir := t.TempDir() + + origRootDir := *pkg.RootDir + origRoot := pkg.Root + defer func() { + pkg.RootDir = &origRootDir + pkg.Root = origRoot + }() + + pkg.RootDir = &tmpDir + root, err := os.OpenRoot(tmpDir) + if err != nil { + t.Fatalf("failed to open root: %v", err) + } + pkg.Root = root + + s := NewY2BService().(*svc) + s.tokenFile = "token.json" + + tok := &oauth2.Token{AccessToken: "test-token"} + err = s.saveToken(tok) + if err != nil { + t.Fatalf("saveToken returned error: %v", err) + } + + data, err := os.ReadFile(tmpDir + "/token.json") + if err != nil { + t.Fatalf("failed to read saved token file: %v", err) + } + + var savedToken oauth2.Token + if err := json.Unmarshal(data, &savedToken); err != nil { + t.Fatalf("failed to unmarshal saved token: %v", err) + } + if savedToken.AccessToken != "test-token" { + t.Errorf("expected AccessToken=test-token, got %s", savedToken.AccessToken) + } +} + +func TestGetConfig_Scopes(t *testing.T) { + s := NewY2BService( + WithCredential(validCredentialJSON("http://localhost:8216"), os.DirFS(".")), + ).(*svc) + + config, err := s.getConfig() + if err != nil { + t.Fatalf("getConfig returned error: %v", err) + } + + if len(config.Scopes) != 3 { + t.Fatalf("expected 3 scopes, got %d", len(config.Scopes)) + } + + expectedScopes := []string{ + youtube.YoutubeScope, + youtube.YoutubeForceSslScope, + youtube.YoutubeChannelMembershipsCreatorScope, + } + for i, expected := range expectedScopes { + if config.Scopes[i] != expected { + t.Errorf("scope[%d] = %q, want %q", i, config.Scopes[i], expected) + } + } +} + +func TestGetCodeFromPrompt_ReadError(t *testing.T) { + errReader := iotest.ErrReader(fmt.Errorf("read error")) + var out bytes.Buffer + + s := NewY2BService(WithIO(errReader, &out)).(*svc) + + _, err := s.getCodeFromPrompt("http://example.com/auth", "http://localhost:8216") + if err == nil { + t.Fatalf("expected error from getCodeFromPrompt, got nil") + } + if !strings.Contains(err.Error(), "failed to read prompt") { + t.Errorf("expected error to contain %q, got %q", "failed to read prompt", err.Error()) + } +} diff --git a/pkg/common/common_test.go b/pkg/common/common_test.go new file mode 100644 index 0000000..c2025c6 --- /dev/null +++ b/pkg/common/common_test.go @@ -0,0 +1,366 @@ +// Copyright 2026 eat-pray-ai & OpenWaygate +// SPDX-License-Identifier: Apache-2.0 + +package common + +import ( + "bytes" + "fmt" + "math" + "net/http" + "strings" + "testing" + + "github.com/jedib0t/go-pretty/v6/table" + "google.golang.org/api/youtube/v3" +) + +// testResource is a minimal type that satisfies HasFields for option testing. +type testResource struct { + Fields +} + +func (r *testResource) GetFields() *Fields { + return &r.Fields +} + +func (r *testResource) EnsureService() error { + return r.Fields.EnsureService() +} + +// videoExtract is the extract function for Paginate with youtube.VideoListResponse. +func videoExtract(r *youtube.VideoListResponse) ([]*youtube.Video, string) { + return r.Items, r.NextPageToken +} + +// ---------- TestPaginate ---------- + +func TestPaginate(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + maxResults int64 + wantLen int + wantErr bool + }{ + { + name: "single page", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "items": [ + {"id": "v1"}, + {"id": "v2"} + ], + "nextPageToken": "" + }`)) + }), + maxResults: 10, + wantLen: 2, + wantErr: false, + }, + { + name: "multi page", + handler: func() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + pageToken := r.URL.Query().Get("pageToken") + w.Header().Set("Content-Type", "application/json") + if pageToken == "" { + items := make([]string, 20) + for i := range 20 { + items[i] = fmt.Sprintf(`{"id": "v%d"}`, i+1) + } + jsonItems := "[" + strings.Join(items, ",") + "]" + _, _ = fmt.Fprintf(w, `{ + "items": %s, + "nextPageToken": "page-2" + }`, jsonItems) + } else if pageToken == "page-2" { + _, _ = w.Write([]byte(`{ + "items": [{"id": "v21"}, {"id": "v22"}], + "nextPageToken": "" + }`)) + } + } + }(), + maxResults: 22, + wantLen: 22, + wantErr: false, + }, + { + name: "zero results", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "items": [], + "nextPageToken": "" + }`)) + }), + maxResults: 10, + wantLen: 0, + wantErr: false, + }, + { + name: "API error first page", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + }), + maxResults: 10, + wantLen: 0, + wantErr: true, + }, + { + name: "API error second page", + handler: func() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + pageToken := r.URL.Query().Get("pageToken") + w.Header().Set("Content-Type", "application/json") + if pageToken == "" { + items := make([]string, 20) + for i := range 20 { + items[i] = fmt.Sprintf(`{"id": "v%d"}`, i+1) + } + jsonItems := "[" + strings.Join(items, ",") + "]" + _, _ = fmt.Fprintf(w, `{ + "items": %s, + "nextPageToken": "page-2" + }`, jsonItems) + } else { + http.Error(w, "internal server error", http.StatusInternalServerError) + } + } + }(), + maxResults: 40, + wantLen: 20, + wantErr: true, + }, + { + name: "maxResults smaller than page", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + items := make([]string, 5) + for i := range 5 { + items[i] = fmt.Sprintf(`{"id": "v%d"}`, i+1) + } + jsonItems := "[" + strings.Join(items, ",") + "]" + _, _ = fmt.Fprintf(w, `{ + "items": %s, + "nextPageToken": "" + }`, jsonItems) + }), + maxResults: 5, + wantLen: 5, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewTestService(t, tt.handler) + f := &Fields{ + Service: svc, + MaxResults: tt.maxResults, + } + call := svc.Videos.List([]string{"id"}) + got, err := Paginate(f, call, videoExtract, fmt.Errorf("video list failed")) + if (err != nil) != tt.wantErr { + t.Errorf("Paginate() error = %v, wantErr %v", err, tt.wantErr) + } + if len(got) != tt.wantLen { + t.Errorf("Paginate() got %d items, want %d", len(got), tt.wantLen) + } + }) + } +} + +// ---------- TestPrintList ---------- + +func TestPrintList(t *testing.T) { + items := []*youtube.Video{ + {Id: "v1", Snippet: &youtube.VideoSnippet{Title: "Title 1"}}, + {Id: "v2", Snippet: &youtube.VideoSnippet{Title: "Title 2"}}, + } + header := table.Row{"ID", "Title"} + rowFn := func(v *youtube.Video) table.Row { + return table.Row{v.Id, v.Snippet.Title} + } + + tests := []struct { + name string + output string + }{ + {name: "json output", output: "json"}, + {name: "yaml output", output: "yaml"}, + {name: "table output", output: "table"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + PrintList(tt.output, items, &buf, header, rowFn) + if buf.Len() == 0 { + t.Errorf("PrintList(%q) produced empty output", tt.output) + } + }) + } +} + +// ---------- TestPrintResult ---------- + +func TestPrintResult(t *testing.T) { + data := map[string]string{"id": "v1", "title": "Title 1"} + + tests := []struct { + name string + output string + wantEmpty bool + wantSub string + }{ + { + name: "json output", + output: "json", + wantEmpty: false, + }, + { + name: "yaml output", + output: "yaml", + wantEmpty: false, + }, + { + name: "silent output", + output: "silent", + wantEmpty: true, + }, + { + name: "default output", + output: "", + wantEmpty: false, + wantSub: "Video v1 updated", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + PrintResult(tt.output, data, &buf, "Video %s updated", "v1") + if tt.wantEmpty && buf.Len() != 0 { + t.Errorf("PrintResult(%q) should produce no output, got %q", tt.output, buf.String()) + } + if !tt.wantEmpty && buf.Len() == 0 { + t.Errorf("PrintResult(%q) produced empty output", tt.output) + } + if tt.wantSub != "" && !strings.Contains(buf.String(), tt.wantSub) { + t.Errorf("PrintResult(%q) = %q, want substring %q", tt.output, buf.String(), tt.wantSub) + } + }) + } +} + +// ---------- TestEnsureService ---------- + +func TestEnsureService(t *testing.T) { + t.Run("service already set", func(t *testing.T) { + svc := &youtube.Service{} + f := &Fields{Service: svc} + err := f.EnsureService() + if err != nil { + t.Errorf("EnsureService() error = %v, want nil", err) + } + if f.Service != svc { + t.Errorf("EnsureService() changed Service pointer") + } + }) +} + +// ---------- Generic option functions ---------- + +func TestWithMaxResults(t *testing.T) { + tests := []struct { + name string + input int64 + want int64 + }{ + {name: "positive", input: 42, want: 42}, + {name: "zero becomes MaxInt64", input: 0, want: math.MaxInt64}, + {name: "negative becomes 1", input: -5, want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &testResource{} + WithMaxResults[*testResource](tt.input)(r) + if r.Fields.MaxResults != tt.want { + t.Errorf("WithMaxResults(%d) = %d, want %d", tt.input, r.Fields.MaxResults, tt.want) + } + }) + } +} + +func TestWithIds(t *testing.T) { + r := &testResource{} + ids := []string{"id1", "id2", "id3"} + WithIds[*testResource](ids)(r) + if len(r.Fields.Ids) != len(ids) { + t.Fatalf("WithIds() got %d ids, want %d", len(r.Fields.Ids), len(ids)) + } + for i, id := range r.Fields.Ids { + if id != ids[i] { + t.Errorf("WithIds()[%d] = %q, want %q", i, id, ids[i]) + } + } +} + +func TestWithParts(t *testing.T) { + r := &testResource{} + parts := []string{"snippet", "contentDetails"} + WithParts[*testResource](parts)(r) + if len(r.Fields.Parts) != len(parts) { + t.Fatalf("WithParts() got %d parts, want %d", len(r.Fields.Parts), len(parts)) + } + for i, p := range r.Fields.Parts { + if p != parts[i] { + t.Errorf("WithParts()[%d] = %q, want %q", i, p, parts[i]) + } + } +} + +func TestWithOutput(t *testing.T) { + r := &testResource{} + WithOutput[*testResource]("json")(r) + if r.Fields.Output != "json" { + t.Errorf("WithOutput() = %q, want %q", r.Fields.Output, "json") + } +} + +func TestWithService(t *testing.T) { + r := &testResource{} + svc := &youtube.Service{} + WithService[*testResource](svc)(r) + if r.Fields.Service != svc { + t.Errorf("WithService() did not set service") + } +} + +func TestWithHl(t *testing.T) { + r := &testResource{} + WithHl[*testResource]("en")(r) + if r.Fields.Hl != "en" { + t.Errorf("WithHl() = %q, want %q", r.Fields.Hl, "en") + } +} + +func TestWithChannelId(t *testing.T) { + r := &testResource{} + WithChannelId[*testResource]("chan-123")(r) + if r.Fields.ChannelId != "chan-123" { + t.Errorf("WithChannelId() = %q, want %q", r.Fields.ChannelId, "chan-123") + } +} + +func TestWithOnBehalfOfContentOwner(t *testing.T) { + r := &testResource{} + WithOnBehalfOfContentOwner[*testResource]("owner-456")(r) + if r.Fields.OnBehalfOfContentOwner != "owner-456" { + t.Errorf("WithOnBehalfOfContentOwner() = %q, want %q", r.Fields.OnBehalfOfContentOwner, "owner-456") + } +} diff --git a/pkg/init_test.go b/pkg/init_test.go index 6c8a84a..0c570a7 100644 --- a/pkg/init_test.go +++ b/pkg/init_test.go @@ -17,6 +17,10 @@ func TestInitLogger(t *testing.T) { {"info", "info"}, {"warn", "warn"}, {"error", "error"}, + {"DEBUG uppercase", "DEBUG"}, + {"INFO uppercase", "INFO"}, + {"WARN uppercase", "WARN"}, + {"ERROR uppercase", "ERROR"}, {"default", ""}, } diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index a8a659c..eb527a4 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -43,6 +43,16 @@ func TestStrToBoolPtr(t *testing.T) { args: args{b: new("")}, want: nil, }, + { + name: "null", + args: args{b: new("null")}, + want: nil, + }, + { + name: "NULL", + args: args{b: new("NULL")}, + want: nil, + }, } for _, tt := range tests { t.Run( @@ -74,6 +84,16 @@ func TestGetFileName(t *testing.T) { args: args{file: "example"}, want: "example", }, + { + name: "with path", + args: args{file: "/foo/bar/example.txt"}, + want: "example", + }, + { + name: "double extension", + args: args{file: "archive.tar.gz"}, + want: "archive.tar", + }, } for _, tt := range tests { t.Run( @@ -300,6 +320,17 @@ func TestBoolToStrPtr(t *testing.T) { } } +func TestRandomStage(t *testing.T) { + s1 := RandomStage() + s2 := RandomStage() + if s1 == "" { + t.Error("RandomStage() returned empty string") + } + if s1 == s2 { + t.Error("RandomStage() returned same value twice") + } +} + func TestHandleCmdError(t *testing.T) { tests := []struct { name string diff --git a/scripts/npm-publish.sh b/scripts/npm-publish.sh index 398c550..fff4e37 100755 --- a/scripts/npm-publish.sh +++ b/scripts/npm-publish.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# Copyright 2026 eat-pray-ai & OpenWaygate +# SPDX-License-Identifier: Apache-2.0 + set -euo pipefail # Usage: scripts/npm-publish.sh [--dry-run]