馃П Chunk is a download manager for slow and unstable servers
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 528 lines 14 kB view raw
1package chunk 2 3import ( 4 "archive/zip" 5 "bytes" 6 "context" 7 "io" 8 "math/rand" 9 "net/http" 10 "net/http/httptest" 11 "os" 12 "path/filepath" 13 "strings" 14 "sync/atomic" 15 "testing" 16 "time" 17) 18 19var timeout = 250 * time.Millisecond 20 21func TestDownload_HTTPFailure(t *testing.T) { 22 t.Parallel() 23 s := httptest.NewServer(http.HandlerFunc( 24 func(w http.ResponseWriter, r *http.Request) { 25 if r.Method == http.MethodHead { 26 w.Header().Add("Content-Length", "2") 27 return 28 } 29 w.WriteHeader(http.StatusBadRequest) 30 }, 31 )) 32 defer s.Close() 33 d := Downloader{ 34 OutputDir: t.TempDir(), 35 Timeout: timeout, 36 MaxRetries: 4, 37 ConcurrencyPerServer: 1, 38 ChunkSize: 1024, 39 WaitRetry: 1 * time.Millisecond, 40 } 41 ch := d.Download(s.URL) 42 <-ch // discard the first got (just the file size) 43 got := <-ch 44 if got.Error == nil { 45 t.Error("expected an error, but got nil") 46 } 47 if _, ok := <-ch; ok { 48 t.Error("expected channel closed, but did not get it") 49 } 50} 51 52func TestDownload_ServerTimeout(t *testing.T) { 53 t.Parallel() 54 s := httptest.NewServer(http.HandlerFunc( 55 func(w http.ResponseWriter, r *http.Request) { 56 if r.Method == http.MethodHead { 57 w.Header().Add("Content-Length", "2") 58 return 59 } 60 time.Sleep(10 * timeout) // server sleeps, causing client timeout 61 }, 62 )) 63 defer s.Close() 64 d := Downloader{ 65 OutputDir: t.TempDir(), 66 Timeout: timeout, 67 MaxRetries: 4, 68 ConcurrencyPerServer: 1, 69 ChunkSize: 1024, 70 WaitRetry: 1 * time.Millisecond, 71 } 72 // context with timeout to eventually terminate the download. 73 ctx, cancel := context.WithTimeout(context.Background(), 3*timeout) 74 defer cancel() 75 ch := d.DownloadWithContext(ctx, s.URL) 76 <-ch // discard the first status (file size) 77 got := <-ch 78 if got.Error == nil { 79 t.Error("expected an error due to context timeout, but got nil") 80 } 81 if _, ok := <-ch; ok { 82 t.Error("expected channel closed, but did not get it") 83 } 84} 85 86func TestDownload_DefaultDownloader(t *testing.T) { 87 t.Parallel() 88 s := httptest.NewServer(http.HandlerFunc( 89 func(w http.ResponseWriter, r *http.Request) { 90 if r.Method == http.MethodHead { 91 w.Header().Add("Content-Length", "2") 92 return 93 } 94 if _, err := io.WriteString(w, "42"); err != nil { 95 t.Errorf("failed to write response: %v", err) 96 } 97 }, 98 )) 99 defer s.Close() 100 101 d := DefaultDownloader() 102 d.OutputDir = t.TempDir() 103 ch := d.Download(s.URL + "/My%20File.txt") 104 <-ch // discard the first status (just the file size) 105 got := <-ch 106 defer func() { 107 if err := os.Remove(got.DownloadedFilePath); err != nil { 108 t.Errorf("failed to remove test file: %v", err) 109 } 110 }() 111 112 if got.Error != nil { 113 t.Errorf("invalid error. want:nil got:%q", got.Error) 114 } 115 if got.URL != s.URL+"/My%20File.txt" { 116 t.Errorf("invalid URL. want:%s got:%s", s.URL+"/My%20File.txt", got.URL) 117 } 118 if got.DownloadedFileBytes != 2 { 119 t.Errorf("invalid DownloadedFileBytes. want:2 got:%d", got.DownloadedFileBytes) 120 } 121 if !strings.HasSuffix(got.DownloadedFilePath, "My File.txt") { 122 t.Errorf("expected DownloadedFilePath to enfd with My File.txt, got %s", got.DownloadedFilePath) 123 } 124 if got.FileSizeBytes != 2 { 125 t.Errorf("invalid FileSizeBytes. want:2 got:%d", got.FileSizeBytes) 126 } 127 b, err := os.ReadFile(got.DownloadedFilePath) 128 if err != nil { 129 t.Errorf("error reading downloaded file (%s): %q", got.DownloadedFilePath, err) 130 } 131 if string(b) != "42" { 132 t.Errorf("invalid downloaded file content. want:42 got:%s", string(b)) 133 } 134 if _, ok := <-ch; ok { 135 t.Error("expected channel closed, but did not get it") 136 } 137} 138 139func TestDownload_ZIPArchive(t *testing.T) { 140 t.Parallel() 141 tmp := t.TempDir() 142 pth := filepath.Join(tmp, "archive.zip") 143 expected := make([]byte, 2<<20) 144 for i := range 2 << 20 { 145 expected[i] = byte(97 + rand.Intn(122-97)) 146 } 147 148 // create a zip archive 149 func() { 150 z, err := os.Create(pth) 151 if err != nil { 152 t.Errorf("expected no error creating zip archive, got %s", err) 153 } 154 defer func() { 155 if err := z.Close(); err != nil { 156 t.Errorf("failed to close zip file: %v", err) 157 } 158 }() 159 w := zip.NewWriter(z) 160 f, err := w.Create("file.txt") 161 if err != nil { 162 t.Errorf("expected no error creating archived file, got %s", err) 163 } 164 defer func() { 165 if err := w.Close(); err != nil { 166 t.Errorf("failed to close zip writer: %v", err) 167 } 168 }() 169 if _, err := f.Write(expected); err != nil { 170 t.Errorf("expected no error writing to archived file, got %s", err) 171 } 172 }() 173 174 // create a server to serve the zip archive 175 s := httptest.NewServer(http.HandlerFunc( 176 func(w http.ResponseWriter, r *http.Request) { 177 http.ServeFile(w, r, pth) 178 }, 179 )) 180 defer s.Close() 181 182 // download 183 var got string 184 defer func() { 185 if got != "" { 186 if err := os.Remove(got); err != nil { 187 t.Errorf("failed to remove test file: %v", err) 188 } 189 } 190 }() 191 d := DefaultDownloader() 192 d.OutputDir = t.TempDir() 193 for g := range d.Download(s.URL + "/archive.zip") { 194 got = g.DownloadedFilePath 195 if g.Error != nil { 196 t.Errorf("expected no error during the download of the zip archive, got %s", g.Error) 197 } 198 } 199 200 // unarchive and check contents 201 a, err := zip.OpenReader(got) 202 if err != nil { 203 t.Errorf("expected no error opening downloaded zip archive %s, got %s", got, err) 204 } 205 defer func() { 206 if err := a.Close(); err != nil { 207 t.Errorf("failed to close zip reader: %v", err) 208 } 209 }() 210 r, err := a.Open("file.txt") 211 if err != nil { 212 t.Errorf("expected no error reading downloaded zip archive, got %s", err) 213 } 214 defer func() { 215 if err := r.Close(); err != nil { 216 t.Errorf("failed to close zip file reader: %v", err) 217 } 218 }() 219 var b bytes.Buffer 220 if _, err := io.Copy(&b, r); err != nil { 221 t.Errorf("expected no error reading archived file, got %s", err) 222 } 223 if !bytes.Equal(expected, b.Bytes()) { 224 t.Error("archived contents differ from expected") // not printing because it's a lot of data 225 } 226} 227 228func TestDownload_Retry(t *testing.T) { 229 t.Parallel() 230 var done atomic.Bool 231 s := httptest.NewServer(http.HandlerFunc( 232 func(w http.ResponseWriter, r *http.Request) { 233 if r.Method == http.MethodHead { 234 w.Header().Add("Content-Length", "2") 235 return 236 } 237 if done.CompareAndSwap(false, true) { 238 w.WriteHeader(http.StatusTeapot) 239 return 240 } 241 if _, err := io.WriteString(w, "42"); err != nil { 242 t.Errorf("failed to write response: %v", err) 243 } 244 }, 245 )) 246 defer s.Close() 247 248 d := Downloader{ 249 OutputDir: t.TempDir(), 250 Timeout: timeout, 251 MaxRetries: 4, 252 ConcurrencyPerServer: 1, 253 ChunkSize: 1024, 254 WaitRetry: 1 * time.Millisecond, 255 } 256 ch := d.Download(s.URL) 257 <-ch // discard the first status (just the file size) 258 got := <-ch 259 if got.Error != nil { 260 t.Errorf("invalid error. want:nil got:%q", got.Error) 261 } 262 if !done.Load() { 263 t.Error("expected server to be done, it is not") 264 } 265 if got.URL != s.URL { 266 t.Errorf("invalid URL. want:%s got:%s", s.URL, got.URL) 267 } 268 if got.DownloadedFileBytes != 2 { 269 t.Errorf("invalid DownloadedFileBytes. want:2 got:%d", got.DownloadedFileBytes) 270 } 271 if got.FileSizeBytes != 2 { 272 t.Errorf("invalid FileSizeBytes. want:2 got:%d", got.FileSizeBytes) 273 } 274 b, err := os.ReadFile(got.DownloadedFilePath) 275 if err != nil { 276 t.Errorf("error reading downloaded file (%s): %q", got.DownloadedFilePath, err) 277 } 278 if string(b) != "42" { 279 t.Errorf("invalid downloaded file content. want:42 got:%s", string(b)) 280 } 281 if _, ok := <-ch; ok { 282 t.Error("expected channel closed, but did not get it") 283 } 284} 285 286func TestDownload_ReportPreviouslyDownloadedBytes(t *testing.T) { 287 t.Parallel() 288 tmp := t.TempDir() 289 pdir := t.TempDir() 290 291 // server that serves first chunk but always fails on second chunk 292 s := httptest.NewServer(http.HandlerFunc( 293 func(w http.ResponseWriter, r *http.Request) { 294 if r.Method == http.MethodHead { 295 w.Header().Add("Content-Length", "4") 296 return 297 } 298 rangeHeader := r.Header.Get("Range") 299 if strings.HasPrefix(rangeHeader, "bytes=0-") { 300 w.WriteHeader(http.StatusPartialContent) 301 if _, err := io.WriteString(w, "42"); err != nil { 302 t.Errorf("failed to write response: %v", err) 303 } 304 return 305 } 306 w.WriteHeader(http.StatusTeapot) 307 }, 308 )) 309 defer s.Close() 310 311 // first download attempt: first chunk succeeds, second chunk always fails 312 d := Downloader{ 313 OutputDir: tmp, 314 ProgressDir: pdir, 315 Timeout: timeout, 316 MaxRetries: 1, 317 ConcurrencyPerServer: 1, 318 ChunkSize: 2, 319 } 320 ch := d.Download(s.URL) 321 var first DownloadStatus 322 for s := range ch { 323 first = s 324 } 325 326 // second download attempt: should report the previously downloaded bytes 327 d = Downloader{ 328 OutputDir: tmp, 329 ProgressDir: pdir, 330 Timeout: timeout, 331 MaxRetries: 1, 332 ConcurrencyPerServer: 1, 333 ChunkSize: 2, 334 } 335 ch = d.Download(s.URL) 336 var second DownloadStatus 337 for s := range ch { 338 second = s 339 } 340 if first.DownloadedFileBytes != second.DownloadedFileBytes { 341 t.Errorf("expected the same number of downloaded bytes, got %d and %d", first.DownloadedFileBytes, second.DownloadedFileBytes) 342 } 343} 344 345func TestDownloadWithContext_ErrorUserTimeout(t *testing.T) { 346 t.Parallel() 347 usrTimeout := 250 * time.Millisecond // smaller than overall timeout 348 chunkTimeout := 10 * usrTimeout 349 s := httptest.NewServer(http.HandlerFunc( 350 func(w http.ResponseWriter, r *http.Request) { 351 if r.Method == http.MethodHead { 352 w.Header().Add("Content-Length", "2") 353 return 354 } 355 time.Sleep(2 * usrTimeout) // greater than the user timeout, but shorter than the timeout per chunk. 356 }, 357 )) 358 defer s.Close() 359 d := Downloader{ 360 OutputDir: t.TempDir(), 361 Timeout: chunkTimeout, 362 MaxRetries: 4, 363 ConcurrencyPerServer: 1, 364 ChunkSize: 1024, 365 WaitRetry: 0 * time.Second, 366 } 367 userCtx, cancFunc := context.WithTimeout(context.Background(), usrTimeout) 368 defer cancFunc() 369 370 ch := d.DownloadWithContext(userCtx, s.URL) 371 <-ch // discard the first got (just the file size) 372 got := <-ch 373 if got.Error == nil { 374 t.Error("expected an error, but got nil") 375 } 376 if _, ok := <-ch; ok { 377 t.Error("expected channel closed, but did not get it") 378 } 379} 380 381func TestDownload_Chunks(t *testing.T) { 382 t.Parallel() 383 d := DefaultDownloader() 384 d.ChunkSize = 5 385 got := d.chunks(12) 386 chunks := []chunk{{0, 4}, {5, 9}, {10, 11}} 387 sizes := []int64{5, 5, 2} 388 headers := []string{"bytes=0-4", "bytes=5-9", "bytes=10-11"} 389 if len(got) != len(chunks) { 390 t.Errorf("expected %d chunks, got %d", len(chunks), len(got)) 391 } 392 for i := range got { 393 if got[i].start != chunks[i].start { 394 t.Errorf("expected chunk #%d to start at %d, got %d", i+1, chunks[i].start, got[i].start) 395 } 396 if got[i].end != chunks[i].end { 397 t.Errorf("expected chunk #%d to end at %d, got %d", i+1, chunks[i].end, got[i].end) 398 } 399 if got[i].size() != sizes[i] { 400 t.Errorf("expected chunk #%d to have size %d, got %d", i+1, sizes[i], got[i].size()) 401 } 402 if got[i].rangeHeader() != headers[i] { 403 t.Errorf("expected chunk #%d header to be %s, got %s", i+1, headers[i], got[i].rangeHeader()) 404 } 405 } 406} 407 408func TestGetDownload_WithUserAgent(t *testing.T) { 409 t.Parallel() 410 ua := "Answer/42.0" 411 s := httptest.NewServer(http.HandlerFunc( 412 func(w http.ResponseWriter, r *http.Request) { 413 if r.UserAgent() != ua { 414 t.Errorf("expected user-agent to be %s, got %s", ua, r.UserAgent()) 415 } 416 }, 417 )) 418 defer s.Close() 419 d := DefaultDownloader() 420 d.UserAgent = ua 421 <-d.Download(s.URL) 422} 423 424func TestGetDownloadSize_ContentLength(t *testing.T) { 425 t.Parallel() 426 s := httptest.NewServer(http.HandlerFunc( 427 func(w http.ResponseWriter, r *http.Request) { 428 if _, err := io.WriteString(w, "Test"); err != nil { 429 t.Errorf("failed to write response: %v", err) 430 } 431 }, 432 )) 433 defer s.Close() 434 435 d := DefaultDownloader() 436 got, err := d.getDownloadSize(context.Background(), s.URL) 437 438 if err != nil { 439 t.Errorf("expected no error getting the file size, got %s", err) 440 } 441 if got != 4 { 442 t.Errorf("invalid size, expected 4, got: %d", got) 443 } 444} 445 446func TestGetDownloadSize_WithRetry(t *testing.T) { 447 t.Parallel() 448 attempts := int32(0) 449 s := httptest.NewServer(http.HandlerFunc( 450 func(w http.ResponseWriter, r *http.Request) { 451 if atomic.CompareAndSwapInt32(&attempts, 0, 1) { 452 w.WriteHeader(http.StatusTooManyRequests) 453 return 454 } 455 if _, err := io.WriteString(w, "Test"); err != nil { 456 t.Errorf("failed to write response: %v", err) 457 } 458 }, 459 )) 460 defer s.Close() 461 462 d := DefaultDownloader() 463 got, err := d.getDownloadSize(context.Background(), s.URL) 464 465 if err != nil { 466 t.Errorf("expected no error getting the file size, got %s", err) 467 } 468 if got != 4 { 469 t.Errorf("invalid size, expected 4, got: %d", got) 470 } 471} 472 473func TestGetDownloadSize_ContentRange(t *testing.T) { 474 t.Parallel() 475 s := httptest.NewServer(http.HandlerFunc( 476 func(w http.ResponseWriter, r *http.Request) { 477 w.Header().Set("Content-Range", "bytes 1-10/123") 478 if _, err := io.WriteString(w, ""); err != nil { 479 t.Errorf("failed to write response: %v", err) 480 } 481 }, 482 )) 483 defer s.Close() 484 485 d := DefaultDownloader() 486 got, err := d.getDownloadSize(context.Background(), s.URL) 487 488 if err != nil { 489 t.Errorf("expected no error getting the file size, got %s", err) 490 } 491 if got != 123 { 492 t.Errorf("invalid size, expected 123, got: %d", got) 493 } 494} 495 496func TestGetDownloadSize_ErrorInvalidURL(t *testing.T) { 497 t.Parallel() 498 d := Downloader{ 499 MaxRetries: 1, 500 WaitRetry: 0, 501 Client: http.DefaultClient, 502 } 503 got, err := d.getDownloadSize(context.Background(), "test") 504 505 if err == nil { 506 t.Errorf("expected an error, got nil") 507 } 508 if got != 0 { 509 t.Errorf("invalid size, expected 0, got: %d", got) 510 } 511} 512 513func TestGetDownloadSize_NoContent(t *testing.T) { 514 t.Parallel() 515 s := httptest.NewServer(http.HandlerFunc( 516 func(w http.ResponseWriter, r *http.Request) { 517 if _, err := io.WriteString(w, ""); err != nil { 518 t.Errorf("failed to write response: %v", err) 519 } 520 }, 521 )) 522 defer s.Close() 523 524 d := DefaultDownloader() 525 if _, err := d.getDownloadSize(context.Background(), s.URL); err == nil { 526 t.Error("expected error getting the file size, got nil") 527 } 528}