diff --git a/README.md b/README.md index 6864c39..bffbcb3 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,7 @@ As a GitHub user, perhaps you have tried to change your GitHub avatar and realized there is a 1MB limit for images you can upload. Git Fit's purpose is to provide a tool in your command line (CLI) to compress your avatar while maintaining high quality output. -> NOTE! -> GitHub does not allow updating avatars through the GitHub API. GitHub avatars are generated and hosted from Gravatar (https://gravatar.com). The only other option is to use Gravatar's API endpoint to upload new avatar images (https://api.gravatar.com/v3/me/avatars). - -- [ ] **TO DO: Connect to Gravatar API to update avatars.** - -## useful gravatar links: - -1. https://docs.gravatar.com/rest/getting-started/ -2. https://gravatar.com/developers/console - -## using the tool in its current state: +## using the tool gitfit -input input.jpeg -output output.jpeg -maxsize -quality <1-100 for jpeg> -v [for verbose output] diff --git a/cmd/gitfit/main.go b/cmd/gitfit/main.go index a1967bb..de8bb14 100644 --- a/cmd/gitfit/main.go +++ b/cmd/gitfit/main.go @@ -27,7 +27,7 @@ type Config struct { // main() - entry point func main() { - cfg := parseFlags() + cfg := parseFlags(os.Args[1:]) showUsage, err := validateConfig(cfg) if showUsage { @@ -49,28 +49,29 @@ func main() { } // parseFlags() - extract flags into a Config struct -func parseFlags() *Config { +func parseFlags(args []string) *Config { + fs := flag.NewFlagSet("gitfit", flag.ExitOnError) + // define command-line flags - inputPath := flag.String("input", "", "Path to the input image file") - outputPath := flag.String("output", "", "Path to save the compressed image") - maxSize := flag.Int("maxsize", 1048576, "Maximum file size in bytes (default 1MB)") - outputFormat := flag.String("format", "", "Output image format (jpeg, png, or gif)") - quality := flag.Int("quality", 85, "JPEG compression quality (1-100; 85 by default)") - verbose := flag.Bool("v", false, "Verbose logging enabled") - uploadGravatar := flag.Bool("upload-gravatar", false, "Upload compressed image to Gravatar") + inputPath := fs.String("input", "", "Path to the input image file") + outputPath := fs.String("output", "", "Path to save the compressed image") + maxSize := fs.Int("maxsize", 1048576, "Maximum file size in bytes (default 1MB)") + outputFormat := fs.String("format", "", "Output image format (jpeg, png, or gif)") + quality := fs.Int("quality", 85, "JPEG compression quality (1-100; 85 by default)") + verbose := fs.Bool("v", false, "Verbose logging enabled") + uploadGravatar := fs.Bool("upload-gravatar", false, "Upload compressed image to Gravatar") // custom usage message for flags - flag.Usage = func() { + fs.Usage = func() { fmt.Println("Usage: gitfit -input -output -maxsize " + "-format -quality <0-100> -v [for verbose logging] -upload-gravatar [to upload to Gravatar]") fmt.Println("Example: gitfit -input input.jpeg -output output.jpeg -maxsize 1000000 -format jpeg -quality 85 -v -upload-gravatar") fmt.Println("Flags:") - flag.PrintDefaults() + fs.PrintDefaults() } - flag.Parse() + fs.Parse(args) - // Config struct populated with flag values return &Config{ InputPath: *inputPath, OutputPath: *outputPath, @@ -87,7 +88,6 @@ func parseFlags() *Config { func validateConfig(cfg *Config) (bool, error) { // check if input and/or output path is missing if cfg.InputPath == "" || cfg.OutputPath == "" { - // assume user knows about both flags if one is given if cfg.InputPath == "" && cfg.OutputPath == "" { return true, nil } diff --git a/cmd/gitfit/main_test.go b/cmd/gitfit/main_test.go index ab5a1e3..7d0c429 100644 --- a/cmd/gitfit/main_test.go +++ b/cmd/gitfit/main_test.go @@ -9,112 +9,262 @@ import ( "testing" ) -// helper to create a small JPEG file for tests -func writeTestJPEG(path string, w, h, quality int) error { - img := image.NewRGBA(image.Rect(0, 0, w, h)) - col := color.RGBA{R: 180, G: 120, B: 80, A: 255} - - // fill with solid color - for y := 0; y < h; y++ { - for x := 0; x < w; x++ { - img.Set(x, y, col) - } +// TestParseFlags() - tests the parseFlags function +func TestParseFlags(t *testing.T) { + tests := []struct { + name string + args []string + expected Config + }{ + { + name: "All Flags", + args: []string{ + "-input", "in.jpg", + "-output", "out.jpg", + "-maxsize", "500", + "-format", "png", + "-quality", "90", + "-v", + "-upload-gravatar", + }, + expected: Config{ + InputPath: "in.jpg", + OutputPath: "out.jpg", + MaxSize: 500, + OutputFormat: "png", + Quality: 90, + Verbose: true, + UploadGravatar: true, + }, + }, + { + name: "Defaults", + args: []string{}, + expected: Config{ + MaxSize: 1048576, + Quality: 85, + }, + }, } - f, err := os.Create(path) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := parseFlags(tt.args) + if cfg.InputPath != tt.expected.InputPath { + t.Errorf("expected InputPath %s, got %s", tt.expected.InputPath, cfg.InputPath) + } + + if cfg.OutputPath != tt.expected.OutputPath { + t.Errorf("expected OutputPath %s, got %s", tt.expected.OutputPath, cfg.OutputPath) + } + + if cfg.MaxSize != tt.expected.MaxSize { + t.Errorf("expected MaxSize %d, got %d", tt.expected.MaxSize, cfg.MaxSize) + } + + if cfg.OutputFormat != tt.expected.OutputFormat { + t.Errorf("expected OutputFormat %s, got %s", tt.expected.OutputFormat, cfg.OutputFormat) + } + + if cfg.Quality != tt.expected.Quality { + t.Errorf("expected Quality %d, got %d", tt.expected.Quality, cfg.Quality) + } + + if cfg.Verbose != tt.expected.Verbose { + t.Errorf("expected Verbose %v, got %v", tt.expected.Verbose, cfg.Verbose) + } + + if cfg.UploadGravatar != tt.expected.UploadGravatar { + t.Errorf("expected UploadGravatar %v, got %v", tt.expected.UploadGravatar, cfg.UploadGravatar) + } + }) + } +} + +// TestValidateConfig() - tests the validateConfig function +func TestValidateConfig(t *testing.T) { + // create a dummy file for existence check + tmpFile, err := os.CreateTemp("", "test-image-*.jpg") if err != nil { - return err + t.Fatalf("failed to create temp file: %v", err) } - defer f.Close() + defer os.Remove(tmpFile.Name()) + tmpFile.Close() - return jpeg.Encode(f, img, &jpeg.Options{Quality: quality}) -} + tests := []struct { + name string + cfg Config + wantUsage bool + wantErr bool + }{ + { + name: "Empty Paths", + cfg: Config{}, + wantUsage: true, + wantErr: false, + }, + { + name: "Missing Input", + cfg: Config{ + OutputPath: "out.jpg", + }, + wantUsage: false, + wantErr: true, + }, + { + name: "Missing Output", + cfg: Config{ + InputPath: "in.jpg", + }, + wantUsage: false, + wantErr: true, + }, + { + name: "Input Not Exist", + cfg: Config{ + InputPath: "nonexistent.jpg", + OutputPath: "out.jpg", + }, + wantUsage: false, + wantErr: true, + }, + { + name: "Invalid MaxSize", + cfg: Config{ + InputPath: tmpFile.Name(), + OutputPath: "out.jpg", + MaxSize: 0, + }, + wantUsage: false, + wantErr: true, + }, + { + name: "Invalid Quality", + cfg: Config{ + InputPath: tmpFile.Name(), + OutputPath: "out.jpg", + MaxSize: 100, + Quality: 101, + }, + wantUsage: false, + wantErr: true, + }, + { + name: "Valid Config (Auto Format)", + cfg: Config{ + InputPath: tmpFile.Name(), + OutputPath: "out.jpg", + MaxSize: 100, + Quality: 80, + }, + wantUsage: false, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + showUsage, err := validateConfig(&tt.cfg) + if showUsage != tt.wantUsage { + t.Errorf("expected showUsage %v, got %v", tt.wantUsage, showUsage) + } -// TestValidateConfig_ShowUsageWhenEmpty() - test that validateConfig signals to show usage when config is empty -func TestValidateConfig_ShowUsageWhenEmpty(t *testing.T) { - cfg := &Config{} - show, err := validateConfig(cfg) - if !show || err != nil { - t.Fatalf("expected showUsage=true and err=nil for empty cfg; got show=%v err=%v", show, err) + if (err != nil) != tt.wantErr { + t.Errorf("expected error %v, got %v", tt.wantErr, err) + } + if !tt.wantErr && !tt.wantUsage && tt.cfg.OutputFormat == "" { + // Check if format was inferred + ext := filepath.Ext(tt.cfg.InputPath) + if ext == ".jpg" && tt.cfg.OutputFormat != "jpeg" { + t.Errorf("expected inferred format jpeg, got %s", tt.cfg.OutputFormat) + } + } + }) } } -// TestValidateConfig_MissingOneFlag() - test that validateConfig returns error when one of input/output is missing -func TestValidateConfig_MissingOneFlag(t *testing.T) { - cfg := &Config{OutputPath: "out.jpg"} - show, err := validateConfig(cfg) - if show || err == nil { - t.Fatalf("expected showUsage=false and err!=nil when one of input/output is missing; got show=%v err=%v", show, err) +// TestRunCompress() - tests the runCompress function +func TestRunCompress(t *testing.T) { + // Create a dummy image + tmpIn, err := os.CreateTemp("", "test-in-*.jpg") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) } -} + defer os.Remove(tmpIn.Name()) -// TestValidateConfig_InputFileDoesNotExist() - test that validateConfig returns error when input file does not exist -func TestValidateConfig_InputFileDoesNotExist(t *testing.T) { - cfg := &Config{InputPath: "no-such-file-xyz.jpg", OutputPath: "out.jpg"} - _, err := validateConfig(cfg) - if err == nil { - t.Fatalf("expected error for non-existent input file") + if err := createTestImage(tmpIn.Name(), 100, 100, "jpg"); err != nil { + t.Fatalf("failed to create test image: %v", err) } -} + tmpIn.Close() -// TestValidateConfig_DefaultFormatAndExtension() - test that validateConfig sets default output format and appends extension -func TestValidateConfig_DefaultFormatAndExtension(t *testing.T) { - td := t.TempDir() - in := filepath.Join(td, "in.png") + tmpOut, err := os.CreateTemp("", "test-out-*.jpg") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpOut.Name()) + tmpOut.Close() - // create empty file to satisfy Stat - if err := os.WriteFile(in, []byte(""), 0644); err != nil { - t.Fatalf("failed to create input file: %v", err) + cfg := &Config{ + InputPath: tmpIn.Name(), + OutputPath: tmpOut.Name(), + MaxSize: 1024 * 1024, + OutputFormat: "jpeg", + Quality: 80, + Verbose: true, } - out := filepath.Join(td, "out") - cfg := &Config{InputPath: in, OutputPath: out, OutputFormat: "", MaxSize: 1048576, Quality: 85} - show, err := validateConfig(cfg) - if show || err != nil { - t.Fatalf("unexpected validateConfig result: show=%v err=%v", show, err) + if err := runCompress(cfg); err != nil { + t.Fatalf("runCompress failed: %v", err) } - if cfg.OutputFormat != "png" { - t.Fatalf("expected OutputFormat=png, got %s", cfg.OutputFormat) + // check if output exists and has content + info, err := os.Stat(tmpOut.Name()) + if err != nil { + t.Fatalf("failed to stat output file: %v", err) } -} -// TestValidateConfig_QualityRange() - test that validateConfig returns error for invalid quality range -func TestValidateConfig_QualityRange(t *testing.T) { - td := t.TempDir() - in := filepath.Join(td, "in.jpg") - if err := writeTestJPEG(in, 20, 20, 80); err != nil { - t.Fatalf("failed to write in.jpg: %v", err) + if info.Size() == 0 { + t.Error("output file is empty") } - cfg := &Config{InputPath: in, OutputPath: filepath.Join(td, "out.jpg"), Quality: 0} - _, err := validateConfig(cfg) - if err == nil { - t.Fatalf("expected error for invalid quality range") + // test with mode Gravatar upload + cfg.UploadGravatar = true + + // ensure env vars are unset + os.Unsetenv("GRAVATAR_CLIENT_ID") + if err := runCompress(cfg); err == nil { + t.Error("expected error for missing env vars") } } -// TestRunCompress_EndToEnd() - end-to-end test of runCompress() -func TestRunCompress_EndToEnd(t *testing.T) { - td := t.TempDir() - in := filepath.Join(td, "in.jpg") - out := filepath.Join(td, "out.jpg") - if err := writeTestJPEG(in, 320, 240, 100); err != nil { - t.Fatalf("failed to write test jpeg: %v", err) +func createTestImage(path string, width, height int, format string) error { + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, color.RGBA{255, 0, 0, 255}) + } } - // run compression - cfg := &Config{InputPath: in, OutputPath: out, MaxSize: 5 * 1024 * 1024, OutputFormat: "jpeg", Quality: 90, Verbose: false} - if err := runCompress(cfg); err != nil { - t.Fatalf("runCompress failed: %v", err) + f, err := os.Create(path) + if err != nil { + return err } + defer f.Close() - fi, err := os.Stat(out) - if err != nil { - t.Fatalf("expected output file, got error: %v", err) + return jpeg.Encode(f, img, nil) +} + +// TestRunCompress_InvalidFile() - tests the runCompress function with an invalid input file +func TestRunCompress_InvalidFile(t *testing.T) { + cfg := &Config{ + InputPath: "nonexistent.jpg", + OutputPath: "out.jpg", + MaxSize: 1024, + OutputFormat: "jpeg", + Quality: 80, } - if fi.Size() == 0 { - t.Fatalf("output file is empty") + if err := runCompress(cfg); err == nil { + t.Error("expected error for nonexistent input file") } } diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index a5863a8..407834f 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -37,6 +37,7 @@ func TestHealthCheck(t *testing.T) { gin.SetMode(gin.TestMode) r := setupRouter() + // send request w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/health", nil) r.ServeHTTP(w, req) @@ -105,6 +106,7 @@ func TestCompressEndpointMissingFile(t *testing.T) { gin.SetMode(gin.TestMode) r := setupRouter() + // send request w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/compress", nil) r.ServeHTTP(w, req) @@ -178,3 +180,61 @@ func TestNotFound(t *testing.T) { t.Errorf("Expected status 404, got %d", w.Code) } } + +// TestCompressEndpoint_InvalidFormat() - test compress endpoint with invalid format +func TestCompressEndpoint_InvalidFormat(t *testing.T) { + gin.SetMode(gin.TestMode) + r := setupRouter() + + // create multipart form with invalid parameters + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // add a dummy file + part, _ := writer.CreateFormFile("avatar", "test.png") + part.Write([]byte("fake image data")) + + // add invalid quality parameter + writer.WriteField("quality", "200") + writer.Close() + + // send request + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/compress", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + r.ServeHTTP(w, req) + + // should still process (clamped to valid range) + if w.Code == http.StatusOK { + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + } +} + +// TestDownloadEndpoint_Expired() - test download endpoint with expired file +func TestDownloadEndpoint_Expired(t *testing.T) { + gin.SetMode(gin.TestMode) + r := setupRouter() + + // manually inject an expired file into the store + id := "expired-id" + token := "expired-token" + fileStore.Lock() + fileStore.m[id] = storedFile{ + Data: []byte("expired data"), + Mime: "image/png", + Filename: "expired.png", + Expires: time.Now().Add(-time.Minute), // expired 1 minute ago + Token: token, + } + fileStore.Unlock() + + // send request + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/download/"+id+"?token="+token, nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404 for expired file, got %d", w.Code) + } +} diff --git a/internal/gravatar/crop_test.go b/internal/gravatar/crop_test.go new file mode 100644 index 0000000..a3f2e58 --- /dev/null +++ b/internal/gravatar/crop_test.go @@ -0,0 +1,90 @@ +package gravatar + +import ( + "image" + "image/color" + "image/jpeg" + "image/png" + "os" + "path/filepath" + "testing" +) + +// createTestImage() - creates a test image +/* path (string) - path to save test image + width (int) - width of test image + height (int) - height of test image + format (string) - format of test image */ +func createTestImage(path string, width, height int, format string) error { + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, color.RGBA{255, 0, 0, 255}) + } + } + + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + if format == "png" { + return png.Encode(f, img) + } + + return jpeg.Encode(f, img, nil) +} + +// TestCropToSquare() - tests the cropToSquare function +func TestCropToSquare(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "crop_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + tests := []struct { + name string + width int + height int + format string + expected int // expected square size + }{ + {"Landscape", 100, 50, "jpg", 50}, + {"Portrait", 50, 100, "jpg", 50}, + {"Square", 80, 80, "png", 80}, + } + + // test crop to square + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filename := filepath.Join(tmpDir, tt.name+"."+tt.format) + if err := createTestImage(filename, tt.width, tt.height, tt.format); err != nil { + t.Fatalf("failed to create test image: %v", err) + } + + croppedPath, err := cropToSquare(filename) + if err != nil { + t.Fatalf("cropToSquare failed: %v", err) + } + + // verify output file exists + f, err := os.Open(croppedPath) + if err != nil { + t.Fatalf("failed to open cropped image: %v", err) + } + defer f.Close() + + img, _, err := image.Decode(f) + if err != nil { + t.Fatalf("failed to decode cropped image: %v", err) + } + + bounds := img.Bounds() + if bounds.Dx() != tt.expected || bounds.Dy() != tt.expected { + t.Errorf("expected size %dx%d, got %dx%d", tt.expected, tt.expected, bounds.Dx(), bounds.Dy()) + } + }) + } +} diff --git a/internal/gravatar/gravatar_test.go b/internal/gravatar/gravatar_test.go index 351b4b3..594ccf0 100644 --- a/internal/gravatar/gravatar_test.go +++ b/internal/gravatar/gravatar_test.go @@ -1,100 +1,100 @@ package gravatar import ( - "image" - "image/color" - "image/jpeg" - "io" "net/http" "net/http/httptest" "os" - "strings" "testing" ) -// TestUploadAvatar() - tests the UploadAvatar() function -/* t (*testing.T) - test context */ -func TestUploadAvatar(t *testing.T) { - tmpFile, err := os.CreateTemp("", "test-image-*.jpg") - if err != nil { - t.Fatalf("failed to create temp file: %v", err) +// TestGenerateRandomState() - test the generateRandomState method +func TestGenerateRandomState(t *testing.T) { + state := generateRandomState() + if len(state) == 0 { + t.Error("generated state is empty") } - defer os.Remove(tmpFile.Name()) - // write a real square JPEG image (100x100) to temp file for testing - img := image.NewRGBA(image.Rect(0, 0, 100, 100)) - col := color.RGBA{R: 255, G: 0, B: 0, A: 255} - for y := 0; y < 100; y++ { - for x := 0; x < 100; x++ { - img.Set(x, y, col) - } + if len(state) != 32 { // 16 bytes hex encoded + t.Errorf("expected state length 32, got %d", len(state)) } +} - if err := jpeg.Encode(tmpFile, img, &jpeg.Options{Quality: 90}); err != nil { - t.Fatalf("failed to write JPEG to temp file: %v", err) +// TestUploadAvatar_NotAuthenticated() - test the UploadAvatar method when not authenticated +func TestUploadAvatar_NotAuthenticated(t *testing.T) { + client := NewClient("id", "secret", "uri", false) + err := client.UploadAvatar("test.jpg") + if err == nil { + t.Error("expected error for unauthenticated upload") } - tmpFile.Close() - content, err := os.ReadFile(tmpFile.Name()) + if err.Error() != "not authenticated - call Authenticate() first" { + t.Errorf("unexpected error message: %v", err) + } +} + +// TestUploadAvatar_Success() - test the UploadAvatar method when authenticated +func TestUploadAvatar_Success(t *testing.T) { + tmpFile, err := os.CreateTemp("", "test-avatar.jpg") if err != nil { - t.Fatalf("failed to read back temp file: %v", err) + t.Fatalf("failed to create temp file: %v", err) } + defer os.Remove(tmpFile.Name()) + if err := createTestImage(tmpFile.Name(), 100, 100, "jpg"); err != nil { + t.Fatalf("failed to create test image: %v", err) + } + tmpFile.Close() - // mock Gravatar REST API server + // mock Gravatar API server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Errorf("expected POST request, got %s", r.Method) } - // check authorization header - authHeader := r.Header.Get("Authorization") - if !strings.HasPrefix(authHeader, "Bearer ") { - t.Errorf("missing or invalid Authorization header: %s", authHeader) + if r.URL.Path != "/me/avatars" { + t.Errorf("expected path /me/avatars, got %s", r.URL.Path) } - if authHeader != "Bearer test-access-token" { - t.Errorf("wrong access token in header: %s", authHeader) + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("expected Bearer token, got %s", r.Header.Get("Authorization")) } - contentType := r.Header.Get("Content-Type") - if !strings.Contains(contentType, "multipart/form-data") { - t.Errorf("wrong content type: %s", contentType) - } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success": true}`)) + })) + defer server.Close() - // parse multipart form - err := r.ParseMultipartForm(10 << 20) // 10 MB - if err != nil { - t.Errorf("failed to parse multipart form: %v", err) - } + // override base URL for testing + originalBaseURL := apiBaseURL + apiBaseURL = server.URL + defer func() { apiBaseURL = originalBaseURL }() - file, _, err := r.FormFile("image") - if err != nil { - t.Errorf("failed to get file from form: %v", err) - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - defer file.Close() + client := NewClient("id", "secret", "uri", true) + client.AccessToken = "test-token" - fileContent, _ := io.ReadAll(file) - if string(fileContent) != string(content) { - t.Errorf("file content mismatch") - } + if err := client.UploadAvatar(tmpFile.Name()); err != nil { + t.Fatalf("UploadAvatar failed: %v", err) + } +} +// TestUploadAvatar_CropError() - test the UploadAvatar method when crop fails +func TestUploadAvatar_CropError(t *testing.T) { + // mock API server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"success": true}`)) })) defer server.Close() - // override API base URL for testing - originalURL := apiBaseURL + originalBaseURL := apiBaseURL apiBaseURL = server.URL - defer func() { apiBaseURL = originalURL }() + defer func() { apiBaseURL = originalBaseURL }() - // manually set access token for testing (skip OAuth flow) - client := NewClient("test-client-id", "test-client-secret", "http://localhost:8080/callback", false) - client.AccessToken = "test-access-token" + client := NewClient("id", "secret", "uri", false) + client.AccessToken = "test-token" - if err := client.UploadAvatar(tmpFile.Name()); err != nil { - t.Errorf("uploadAvatar failed: %v", err) + // try to upload a file that doesn't exist - should fail during crop + err := client.UploadAvatar("nonexistent.jpg") + if err == nil { + t.Error("expected error for nonexistent file") } } diff --git a/internal/gravatar/oauth.go b/internal/gravatar/oauth.go index 0cc62cb..8dd46a3 100644 --- a/internal/gravatar/oauth.go +++ b/internal/gravatar/oauth.go @@ -22,13 +22,16 @@ const ( // OAuthConfig holds OAuth 2.0 configuration type OAuthConfig struct { - ClientID string - ClientSecret string - RedirectURI string - Scopes []string - state string - codeChan chan string - errChan chan error + ClientID string + ClientSecret string + RedirectURI string + Scopes []string + AuthEndpoint string + TokenEndpoint string + LocalServerPort string + state string + codeChan chan string + errChan chan error } // TokenResponse represents the OAuth token response @@ -45,24 +48,34 @@ type TokenResponse struct { redirectURI (string) - redirect URI for OAuth authentication */ func NewOAuthConfig(clientID, clientSecret, redirectURI string) *OAuthConfig { return &OAuthConfig{ - ClientID: clientID, - ClientSecret: clientSecret, - RedirectURI: redirectURI, - Scopes: []string{"auth", "gravatar-profile:manage"}, - codeChan: make(chan string), - errChan: make(chan error), + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURI: redirectURI, + Scopes: []string{"auth", "gravatar-profile:manage"}, + AuthEndpoint: authorizationEndpoint, + TokenEndpoint: tokenEndpoint, + LocalServerPort: ":8080", + codeChan: make(chan string), + errChan: make(chan error), } } -// StartOAuthFlow() - initiates the OAuth flow and returns an access token -// verbose (bool) - enable verbose logging +// OAuthTimeout is the duration to wait for the OAuth callback +var OAuthTimeout = 5 * time.Minute + func (c *OAuthConfig) StartOAuthFlow(verbose bool) (string, error) { // generate random state for CSRF protection c.state = generateRandomState() + // create a new mux to avoid conflicts with global DefaultServeMux + mux := http.NewServeMux() + mux.HandleFunc("/callback", c.callbackHandler) + // start local server - server := &http.Server{Addr: ":8080"} - http.HandleFunc("/callback", c.callbackHandler) + server := &http.Server{ + Addr: c.LocalServerPort, + Handler: mux, + } // start server in background go func() { @@ -70,7 +83,7 @@ func (c *OAuthConfig) StartOAuthFlow(verbose bool) (string, error) { c.errChan <- fmt.Errorf("failed to start callback server: %v", err) } }() - + time.Sleep(100 * time.Millisecond) // build authorization URL @@ -81,7 +94,6 @@ func (c *OAuthConfig) StartOAuthFlow(verbose bool) (string, error) { fmt.Printf("If browser doesn't open, visit: %s\n", authURL) } - // open browser if err := openBrowser(authURL); err != nil { fmt.Printf("Failed to open browser automatically: %v\n", err) fmt.Printf("Please visit this URL manually: %s\n", authURL) @@ -95,9 +107,9 @@ func (c *OAuthConfig) StartOAuthFlow(verbose bool) (string, error) { case err := <-c.errChan: server.Shutdown(context.Background()) return "", err - case <-time.After(5 * time.Minute): + case <-time.After(OAuthTimeout): server.Shutdown(context.Background()) - return "", fmt.Errorf("authorization timeout after 5 minutes") + return "", fmt.Errorf("authorization timeout after %v", OAuthTimeout) } // shutdown server @@ -107,7 +119,7 @@ func (c *OAuthConfig) StartOAuthFlow(verbose bool) (string, error) { fmt.Println("Authorization successful! Exchanging code for token...") } - // exchange code for token + // exchange authorization code for access token token, err := c.exchangeCodeForToken(code) if err != nil { return "", fmt.Errorf("failed to exchange code for token: %v", err) @@ -117,7 +129,7 @@ func (c *OAuthConfig) StartOAuthFlow(verbose bool) (string, error) { } // buildAuthURL() - constructs the OAuth authorization URL -//c (*OAuthConfig) - OAuth configuration +// c (*OAuthConfig) - OAuth configuration func (c *OAuthConfig) buildAuthURL() string { params := url.Values{} params.Set("client_id", c.ClientID) @@ -126,11 +138,11 @@ func (c *OAuthConfig) buildAuthURL() string { params.Set("scope", strings.Join(c.Scopes, " ")) params.Set("state", c.state) - return authorizationEndpoint + "?" + params.Encode() + return c.AuthEndpoint + "?" + params.Encode() } // callbackHandler() - handles the OAuth callback -/* w (*http.ResponseWriter) - HTTP response writer +/* w (*http.ResponseWriter) - HTTP response writer; r (*http.Request) - HTTP request */ func (c *OAuthConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() @@ -179,7 +191,7 @@ func (c *OAuthConfig) exchangeCodeForToken(code string) (string, error) { data.Set("redirect_uri", c.RedirectURI) data.Set("grant_type", "authorization_code") - req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(data.Encode())) + req, err := http.NewRequest("POST", c.TokenEndpoint, strings.NewReader(data.Encode())) if err != nil { return "", err } @@ -209,12 +221,14 @@ func (c *OAuthConfig) exchangeCodeForToken(code string) (string, error) { return tokenResp.AccessToken, nil } -// openBrowser() - opens the default browser to the specified URL +// openBrowserFunc() - opens the default browser to the specified URL // url (string) - URL to open in the browser -func openBrowser(url string) error { +// OpenBrowserFunc is a variable to allow mocking in tests +var OpenBrowserFunc = func(url string) error { var cmd string var args []string + // open browser based on OS switch runtime.GOOS { case "darwin": cmd = "open" @@ -232,6 +246,12 @@ func openBrowser(url string) error { return exec.Command(cmd, args...).Start() } +// openBrowser() - opens the default browser to the specified URL +// url (string) - URL to open in the browser +func openBrowser(url string) error { + return OpenBrowserFunc(url) +} + // generateRandomState() - generates a random state string for CSRF protection func generateRandomState() string { b := make([]byte, 16) diff --git a/internal/gravatar/oauth_flow_test.go b/internal/gravatar/oauth_flow_test.go new file mode 100644 index 0000000..f40406b --- /dev/null +++ b/internal/gravatar/oauth_flow_test.go @@ -0,0 +1,80 @@ +package gravatar + +import ( + "testing" + "time" +) + +// TestStartOAuthFlow() - test the StartOAuthFlow method +func TestStartOAuthFlow(t *testing.T) { + // mock openBrowser to avoid opening real browser + originalOpenBrowser := OpenBrowserFunc + defer func() { OpenBrowserFunc = originalOpenBrowser }() + + t.Run("Timeout", func(t *testing.T) { + // mock OpenBrowser to do nothing + OpenBrowserFunc = func(url string) error { return nil } + + // set a short timeout for testing + originalTimeout := OAuthTimeout + OAuthTimeout = 100 * time.Millisecond + defer func() { OAuthTimeout = originalTimeout }() + + config := NewOAuthConfig("id", "secret", "uri") + config.LocalServerPort = ":18080" // use different port to avoid conflicts + + _, err := config.StartOAuthFlow(false) + if err == nil { + t.Error("expected timeout error") + } + + if err.Error() != "authorization timeout after 100ms" { + t.Errorf("expected timeout error message, got: %v", err) + } + }) + + // test open browser error + t.Run("OpenBrowser Error", func(t *testing.T) { + // mock OpenBrowser to return an error + OpenBrowserFunc = func(url string) error { + return nil // errors opening browser are handled gracefully + } + + // set a very short timeout + originalTimeout := OAuthTimeout + OAuthTimeout = 100 * time.Millisecond + defer func() { OAuthTimeout = originalTimeout }() + + config := NewOAuthConfig("id", "secret", "uri") + config.LocalServerPort = ":18081" + + _, err := config.StartOAuthFlow(false) + // should still timeout since we don't send a callback + if err == nil { + t.Error("expected timeout error") + } + }) +} + +// TestOpenBrowser() - test the openBrowser method +func TestOpenBrowser(t *testing.T) { + // Test that the mock can be set + originalOpenBrowser := OpenBrowserFunc + defer func() { OpenBrowserFunc = originalOpenBrowser }() + + // test open browser + called := false + OpenBrowserFunc = func(url string) error { + called = true + return nil + } + + err := openBrowser("https://example.com") + if err != nil { + t.Errorf("openBrowser failed: %v", err) + } + + if !called { + t.Error("OpenBrowserFunc was not called") + } +} diff --git a/internal/gravatar/oauth_test.go b/internal/gravatar/oauth_test.go new file mode 100644 index 0000000..1c4f313 --- /dev/null +++ b/internal/gravatar/oauth_test.go @@ -0,0 +1,130 @@ +package gravatar + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// TestBuildAuthURL() - test the buildAuthURL method +func TestBuildAuthURL(t *testing.T) { + config := NewOAuthConfig("client-id", "client-secret", "http://localhost:8080/callback") + config.state = "test-state" + + authURL := config.buildAuthURL() + u, err := url.Parse(authURL) + if err != nil { + t.Fatalf("failed to parse auth URL: %v", err) + } + + q := u.Query() + if q.Get("client_id") != "client-id" { + t.Errorf("expected client_id=client-id, got %s", q.Get("client_id")) + } + + if q.Get("redirect_uri") != "http://localhost:8080/callback" { + t.Errorf("expected redirect_uri=http://localhost:8080/callback, got %s", q.Get("redirect_uri")) + } + + if q.Get("state") != "test-state" { + t.Errorf("expected state=test-state, got %s", q.Get("state")) + } + + if !strings.Contains(q.Get("scope"), "auth") { + t.Error("scope missing 'auth'") + } +} + +// TestExchangeCodeForToken() - test the exchangeCodeForToken method +func TestExchangeCodeForToken(t *testing.T) { + // mock token endpoint + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Fatalf("failed to parse form: %v", err) + } + + if r.Form.Get("code") != "test-code" { + t.Errorf("expected code=test-code, got %s", r.Form.Get("code")) + } + + resp := TokenResponse{ + AccessToken: "access-token-123", + TokenType: "bearer", + BlogID: "123", + BlogURL: "https://example.com", + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // test exchange code for token + config := NewOAuthConfig("client-id", "client-secret", "redirect-uri") + config.TokenEndpoint = server.URL // mock server + + token, err := config.exchangeCodeForToken("test-code") + if err != nil { + t.Fatalf("exchangeCodeForToken failed: %v", err) + } + + if token != "access-token-123" { + t.Errorf("expected token=access-token-123, got %s", token) + } +} + +// TestCallbackHandler() - test the callbackHandler method +func TestCallbackHandler(t *testing.T) { + config := NewOAuthConfig("id", "secret", "uri") + config.state = "valid-state" + + // test success + req, _ := http.NewRequest("GET", "/callback?code=123&state=valid-state", nil) + w := httptest.NewRecorder() + go func() { + code := <-config.codeChan + if code != "123" { + t.Errorf("expected code 123, got %s", code) + } + }() + + config.callbackHandler(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + // test invalid state + req, _ = http.NewRequest("GET", "/callback?code=123&state=invalid", nil) + w = httptest.NewRecorder() + go func() { + err := <-config.errChan + if err == nil { + t.Error("expected error for invalid state") + } + }() + + config.callbackHandler(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", w.Code) + } + + // test error param + req, _ = http.NewRequest("GET", "/callback?error=access_denied", nil) + w = httptest.NewRecorder() + go func() { + err := <-config.errChan + if err == nil { + t.Error("expected error for error param") + } + }() + + config.callbackHandler(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", w.Code) + } +}