package version import ( "bytes" "context" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetCurrentVersion(t *testing.T) { version := GetCurrentVersion() assert.NotEmpty(t, version) assert.Equal(t, CurrentVersion, version) } func TestGetBinaryIdentity(t *testing.T) { oldVersion, oldBuildTime, oldCommit := CurrentVersion, BuildTime, GitCommit t.Cleanup(func() { CurrentVersion, BuildTime, GitCommit = oldVersion, oldBuildTime, oldCommit }) CurrentVersion = "v9.9.9" BuildTime = "2026-08-30T12:00:00Z" GitCommit = "abcdef0" identity := GetBinaryIdentity() assert.Equal(t, ProductID, identity.Product) assert.Equal(t, ComponentID, identity.Component) assert.Equal(t, "9.9.9", identity.Version) assert.Equal(t, runtime.GOOS, identity.GOOS) assert.Equal(t, runtime.GOARCH, identity.GOARCH) assert.Equal(t, BuildTime, identity.BuildTime) assert.Equal(t, GitCommit, identity.GitCommit) } func TestCompareVersions(t *testing.T) { tests := []struct { name string v1 string v2 string want int }{ { name: "v1 less than v2", v1: "0.1.0", v2: "0.2.0", want: -1, }, { name: "v1 greater than v2", v1: "1.0.0", v2: "0.9.0", want: 1, }, { name: "v1 equal to v2", v1: "1.0.0", v2: "1.0.0", want: 0, }, { name: "with v prefix", v1: "v0.1.0", v2: "v0.2.0", want: -1, }, { name: "mixed v prefix", v1: "0.1.0", v2: "v0.2.0", want: -1, }, { name: "different major version", v1: "1.0.0", v2: "2.0.0", want: -1, }, { name: "different minor version", v1: "1.0.0", v2: "1.1.0", want: -1, }, { name: "different patch version", v1: "1.0.0", v2: "1.0.1", want: -1, }, { name: "v1 has more parts", v1: "1.0.0.1", v2: "1.0.0", want: 1, }, { name: "v2 has more parts", v1: "1.0.0", v2: "1.0.0.1", want: -1, }, { name: "empty strings", v1: "", v2: "", want: 0, }, { name: "v1 empty", v1: "", v2: "1.0.0", want: -1, }, { name: "v2 empty", v1: "1.0.0", v2: "", want: 1, }, { name: "prerelease is older than stable", v1: "1.0.0-alpha", v2: "1.0.0", want: -1, }, { name: "prerelease numeric identifiers", v1: "1.0.0-rc.10", v2: "1.0.0-rc.2", want: 1, }, { name: "numeric prerelease sorts before text", v1: "1.0.0-1", v2: "1.0.0-alpha", want: -1, }, { name: "build metadata is ignored", v1: "1.0.0+build.1", v2: "1.0.0+build.2", want: 0, }, { name: "large version numbers", v1: "100.200.300", v2: "99.201.300", want: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := CompareVersions(tt.v1, tt.v2) assert.Equal(t, tt.want, got) }) } } func TestIsVersionUpdateAvailable(t *testing.T) { tests := []struct { name string current string latest string wantUpdate bool }{ { name: "update available", current: "0.1.0", latest: "0.2.0", wantUpdate: true, }, { name: "no update available", current: "1.0.0", latest: "0.9.0", wantUpdate: false, }, { name: "same version", current: "1.0.0", latest: "1.0.0", wantUpdate: false, }, { name: "major version update", current: "1.0.0", latest: "2.0.0", wantUpdate: true, }, { name: "patch update", current: "1.0.0", latest: "1.0.1", wantUpdate: true, }, { name: "with v prefix", current: "v0.1.0", latest: "v0.2.0", wantUpdate: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := IsVersionUpdateAvailable(tt.current, tt.latest) assert.Equal(t, tt.wantUpdate, got) }) } } func TestCheckForUpdate(t *testing.T) { t.Run("check for update", func(t *testing.T) { result := CheckForUpdate() assert.NotNil(t, result) assert.Equal(t, CurrentVersion, result.CurrentVersion) if result.Error != nil { assert.Contains(t, result.Error.Error(), "failed to check for updates") } }) } func TestVersionCheckResult(t *testing.T) { t.Run("create version check result", func(t *testing.T) { result := &VersionCheckResult{ CurrentVersion: "0.1.0", LatestVersion: "0.2.0", MpvVersion: "0.36.0", UpdateAvailable: true, URL: "https://example.com/update", BLAKE3: "blake3:abc123", } assert.Equal(t, "0.1.0", result.CurrentVersion) assert.Equal(t, "0.2.0", result.LatestVersion) assert.Equal(t, "0.36.0", result.MpvVersion) assert.True(t, result.UpdateAvailable) assert.Equal(t, "https://example.com/update", result.URL) assert.Equal(t, "blake3:abc123", result.BLAKE3) assert.Nil(t, result.Error) }) } func TestProgressWriter(t *testing.T) { t.Run("Write updates written field", func(t *testing.T) { var buf bytes.Buffer pw := &progressWriter{ total: 100, underlying: &buf, } n, err := pw.Write([]byte("hello")) assert.NoError(t, err) assert.Equal(t, 5, n) assert.Equal(t, int64(5), pw.written) assert.Equal(t, "hello", buf.String()) }) t.Run("Callback is called when throttle exceeded", func(t *testing.T) { var buf bytes.Buffer callbackCalled := 0 var lastWritten, lastTotal int64 pw := &progressWriter{ total: 100, underlying: &buf, callback: func(written, total int64) { callbackCalled++ lastWritten = written lastTotal = total }, lastUpdate: time.Now().Add(-600 * time.Millisecond), } pw.Write([]byte("test")) assert.Equal(t, 1, callbackCalled) assert.Equal(t, int64(4), lastWritten) assert.Equal(t, int64(100), lastTotal) }) t.Run("Callback is not called when throttle not exceeded", func(t *testing.T) { var buf bytes.Buffer callbackCalled := false pw := &progressWriter{ total: 100, underlying: &buf, callback: func(written, total int64) { callbackCalled = true }, lastUpdate: time.Now(), } pw.Write([]byte("test")) assert.False(t, callbackCalled) }) t.Run("Callback called when download completes", func(t *testing.T) { var buf bytes.Buffer callbackCalled := false pw := &progressWriter{ total: 4, underlying: &buf, callback: func(written, total int64) { callbackCalled = true }, lastUpdate: time.Now(), } pw.Write([]byte("test")) assert.True(t, callbackCalled) }) t.Run("Error propagates from underlying writer", func(t *testing.T) { pw := &progressWriter{ total: 100, underlying: &errorWriter{}, callback: func(written, total int64) {}, } _, err := pw.Write([]byte("test")) assert.Error(t, err) assert.Equal(t, "simulated write error", err.Error()) }) t.Run("callback called even on subsequent write error", func(t *testing.T) { callbackCalled := false pw := &progressWriter{ total: 100, underlying: &errorWriter{}, callback: func(written, total int64) { callbackCalled = true }, lastUpdate: time.Now().Add(-600 * time.Millisecond), } _, err := pw.Write([]byte("test")) assert.Error(t, err) assert.True(t, callbackCalled, "callback should be called with progress before error") }) } type errorWriter struct{} func (e *errorWriter) Write(p []byte) (n int, err error) { return 0, fmt.Errorf("simulated write error") } func TestDownloadFileWithProgress(t *testing.T) { t.Run("context cancellation interrupts an active response", func(t *testing.T) { started := make(chan struct{}) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(started) w.WriteHeader(http.StatusOK) if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } <-r.Context().Done() })) defer server.Close() ctx, cancel := context.WithCancel(context.Background()) result := make(chan error, 1) destination := filepath.Join(t.TempDir(), "cancel.bin") go func() { result <- downloadFileWithProgressContext(ctx, server.URL, destination, nil, 3) }() <-started cancel() select { case err := <-result: assert.True(t, errors.Is(err, context.Canceled), "unexpected cancellation error: %v", err) case <-time.After(2 * time.Second): t.Fatal("active self-update download ignored context cancellation") } }) t.Run("successful download with progress", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("test file content")) })) defer server.Close() tmpDir := t.TempDir() destPath := filepath.Join(tmpDir, "test-file.bin") progressUpdates := []struct{ written, total int64 }{} err := downloadFileWithProgressContext(context.Background(), server.URL, destPath, func(written, total int64) { progressUpdates = append(progressUpdates, struct{ written, total int64 }{written, total}) }, 3) assert.NoError(t, err) assert.FileExists(t, destPath) content, _ := os.ReadFile(destPath) assert.Equal(t, "test file content", string(content)) assert.NotEmpty(t, progressUpdates, "progress callback should be called") }) t.Run("retry on connection error", func(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ if attempts < 3 { w.WriteHeader(http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) w.Write([]byte("success")) })) defer server.Close() tmpDir := t.TempDir() destPath := filepath.Join(tmpDir, "retry-test.bin") err := downloadFileWithProgressContext(context.Background(), server.URL, destPath, nil, 3) assert.NoError(t, err) assert.Equal(t, 3, attempts) content, _ := os.ReadFile(destPath) assert.Equal(t, "success", string(content)) }) t.Run("max retries exceeded", func(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ w.WriteHeader(http.StatusInternalServerError) })) defer server.Close() tmpDir := t.TempDir() destPath := filepath.Join(tmpDir, "fail-test.bin") err := downloadFileWithProgressContext(context.Background(), server.URL, destPath, nil, 3) assert.Error(t, err) assert.Contains(t, err.Error(), "download failed after 3 attempts") assert.Equal(t, 3, attempts) }) t.Run("HTTP 404 error", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) })) defer server.Close() tmpDir := t.TempDir() destPath := filepath.Join(tmpDir, "404-test.bin") err := downloadFileWithProgressContext(context.Background(), server.URL, destPath, nil, 1) assert.Error(t, err) assert.Contains(t, err.Error(), "download failed") }) t.Run("file write error", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("test content")) })) defer server.Close() // Create a temporary directory with a subdirectory tmpDir := t.TempDir() dirPath := filepath.Join(tmpDir, "directory") err := os.Mkdir(dirPath, 0755) require.NoError(t, err) // Try to write to a path that's a directory (will fail) // os.Create() cannot create a file if a directory with that name already exists destPath := dirPath err = downloadFileWithProgressContext(context.Background(), server.URL, destPath, nil, 1) assert.Error(t, err) assert.Contains(t, err.Error(), "is a directory") }) t.Run("exponential backoff delays", func(t *testing.T) { attempts := 0 startTimes := []time.Time{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { startTimes = append(startTimes, time.Now()) attempts++ if attempts < 4 { w.WriteHeader(http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) w.Write([]byte("success")) })) defer server.Close() tmpDir := t.TempDir() destPath := filepath.Join(tmpDir, "backoff-test.bin") _ = downloadFileWithProgressContext(context.Background(), server.URL, destPath, nil, 4) assert.Equal(t, 4, attempts) if len(startTimes) >= 2 { delay1 := startTimes[1].Sub(startTimes[0]) assert.True(t, delay1 >= 900*time.Millisecond && delay1 <= 1100*time.Millisecond, fmt.Sprintf("first delay should be ~1s, got %v", delay1)) } if len(startTimes) >= 3 { delay2 := startTimes[2].Sub(startTimes[1]) assert.True(t, delay2 >= 1900*time.Millisecond && delay2 <= 2100*time.Millisecond, fmt.Sprintf("second delay should be ~2s, got %v", delay2)) } }) t.Run("progress callback frequency", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(strings.Repeat("test", 100))) })) defer server.Close() tmpDir := t.TempDir() destPath := filepath.Join(tmpDir, "progress-test.bin") progressCount := 0 _ = downloadFileWithProgressContext(context.Background(), server.URL, destPath, func(written, total int64) { progressCount++ }, 1) assert.Greater(t, progressCount, 0, "progress callback should be called at least once") }) t.Run("empty download", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() tmpDir := t.TempDir() destPath := filepath.Join(tmpDir, "empty-test.bin") progressUpdates := []struct{ written, total int64 }{} err := downloadFileWithProgressContext(context.Background(), server.URL, destPath, func(written, total int64) { progressUpdates = append(progressUpdates, struct{ written, total int64 }{written, total}) }, 1) assert.NoError(t, err) assert.FileExists(t, destPath) content, _ := os.ReadFile(destPath) assert.Equal(t, 0, len(content)) assert.Empty(t, progressUpdates) }) t.Run("one total deadline covers all retry attempts", func(t *testing.T) { previousTimeout := selfUpdateTotalTimeout selfUpdateTotalTimeout = 50 * time.Millisecond t.Cleanup(func() { selfUpdateTotalTimeout = previousTimeout }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) defer server.Close() started := time.Now() err := downloadFileWithProgressContext(context.Background(), server.URL, filepath.Join(t.TempDir(), "deadline.bin"), nil, 3) require.Error(t, err) assert.ErrorIs(t, err, context.DeadlineExceeded) assert.Less(t, time.Since(started), time.Second) }) }