package installer import ( "bytes" "context" "errors" "io" "net/http" "os" "path/filepath" "strings" "testing" "gitgud.io/mike/mpv-manager/internal/assets" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // serveBytes configures the mock HTTP client to respond with status and body. func serveBytes(mc *MockHTTPClient, status int, body []byte) { mc.DoFunc = func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: status, Body: io.NopCloser(bytes.NewReader(body)), ContentLength: int64(len(body)), }, nil } } func TestNewInstaller_DefaultsAndOptions(t *testing.T) { inst := NewInstaller(ReleaseInfo{}, "/tmp/install") assert.NotNil(t, inst.fs, "default FileSystem should be set") assert.NotNil(t, inst.downloader, "default Downloader should be set") assert.NotNil(t, inst.httpClient, "default HTTPClient should be set") assert.Nil(t, inst.executor, "executor defaults to nil (CommandRunner/exec fallback)") mfs := NewMockFileSystem() md := NewMockDownloader() mc := NewMockHTTPClient() me := NewMockCommandExecutor() custom := NewInstaller(ReleaseInfo{}, "/tmp/install", WithFileSystem(mfs), WithDownloader(md), WithHTTPClient(mc), WithExecutor(me), ) assert.True(t, custom.fs == mfs) assert.True(t, custom.downloader == md) assert.True(t, custom.httpClient == mc) assert.True(t, custom.executor == me) // A zero-value Installer (as constructed by older tests) must still work. zero := &Installer{} assert.False(t, zero.FileExists(filepath.Join(t.TempDir(), "nope"))) } func TestCloneForOperationSnapshotsMutableInputs(t *testing.T) { base := NewInstaller(ReleaseInfo{Version: "old"}, "/original") clone := base.CloneForOperation(ReleaseInfo{Version: "operation"}, "/operation") base.ReleaseInfo.Version = "new" base.SetInstallDir("/changed") assert.Equal(t, "operation", clone.ReleaseInfo.Version) assert.Equal(t, "/operation", clone.InstallDir) } func TestOSFileSystem(t *testing.T) { fs := OSFileSystem{} dir := t.TempDir() sub := filepath.Join(dir, "a", "b") require.NoError(t, fs.MkdirAll(sub, 0755)) assert.True(t, fs.IsDir(sub)) assert.False(t, fs.IsDir(filepath.Join(sub, "nope"))) file := filepath.Join(sub, "f.txt") require.NoError(t, fs.WriteFile(file, []byte("hi"), 0644)) assert.True(t, fs.Exists(file)) data, err := fs.ReadFile(file) require.NoError(t, err) assert.Equal(t, "hi", string(data)) info, err := fs.Stat(file) require.NoError(t, err) assert.False(t, info.IsDir()) assert.Equal(t, "f.txt", info.Name()) copied := filepath.Join(dir, "copied.txt") require.NoError(t, fs.CopyFile(file, copied, 0644)) data, err = fs.ReadFile(copied) require.NoError(t, err) assert.Equal(t, "hi", string(data)) renamed := filepath.Join(dir, "renamed.txt") require.NoError(t, fs.Rename(copied, renamed)) assert.True(t, fs.Exists(renamed)) assert.False(t, fs.Exists(copied)) tmpDir, err := fs.MkdirTemp("", "osfs") require.NoError(t, err) defer fs.RemoveAll(tmpDir) assert.True(t, fs.IsDir(tmpDir)) matches, err := fs.Glob(filepath.Join(sub, "*.txt")) require.NoError(t, err) assert.Equal(t, []string{file}, matches) var walked []string require.NoError(t, fs.Walk(dir, func(path string, info os.FileInfo, err error) error { if err == nil && !info.IsDir() { walked = append(walked, path) } return nil })) assert.Len(t, walked, 2) copyDst := filepath.Join(dir, "copy") require.NoError(t, fs.CopyDir(sub, copyDst)) assert.True(t, fs.Exists(filepath.Join(copyDst, "f.txt"))) require.NoError(t, fs.Remove(renamed)) assert.False(t, fs.Exists(renamed)) require.NoError(t, fs.RemoveAll(sub)) assert.False(t, fs.Exists(file)) } func TestHTTPDownloader_DownloadFile(t *testing.T) { body := []byte("downloaded-bytes") t.Run("success with progress", func(t *testing.T) { mc := NewMockHTTPClient() serveBytes(mc, http.StatusOK, body) d := NewHTTPDownloader(mc) dest := filepath.Join(t.TempDir(), "nested", "file.bin") var gotWritten, gotTotal int64 err := d.DownloadFile(context.Background(), "http://example.com/file.bin", dest, func(written, total int64) { gotWritten, gotTotal = written, total }) require.NoError(t, err) data, err := os.ReadFile(dest) require.NoError(t, err) assert.Equal(t, body, data) assert.Equal(t, int64(len(body)), gotWritten) assert.Equal(t, int64(len(body)), gotTotal) requests := mc.GetRequests() require.Len(t, requests, 1) assert.Equal(t, "http://example.com/file.bin", requests[0].URL.String()) }) t.Run("success without progress", func(t *testing.T) { mc := NewMockHTTPClient() serveBytes(mc, http.StatusOK, body) d := NewHTTPDownloader(mc) dest := filepath.Join(t.TempDir(), "file.bin") require.NoError(t, d.DownloadFile(context.Background(), "http://example.com/file.bin", dest, nil)) data, err := os.ReadFile(dest) require.NoError(t, err) assert.Equal(t, body, data) }) t.Run("http error status", func(t *testing.T) { mc := NewMockHTTPClient() serveBytes(mc, http.StatusInternalServerError, nil) d := NewHTTPDownloader(mc) dest := filepath.Join(t.TempDir(), "file.bin") err := d.DownloadFile(context.Background(), "http://example.com/file.bin", dest, nil) require.Error(t, err) assert.Contains(t, err.Error(), "download failed: HTTP 500") }) t.Run("request failure", func(t *testing.T) { mc := NewMockHTTPClient() mc.DoFunc = func(req *http.Request) (*http.Response, error) { return nil, errors.New("connection refused") } d := NewHTTPDownloader(mc) dest := filepath.Join(t.TempDir(), "file.bin") err := d.DownloadFile(context.Background(), "http://example.com/file.bin", dest, nil) require.Error(t, err) assert.Contains(t, err.Error(), "failed to download: connection refused") }) t.Run("cancelled context cleans up", func(t *testing.T) { mc := NewMockHTTPClient() serveBytes(mc, http.StatusOK, body) d := NewHTTPDownloader(mc) ctx, cancel := context.WithCancel(context.Background()) cancel() dest := filepath.Join(t.TempDir(), "file.bin") err := d.DownloadFile(ctx, "http://example.com/file.bin", dest, nil) require.Error(t, err) assert.Contains(t, err.Error(), "download cancelled") _, statErr := os.Stat(dest) assert.True(t, os.IsNotExist(statErr), "partial file should be removed on cancellation") }) } func TestHTTPDownloaderRejectsOversizedArtifactBeforeWriting(t *testing.T) { mc := NewMockHTTPClient() mc.DoFunc = func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(nil)), ContentLength: maxArtifactDownloadBytes + 1, }, nil } destination := filepath.Join(t.TempDir(), "too-large.bin") err := NewHTTPDownloader(mc).DownloadFile(context.Background(), "https://example.invalid/large", destination, nil) require.Error(t, err) assert.Contains(t, err.Error(), "too large") assert.NoFileExists(t, destination) } func TestArtifactCopyErrorRejectsUnknownLengthOverflow(t *testing.T) { err := artifactCopyError(maxArtifactDownloadBytes+1, nil) require.Error(t, err) assert.Contains(t, err.Error(), "exceeded") } func TestDownloadFileWithContext_DelegatesToDownloader(t *testing.T) { md := NewMockDownloader() md.Func = func(ctx context.Context, url, dest string, progress func(int64, int64)) error { return errors.New("marker error") } inst := NewInstaller(ReleaseInfo{}, "", WithDownloader(md)) err := inst.DownloadFileWithContext(context.Background(), "http://example.com/x", "/tmp/dest/x", nil) require.Error(t, err) assert.Equal(t, "marker error", err.Error()) require.Len(t, md.Downloads, 1) assert.Equal(t, "http://example.com/x", md.Downloads[0].URL) assert.Equal(t, "/tmp/dest/x", md.Downloads[0].Dest) } func TestDownloadFileWithProgressToChannel_UsesInjectedHTTPClient(t *testing.T) { body := []byte("hello world") t.Run("success", func(t *testing.T) { mc := NewMockHTTPClient() serveBytes(mc, http.StatusOK, body) inst := NewInstaller(ReleaseInfo{}, "", WithHTTPClient(mc)) out := make(chan string, 100) cr := NewCommandRunner(out, nil) dest := filepath.Join(t.TempDir(), "f.bin") require.NoError(t, inst.DownloadFileWithProgressToChannel(cr, "http://example.com/f.bin", dest)) data, err := os.ReadFile(dest) require.NoError(t, err) assert.Equal(t, body, data) outputs := drainOutput(out) assert.Contains(t, outputs, "Downloading: http://example.com/f.bin") assert.Contains(t, outputs, "Download complete: f.bin") }) t.Run("http error", func(t *testing.T) { mc := NewMockHTTPClient() serveBytes(mc, http.StatusNotFound, nil) inst := NewInstaller(ReleaseInfo{}, "", WithHTTPClient(mc)) out := make(chan string, 100) cr := NewCommandRunner(out, nil) dest := filepath.Join(t.TempDir(), "f.bin") err := inst.DownloadFileWithProgressToChannel(cr, "http://example.com/f.bin", dest) require.Error(t, err) assert.Contains(t, err.Error(), "download failed: HTTP 404") }) } func TestDownloadAndExtract_WithMocks(t *testing.T) { zipBytes := makeZipBytes(t, map[string][]byte{"bin/mpv": []byte("mpv")}) goodHash := blake3HashOf(zipBytes) const url = "http://example.com/mpv.zip" setup := func(t *testing.T) (*Installer, *MockHTTPClient, *MockCommandExecutor, *CommandRunner, chan string, string) { dir := t.TempDir() mc := NewMockHTTPClient() me := NewMockCommandExecutor() out := make(chan string, 100) cr := NewCommandRunner(out, nil) inst := NewInstaller(ReleaseInfo{}, dir, WithHTTPClient(mc), WithExecutor(me)) return inst, mc, me, cr, out, dir } t.Run("success downloads verifies and extracts", func(t *testing.T) { inst, mc, me, cr, out, dir := setup(t) serveBytes(mc, http.StatusOK, zipBytes) err := DownloadAndExtract(inst, cr, url, dir, "mpv.zip", goodHash) require.NoError(t, err) // The archive was downloaded verbatim via the injected HTTP client. data, err := os.ReadFile(filepath.Join(dir, "mpv.zip")) require.NoError(t, err) assert.Equal(t, zipBytes, data) assert.Empty(t, me.GetCommands(), "archive extraction must not invoke a platform command") assert.FileExists(t, filepath.Join(dir, "bin", "mpv")) outputs := drainOutput(out) assert.Contains(t, outputs, "Extracted to: "+dir) }) t.Run("checksum mismatch aborts before extraction", func(t *testing.T) { inst, mc, me, cr, _, dir := setup(t) serveBytes(mc, http.StatusOK, zipBytes) wrongHash := "blake3:" + strings.Repeat("0", 64) err := DownloadAndExtract(inst, cr, url, dir, "mpv.zip", wrongHash) require.Error(t, err) assert.Contains(t, err.Error(), "checksum verification failed") assert.Empty(t, me.GetCommands(), "extraction must not run after a checksum failure") }) t.Run("download failure aborts before extraction", func(t *testing.T) { inst, mc, me, cr, _, dir := setup(t) serveBytes(mc, http.StatusServiceUnavailable, nil) err := DownloadAndExtract(inst, cr, url, dir, "mpv.zip", goodHash) require.Error(t, err) assert.Contains(t, err.Error(), "download failed") assert.Contains(t, err.Error(), "HTTP 503") assert.Empty(t, me.GetCommands()) }) t.Run("extraction failure propagates", func(t *testing.T) { inst, mc, me, cr, out, dir := setup(t) malformed := []byte("not a ZIP archive") serveBytes(mc, http.StatusOK, malformed) err := DownloadAndExtract(inst, cr, url, dir, "mpv.zip", blake3HashOf(malformed)) require.Error(t, err) assert.Contains(t, err.Error(), "extraction failed") assert.Contains(t, err.Error(), "not a valid zip file") assert.Empty(t, me.GetCommands()) outputs := drainOutput(out) assertOutputContains(t, outputs, "Error: Extraction failed: open ZIP archive") }) } func TestCopyFileWithOutput_MockFileSystem(t *testing.T) { newRunner := func() (*CommandRunner, chan string) { out := make(chan string, 10) return NewCommandRunner(out, nil), out } t.Run("success copies content", func(t *testing.T) { mfs := NewMockFileSystem() mfs.Files["/src/a.bin"] = []byte("payload") inst := NewInstaller(ReleaseInfo{}, "", WithFileSystem(mfs)) cr, out := newRunner() require.NoError(t, inst.CopyFileWithOutput(cr, "/src/a.bin", "/dst/a.bin")) assert.Equal(t, "payload", string(mfs.Files["/dst/a.bin"])) outputs := drainOutput(out) assert.Contains(t, outputs, "Copying: /src/a.bin -> /dst/a.bin") assert.Contains(t, outputs, "Copied successfully") }) t.Run("read error propagates and is reported", func(t *testing.T) { mfs := NewMockFileSystem() mfs.ReadFileFunc = func(path string) ([]byte, error) { return nil, errors.New("read boom") } inst := NewInstaller(ReleaseInfo{}, "", WithFileSystem(mfs)) cr, out := newRunner() err := inst.CopyFileWithOutput(cr, "/src/a.bin", "/dst/a.bin") require.Error(t, err) assert.Equal(t, "read boom", err.Error()) assert.Contains(t, drainOutput(out), "Error: Failed to read source: read boom") }) t.Run("write error propagates and is reported", func(t *testing.T) { mfs := NewMockFileSystem() mfs.Files["/src/a.bin"] = []byte("payload") mfs.WriteFileFunc = func(path string, data []byte, perm os.FileMode) error { return errors.New("write boom") } inst := NewInstaller(ReleaseInfo{}, "", WithFileSystem(mfs)) cr, out := newRunner() err := inst.CopyFileWithOutput(cr, "/src/a.bin", "/dst/a.bin") require.Error(t, err) assert.Equal(t, "write boom", err.Error()) assert.Contains(t, drainOutput(out), "Error: Failed to write destination: write boom") }) } func TestCopyDirWithOutput_MockFileSystem(t *testing.T) { root := t.TempDir() src := filepath.Join(root, "src") dst := filepath.Join(root, "dst") t.Run("success copies all files", func(t *testing.T) { mfs := NewMockFileSystem() mfs.Files[filepath.Join(src, "f1.txt")] = []byte("one") mfs.Files[filepath.Join(src, "sub", "f2.txt")] = []byte("two") inst := NewInstaller(ReleaseInfo{}, "", WithFileSystem(mfs)) out := make(chan string, 10) cr := NewCommandRunner(out, nil) require.NoError(t, inst.CopyDirWithOutput(cr, src, dst)) assert.Equal(t, "one", string(mfs.Files[filepath.Join(dst, "f1.txt")])) assert.Equal(t, "two", string(mfs.Files[filepath.Join(dst, "sub", "f2.txt")])) assert.Contains(t, drainOutput(out), "Copied 2 files successfully") }) t.Run("read error aborts the copy", func(t *testing.T) { mfs := NewMockFileSystem() mfs.Files[filepath.Join(src, "f1.txt")] = []byte("one") mfs.ReadFileFunc = func(path string) ([]byte, error) { return nil, errors.New("read boom") } inst := NewInstaller(ReleaseInfo{}, "", WithFileSystem(mfs)) out := make(chan string, 10) cr := NewCommandRunner(out, nil) err := inst.CopyDirWithOutput(cr, src, dst) require.Error(t, err) assert.Equal(t, "read boom", err.Error()) assert.NotContains(t, drainOutput(out), "Copied 1 files successfully") }) } func TestExtractArchiveWithOutputUsesNativeBoundary(t *testing.T) { dest := filepath.Join(t.TempDir(), "out") src := writeArchiveFixture(t, "a.zip", makeZipBytes(t, map[string][]byte{"a.txt": []byte("payload")})) me := NewMockCommandExecutor() inst := NewInstaller(ReleaseInfo{}, "", WithExecutor(me)) out := make(chan string, 10) cr := NewCommandRunner(out, nil) require.NoError(t, inst.ExtractArchiveWithOutput(cr, src, dest)) assert.Empty(t, me.GetCommands()) assert.FileExists(t, filepath.Join(dest, "a.txt")) outputs := drainOutput(out) assert.Contains(t, outputs, "Extracting: "+src) assert.Contains(t, outputs, "Extracted to: "+dest) t.Run("unsupported format runs no command", func(t *testing.T) { me := NewMockCommandExecutor() inst := NewInstaller(ReleaseInfo{}, "", WithExecutor(me)) out := make(chan string, 10) cr := NewCommandRunner(out, nil) err := inst.ExtractArchiveWithOutput(cr, "/tmp/a.rar", dest) require.Error(t, err) assert.Contains(t, err.Error(), "unsupported archive format: .rar") assert.Empty(t, me.GetCommands()) }) t.Run("malformed archive fails before live writes", func(t *testing.T) { me := NewMockCommandExecutor() inst := NewInstaller(ReleaseInfo{}, "", WithExecutor(me)) out := make(chan string, 10) cr := NewCommandRunner(out, nil) bad := writeArchiveFixture(t, "bad.tar.gz", []byte("bad")) err := inst.ExtractArchiveWithOutput(cr, bad, dest) require.Error(t, err) assert.Empty(t, me.GetCommands()) assertOutputContains(t, drainOutput(out), "Error: Extraction failed:") }) } func TestInstallerFileHelpers_MockFileSystem(t *testing.T) { root := t.TempDir() xDir := filepath.Join(root, "x", "y") xFile := filepath.Join(root, "x", "f.txt") zDir := filepath.Join(root, "z") configDir := filepath.Join(root, "cfg") configFile := filepath.Join(configDir, "mpv.conf") mfs := NewMockFileSystem() inst := NewInstaller(ReleaseInfo{}, "", WithFileSystem(mfs)) require.NoError(t, inst.EnsureDir(xDir)) assert.True(t, mfs.IsDir(xDir)) assert.False(t, inst.FileExists(xFile)) mfs.Files[xFile] = []byte("data") assert.True(t, inst.FileExists(xFile)) out := make(chan string, 10) cr := NewCommandRunner(out, nil) require.NoError(t, inst.EnsureDirWithOutput(cr, zDir)) assert.True(t, mfs.IsDir(zDir)) assert.Contains(t, drainOutput(out), "Created directory: "+zDir) require.NoError(t, inst.CopyAsset(assets.MPVConfig, "mpv.conf", configFile)) assert.NotEmpty(t, mfs.Files[configFile]) assert.True(t, mfs.IsDir(configDir)) }