diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..dc95a84 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: Tests + +on: + push: + branches: [master, main, enhancements] + pull_request: + branches: [master, main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: ['1.22', '1.23'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Download dependencies + run: go mod download + + - name: Build + run: go build -v ./... + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.go-version }} + path: coverage.out diff --git a/example/hello/hello.go b/example/hello/hello.go index 086d859..4ae2c3a 100644 --- a/example/hello/hello.go +++ b/example/hello/hello.go @@ -14,7 +14,11 @@ func main() { fmt.Printf("Hello to hello go version: %s\n", version) u := updater.NewUpdater(version, "ao-data", "go-githubupdate", "update-") - if err := u.BackgroundUpdater(); err != nil { + updated, err := u.BackgroundUpdater() + if err != nil { fmt.Println(err) } + if updated { + fmt.Println("Application was updated, please restart.") + } } diff --git a/go.mod b/go.mod index 22069b8..2214585 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,16 @@ module github.com/ao-data/go-githubupdate -go 1.16 +go 1.22 require ( - github.com/blang/semver v3.5.1+incompatible - github.com/google/go-github v17.0.0+incompatible + github.com/blang/semver/v4 v4.0.0 + github.com/google/go-github/v68 v68.0.0 + github.com/minio/selfupdate v0.6.0 +) + +require ( + aead.dev/minisign v0.2.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect - github.com/kr/binarydist v0.1.0 // indirect - gopkg.in/inconshreveable/go-update.v0 v0.0.0-20150814200126-d8b0b1d421aa + golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b // indirect + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect ) diff --git a/go.sum b/go.sum index ac84721..75f66cf 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,32 @@ -github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= -github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= -github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= +aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk= +aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= -github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v68 v68.0.0 h1:ZW57zeNZiXTdQ16qrDiZ0k6XucrxZ2CGmoTvcCyQG6s= +github.com/google/go-github/v68 v68.0.0/go.mod h1:K9HAUBovM2sLwM408A18h+wd9vqdLOEqTUCbnRIcx68= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= -github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= -github.com/kr/binarydist v0.1.0 h1:6kAoLA9FMMnNGSehX0s1PdjbEaACznAv/W219j2uvyo= -github.com/kr/binarydist v0.1.0/go.mod h1:DY7S//GCoz1BCd0B0EVrinCKAZN3pXe+MDaIZbXQVgM= +github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= +github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/inconshreveable/go-update.v0 v0.0.0-20150814200126-d8b0b1d421aa h1:drvf2JoUL1fz3ttkGNkw+rf3kZa2//7XkYGpSO4NHNA= -gopkg.in/inconshreveable/go-update.v0 v0.0.0-20150814200126-d8b0b1d421aa/go.mod h1:tuNm0ntQ7IH9VSA39XxzLMpee5c2DwgIbjD4x3ydo8Y= diff --git a/updater/githubupdate.go b/updater/githubupdate.go index b44f998..eeae6c1 100644 --- a/updater/githubupdate.go +++ b/updater/githubupdate.go @@ -14,10 +14,9 @@ import ( "io" "runtime" - update "gopkg.in/inconshreveable/go-update.v0" - - "github.com/blang/semver" - "github.com/google/go-github/github" + "github.com/blang/semver/v4" + "github.com/google/go-github/v68/github" + "github.com/minio/selfupdate" ) const ( @@ -27,7 +26,6 @@ const ( var ( ErrorNoBinary = errors.New("No binary for the update found") defaultHTTPRequester = HTTPRequester{} - up = update.New() ) // Updater is the configuration and runtime data for doing an update. @@ -52,21 +50,23 @@ func NewUpdater(currentVersion, githubOwner, githubRepo, filePrefix string) *Upd } // BackgroundUpdater is the all in one update solution for ya. :) -func (u *Updater) BackgroundUpdater() error { +// Returns true if an update was successfully applied, false otherwise. +func (u *Updater) BackgroundUpdater() (bool, error) { available, err := u.CheckUpdateAvailable() if err != nil { - return err + return false, err } if available != "" { fmt.Printf("Version %s available, installing now.\n", available) err := u.Update() if err != nil { - return err + return false, err } + return true, nil } - return nil + return false, nil } // CheckUpdateAvailable fetches the latest releases from github and @@ -96,34 +96,34 @@ func (u *Updater) Update() error { if runtime.GOOS == "windows" { reqFilename = u.FilePrefix + platform + ".exe.gz" } - var foundAsset github.ReleaseAsset + var foundAsset *github.ReleaseAsset for _, asset := range u.latestReleasesResp.Assets { - if *asset.Name == reqFilename { + if asset.GetName() == reqFilename { foundAsset = asset break } } // Not found - if foundAsset.Name == nil { + if foundAsset == nil { return ErrorNoBinary } - dlURL := *foundAsset.BrowserDownloadURL + dlURL := foundAsset.GetBrowserDownloadURL() bin, err := u.fetchGZ(dlURL) if err != nil { return err } - err, errRecover := up.FromStream(bytes.NewReader(bin)) - if errRecover != nil { - return fmt.Errorf("Update and recovery errors: %q %q", err, errRecover) - } + err = selfupdate.Apply(bytes.NewReader(bin), selfupdate.Options{}) if err != nil { - return err + if rerr := selfupdate.RollbackError(err); rerr != nil { + return fmt.Errorf("update failed and rollback also failed: %v", rerr) + } + return fmt.Errorf("update failed: %v", err) } - fmt.Println("Update installed, please restart the program.") + fmt.Println("Update installed successfully.") return nil } diff --git a/updater/githubupdate_test.go b/updater/githubupdate_test.go new file mode 100644 index 0000000..8e4710a --- /dev/null +++ b/updater/githubupdate_test.go @@ -0,0 +1,329 @@ +package updater + +import ( + "bytes" + "compress/gzip" + "errors" + "io" + "runtime" + "strings" + "testing" + + "github.com/blang/semver/v4" + "github.com/google/go-github/v68/github" +) + +func TestNewUpdater(t *testing.T) { + u := NewUpdater("1.0.0", "ao-data", "go-githubupdate", "update-") + + if u.CurrentVersion != "1.0.0" { + t.Errorf("expected CurrentVersion to be '1.0.0', got '%s'", u.CurrentVersion) + } + if u.GithubOwner != "ao-data" { + t.Errorf("expected GithubOwner to be 'ao-data', got '%s'", u.GithubOwner) + } + if u.GithubRepo != "go-githubupdate" { + t.Errorf("expected GithubRepo to be 'go-githubupdate', got '%s'", u.GithubRepo) + } + if u.FilePrefix != "update-" { + t.Errorf("expected FilePrefix to be 'update-', got '%s'", u.FilePrefix) + } + if u.Requester != nil { + t.Error("expected Requester to be nil by default") + } +} + +func TestPlatformConstant(t *testing.T) { + expected := runtime.GOOS + "-" + runtime.GOARCH + if platform != expected { + t.Errorf("expected platform to be '%s', got '%s'", expected, platform) + } +} + +func TestVersionComparison(t *testing.T) { + tests := []struct { + name string + currentVersion string + latestVersion string + expectUpdate bool + }{ + { + name: "update available - major version", + currentVersion: "1.0.0", + latestVersion: "2.0.0", + expectUpdate: true, + }, + { + name: "update available - minor version", + currentVersion: "1.0.0", + latestVersion: "1.1.0", + expectUpdate: true, + }, + { + name: "update available - patch version", + currentVersion: "1.0.0", + latestVersion: "1.0.1", + expectUpdate: true, + }, + { + name: "no update - same version", + currentVersion: "1.0.0", + latestVersion: "1.0.0", + expectUpdate: false, + }, + { + name: "no update - current is newer", + currentVersion: "2.0.0", + latestVersion: "1.0.0", + expectUpdate: false, + }, + { + name: "update available - prerelease to release", + currentVersion: "1.0.0-alpha", + latestVersion: "1.0.0", + expectUpdate: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + current, err := semver.Make(tt.currentVersion) + if err != nil { + t.Fatalf("failed to parse current version: %v", err) + } + + latest, err := semver.Make(tt.latestVersion) + if err != nil { + t.Fatalf("failed to parse latest version: %v", err) + } + + needsUpdate := current.LT(latest) + if needsUpdate != tt.expectUpdate { + t.Errorf("expected update=%v, got update=%v", tt.expectUpdate, needsUpdate) + } + }) + } +} + +func TestUpdateErrorNoBinary(t *testing.T) { + u := NewUpdater("1.0.0", "ao-data", "go-githubupdate", "update-") + + // Set up a mock release with no matching assets + tagName := "2.0.0" + wrongAssetName := "wrong-file.gz" + u.latestReleasesResp = &github.RepositoryRelease{ + TagName: &tagName, + Assets: []*github.ReleaseAsset{ + {Name: &wrongAssetName}, + }, + } + + err := u.Update() + if err == nil { + t.Fatal("expected error when no matching binary found") + } + if !errors.Is(err, ErrorNoBinary) { + t.Errorf("expected ErrorNoBinary, got: %v", err) + } +} + +func TestUpdateFindsCorrectAsset(t *testing.T) { + u := NewUpdater("1.0.0", "ao-data", "go-githubupdate", "myapp-") + + // Create the expected filename based on current platform + expectedFilename := "myapp-" + platform + ".gz" + if runtime.GOOS == "windows" { + expectedFilename = "myapp-" + platform + ".exe.gz" + } + + tagName := "2.0.0" + wrongAssetName := "wrong-file.gz" + downloadURL := "https://example.com/download/" + expectedFilename + + u.latestReleasesResp = &github.RepositoryRelease{ + TagName: &tagName, + Assets: []*github.ReleaseAsset{ + {Name: &wrongAssetName}, + {Name: &expectedFilename, BrowserDownloadURL: &downloadURL}, + }, + } + + // Set up mock requester to return a gzipped binary + mock := &mockRequester{} + mock.handleRequest(func(url string) (io.ReadCloser, error) { + if url != downloadURL { + t.Errorf("expected download URL '%s', got '%s'", downloadURL, url) + } + // Return a gzipped "binary" + return createGzipReader([]byte("fake binary content")), nil + }) + u.Requester = mock + + // Note: This will fail at selfupdate.Apply() since we're not actually + // replacing a real binary, but it proves the asset matching works + err := u.Update() + if err == nil { + t.Log("Update succeeded (unexpected in test environment)") + } else if !strings.Contains(err.Error(), "update failed") { + // If the error is about something other than the update itself failing, + // that's unexpected + if errors.Is(err, ErrorNoBinary) { + t.Error("should have found the correct asset") + } + } +} + +func TestFetchGZ(t *testing.T) { + u := NewUpdater("1.0.0", "ao-data", "go-githubupdate", "update-") + + originalContent := []byte("test binary content 12345") + + mock := &mockRequester{} + mock.handleRequest(func(url string) (io.ReadCloser, error) { + return createGzipReader(originalContent), nil + }) + u.Requester = mock + + result, err := u.fetchGZ("https://example.com/test.gz") + if err != nil { + t.Fatalf("fetchGZ failed: %v", err) + } + + if !bytes.Equal(result, originalContent) { + t.Errorf("expected '%s', got '%s'", string(originalContent), string(result)) + } +} + +func TestFetchGZInvalidGzip(t *testing.T) { + u := NewUpdater("1.0.0", "ao-data", "go-githubupdate", "update-") + + mock := &mockRequester{} + mock.handleRequest(func(url string) (io.ReadCloser, error) { + // Return non-gzipped content + return io.NopCloser(strings.NewReader("not gzipped content")), nil + }) + u.Requester = mock + + _, err := u.fetchGZ("https://example.com/test.gz") + if err == nil { + t.Fatal("expected error for invalid gzip content") + } +} + +func TestFetchGZNetworkError(t *testing.T) { + u := NewUpdater("1.0.0", "ao-data", "go-githubupdate", "update-") + + mock := &mockRequester{} + mock.handleRequest(func(url string) (io.ReadCloser, error) { + return nil, errors.New("network error") + }) + u.Requester = mock + + _, err := u.fetchGZ("https://example.com/test.gz") + if err == nil { + t.Fatal("expected error for network failure") + } + if !strings.Contains(err.Error(), "network error") { + t.Errorf("expected network error, got: %v", err) + } +} + +func TestFetchWithNilRequester(t *testing.T) { + u := NewUpdater("1.0.0", "ao-data", "go-githubupdate", "update-") + // Requester is nil, should use defaultHTTPRequester + + // This will make an actual HTTP request and fail, but proves the code path works + _, err := u.fetch("http://nonexistent.invalid/") + if err == nil { + t.Fatal("expected error for invalid URL") + } +} + +func TestFetchWithNilReadCloser(t *testing.T) { + u := NewUpdater("1.0.0", "ao-data", "go-githubupdate", "update-") + + mock := &mockRequester{} + mock.handleRequest(func(url string) (io.ReadCloser, error) { + return nil, nil // Return nil ReadCloser without error + }) + u.Requester = mock + + _, err := u.fetch("https://example.com/test") + if err == nil { + t.Fatal("expected error for nil ReadCloser") + } + if !strings.Contains(err.Error(), "non-nil ReadCloser") { + t.Errorf("expected non-nil ReadCloser error, got: %v", err) + } +} + +func TestMockRequester(t *testing.T) { + mock := &mockRequester{} + + // Add two handlers + mock.handleRequest(func(url string) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("response 1")), nil + }) + mock.handleRequest(func(url string) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("response 2")), nil + }) + + // First fetch + r1, err := mock.Fetch("url1") + if err != nil { + t.Fatalf("first fetch failed: %v", err) + } + content1, _ := io.ReadAll(r1) + if string(content1) != "response 1" { + t.Errorf("expected 'response 1', got '%s'", string(content1)) + } + + // Second fetch + r2, err := mock.Fetch("url2") + if err != nil { + t.Fatalf("second fetch failed: %v", err) + } + content2, _ := io.ReadAll(r2) + if string(content2) != "response 2" { + t.Errorf("expected 'response 2', got '%s'", string(content2)) + } + + // Third fetch should fail (no more handlers) + _, err = mock.Fetch("url3") + if err == nil { + t.Fatal("expected error when no more handlers available") + } +} + +func TestExpectedFilename(t *testing.T) { + prefix := "myapp-" + expectedGz := prefix + platform + ".gz" + expectedExeGz := prefix + platform + ".exe.gz" + + // Verify the filename construction logic + var reqFilename string + if runtime.GOOS == "windows" { + reqFilename = expectedExeGz + } else { + reqFilename = expectedGz + } + + if runtime.GOOS == "windows" { + if reqFilename != expectedExeGz { + t.Errorf("on windows, expected '%s', got '%s'", expectedExeGz, reqFilename) + } + } else { + if reqFilename != expectedGz { + t.Errorf("on non-windows, expected '%s', got '%s'", expectedGz, reqFilename) + } + } +} + +// Helper function to create a gzipped reader from content +func createGzipReader(content []byte) io.ReadCloser { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + gz.Write(content) + gz.Close() + return io.NopCloser(bytes.NewReader(buf.Bytes())) +} diff --git a/updater/requester_test.go b/updater/requester_test.go new file mode 100644 index 0000000..6e6ccbb --- /dev/null +++ b/updater/requester_test.go @@ -0,0 +1,135 @@ +package updater + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHTTPRequesterFetchSuccess(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("test response body")) + })) + defer server.Close() + + requester := HTTPRequester{} + reader, err := requester.Fetch(server.URL) + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + defer reader.Close() + + body, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("failed to read response: %v", err) + } + + if string(body) != "test response body" { + t.Errorf("expected 'test response body', got '%s'", string(body)) + } +} + +func TestHTTPRequesterFetchNon200(t *testing.T) { + statusCodes := []int{ + http.StatusNotFound, + http.StatusInternalServerError, + http.StatusForbidden, + http.StatusUnauthorized, + } + + for _, statusCode := range statusCodes { + t.Run(http.StatusText(statusCode), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(statusCode) + })) + defer server.Close() + + requester := HTTPRequester{} + _, err := requester.Fetch(server.URL) + if err == nil { + t.Fatalf("expected error for status %d", statusCode) + } + + if !strings.Contains(err.Error(), "bad http status") { + t.Errorf("expected 'bad http status' error, got: %v", err) + } + }) + } +} + +func TestHTTPRequesterFetchInvalidURL(t *testing.T) { + requester := HTTPRequester{} + _, err := requester.Fetch("http://nonexistent.invalid/") + if err == nil { + t.Fatal("expected error for invalid URL") + } +} + +func TestHTTPRequesterFetchMalformedURL(t *testing.T) { + requester := HTTPRequester{} + _, err := requester.Fetch("not-a-valid-url") + if err == nil { + t.Fatal("expected error for malformed URL") + } +} + +func TestRequesterInterface(t *testing.T) { + // Verify HTTPRequester implements Requester interface + var _ Requester = &HTTPRequester{} + var _ Requester = &mockRequester{} +} + +func TestHTTPRequesterFetchLargeResponse(t *testing.T) { + // Test handling of larger responses + largeContent := strings.Repeat("x", 1024*1024) // 1MB + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(largeContent)) + })) + defer server.Close() + + requester := HTTPRequester{} + reader, err := requester.Fetch(server.URL) + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + defer reader.Close() + + body, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("failed to read response: %v", err) + } + + if len(body) != len(largeContent) { + t.Errorf("expected %d bytes, got %d bytes", len(largeContent), len(body)) + } +} + +func TestHTTPRequesterFetchEmptyResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + // Write nothing + })) + defer server.Close() + + requester := HTTPRequester{} + reader, err := requester.Fetch(server.URL) + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + defer reader.Close() + + body, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("failed to read response: %v", err) + } + + if len(body) != 0 { + t.Errorf("expected empty response, got %d bytes", len(body)) + } +}