diff --git a/internal/protocol/http/fetcher_test.go b/internal/protocol/http/fetcher_test.go index c709c46e..cf6575cd 100644 --- a/internal/protocol/http/fetcher_test.go +++ b/internal/protocol/http/fetcher_test.go @@ -114,45 +114,58 @@ func TestFetcher_Resolve(t *testing.T) { }, }, t) + // Test URL without file path - should use domain/host as filename + listener := test.StartTestRootServer() + defer listener.Close() fetcher := buildFetcher() err := fetcher.Resolve(&base.Request{ - URL: "http://github.com", + URL: "http://" + listener.Addr().String() + "/", }) if err != nil { t.Fatal(err) } - if fetcher.Meta().Res.Files[0].Name != "github.com" { - t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, "github.com") + // When no filename is provided, it should use the hostname (without port) as the name + expectedName := "127.0.0.1" + if fetcher.Meta().Res.Files[0].Name != expectedName { + t.Errorf("Resolve() got name = %v, want %v", fetcher.Meta().Res.Files[0].Name, expectedName) } } func TestFetcher_ResolveWithHostHeader(t *testing.T) { + listener := test.StartTestHostHeaderServer() + defer listener.Close() + fetcher := buildFetcher() err := fetcher.Resolve(&base.Request{ - URL: "https://bing.com", + URL: "http://" + listener.Addr().String() + "/", Extra: &http.ReqExtra{ Header: map[string]string{ "Host": "test", }, }, }) + // The server should return 400 for invalid Host header if err == nil || !strings.Contains(err.Error(), "400") { - t.Errorf("Resolve() got = %v, want %v", err, "400") + t.Errorf("Resolve() got = %v, want error containing 400", err) } } func TestFetcher_ResolveWithInvalidHeader(t *testing.T) { + listener := test.StartTestRootServer() + defer listener.Close() + fetcher := buildFetcher() err := fetcher.Resolve(&base.Request{ - URL: "https://bing.com", + URL: "http://" + listener.Addr().String() + "/", Extra: &http.ReqExtra{ Header: map[string]string{ "Referer": "\rtest", }, }, }) + // Invalid header with \r should be sanitized by Go's http client, allowing the request to succeed if err != nil { - t.Errorf("Resolve() got = %v, want nil", err) + t.Errorf("Resolve() got = %v, want nil (invalid headers should be sanitized)", err) } } diff --git a/internal/test/httptest.go b/internal/test/httptest.go index 36467851..4c5c90d8 100644 --- a/internal/test/httptest.go +++ b/internal/test/httptest.go @@ -173,6 +173,39 @@ func StartTestCustomServer() net.Listener { }) } +// StartTestHostHeaderServer starts a server that validates the Host header +// Returns 400 Bad Request if the Host header value equals "test" +func StartTestHostHeaderServer() net.Listener { + return startTestServer(func(sl *shutdownListener) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { + // If the Host header is "test", return 400 (simulating server that validates Host) + if request.Host == "test" { + writer.WriteHeader(400) + writer.Write([]byte("Bad Request: Invalid Host header")) + return + } + writer.WriteHeader(200) + writer.Write([]byte("OK")) + }) + return mux + }) +} + +// StartTestRootServer starts a simple server at the root path +// Used to test URL resolution when no filename is provided +func StartTestRootServer() net.Listener { + return startTestServer(func(sl *shutdownListener) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/html") + writer.WriteHeader(200) + writer.Write([]byte("
Test Page")) + }) + return mux + }) +} + func StartTestRetryServer() net.Listener { counter := 0 return startTestServer(func(sl *shutdownListener) http.Handler { diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index cba7ee1b..41e345b9 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -45,6 +45,20 @@ var ( type Listener func(event *Event) +// ExtractStatus represents the current status of archive extraction +type ExtractStatus string + +const ( + // ExtractStatusNone indicates extraction has not started + ExtractStatusNone ExtractStatus = "" + // ExtractStatusExtracting indicates extraction is in progress + ExtractStatusExtracting ExtractStatus = "extracting" + // ExtractStatusDone indicates extraction completed successfully + ExtractStatusDone ExtractStatus = "done" + // ExtractStatusError indicates extraction failed + ExtractStatusError ExtractStatus = "error" +) + type Progress struct { // Total download time(ns) Used int64 `json:"used"` @@ -56,6 +70,10 @@ type Progress struct { UploadSpeed int64 `json:"uploadSpeed"` // Uploaded size(bytes) Uploaded int64 `json:"uploaded"` + // ExtractStatus indicates the current status of archive extraction + ExtractStatus ExtractStatus `json:"extractStatus"` + // ExtractProgress is the percentage of extraction completed (0-100) + ExtractProgress int `json:"extractProgress"` } type Downloader struct { @@ -881,13 +899,29 @@ func (d *Downloader) watch(task *Task) { // Auto-extract archive files if e.AutoExtract && isArchiveFile(downloadFilePath) { go func() { + // Set extraction status to extracting + task.Progress.ExtractStatus = ExtractStatusExtracting + task.Progress.ExtractProgress = 0 + d.emit(EventKeyProgress, task) + d.storage.Put(bucketTask, task.ID, task.clone()) + // Extract to the same directory as the downloaded file destDir := task.Meta.Opts.Path - extractErr := extractArchive(downloadFilePath, destDir, e.ArchivePassword) + extractErr := extractArchive(downloadFilePath, destDir, e.ArchivePassword, func(extractedFiles int, totalFiles int, progress int) { + task.Progress.ExtractProgress = progress + d.emit(EventKeyProgress, task) + }) if extractErr != nil { d.Logger.Error().Err(extractErr).Msgf("auto extract archive failed, task id: %s", task.ID) + task.Progress.ExtractStatus = ExtractStatusError + d.emit(EventKeyProgress, task) + d.storage.Put(bucketTask, task.ID, task.clone()) } else { d.Logger.Info().Msgf("auto extract archive completed, task id: %s", task.ID) + task.Progress.ExtractStatus = ExtractStatusDone + task.Progress.ExtractProgress = 100 + d.emit(EventKeyProgress, task) + d.storage.Put(bucketTask, task.ID, task.clone()) // Delete archive after successful extraction if enabled if e.DeleteAfterExtract { deleteErr := os.Remove(downloadFilePath) diff --git a/pkg/download/downloader_test.go b/pkg/download/downloader_test.go index 75b1b067..0c36010d 100644 --- a/pkg/download/downloader_test.go +++ b/pkg/download/downloader_test.go @@ -1,15 +1,20 @@ package download import ( - "github.com/GopeedLab/gopeed/internal/test" - "github.com/GopeedLab/gopeed/pkg/base" - "github.com/GopeedLab/gopeed/pkg/protocol/http" + "archive/zip" + "io" + gohttp "net/http" + "net" "os" "strconv" "strings" "sync" "testing" "time" + + "github.com/GopeedLab/gopeed/internal/test" + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/protocol/http" ) func TestDownloader_Resolve(t *testing.T) { @@ -643,3 +648,1065 @@ func TestDownloader_GetTasksByFilter(t *testing.T) { }) } + +func TestDownloader_Stats(t *testing.T) { + listener := test.StartTestFileServer() + defer listener.Close() + + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + // Test Stats for non-existent task + _, err := downloader.Stats("non-existent-id") + if err != ErrTaskNotFound { + t.Errorf("Stats() expected ErrTaskNotFound, got %v", err) + } + + // Create a task + req := &base.Request{ + URL: "http://" + listener.Addr().String() + "/" + test.BuildName, + } + rr, err := downloader.Resolve(req) + if err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + wg.Add(1) + downloader.Listener(func(event *Event) { + if event.Key == EventKeyDone { + wg.Done() + } + }) + + taskId, err := downloader.Create(rr.ID, &base.Options{ + Path: test.Dir, + Name: test.DownloadName, + Extra: http.OptsExtra{ + Connections: 4, + }, + }) + if err != nil { + t.Fatal(err) + } + + wg.Wait() + + // Test Stats for existing task + stats, err := downloader.Stats(taskId) + if err != nil { + t.Errorf("Stats() unexpected error: %v", err) + } + if stats == nil { + t.Error("Stats() returned nil stats") + } +} + +func TestDownloader_Delete(t *testing.T) { + listener := test.StartTestFileServer() + defer listener.Close() + + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + // Create multiple tasks + var wg sync.WaitGroup + taskCount := 3 + wg.Add(taskCount) + downloader.Listener(func(event *Event) { + if event.Key == EventKeyDone { + wg.Done() + } + }) + + taskIds := make([]string, 0) + for i := 0; i < taskCount; i++ { + req := &base.Request{ + URL: "http://" + listener.Addr().String() + "/" + test.BuildName, + } + taskId, err := downloader.CreateDirect(req, &base.Options{ + Path: test.Dir, + Name: test.DownloadName, + Extra: http.OptsExtra{ + Connections: 4, + }, + }) + if err != nil { + t.Fatal(err) + } + taskIds = append(taskIds, taskId) + } + + wg.Wait() + + // Test Delete with filter (single task) + t.Run("Delete single task by ID", func(t *testing.T) { + initialCount := len(downloader.GetTasks()) + err := downloader.Delete(&TaskFilter{IDs: []string{taskIds[0]}}, true) + if err != nil { + t.Errorf("Delete() unexpected error: %v", err) + } + newCount := len(downloader.GetTasks()) + if newCount != initialCount-1 { + t.Errorf("Delete() task count got = %v, want %v", newCount, initialCount-1) + } + }) + + // Test Delete with non-matching filter (should do nothing) + t.Run("Delete with non-matching filter", func(t *testing.T) { + initialCount := len(downloader.GetTasks()) + err := downloader.Delete(&TaskFilter{IDs: []string{"non-existent-id"}}, true) + if err != nil { + t.Errorf("Delete() unexpected error: %v", err) + } + newCount := len(downloader.GetTasks()) + if newCount != initialCount { + t.Errorf("Delete() task count got = %v, want %v", newCount, initialCount) + } + }) + + // Test Delete by status + t.Run("Delete by status", func(t *testing.T) { + initialCount := len(downloader.GetTasks()) + err := downloader.Delete(&TaskFilter{Statuses: []base.Status{base.DownloadStatusDone}}, false) + if err != nil { + t.Errorf("Delete() unexpected error: %v", err) + } + newCount := len(downloader.GetTasks()) + if newCount != 0 { + t.Errorf("Delete() should have deleted all done tasks, got %v remaining", newCount) + } + _ = initialCount // suppress unused variable warning + }) +} + +func TestDownloader_ContinueBatch(t *testing.T) { + listener := test.StartTestSlowFileServer(time.Millisecond * 2000) + defer listener.Close() + + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + // Create multiple tasks and pause them + taskCount := 3 + taskIds := make([]string, 0) + + for i := 0; i < taskCount; i++ { + req := &base.Request{ + URL: "http://" + listener.Addr().String() + "/" + test.BuildName, + } + rr, err := downloader.Resolve(req) + if err != nil { + t.Fatal(err) + } + taskId, err := downloader.Create(rr.ID, &base.Options{ + Path: test.Dir, + Name: test.DownloadName, + Extra: http.OptsExtra{ + Connections: 4, + }, + }) + if err != nil { + t.Fatal(err) + } + taskIds = append(taskIds, taskId) + } + + // Wait a moment for tasks to start + time.Sleep(time.Millisecond * 500) + + // Pause all tasks + err := downloader.Pause(nil) + if err != nil { + t.Fatal(err) + } + + // Verify tasks are paused + for _, taskId := range taskIds { + task := downloader.GetTask(taskId) + if task.Status != base.DownloadStatusPause && task.Status != base.DownloadStatusDone { + t.Errorf("Task %s should be paused or done, got %s", taskId, task.Status) + } + } + + // Test ContinueBatch with specific IDs + t.Run("ContinueBatch with filter", func(t *testing.T) { + var wg sync.WaitGroup + wg.Add(1) + downloader.Listener(func(event *Event) { + if event.Key == EventKeyDone { + wg.Done() + } + }) + + err := downloader.ContinueBatch(&TaskFilter{IDs: []string{taskIds[0]}}) + if err != nil { + t.Errorf("ContinueBatch() unexpected error: %v", err) + } + wg.Wait() + }) + + // Test ContinueBatch with nil filter (continues all) + t.Run("ContinueBatch with nil filter", func(t *testing.T) { + var wg sync.WaitGroup + // Count remaining paused tasks + pausedCount := 0 + for _, taskId := range taskIds { + task := downloader.GetTask(taskId) + if task.Status == base.DownloadStatusPause { + pausedCount++ + } + } + wg.Add(pausedCount) + downloader.Listener(func(event *Event) { + if event.Key == EventKeyDone { + wg.Done() + } + }) + + err := downloader.ContinueBatch(nil) + if err != nil { + t.Errorf("ContinueBatch() unexpected error: %v", err) + } + if pausedCount > 0 { + wg.Wait() + } + }) + + // Clean up + downloader.Delete(nil, true) +} + +func TestDownloader_PauseAndContinue(t *testing.T) { + listener := test.StartTestSlowFileServer(time.Millisecond * 5000) + defer listener.Close() + + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + req := &base.Request{ + URL: "http://" + listener.Addr().String() + "/" + test.BuildName, + } + rr, err := downloader.Resolve(req) + if err != nil { + t.Fatal(err) + } + + taskId, err := downloader.Create(rr.ID, &base.Options{ + Path: test.Dir, + Name: test.DownloadName, + Extra: http.OptsExtra{ + Connections: 4, + }, + }) + if err != nil { + t.Fatal(err) + } + + // Wait for task to start + time.Sleep(time.Millisecond * 1000) + + // Test Pause with empty filter (returns nil, not error - empty filter means no op) + t.Run("Pause with empty filter", func(t *testing.T) { + err := downloader.Pause(&TaskFilter{}) + // Empty filter returns error from Pause function + if err == nil { + // This is fine - different implementations might handle this differently + } + }) + + // Test Pause with filter + t.Run("Pause with filter", func(t *testing.T) { + task := downloader.GetTask(taskId) + if task == nil { + t.Skip("Task already completed") + } + if task.Status == base.DownloadStatusDone { + t.Skip("Task already completed") + } + + err := downloader.Pause(&TaskFilter{IDs: []string{taskId}}) + if err != nil { + // Task might have finished between check and pause + t.Logf("Pause() error (task may have finished): %v", err) + } else { + task = downloader.GetTask(taskId) + if task.Status != base.DownloadStatusPause && task.Status != base.DownloadStatusDone { + t.Errorf("Task should be paused or done, got %s", task.Status) + } + } + }) + + // Test Pause with non-matching filter + t.Run("Pause with non-matching filter", func(t *testing.T) { + err := downloader.Pause(&TaskFilter{IDs: []string{"non-existent-id"}}) + if err == nil { + t.Error("Pause() with non-matching filter should return error") + } + }) + + // Test Continue with empty filter (returns error - empty filter means no op) + t.Run("Continue with empty filter", func(t *testing.T) { + err := downloader.Continue(&TaskFilter{}) + // Empty filter returns error from Continue function + if err == nil { + // This is fine - different implementations might handle this differently + } + }) + + // Test Continue with non-matching filter + t.Run("Continue with non-matching filter", func(t *testing.T) { + err := downloader.Continue(&TaskFilter{IDs: []string{"non-existent-id"}}) + if err == nil { + t.Error("Continue() with non-matching filter should return error") + } + }) + + // Test Continue with valid filter + t.Run("Continue with valid filter", func(t *testing.T) { + task := downloader.GetTask(taskId) + if task == nil { + t.Skip("Task not found") + } + if task.Status == base.DownloadStatusDone { + t.Skip("Task already completed") + } + + var wg sync.WaitGroup + wg.Add(1) + downloader.Listener(func(event *Event) { + if event.Key == EventKeyDone && event.Task != nil && event.Task.ID == taskId { + wg.Done() + } + }) + + err := downloader.Continue(&TaskFilter{IDs: []string{taskId}}) + if err != nil { + // Task might have finished or not be in pausable state + t.Logf("Continue() note: %v", err) + } + wg.Wait() + + task = downloader.GetTask(taskId) + if task != nil && task.Status != base.DownloadStatusDone { + t.Errorf("Task should be done, got %s", task.Status) + } + }) + + // Clean up + downloader.Delete(nil, true) +} + +func TestDownloader_GetTask(t *testing.T) { + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + // Test GetTask for non-existent task + task := downloader.GetTask("non-existent-id") + if task != nil { + t.Errorf("GetTask() expected nil for non-existent task, got %v", task) + } +} + +func TestDownloader_Emit(t *testing.T) { + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + // Test emit with no listener (should not panic) + downloader.emit(EventKeyDone, nil) + + // Test emit with listener + eventReceived := false + downloader.Listener(func(event *Event) { + eventReceived = true + }) + downloader.emit(EventKeyDone, nil) + if !eventReceived { + t.Error("Event should have been received by listener") + } +} + +func TestDownloader_AutoExtract(t *testing.T) { + // Create a temporary directory for extraction tests + tempDir, err := os.MkdirTemp("", "downloader_extract_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a test zip file + zipPath := tempDir + "/test_archive.zip" + if err := createTestArchive(zipPath); err != nil { + t.Fatal(err) + } + + // Verify isArchiveFile works correctly + t.Run("isArchiveFile", func(t *testing.T) { + if !isArchiveFile(zipPath) { + t.Error("isArchiveFile should return true for .zip file") + } + if isArchiveFile(tempDir + "/test.txt") { + t.Error("isArchiveFile should return false for .txt file") + } + }) +} + +// TestDownloader_AutoExtractWithProgress tests the auto-extract functionality with progress tracking +// This test exercises the ExtractStatus and ExtractProgress fields in the Progress struct +func TestDownloader_AutoExtractWithProgress(t *testing.T) { + // Create a temporary directory for the test + tempDir, err := os.MkdirTemp("", "auto_extract_progress_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a test zip file to serve + zipPath := tempDir + "/archive.zip" + if err := createTestArchiveWithMultipleFiles(zipPath, 3); err != nil { + t.Fatal(err) + } + + // Start a simple HTTP server to serve the zip file + server := startTestArchiveServer(zipPath) + defer server.Close() + + // Create downloader + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + // Track extraction status changes + var extractStatusChanges []ExtractStatus + var extractProgressValues []int + var statusMutex sync.Mutex + extractDoneCh := make(chan struct{}) + var extractDoneOnce sync.Once + + downloader.Listener(func(event *Event) { + if event.Key == EventKeyProgress && event.Task != nil && event.Task.Progress != nil { + statusMutex.Lock() + status := event.Task.Progress.ExtractStatus + progress := event.Task.Progress.ExtractProgress + // Record status changes + if status != ExtractStatusNone { + if len(extractStatusChanges) == 0 || extractStatusChanges[len(extractStatusChanges)-1] != status { + extractStatusChanges = append(extractStatusChanges, status) + } + extractProgressValues = append(extractProgressValues, progress) + } + statusMutex.Unlock() + // Signal when extraction is done or errored + if status == ExtractStatusDone || status == ExtractStatusError { + extractDoneOnce.Do(func() { + close(extractDoneCh) + }) + } + } + }) + + // Create request to download the zip file + req := &base.Request{ + URL: "http://" + server.Addr().String() + "/archive.zip", + } + + // Create task with AutoExtract enabled + downloadDir := tempDir + "/downloads" + taskId, err := downloader.CreateDirect(req, &base.Options{ + Path: downloadDir, + Name: "archive.zip", + Extra: http.OptsExtra{ + Connections: 1, + AutoExtract: true, + }, + }) + if err != nil { + t.Fatal(err) + } + + // Wait for extraction to complete (with timeout) + select { + case <-extractDoneCh: + // Extraction completed + case <-time.After(30 * time.Second): + t.Log("Extraction timed out, checking results anyway") + } + + // Give a small buffer for final events to be processed + time.Sleep(100 * time.Millisecond) + + // Verify task exists + task := downloader.GetTask(taskId) + if task == nil { + t.Fatal("Task should exist") + } + + // Verify extraction status changes occurred + statusMutex.Lock() + defer statusMutex.Unlock() + + t.Logf("Recorded extract status changes: %v", extractStatusChanges) + t.Logf("Recorded extract progress values: %v", extractProgressValues) + + // Verify that we went through ExtractStatusExtracting + foundExtracting := false + for _, status := range extractStatusChanges { + if status == ExtractStatusExtracting { + foundExtracting = true + break + } + } + if !foundExtracting { + t.Error("Expected ExtractStatusExtracting in status changes") + } + + // Verify that we reached ExtractStatusDone + foundDone := false + for _, status := range extractStatusChanges { + if status == ExtractStatusDone { + foundDone = true + break + } + } + if !foundDone { + t.Error("Expected ExtractStatusDone in status changes") + } + + // Verify progress values include 100 (final) + found100 := false + for _, p := range extractProgressValues { + if p == 100 { + found100 = true + break + } + } + if !found100 { + t.Error("Expected progress to reach 100") + } + + // Verify extracted files exist + extractedFile := downloadDir + "/test_0.txt" + if _, err := os.Stat(extractedFile); os.IsNotExist(err) { + t.Error("Expected extracted file to exist") + } +} + +// TestDownloader_AutoExtractWithDeleteAfterExtract tests the auto-extract with DeleteAfterExtract option +func TestDownloader_AutoExtractWithDeleteAfterExtract(t *testing.T) { + // Create a temporary directory for the test + tempDir, err := os.MkdirTemp("", "auto_extract_delete_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a test zip file to serve + zipPath := tempDir + "/archive.zip" + if err := createTestArchiveWithMultipleFiles(zipPath, 2); err != nil { + t.Fatal(err) + } + + // Start a simple HTTP server to serve the zip file + server := startTestArchiveServer(zipPath) + defer server.Close() + + // Create downloader + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + // Track extraction status changes + extractDoneCh := make(chan struct{}) + var extractDoneOnce sync.Once + + downloader.Listener(func(event *Event) { + if event.Key == EventKeyProgress && event.Task != nil && event.Task.Progress != nil { + status := event.Task.Progress.ExtractStatus + if status == ExtractStatusDone || status == ExtractStatusError { + extractDoneOnce.Do(func() { + close(extractDoneCh) + }) + } + } + }) + + // Create request to download the zip file + req := &base.Request{ + URL: "http://" + server.Addr().String() + "/archive.zip", + } + + // Create task with AutoExtract and DeleteAfterExtract enabled + downloadDir := tempDir + "/downloads" + _, err = downloader.CreateDirect(req, &base.Options{ + Path: downloadDir, + Name: "archive.zip", + Extra: http.OptsExtra{ + Connections: 1, + AutoExtract: true, + DeleteAfterExtract: true, + }, + }) + if err != nil { + t.Fatal(err) + } + + // Wait for extraction to complete (with timeout) + select { + case <-extractDoneCh: + // Extraction completed + case <-time.After(10 * time.Second): + t.Log("Extraction timed out") + } + + // Give time for file deletion + time.Sleep(200 * time.Millisecond) + + // Verify archive was deleted + archivePath := downloadDir + "/archive.zip" + if _, err := os.Stat(archivePath); !os.IsNotExist(err) { + t.Error("Expected archive to be deleted after extraction") + } + + // Verify extracted files exist + extractedFile := downloadDir + "/test_0.txt" + if _, err := os.Stat(extractedFile); os.IsNotExist(err) { + t.Error("Expected extracted file to exist") + } +} + +// TestDownloader_AutoExtractError tests the auto-extract error handling path +func TestDownloader_AutoExtractError(t *testing.T) { + // Create a temporary directory for the test + tempDir, err := os.MkdirTemp("", "auto_extract_error_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a corrupt zip file (just invalid data with .zip extension) + corruptZipPath := tempDir + "/corrupt.zip" + if err := os.WriteFile(corruptZipPath, []byte("this is not a valid zip file"), 0644); err != nil { + t.Fatal(err) + } + + // Start a simple HTTP server to serve the corrupt zip file + server := startTestArchiveServer(corruptZipPath) + defer server.Close() + + // Create downloader + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + // Track extraction status changes + var extractStatusChanges []ExtractStatus + var statusMutex sync.Mutex + extractDoneCh := make(chan struct{}) + var extractDoneOnce sync.Once + + downloader.Listener(func(event *Event) { + if event.Key == EventKeyProgress && event.Task != nil && event.Task.Progress != nil { + statusMutex.Lock() + status := event.Task.Progress.ExtractStatus + if status != ExtractStatusNone { + if len(extractStatusChanges) == 0 || extractStatusChanges[len(extractStatusChanges)-1] != status { + extractStatusChanges = append(extractStatusChanges, status) + } + } + statusMutex.Unlock() + if status == ExtractStatusDone || status == ExtractStatusError { + extractDoneOnce.Do(func() { + close(extractDoneCh) + }) + } + } + }) + + // Create request to download the corrupt zip file + req := &base.Request{ + URL: "http://" + server.Addr().String() + "/corrupt.zip", + } + + // Create task with AutoExtract enabled + downloadDir := tempDir + "/downloads" + _, err = downloader.CreateDirect(req, &base.Options{ + Path: downloadDir, + Name: "corrupt.zip", + Extra: http.OptsExtra{ + Connections: 1, + AutoExtract: true, + }, + }) + if err != nil { + t.Fatal(err) + } + + // Wait for extraction to complete (with timeout) + select { + case <-extractDoneCh: + // Extraction completed (should be error) + case <-time.After(10 * time.Second): + t.Log("Extraction timed out") + } + + // Give a small buffer for final events to be processed + time.Sleep(100 * time.Millisecond) + + // Verify extraction status changes include error + statusMutex.Lock() + defer statusMutex.Unlock() + + t.Logf("Recorded extract status changes: %v", extractStatusChanges) + + // Verify that we went through ExtractStatusExtracting + foundExtracting := false + for _, status := range extractStatusChanges { + if status == ExtractStatusExtracting { + foundExtracting = true + break + } + } + if !foundExtracting { + t.Error("Expected ExtractStatusExtracting in status changes") + } + + // Verify that we reached ExtractStatusError + foundError := false + for _, status := range extractStatusChanges { + if status == ExtractStatusError { + foundError = true + break + } + } + if !foundError { + t.Error("Expected ExtractStatusError in status changes") + } +} + +// TestExtractStatus tests the ExtractStatus constants +func TestExtractStatus(t *testing.T) { + tests := []struct { + status ExtractStatus + expected string + }{ + {ExtractStatusNone, ""}, + {ExtractStatusExtracting, "extracting"}, + {ExtractStatusDone, "done"}, + {ExtractStatusError, "error"}, + } + + for _, tt := range tests { + t.Run(string(tt.status), func(t *testing.T) { + if string(tt.status) != tt.expected { + t.Errorf("ExtractStatus %v = %q, want %q", tt.status, string(tt.status), tt.expected) + } + }) + } +} + +// TestProgress_ExtractFields tests the ExtractStatus and ExtractProgress fields in Progress struct +func TestProgress_ExtractFields(t *testing.T) { + progress := &Progress{ + ExtractStatus: ExtractStatusExtracting, + ExtractProgress: 50, + } + + if progress.ExtractStatus != ExtractStatusExtracting { + t.Errorf("ExtractStatus = %v, want %v", progress.ExtractStatus, ExtractStatusExtracting) + } + if progress.ExtractProgress != 50 { + t.Errorf("ExtractProgress = %v, want %v", progress.ExtractProgress, 50) + } + + // Test status transitions + progress.ExtractStatus = ExtractStatusDone + progress.ExtractProgress = 100 + if progress.ExtractStatus != ExtractStatusDone { + t.Errorf("ExtractStatus after update = %v, want %v", progress.ExtractStatus, ExtractStatusDone) + } + if progress.ExtractProgress != 100 { + t.Errorf("ExtractProgress after update = %v, want %v", progress.ExtractProgress, 100) + } +} + +// createTestArchiveWithMultipleFiles creates a test zip file with multiple files +func createTestArchiveWithMultipleFiles(path string, count int) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + zipWriter := zip.NewWriter(file) + defer zipWriter.Close() + + for i := 0; i < count; i++ { + w, err := zipWriter.Create("test_" + strconv.Itoa(i) + ".txt") + if err != nil { + return err + } + _, err = w.Write([]byte("test content " + strconv.Itoa(i))) + if err != nil { + return err + } + } + return nil +} + +// startTestArchiveServer starts a simple HTTP server that serves a zip file +func startTestArchiveServer(zipPath string) net.Listener { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + panic(err) + } + + go func() { + gohttp.Serve(listener, gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + file, err := os.Open(zipPath) + if err != nil { + gohttp.Error(w, err.Error(), gohttp.StatusInternalServerError) + return + } + defer file.Close() + + stat, _ := file.Stat() + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Length", strconv.FormatInt(stat.Size(), 10)) + io.Copy(w, file) + })) + }() + + return listener +} + +// createTestArchive creates a simple test zip file for testing +func createTestArchive(path string) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + // Create a simple zip archive + zipWriter := zip.NewWriter(file) + defer zipWriter.Close() + + // Add a test file + w, err := zipWriter.Create("test.txt") + if err != nil { + return err + } + _, err = w.Write([]byte("test content")) + return err +} + +func TestDownloader_Close(t *testing.T) { + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + + // Close should not error + err := downloader.Close() + if err != nil { + t.Errorf("Close() unexpected error: %v", err) + } + + // Calling Close again should not panic + err = downloader.Close() + if err != nil { + t.Errorf("Close() second call unexpected error: %v", err) + } +} + +func TestDownloader_DeleteAll(t *testing.T) { + listener := test.StartTestFileServer() + defer listener.Close() + + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + // Create multiple tasks + var wg sync.WaitGroup + taskCount := 3 + wg.Add(taskCount) + downloader.Listener(func(event *Event) { + if event.Key == EventKeyDone { + wg.Done() + } + }) + + for i := 0; i < taskCount; i++ { + req := &base.Request{ + URL: "http://" + listener.Addr().String() + "/" + test.BuildName, + } + _, err := downloader.CreateDirect(req, &base.Options{ + Path: test.Dir, + Name: test.DownloadName, + Extra: http.OptsExtra{ + Connections: 4, + }, + }) + if err != nil { + t.Fatal(err) + } + } + + wg.Wait() + + // Verify tasks were created + if len(downloader.GetTasks()) != taskCount { + t.Errorf("Expected %d tasks, got %d", taskCount, len(downloader.GetTasks())) + } + + // Delete all tasks with nil filter + err := downloader.Delete(nil, true) + if err != nil { + t.Errorf("Delete(nil) unexpected error: %v", err) + } + + // Verify all tasks are deleted + if len(downloader.GetTasks()) != 0 { + t.Errorf("All tasks should be deleted, got %d remaining", len(downloader.GetTasks())) + } +} + +func TestDownloader_ContinueAll(t *testing.T) { + listener := test.StartTestFileServer() + defer listener.Close() + + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + // Create a task and wait for completion + req := &base.Request{ + URL: "http://" + listener.Addr().String() + "/" + test.BuildName, + } + + var wg sync.WaitGroup + wg.Add(1) + downloader.Listener(func(event *Event) { + if event.Key == EventKeyDone { + wg.Done() + } + }) + + taskId, err := downloader.CreateDirect(req, &base.Options{ + Path: test.Dir, + Name: test.DownloadName, + Extra: http.OptsExtra{ + Connections: 4, + }, + }) + if err != nil { + t.Fatal(err) + } + + wg.Wait() + + // Verify task completed + task := downloader.GetTask(taskId) + if task == nil { + t.Fatal("Task should exist") + } + if task.Status != base.DownloadStatusDone { + t.Errorf("Task should be done, got %s", task.Status) + } + + // Test ContinueBatch with nil on an already done task (should be a no-op) + err = downloader.ContinueBatch(nil) + if err != nil { + t.Logf("ContinueBatch(nil) on done tasks returned: %v", err) + } + + // Clean up + downloader.Delete(nil, true) +} + +func TestDownloader_ProtocolConfigNotExist(t *testing.T) { + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + // Test getting a protocol config that doesn't exist + var unknownCfg map[string]any + exists := downloader.getProtocolConfig("unknown-protocol", &unknownCfg) + if exists { + t.Errorf("getProtocolConfig() for unknown protocol should return false") + } +} + +func TestTaskFilter_IsEmpty(t *testing.T) { + tests := []struct { + name string + filter *TaskFilter + expected bool + }{ + { + name: "nil IDs, Statuses, NotStatuses", + filter: &TaskFilter{}, + expected: true, + }, + { + name: "empty IDs only", + filter: &TaskFilter{IDs: []string{}}, + expected: true, + }, + { + name: "non-empty IDs", + filter: &TaskFilter{IDs: []string{"id1"}}, + expected: false, + }, + { + name: "non-empty Statuses", + filter: &TaskFilter{Statuses: []base.Status{base.DownloadStatusDone}}, + expected: false, + }, + { + name: "non-empty NotStatuses", + filter: &TaskFilter{NotStatuses: []base.Status{base.DownloadStatusError}}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.filter.IsEmpty() + if result != tt.expected { + t.Errorf("TaskFilter.IsEmpty() = %v, want %v", result, tt.expected) + } + }) + } +} diff --git a/pkg/download/extract.go b/pkg/download/extract.go index 63e0746d..8c12c721 100644 --- a/pkg/download/extract.go +++ b/pkg/download/extract.go @@ -3,10 +3,12 @@ package download import ( "context" "io" + "math" "os" "path/filepath" "slices" "strings" + "sync/atomic" "github.com/mholt/archives" ) @@ -35,6 +37,9 @@ var supportedArchiveExtensions = []string{ ".lz", } +// ExtractProgressCallback is called to report extraction progress +type ExtractProgressCallback func(extractedFiles int, totalFiles int, progress int) + // isArchiveFile checks if a file is a supported archive format func isArchiveFile(filename string) bool { lowerName := strings.ToLower(filename) @@ -43,35 +48,35 @@ func isArchiveFile(filename string) bool { }) } -// extractArchive extracts an archive file to a destination directory -func extractArchive(archivePath string, destDir string, password string) error { - // Open the archive file +// archiveInfo holds information about an opened archive +type archiveInfo struct { + file *os.File + stat os.FileInfo + format archives.Format + input io.Reader +} + +// openArchive opens an archive file and identifies its format +func openArchive(archivePath string, password string) (*archiveInfo, error) { file, err := os.Open(archivePath) if err != nil { - return err + return nil, err } - defer file.Close() - // Get file info for format identification stat, err := file.Stat() if err != nil { - return err + file.Close() + return nil, err } - // Identify the archive format format, input, err := archives.Identify(context.Background(), archivePath, file) if err != nil { - return err - } - - // Create destination directory if it doesn't exist - if err := os.MkdirAll(destDir, 0755); err != nil { - return err + file.Close() + return nil, err } // Handle password-protected archives if password != "" { - // Try to set password for formats that support it if rar, ok := format.(archives.Rar); ok { rar.Password = password format = rar @@ -80,17 +85,57 @@ func extractArchive(archivePath string, destDir string, password string) error { sz.Password = password format = sz } - // Note: Zip format in archives doesn't have a Password field - // Password-protected zips are not fully supported + } + + return &archiveInfo{ + file: file, + stat: stat, + format: format, + input: input, + }, nil +} + +// createExtractionHandler creates a handler function for extracting files with progress tracking +func createExtractionHandler(destDir string, totalFiles int, progressCallback ExtractProgressCallback) func(ctx context.Context, fileInfo archives.FileInfo) error { + var extractedFiles atomic.Int32 + return func(ctx context.Context, fileInfo archives.FileInfo) error { + err := extractFile(ctx, fileInfo, destDir) + if err == nil && !fileInfo.IsDir() { + extracted := int(extractedFiles.Add(1)) + if progressCallback != nil && totalFiles > 0 { + progress := int(math.Min(float64((extracted*100)/totalFiles), 100)) + progressCallback(extracted, totalFiles, progress) + } + } + return err + } +} + +// extractArchive extracts an archive file to a destination directory +func extractArchive(archivePath string, destDir string, password string, progressCallback ExtractProgressCallback) error { + // Open the archive file + info, err := openArchive(archivePath, password) + if err != nil { + return err + } + defer info.file.Close() + + // Create destination directory if it doesn't exist + if err := os.MkdirAll(destDir, 0755); err != nil { + return err } // Handle extraction based on format type - switch f := format.(type) { + switch f := info.format.(type) { case archives.Extractor: // For archive formats (zip, rar, 7z, tar, etc.) - return f.Extract(context.Background(), input, func(ctx context.Context, fileInfo archives.FileInfo) error { - return extractFile(ctx, fileInfo, destDir) - }) + // First, count total files for progress tracking + totalFiles, err := countArchiveFiles(archivePath, password) + if err != nil { + // If counting fails, proceed without progress reporting + totalFiles = 0 + } + return f.Extract(context.Background(), info.input, createExtractionHandler(destDir, totalFiles, progressCallback)) case archives.Decompressor: // For single-file compression formats (gz, bz2, xz, etc.) // Decompress to a file without the compression extension @@ -106,7 +151,7 @@ func extractArchive(archivePath string, destDir string, password string) error { } destPath := filepath.Join(destDir, baseName) - reader, err := f.OpenReader(input) + reader, err := f.OpenReader(info.input) if err != nil { return err } @@ -118,24 +163,71 @@ func extractArchive(archivePath string, destDir string, password string) error { } defer destFile.Close() + // Report progress at start and end for decompression + if progressCallback != nil { + progressCallback(0, 1, 0) + } _, err = io.Copy(destFile, reader) + if err == nil && progressCallback != nil { + progressCallback(1, 1, 100) + } return err case archives.Archiver: // This format is an archiver, try to extract using the extractor interface - if ext, ok := format.(archives.Extractor); ok { + if ext, ok := info.format.(archives.Extractor); ok { // Reset file position - if seeker, ok := input.(io.Seeker); ok { + if seeker, ok := info.input.(io.Seeker); ok { seeker.Seek(0, io.SeekStart) } - return ext.Extract(context.Background(), io.NewSectionReader(file, 0, stat.Size()), func(ctx context.Context, fileInfo archives.FileInfo) error { - return extractFile(ctx, fileInfo, destDir) - }) + // Count total files for progress tracking + totalFiles, err := countArchiveFiles(archivePath, password) + if err != nil { + totalFiles = 0 + } + return ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createExtractionHandler(destDir, totalFiles, progressCallback)) } } return nil } +// createCountingHandler creates a handler function for counting files in an archive +func createCountingHandler(count *int) func(ctx context.Context, fileInfo archives.FileInfo) error { + return func(ctx context.Context, fileInfo archives.FileInfo) error { + if !fileInfo.IsDir() { + *count++ + } + return nil + } +} + +// countArchiveFiles counts the number of files in an archive for progress calculation +func countArchiveFiles(archivePath string, password string) (int, error) { + info, err := openArchive(archivePath, password) + if err != nil { + return 0, err + } + defer info.file.Close() + + count := 0 + switch f := info.format.(type) { + case archives.Extractor: + err = f.Extract(context.Background(), info.input, createCountingHandler(&count)) + case archives.Archiver: + if ext, ok := info.format.(archives.Extractor); ok { + if seeker, ok := info.input.(io.Seeker); ok { + seeker.Seek(0, io.SeekStart) + } + err = ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createCountingHandler(&count)) + } + case archives.Decompressor: + // Single file compression, count as 1 + return 1, nil + } + + return count, err +} + // extractFile handles extracting a single file from an archive func extractFile(ctx context.Context, fileInfo archives.FileInfo, destDir string) error { // Skip directories, they will be created when extracting files diff --git a/pkg/download/extract_test.go b/pkg/download/extract_test.go index febbd50a..127222c0 100644 --- a/pkg/download/extract_test.go +++ b/pkg/download/extract_test.go @@ -1,7 +1,9 @@ package download import ( + "archive/tar" "archive/zip" + "compress/gzip" "os" "path/filepath" "testing" @@ -70,7 +72,7 @@ func TestExtractArchive_Zip(t *testing.T) { } // Extract the archive - err = extractArchive(zipPath, destDir, "") + err = extractArchive(zipPath, destDir, "", nil) if err != nil { t.Fatalf("extractArchive failed: %v", err) } @@ -114,7 +116,7 @@ func TestExtractArchive_NonArchive(t *testing.T) { destDir := filepath.Join(tempDir, "extracted") // Trying to extract a non-archive should return an error - err = extractArchive(txtPath, destDir, "") + err = extractArchive(txtPath, destDir, "", nil) if err == nil { t.Error("expected error when extracting non-archive file") } @@ -152,3 +154,1167 @@ func createTestZip(path string) error { return w.Close() } + +func TestExtractArchive_Progress(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "extract_progress_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a test zip file with multiple files + zipPath := filepath.Join(tempDir, "test.zip") + destDir := filepath.Join(tempDir, "extracted") + + // Create zip with 4 files + if err := createTestZipWithMultipleFiles(zipPath, 4); err != nil { + t.Fatal(err) + } + + // Track progress callbacks + progressCalls := make([]struct { + extracted int + total int + progress int + }, 0) + + // Extract the archive with progress tracking + err = extractArchive(zipPath, destDir, "", func(extracted int, total int, progress int) { + progressCalls = append(progressCalls, struct { + extracted int + total int + progress int + }{extracted, total, progress}) + }) + if err != nil { + t.Fatalf("extractArchive failed: %v", err) + } + + // Verify that progress callbacks were made + if len(progressCalls) != 4 { + t.Errorf("expected 4 progress callbacks, got %d", len(progressCalls)) + } + + // Verify the first progress call + if len(progressCalls) > 0 { + first := progressCalls[0] + if first.extracted != 1 || first.total != 4 { + t.Errorf("first progress call: expected extracted=1, total=4, got extracted=%d, total=%d", first.extracted, first.total) + } + } + + // Verify the last progress call + if len(progressCalls) > 0 { + last := progressCalls[len(progressCalls)-1] + if last.extracted != 4 || last.total != 4 || last.progress != 100 { + t.Errorf("last progress call: expected extracted=4, total=4, progress=100, got extracted=%d, total=%d, progress=%d", last.extracted, last.total, last.progress) + } + } +} + +// createTestZipWithMultipleFiles creates a test zip file with the specified number of files +func createTestZipWithMultipleFiles(path string, numFiles int) error { + zipFile, err := os.Create(path) + if err != nil { + return err + } + defer zipFile.Close() + + w := zip.NewWriter(zipFile) + + for i := 0; i < numFiles; i++ { + f, err := w.Create(filepath.Join("dir", "file"+string(rune('A'+i))+".txt")) + if err != nil { + return err + } + _, err = f.Write([]byte("Content of file " + string(rune('A'+i)))) + if err != nil { + return err + } + } + + return w.Close() +} + +func TestOpenArchive_NonExistentFile(t *testing.T) { + _, err := openArchive("/nonexistent/path/file.zip", "") + if err == nil { + t.Error("expected error when opening non-existent file") + } +} + +func TestOpenArchive_InvalidFormat(t *testing.T) { + tempDir, err := os.MkdirTemp("", "open_archive_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a file that's not a valid archive format + // Use a txt extension so it's not detected as an archive + invalidPath := filepath.Join(tempDir, "invalid.txt") + if err := os.WriteFile(invalidPath, []byte("not an archive file"), 0644); err != nil { + t.Fatal(err) + } + + _, err = openArchive(invalidPath, "") + if err == nil { + // archives.Identify may return a format even for non-archive files + // This is expected behavior - it identifies based on content/extension + t.Log("openArchive accepted the file - this is acceptable behavior") + } +} + +func TestOpenArchive_WithPassword(t *testing.T) { + tempDir, err := os.MkdirTemp("", "open_archive_pwd_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a valid zip file to test password parameter handling + zipPath := filepath.Join(tempDir, "test.zip") + if err := createTestZip(zipPath); err != nil { + t.Fatal(err) + } + + // Test that password is accepted (even if zip doesn't use it) + info, err := openArchive(zipPath, "testpassword") + if err != nil { + t.Fatalf("openArchive with password failed: %v", err) + } + defer info.file.Close() + + if info.format == nil { + t.Error("expected format to be set") + } +} + +func TestExtractArchive_NonExistentFile(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_nonexistent_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + destDir := filepath.Join(tempDir, "extracted") + err = extractArchive("/nonexistent/file.zip", destDir, "", nil) + if err == nil { + t.Error("expected error when extracting non-existent file") + } +} + +func TestExtractArchive_WithPassword(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_pwd_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a test zip file + zipPath := filepath.Join(tempDir, "test.zip") + destDir := filepath.Join(tempDir, "extracted") + + if err := createTestZip(zipPath); err != nil { + t.Fatal(err) + } + + // Extract with password (zip doesn't require it, but tests the code path) + err = extractArchive(zipPath, destDir, "password123", nil) + if err != nil { + t.Fatalf("extractArchive with password failed: %v", err) + } + + // Verify extraction succeeded + if _, err := os.Stat(filepath.Join(destDir, "test.txt")); os.IsNotExist(err) { + t.Error("expected file not found after extraction") + } +} + +func TestExtractArchive_Gzip(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_gzip_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a gzip compressed file + gzPath := filepath.Join(tempDir, "test.txt.gz") + destDir := filepath.Join(tempDir, "extracted") + + if err := createTestGzip(gzPath, "Hello from gzip!"); err != nil { + t.Fatal(err) + } + + // Track progress callback values + var progressCalls []struct { + extracted int + total int + progress int + } + err = extractArchive(gzPath, destDir, "", func(extracted int, total int, progress int) { + progressCalls = append(progressCalls, struct { + extracted int + total int + progress int + }{extracted, total, progress}) + }) + if err != nil { + t.Fatalf("extractArchive failed for gzip: %v", err) + } + + // Verify the decompressed file exists + destPath := filepath.Join(destDir, "test.txt") + if _, err := os.Stat(destPath); os.IsNotExist(err) { + t.Error("expected decompressed file not found") + } + + // Verify content + content, err := os.ReadFile(destPath) + if err != nil { + t.Fatal(err) + } + if string(content) != "Hello from gzip!" { + t.Errorf("unexpected content: %q", string(content)) + } + + // Verify progress callbacks - should have exactly 2 calls for gzip: start (0,1,0) and end (1,1,100) + if len(progressCalls) != 2 { + t.Errorf("expected 2 progress callbacks for gzip, got %d", len(progressCalls)) + } + if len(progressCalls) >= 2 { + // Verify start callback + if progressCalls[0].extracted != 0 || progressCalls[0].total != 1 || progressCalls[0].progress != 0 { + t.Errorf("expected start callback (0,1,0), got (%d,%d,%d)", progressCalls[0].extracted, progressCalls[0].total, progressCalls[0].progress) + } + // Verify end callback + if progressCalls[1].extracted != 1 || progressCalls[1].total != 1 || progressCalls[1].progress != 100 { + t.Errorf("expected end callback (1,1,100), got (%d,%d,%d)", progressCalls[1].extracted, progressCalls[1].total, progressCalls[1].progress) + } + } +} + +func createTestGzip(path string, content string) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + gz := gzip.NewWriter(file) + _, err = gz.Write([]byte(content)) + if err != nil { + gz.Close() + return err + } + return gz.Close() +} + +func TestCountArchiveFiles(t *testing.T) { + tempDir, err := os.MkdirTemp("", "count_archive_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a test zip with known number of files + zipPath := filepath.Join(tempDir, "test.zip") + if err := createTestZipWithMultipleFiles(zipPath, 5); err != nil { + t.Fatal(err) + } + + count, err := countArchiveFiles(zipPath, "") + if err != nil { + t.Fatalf("countArchiveFiles failed: %v", err) + } + + if count != 5 { + t.Errorf("expected 5 files, got %d", count) + } +} + +func TestCountArchiveFiles_NonExistent(t *testing.T) { + _, err := countArchiveFiles("/nonexistent/file.zip", "") + if err == nil { + t.Error("expected error for non-existent file") + } +} + +func TestCountArchiveFiles_Gzip(t *testing.T) { + tempDir, err := os.MkdirTemp("", "count_gzip_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a gzip file + gzPath := filepath.Join(tempDir, "test.txt.gz") + if err := createTestGzip(gzPath, "test content"); err != nil { + t.Fatal(err) + } + + count, err := countArchiveFiles(gzPath, "") + if err != nil { + t.Fatalf("countArchiveFiles failed: %v", err) + } + + // Gzip is single-file compression, should return 1 + if count != 1 { + t.Errorf("expected 1 file for gzip, got %d", count) + } +} + +func TestExtractArchive_ProgressWithZeroFiles(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_zero_progress_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create an empty zip file + zipPath := filepath.Join(tempDir, "empty.zip") + destDir := filepath.Join(tempDir, "extracted") + + if err := createEmptyZip(zipPath); err != nil { + t.Fatal(err) + } + + progressCalled := false + err = extractArchive(zipPath, destDir, "", func(extracted int, total int, progress int) { + progressCalled = true + }) + if err != nil { + t.Fatalf("extractArchive failed: %v", err) + } + + // Progress should not be called for empty archive + if progressCalled { + t.Error("progress callback should not be called for empty archive") + } +} + +func createEmptyZip(path string) error { + zipFile, err := os.Create(path) + if err != nil { + return err + } + defer zipFile.Close() + + w := zip.NewWriter(zipFile) + return w.Close() +} + +func TestExtractArchive_WithDirectories(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_dirs_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + zipPath := filepath.Join(tempDir, "test.zip") + destDir := filepath.Join(tempDir, "extracted") + + if err := createTestZipWithDirectories(zipPath); err != nil { + t.Fatal(err) + } + + err = extractArchive(zipPath, destDir, "", nil) + if err != nil { + t.Fatalf("extractArchive failed: %v", err) + } + + // Verify files were created (directories are created implicitly) + expectedPaths := []string{ + filepath.Join(destDir, "dir1", "file1.txt"), + filepath.Join(destDir, "dir2", "subdir", "file2.txt"), + } + + for _, path := range expectedPaths { + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected path %q not found", path) + } + } +} + +func createTestZipWithDirectories(path string) error { + zipFile, err := os.Create(path) + if err != nil { + return err + } + defer zipFile.Close() + + w := zip.NewWriter(zipFile) + + // Create file in directory (directory will be created automatically) + f, err := w.Create("dir1/file1.txt") + if err != nil { + return err + } + _, err = f.Write([]byte("content1")) + if err != nil { + return err + } + + // Create nested directory structure + f, err = w.Create("dir2/subdir/file2.txt") + if err != nil { + return err + } + _, err = f.Write([]byte("content2")) + if err != nil { + return err + } + + return w.Close() +} + +func TestExtractArchive_NilProgressCallback(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_nil_progress_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + zipPath := filepath.Join(tempDir, "test.zip") + destDir := filepath.Join(tempDir, "extracted") + + if err := createTestZipWithMultipleFiles(zipPath, 3); err != nil { + t.Fatal(err) + } + + // Should not panic with nil callback + err = extractArchive(zipPath, destDir, "", nil) + if err != nil { + t.Fatalf("extractArchive failed: %v", err) + } +} + +func TestExtractArchive_GzipUppercase(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_gzip_upper_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a gzip compressed file with uppercase extension + gzPath := filepath.Join(tempDir, "test.txt.GZ") + destDir := filepath.Join(tempDir, "extracted") + + if err := createTestGzip(gzPath, "Hello from gzip!"); err != nil { + t.Fatal(err) + } + + err = extractArchive(gzPath, destDir, "", nil) + if err != nil { + t.Fatalf("extractArchive failed for gzip with uppercase: %v", err) + } + + // The base name should have the extension stripped + destPath := filepath.Join(destDir, "test.txt") + if _, err := os.Stat(destPath); os.IsNotExist(err) { + t.Error("expected decompressed file not found") + } +} + +func TestExtractArchive_PathTraversalPrevention(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_traversal_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a zip with a path traversal attempt + zipPath := filepath.Join(tempDir, "malicious.zip") + destDir := filepath.Join(tempDir, "extracted") + + if err := createMaliciousZip(zipPath); err != nil { + t.Fatal(err) + } + + err = extractArchive(zipPath, destDir, "", nil) + if err != nil { + t.Fatalf("extractArchive failed: %v", err) + } + + // Verify that the file was not created outside destDir + // The malicious path should be sanitized + dangerousPath := filepath.Join(tempDir, "evil.txt") + if _, err := os.Stat(dangerousPath); err == nil { + t.Error("path traversal attack succeeded - file created outside destDir") + } +} + +func createMaliciousZip(path string) error { + zipFile, err := os.Create(path) + if err != nil { + return err + } + defer zipFile.Close() + + w := zip.NewWriter(zipFile) + + // Create a file with path traversal attempt + // Note: Go's zip library may sanitize this, but we test it anyway + f, err := w.Create("../evil.txt") + if err != nil { + return err + } + _, err = f.Write([]byte("malicious content")) + if err != nil { + return err + } + + // Also add a normal file + f, err = w.Create("safe.txt") + if err != nil { + return err + } + _, err = f.Write([]byte("safe content")) + if err != nil { + return err + } + + return w.Close() +} + +func TestIsArchiveFile_AdditionalFormats(t *testing.T) { + // Test additional archive formats + tests := []struct { + filename string + expected bool + }{ + // More compression formats + {"file.tar.lz4", true}, + {"file.tlz4", true}, + {"file.tar.sz", true}, + {"file.tsz", true}, + // Edge cases + {"file.tar.gz.backup", false}, + {".gz", true}, + {"archive.ZIP.bak", false}, + } + + for _, tt := range tests { + t.Run(tt.filename, func(t *testing.T) { + result := isArchiveFile(tt.filename) + if result != tt.expected { + t.Errorf("isArchiveFile(%q) = %v, expected %v", tt.filename, result, tt.expected) + } + }) + } +} + +func TestExtractArchive_DestDirCreation(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_destdir_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + zipPath := filepath.Join(tempDir, "test.zip") + // Use a nested path that doesn't exist + destDir := filepath.Join(tempDir, "level1", "level2", "extracted") + + if err := createTestZip(zipPath); err != nil { + t.Fatal(err) + } + + err = extractArchive(zipPath, destDir, "", nil) + if err != nil { + t.Fatalf("extractArchive failed: %v", err) + } + + // Verify destDir was created + if _, err := os.Stat(destDir); os.IsNotExist(err) { + t.Error("destDir was not created") + } + + // Verify files were extracted + if _, err := os.Stat(filepath.Join(destDir, "test.txt")); os.IsNotExist(err) { + t.Error("file not extracted") + } +} + +func TestExtractArchive_ProgressCallbackValues(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_progress_values_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + zipPath := filepath.Join(tempDir, "test.zip") + destDir := filepath.Join(tempDir, "extracted") + + // Create zip with 10 files to test progress calculation + if err := createTestZipWithMultipleFiles(zipPath, 10); err != nil { + t.Fatal(err) + } + + var progressValues []int + err = extractArchive(zipPath, destDir, "", func(extracted int, total int, progress int) { + progressValues = append(progressValues, progress) + }) + if err != nil { + t.Fatalf("extractArchive failed: %v", err) + } + + // Verify progress increases monotonically + for i := 1; i < len(progressValues); i++ { + if progressValues[i] < progressValues[i-1] { + t.Errorf("progress decreased from %d to %d", progressValues[i-1], progressValues[i]) + } + } + + // Verify last progress is 100 + if len(progressValues) > 0 && progressValues[len(progressValues)-1] != 100 { + t.Errorf("final progress should be 100, got %d", progressValues[len(progressValues)-1]) + } +} + +func TestCountArchiveFiles_WithDirectories(t *testing.T) { + tempDir, err := os.MkdirTemp("", "count_dirs_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + zipPath := filepath.Join(tempDir, "test.zip") + if err := createTestZipWithDirectories(zipPath); err != nil { + t.Fatal(err) + } + + count, err := countArchiveFiles(zipPath, "") + if err != nil { + t.Fatalf("countArchiveFiles failed: %v", err) + } + + // Should only count files, not directories + // createTestZipWithDirectories creates 2 files + if count != 2 { + t.Errorf("expected 2 files, got %d", count) + } +} + +func TestOpenArchive_FileStatError(t *testing.T) { + // Test error path when file can't be stat'd + // This is hard to test without mocking, so we just ensure + // the function handles basic error cases + + tempDir, err := os.MkdirTemp("", "open_archive_stat_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a valid zip first + zipPath := filepath.Join(tempDir, "test.zip") + if err := createTestZip(zipPath); err != nil { + t.Fatal(err) + } + + // Test normal opening + info, err := openArchive(zipPath, "") + if err != nil { + t.Fatalf("openArchive failed: %v", err) + } + defer info.file.Close() + + if info.stat == nil { + t.Error("stat should not be nil") + } + if info.format == nil { + t.Error("format should not be nil") + } + if info.input == nil { + t.Error("input should not be nil") + } +} + +func TestExtractArchive_FilePermissions(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_perms_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + zipPath := filepath.Join(tempDir, "test.zip") + destDir := filepath.Join(tempDir, "extracted") + + if err := createTestZip(zipPath); err != nil { + t.Fatal(err) + } + + err = extractArchive(zipPath, destDir, "", nil) + if err != nil { + t.Fatalf("extractArchive failed: %v", err) + } + + // Check that files are readable + content, err := os.ReadFile(filepath.Join(destDir, "test.txt")) + if err != nil { + t.Fatalf("couldn't read extracted file: %v", err) + } + if string(content) != "Hello, World!" { + t.Errorf("unexpected content: %q", string(content)) + } +} + +func TestExtractArchive_TarGz(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_targz_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + tarGzPath := filepath.Join(tempDir, "test.tar.gz") + destDir := filepath.Join(tempDir, "extracted") + + if err := createTestTarGz(tarGzPath); err != nil { + t.Fatal(err) + } + + var progressCalls int + err = extractArchive(tarGzPath, destDir, "", func(extracted int, total int, progress int) { + progressCalls++ + }) + if err != nil { + t.Fatalf("extractArchive failed for tar.gz: %v", err) + } + + // Verify the extracted file exists + destPath := filepath.Join(destDir, "test.txt") + content, err := os.ReadFile(destPath) + if err != nil { + t.Fatalf("couldn't read extracted file: %v", err) + } + if string(content) != "Hello from tar.gz!" { + t.Errorf("unexpected content: %q", string(content)) + } + + // Verify nested file exists + nestedPath := filepath.Join(destDir, "subdir", "nested.txt") + content, err = os.ReadFile(nestedPath) + if err != nil { + t.Fatalf("couldn't read nested file: %v", err) + } + if string(content) != "Nested content" { + t.Errorf("unexpected nested content: %q", string(content)) + } +} + +func createTestTarGz(path string) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + gzWriter := gzip.NewWriter(file) + defer gzWriter.Close() + + tarWriter := tar.NewWriter(gzWriter) + defer tarWriter.Close() + + // Add a file + content := []byte("Hello from tar.gz!") + header := &tar.Header{ + Name: "test.txt", + Mode: 0644, + Size: int64(len(content)), + } + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + if _, err := tarWriter.Write(content); err != nil { + return err + } + + // Add a nested file + nestedContent := []byte("Nested content") + nestedHeader := &tar.Header{ + Name: "subdir/nested.txt", + Mode: 0644, + Size: int64(len(nestedContent)), + } + if err := tarWriter.WriteHeader(nestedHeader); err != nil { + return err + } + if _, err := tarWriter.Write(nestedContent); err != nil { + return err + } + + return nil +} + +func TestCountArchiveFiles_TarGz(t *testing.T) { + tempDir, err := os.MkdirTemp("", "count_targz_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + tarGzPath := filepath.Join(tempDir, "test.tar.gz") + if err := createTestTarGz(tarGzPath); err != nil { + t.Fatal(err) + } + + count, err := countArchiveFiles(tarGzPath, "") + if err != nil { + t.Fatalf("countArchiveFiles failed: %v", err) + } + + // Should have 2 files + if count != 2 { + t.Errorf("expected 2 files, got %d", count) + } +} + +func TestExtractArchive_LargeFileCount(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_large_count_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + zipPath := filepath.Join(tempDir, "test.zip") + destDir := filepath.Join(tempDir, "extracted") + + // Create zip with many files to test progress capping at 100 + numFiles := 100 + if err := createTestZipWithMultipleFiles(zipPath, numFiles); err != nil { + t.Fatal(err) + } + + var maxProgress int + err = extractArchive(zipPath, destDir, "", func(extracted int, total int, progress int) { + if progress > maxProgress { + maxProgress = progress + } + // Progress should never exceed 100 + if progress > 100 { + t.Errorf("progress exceeded 100: %d", progress) + } + }) + if err != nil { + t.Fatalf("extractArchive failed: %v", err) + } + + // Max progress should be 100 + if maxProgress != 100 { + t.Errorf("expected max progress 100, got %d", maxProgress) + } +} + +func TestExtractArchive_GzipNoExtension(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_gzip_noext_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a gzip with just .gz (no original extension) + gzPath := filepath.Join(tempDir, "compressed.gz") + destDir := filepath.Join(tempDir, "extracted") + + if err := createTestGzip(gzPath, "Compressed content"); err != nil { + t.Fatal(err) + } + + err = extractArchive(gzPath, destDir, "", nil) + if err != nil { + t.Fatalf("extractArchive failed: %v", err) + } + + // Should create file named "compressed" (without .gz) + destPath := filepath.Join(destDir, "compressed") + if _, err := os.Stat(destPath); os.IsNotExist(err) { + t.Error("expected decompressed file not found") + } +} + +func TestExtractArchive_ReadOnlyDestDir(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_readonly_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + zipPath := filepath.Join(tempDir, "test.zip") + destDir := filepath.Join(tempDir, "readonly") + + if err := createTestZip(zipPath); err != nil { + t.Fatal(err) + } + + // Create a read-only directory + if err := os.MkdirAll(destDir, 0555); err != nil { + t.Fatal(err) + } + // Make it writable for cleanup + defer os.Chmod(destDir, 0755) + + // Extraction should fail due to read-only destination + err = extractArchive(zipPath, destDir, "", nil) + if err == nil { + t.Error("expected error when extracting to read-only directory") + } +} + +func TestSupportedArchiveExtensions(t *testing.T) { + // Test that all listed extensions are recognized + for _, ext := range supportedArchiveExtensions { + filename := "test" + ext + if !isArchiveFile(filename) { + t.Errorf("extension %q should be recognized as archive", ext) + } + } +} + +func TestExtractArchive_Tar(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_tar_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + tarPath := filepath.Join(tempDir, "test.tar") + destDir := filepath.Join(tempDir, "extracted") + + if err := createTestTar(tarPath); err != nil { + t.Fatal(err) + } + + err = extractArchive(tarPath, destDir, "", nil) + if err != nil { + t.Fatalf("extractArchive failed for tar: %v", err) + } + + // Verify files were extracted + destPath := filepath.Join(destDir, "test.txt") + if _, err := os.Stat(destPath); os.IsNotExist(err) { + t.Error("expected file not found") + } +} + +func createTestTar(path string) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + tarWriter := tar.NewWriter(file) + defer tarWriter.Close() + + content := []byte("Hello from tar!") + header := &tar.Header{ + Name: "test.txt", + Mode: 0644, + Size: int64(len(content)), + } + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + if _, err := tarWriter.Write(content); err != nil { + return err + } + + return nil +} + +func TestCountArchiveFiles_Tar(t *testing.T) { + tempDir, err := os.MkdirTemp("", "count_tar_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + tarPath := filepath.Join(tempDir, "test.tar") + if err := createTestTar(tarPath); err != nil { + t.Fatal(err) + } + + count, err := countArchiveFiles(tarPath, "") + if err != nil { + t.Fatalf("countArchiveFiles failed: %v", err) + } + + if count != 1 { + t.Errorf("expected 1 file, got %d", count) + } +} + +func TestOpenArchive_ValidArchive(t *testing.T) { + tempDir, err := os.MkdirTemp("", "open_valid_archive_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + zipPath := filepath.Join(tempDir, "test.zip") + if err := createTestZip(zipPath); err != nil { + t.Fatal(err) + } + + info, err := openArchive(zipPath, "") + if err != nil { + t.Fatalf("openArchive failed: %v", err) + } + defer info.file.Close() + + // Verify all fields are set + if info.file == nil { + t.Error("file should not be nil") + } + if info.stat == nil { + t.Error("stat should not be nil") + } + if info.format == nil { + t.Error("format should not be nil") + } + if info.input == nil { + t.Error("input should not be nil") + } + if info.stat.Size() == 0 { + t.Error("file size should be > 0") + } +} + +func TestExtractArchive_NestedDirectoryStructure(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_nested_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + zipPath := filepath.Join(tempDir, "nested.zip") + destDir := filepath.Join(tempDir, "extracted") + + if err := createDeeplyNestedZip(zipPath); err != nil { + t.Fatal(err) + } + + err = extractArchive(zipPath, destDir, "", nil) + if err != nil { + t.Fatalf("extractArchive failed: %v", err) + } + + // Verify deeply nested file + deepPath := filepath.Join(destDir, "a", "b", "c", "d", "deep.txt") + if _, err := os.Stat(deepPath); os.IsNotExist(err) { + t.Error("deeply nested file not found") + } +} + +func createDeeplyNestedZip(path string) error { + zipFile, err := os.Create(path) + if err != nil { + return err + } + defer zipFile.Close() + + w := zip.NewWriter(zipFile) + + f, err := w.Create("a/b/c/d/deep.txt") + if err != nil { + return err + } + _, err = f.Write([]byte("Deep content")) + if err != nil { + return err + } + + return w.Close() +} + +func TestExtractArchive_EmptyFileName(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_empty_name_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + zipPath := filepath.Join(tempDir, "test.zip") + destDir := filepath.Join(tempDir, "extracted") + + // Create a normal zip - the archive library handles empty names gracefully + if err := createTestZip(zipPath); err != nil { + t.Fatal(err) + } + + err = extractArchive(zipPath, destDir, "", nil) + if err != nil { + t.Fatalf("extractArchive failed: %v", err) + } +} + +func TestExtractArchive_ProgressTracking(t *testing.T) { + tempDir, err := os.MkdirTemp("", "extract_progress_track_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + zipPath := filepath.Join(tempDir, "test.zip") + destDir := filepath.Join(tempDir, "extracted") + + numFiles := 5 + if err := createTestZipWithMultipleFiles(zipPath, numFiles); err != nil { + t.Fatal(err) + } + + var extractedValues []int + var totalValues []int + + err = extractArchive(zipPath, destDir, "", func(extracted int, total int, progress int) { + extractedValues = append(extractedValues, extracted) + totalValues = append(totalValues, total) + }) + if err != nil { + t.Fatalf("extractArchive failed: %v", err) + } + + // Verify total is always the same + for _, total := range totalValues { + if total != numFiles { + t.Errorf("total should be %d, got %d", numFiles, total) + } + } + + // Verify extracted increases sequentially + for i, extracted := range extractedValues { + if extracted != i+1 { + t.Errorf("extracted should be %d, got %d", i+1, extracted) + } + } +} + +func TestArchiveInfo_Fields(t *testing.T) { + tempDir, err := os.MkdirTemp("", "archive_info_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + zipPath := filepath.Join(tempDir, "test.zip") + if err := createTestZip(zipPath); err != nil { + t.Fatal(err) + } + + info, err := openArchive(zipPath, "") + if err != nil { + t.Fatalf("openArchive failed: %v", err) + } + defer info.file.Close() + + // Verify file name + if info.file.Name() != zipPath { + t.Errorf("expected file name %q, got %q", zipPath, info.file.Name()) + } + + // Verify stat mode + if info.stat.Mode().IsDir() { + t.Error("expected file, not directory") + } +} diff --git a/ui/flutter/lib/api/model/task.dart b/ui/flutter/lib/api/model/task.dart index 53acf9e6..2fe9161f 100644 --- a/ui/flutter/lib/api/model/task.dart +++ b/ui/flutter/lib/api/model/task.dart @@ -8,6 +8,18 @@ enum Status { ready, running, pause, wait, error, done } enum Protocol { http, bt } +// ExtractStatus enum matching Go backend +enum ExtractStatus { + @JsonValue('') + none, + @JsonValue('extracting') + extracting, + @JsonValue('done') + done, + @JsonValue('error') + error +} + @JsonSerializable(explicitToJson: true) class Task { String id; @@ -43,6 +55,8 @@ class Progress { int downloaded; int uploadSpeed; int uploaded; + ExtractStatus extractStatus; + int extractProgress; Progress({ required this.used, @@ -50,6 +64,8 @@ class Progress { required this.downloaded, required this.uploadSpeed, required this.uploaded, + this.extractStatus = ExtractStatus.none, + this.extractProgress = 0, }); factory Progress.fromJson(Map