Skip to content

Commit 43a6606

Browse files
feat: Add integration test for download pause and resume, ensuring graceful queue shutdown on interruption.
1 parent cf1f007 commit 43a6606

2 files changed

Lines changed: 177 additions & 1 deletion

File tree

‎internal/download/concurrent/downloader.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,10 @@ func (d *ConcurrentDownloader) Download(ctx context.Context, rawurl, destPath st
258258
for {
259259
select {
260260
case <-ctx.Done():
261+
queue.Close()
261262
return
262263
case <-balancerCtx.Done():
264+
queue.Close()
263265
return
264266
case <-ticker.C:
265267
if queue.Len() == 0 && int(queue.IdleWorkers()) == numConns {
@@ -315,7 +317,7 @@ func (d *ConcurrentDownloader) Download(ctx context.Context, rawurl, destPath st
315317
}
316318
}
317319

318-
// Handle pause: save state and exit gracefully
320+
// Handle pause: state saved
319321
if d.State != nil && d.State.IsPaused() {
320322
// Collect remaining tasks
321323
remainingTasks := queue.DrainRemaining()

‎internal/download/resume_test.go‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package download_test
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
10+
tea "github.com/charmbracelet/bubbletea"
11+
"github.com/google/uuid"
12+
"github.com/surge-downloader/surge/internal/download"
13+
"github.com/surge-downloader/surge/internal/download/state"
14+
"github.com/surge-downloader/surge/internal/download/types"
15+
"github.com/surge-downloader/surge/internal/testutil"
16+
)
17+
18+
func TestIntegration_PauseResume(t *testing.T) {
19+
// 1. Setup temporary directory for DB and downloads
20+
tmpDir, err := os.MkdirTemp("", "surge-integration-*")
21+
if err != nil {
22+
t.Fatal(err)
23+
}
24+
defer os.RemoveAll(tmpDir)
25+
26+
// Set XDG_CONFIG_HOME to tmpDir so state.GetDB() creates DB there
27+
// The config package uses "surge" subdirectory
28+
configDir := tmpDir // XDG_CONFIG_HOME usually contains the app dir
29+
t.Setenv("XDG_CONFIG_HOME", configDir)
30+
31+
// Ensure clean state
32+
state.CloseDB()
33+
34+
// Force DB init
35+
if _, err := state.GetDB(); err != nil {
36+
t.Fatalf("Failed to init DB: %v", err)
37+
}
38+
defer state.CloseDB()
39+
40+
// 2. Setup Mock Server (50MB file)
41+
fileSize := int64(50 * 1024 * 1024) // 50MB
42+
server := testutil.NewMockServer(
43+
testutil.WithFileSize(fileSize),
44+
testutil.WithRangeSupport(true),
45+
testutil.WithLatency(20*time.Millisecond), // Add meaningful latency to allow interruption
46+
)
47+
defer server.Close()
48+
49+
url := server.URL()
50+
// Use a fixed filename to make checking easier
51+
filename := "largefile.bin"
52+
outputPath := tmpDir
53+
destPath := filepath.Join(outputPath, filename)
54+
55+
// 3. Start Download and Interrupt
56+
// Use background context, as interruption is handled by progState.Pause()
57+
ctx := context.Background()
58+
59+
// Create a progress channel we can ignore
60+
progressCh := make(chan tea.Msg, 100)
61+
62+
// Create a runtime config
63+
runtime := &types.RuntimeConfig{}
64+
65+
// Create a dummy state
66+
progState := types.NewProgressState(uuid.New().String(), fileSize)
67+
68+
cfg := types.DownloadConfig{
69+
URL: url,
70+
OutputPath: outputPath,
71+
Filename: filename,
72+
ID: progState.ID,
73+
ProgressCh: progressCh,
74+
State: progState,
75+
Runtime: runtime,
76+
IsResume: false,
77+
}
78+
79+
// Start download in a goroutine
80+
errCh := make(chan error)
81+
go func() {
82+
// Provide a valid context for the download
83+
errCh <- download.TUIDownload(ctx, cfg)
84+
}()
85+
86+
// Wait a bit to let it download some data but not finish
87+
time.Sleep(50 * time.Millisecond)
88+
89+
// Interrupt!
90+
// We rely on progState.Pause() to trigger the cancellation via the callback registered in TUIDownload
91+
progState.Pause()
92+
93+
// Wait for download to return
94+
select {
95+
case err := <-errCh:
96+
// We expect nil (graceful exit) or context canceled
97+
if err != nil && err != context.Canceled {
98+
t.Logf("Download returned error (might be expected): %v", err)
99+
}
100+
case <-time.After(5 * time.Second):
101+
t.Fatal("Download did not return after cancellation")
102+
}
103+
104+
// 4. Verify State is Saved
105+
savedState, err := state.LoadState(url, destPath)
106+
if err != nil {
107+
t.Fatalf("Failed to load saved state: %v", err)
108+
}
109+
110+
if savedState.Downloaded == 0 {
111+
t.Error("Saved state shows 0 downloaded bytes")
112+
}
113+
if savedState.Downloaded >= fileSize {
114+
t.Errorf("Download finished too fast! Downloaded %d of %d", savedState.Downloaded, fileSize)
115+
}
116+
if len(savedState.Tasks) == 0 {
117+
t.Error("Saved state has no tasks")
118+
}
119+
120+
// Verify .surge file exists
121+
incompletePath := destPath + types.IncompleteSuffix
122+
info, err := os.Stat(incompletePath)
123+
if err != nil {
124+
t.Fatalf("Incomplete file not found: %v", err)
125+
}
126+
if info.Size() != fileSize {
127+
// Note: we preallocate file size, so it should match total size
128+
t.Errorf("Incomplete file size = %d, want %d", info.Size(), fileSize)
129+
}
130+
131+
t.Logf("Paused successfully. Downloaded: %d bytes", savedState.Downloaded)
132+
133+
// 5. Resume Download
134+
// Create new context
135+
resumeCtx, resumeCancel := context.WithTimeout(context.Background(), 10*time.Second)
136+
defer resumeCancel()
137+
138+
// Update config for resume
139+
cfg.IsResume = true
140+
cfg.DestPath = destPath // Important for resume lookup
141+
142+
// Re-initialize state to clean values but keep ID if needed
143+
// But TUIDownload re-creates concurrent downloader which relies on LoadState
144+
// We should probably reset existing state or use a fresh one to simulate app restart?
145+
// But `cfg.State` is passed in. TUIDownload updates it.
146+
// Let's reset the Pause flag in the state at least.
147+
progState.Resume()
148+
149+
err = download.TUIDownload(resumeCtx, cfg)
150+
if err != nil {
151+
t.Fatalf("Resume failed: %v", err)
152+
}
153+
154+
// 6. Verify Completion
155+
// .surge file should be gone
156+
if _, err := os.Stat(incompletePath); !os.IsNotExist(err) {
157+
t.Error("Incomplete file still exists after resume completion")
158+
}
159+
160+
// Final file should exist
161+
finalInfo, err := os.Stat(destPath)
162+
if err != nil {
163+
t.Fatalf("Final file not found: %v", err)
164+
}
165+
if finalInfo.Size() != fileSize {
166+
t.Errorf("Final file size = %d, want %d", finalInfo.Size(), fileSize)
167+
}
168+
169+
// State should be deleted
170+
_, err = state.LoadState(url, destPath)
171+
if err == nil {
172+
t.Error("State should be deleted after completion")
173+
}
174+
}

0 commit comments

Comments
 (0)