execute_download_test.go
⎇
Raw
1package service
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10
11 "vidarchive/internal/config"
12 "vidarchive/internal/models"
13 "vidarchive/internal/repository"
14)
15
16// execEnv is a DownloadService wired to a real database and temp directories,
17// with yt-dlp and ffprobe replaced by scripts the test controls.
18type execEnv struct {
19 svc *DownloadService
20 repo *repository.DownloadRepository
21 subRepo *repository.SubscriptionRepository
22 settings *SettingsService
23 cfg *config.Config
24 // scratch holds the marker/signal files the fake tools read and write.
25 scratch string
26}
27
28func newExecEnv(t *testing.T) *execEnv {
29 t.Helper()
30
31 db := setupTestDB(t)
32 t.Cleanup(func() { db.Close() })
33
34 root := t.TempDir()
35 cfg := &config.Config{
36 LibraryDir: filepath.Join(root, "library"),
37 TempDir: filepath.Join(root, "temp"),
38 YTDLPPath: "/bin/false",
39 FFmpegPath: "ffmpeg",
40 FFprobePath: "ffprobe",
41 }
42 for _, dir := range []string{cfg.LibraryDir, cfg.TempDir} {
43 if err := os.MkdirAll(dir, 0o755); err != nil {
44 t.Fatal(err)
45 }
46 }
47
48 downloadRepo := repository.NewDownloadRepository(db)
49 subRepo := repository.NewSubscriptionRepository(db)
50 settingsSvc := NewSettingsService(repository.NewSettingsRepository(db))
51 svc := NewDownloadService(
52 downloadRepo,
53 NewLibraryService(cfg.LibraryDir, cfg.FFmpegPath, cfg.FFprobePath),
54 NewPresetService(repository.NewPresetRepository(db)),
55 settingsSvc,
56 NewSubscriptionService(subRepo, cfg),
57 cfg,
58 )
59
60 return &execEnv{svc: svc, repo: downloadRepo, subRepo: subRepo, settings: settingsSvc, cfg: cfg, scratch: root}
61}
62
63// fakeYTDLP installs a stand-in for yt-dlp. body is shell run with $DEST set to
64// the directory yt-dlp was told to write into (its -P argument), $SCRATCH set to
65// the test's scratch dir, and $RUN set to the invocation count, so a body can
66// behave differently on the metadata second pass.
67func (e *execEnv) fakeYTDLP(t *testing.T, body string) {
68 t.Helper()
69 e.cfg.YTDLPPath = writeScript(t, filepath.Join(e.scratch, "yt-dlp"), `
70DEST=""
71prev=""
72for a in "$@"; do
73 if [ "$prev" = "-P" ]; then DEST="$a"; fi
74 prev="$a"
75done
76export DEST
77export SCRATCH="`+e.scratch+`"
78RUN=$(( $(cat "$SCRATCH/runs" 2>/dev/null || echo 0) + 1 ))
79echo "$RUN" > "$SCRATCH/runs"
80export RUN
81`+body)
82}
83
84// fakeFFprobe installs a stand-in for ffprobe. It reports a 3 second duration,
85// which is what the real one would say about the test videos.
86func (e *execEnv) fakeFFprobe(t *testing.T, body string) {
87 t.Helper()
88 e.cfg.FFprobePath = writeScript(t, filepath.Join(e.scratch, "ffprobe"), `
89export SCRATCH="`+e.scratch+`"
90`+body+`
91echo 3.0`)
92}
93
94func writeScript(t *testing.T, path, body string) string {
95 t.Helper()
96 if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
97 t.Fatal(err)
98 }
99 return path
100}
101
102// writeItem is shell that creates one importable item under $DEST. The media
103// file is a copy of a real video, so the mimetype classification in
104// importItemDir sees actual video content.
105func (e *execEnv) writeItem(t *testing.T, dirName, title, videoID string) string {
106 t.Helper()
107 requireFFmpeg(t)
108 src := filepath.Join(e.scratch, "source-"+dirName+".mp4")
109 makeTestVideo(t, src)
110 return `
111mkdir -p "$DEST/` + dirName + `"
112cp "` + src + `" "$DEST/` + dirName + `/clip.mp4"
113printf '{"id":"` + videoID + `","title":"` + title + `","webpage_url":"https://example.com/` + videoID + `"}' \
114 > "$DEST/` + dirName + `/clip.info.json"
115`
116}
117
118// queue inserts a queued download, the state ExecuteDownload expects.
119func (e *execEnv) queue(t *testing.T, d *models.Download) *models.Download {
120 t.Helper()
121 d.Status = "queued"
122 if d.URL == "" {
123 d.URL = "https://example.com/watch"
124 }
125 if err := e.repo.Create(d); err != nil {
126 t.Fatalf("create download: %v", err)
127 }
128 return d
129}
130
131func (e *execEnv) status(t *testing.T, id int64) *models.Download {
132 t.Helper()
133 got, err := e.repo.GetByID(id)
134 if err != nil {
135 t.Fatalf("reload download %d: %v", id, err)
136 }
137 return got
138}
139
140// waitForFile blocks until path exists. The fake tools touch a file to say they
141// have started, which lets a test act at a known point instead of sleeping.
142func waitForFile(t *testing.T, path string) {
143 t.Helper()
144 deadline := time.Now().Add(10 * time.Second)
145 for time.Now().Before(deadline) {
146 if _, err := os.Stat(path); err == nil {
147 return
148 }
149 time.Sleep(5 * time.Millisecond)
150 }
151 t.Fatalf("timed out waiting for %s", path)
152}
153
154func libraryEntries(t *testing.T, dir string) []string {
155 t.Helper()
156 entries, err := os.ReadDir(dir)
157 if err != nil {
158 t.Fatal(err)
159 }
160 var names []string
161 for _, entry := range entries {
162 names = append(names, entry.Name())
163 }
164 return names
165}
166
167func TestExecuteDownloadCompletes(t *testing.T) {
168 e := newExecEnv(t)
169 e.fakeYTDLP(t, e.writeItem(t, "item-00001", "My Clip", "abc123"))
170
171 d := e.queue(t, &models.Download{})
172 processed, err := e.svc.ExecuteDownload(context.Background(), d)
173 if err != nil {
174 t.Fatalf("ExecuteDownload: %v", err)
175 }
176 if !processed {
177 t.Error("processed = false, want true")
178 }
179
180 got := e.status(t, d.ID)
181 if got.Status != "completed" {
182 t.Errorf("status = %q, want completed", got.Status)
183 }
184 if !got.CompletedAt.Valid {
185 t.Error("completed_at not set")
186 }
187 if names := libraryEntries(t, e.cfg.LibraryDir); len(names) != 1 || names[0] != "My Clip" {
188 t.Errorf("library = %v, want [My Clip]", names)
189 }
190 if _, err := os.Stat(e.svc.tempDirFor(d.ID)); !os.IsNotExist(err) {
191 t.Error("temp download dir was not removed")
192 }
193}
194
195// A download already claimed by another worker must be left alone: Submit and
196// the 2s queue checker can both enqueue the same row.
197func TestExecuteDownloadSkipsAlreadyClaimed(t *testing.T) {
198 e := newExecEnv(t)
199 e.fakeYTDLP(t, `touch "$SCRATCH/ran"`)
200
201 d := e.queue(t, &models.Download{})
202 if _, err := e.repo.MarkStarted(d.ID); err != nil {
203 t.Fatal(err)
204 }
205
206 processed, err := e.svc.ExecuteDownload(context.Background(), d)
207 if err != nil {
208 t.Errorf("error = %v, want nil", err)
209 }
210 if processed {
211 t.Error("processed = true, want false for an already-claimed download")
212 }
213 if _, err := os.Stat(filepath.Join(e.scratch, "ran")); err == nil {
214 t.Error("yt-dlp ran for a download this worker did not claim")
215 }
216}
217
218func TestExecuteDownloadRecordsYTDLPFailure(t *testing.T) {
219 e := newExecEnv(t)
220 e.fakeYTDLP(t, `echo "ERROR: video unavailable" >&2; exit 3`)
221
222 d := e.queue(t, &models.Download{})
223 processed, err := e.svc.ExecuteDownload(context.Background(), d)
224 if err == nil {
225 t.Fatal("expected an error from a failing yt-dlp")
226 }
227 if processed {
228 t.Error("processed = true, want false")
229 }
230
231 got := e.status(t, d.ID)
232 if got.Status != "error" {
233 t.Errorf("status = %q, want error", got.Status)
234 }
235 if !got.ErrorMessage.Valid || got.ErrorMessage.String == "" {
236 t.Error("error_message not recorded")
237 }
238 if !strings.Contains(got.Logs.String, "video unavailable") {
239 t.Errorf("yt-dlp output not persisted to logs: %q", got.Logs.String)
240 }
241}
242
243// yt-dlp can exit 0 having downloaded nothing (every entry filtered out). For a
244// plain download that is a failure, not a silent success.
245func TestExecuteDownloadEmptyResultIsFailure(t *testing.T) {
246 e := newExecEnv(t)
247 e.fakeYTDLP(t, `mkdir -p "$DEST/item-00001"; exit 0`)
248
249 d := e.queue(t, &models.Download{})
250 if _, err := e.svc.ExecuteDownload(context.Background(), d); err == nil {
251 t.Fatal("expected an error when no media was downloaded")
252 }
253
254 got := e.status(t, d.ID)
255 if got.Status != "error" {
256 t.Errorf("status = %q, want error", got.Status)
257 }
258 if !strings.Contains(got.ErrorMessage.String, "no media files") {
259 t.Errorf("error_message = %q, want it to mention no media files", got.ErrorMessage.String)
260 }
261}
262
263// yt-dlp exits non-zero when a single playlist entry fails. The entries that did
264// download must still be imported, and the run must read as completed with a
265// note in the log.
266func TestExecuteDownloadPartialPlaylistFailureCompletes(t *testing.T) {
267 e := newExecEnv(t)
268 e.fakeYTDLP(t, e.writeItem(t, "item-00001", "My Clip", "abc123")+`
269echo "ERROR: [youtube] bad2: Video unavailable" >&2
270exit 1`)
271
272 d := e.queue(t, &models.Download{})
273 processed, err := e.svc.ExecuteDownload(context.Background(), d)
274 if err != nil {
275 t.Fatalf("ExecuteDownload: %v", err)
276 }
277 if !processed {
278 t.Error("processed = false, want true")
279 }
280
281 got := e.status(t, d.ID)
282 if got.Status != "completed" {
283 t.Errorf("status = %q, want completed", got.Status)
284 }
285 if names := libraryEntries(t, e.cfg.LibraryDir); len(names) != 1 || names[0] != "My Clip" {
286 t.Errorf("library = %v, want [My Clip], the entry that did download", names)
287 }
288 if !strings.Contains(got.Logs.String, "1 item(s) were imported anyway") {
289 t.Errorf("logs do not report the partial failure: %q", got.Logs.String)
290 }
291}
292
293// The same failure with nothing downloaded is a plain error.
294func TestExecuteDownloadTotalFailureIsError(t *testing.T) {
295 e := newExecEnv(t)
296 e.fakeYTDLP(t, `echo "ERROR: [youtube] bad: Video unavailable" >&2; exit 1`)
297
298 d := e.queue(t, &models.Download{})
299 if _, err := e.svc.ExecuteDownload(context.Background(), d); err == nil {
300 t.Fatal("expected an error when nothing was downloaded")
301 }
302
303 if got := e.status(t, d.ID); got.Status != "error" {
304 t.Errorf("status = %q, want error", got.Status)
305 }
306}
307
308// A reserved flag must fail before yt-dlp is ever started, and a preset's flags
309// are checked as well as the download's own.
310func TestExecuteDownloadRejectsReservedPresetFlag(t *testing.T) {
311 e := newExecEnv(t)
312 e.fakeYTDLP(t, `touch "$SCRATCH/ran"`)
313
314 preset := &models.Preset{Name: "Bad", CustomFlags: "-o /tmp/anywhere.mp4"}
315 if err := e.svc.presetSvc.Create(preset); err != nil {
316 t.Fatalf("create preset: %v", err)
317 }
318
319 d := e.queue(t, &models.Download{PresetID: sqlNullInt64(preset.ID)})
320 if _, err := e.svc.ExecuteDownload(context.Background(), d); err == nil {
321 t.Fatal("expected the reserved flag to be rejected")
322 }
323
324 if got := e.status(t, d.ID); got.Status != "error" {
325 t.Errorf("status = %q, want error", got.Status)
326 }
327 if _, err := os.Stat(filepath.Join(e.scratch, "ran")); err == nil {
328 t.Error("yt-dlp ran despite a reserved flag")
329 }
330}
331
332// A user-initiated cancel while yt-dlp runs is terminal: the row is recorded
333// "cancelled" and nothing reaches the library.
334func TestExecuteDownloadCancelDuringRun(t *testing.T) {
335 e := newExecEnv(t)
336 e.fakeYTDLP(t, `touch "$SCRATCH/started"; sleep 60`)
337
338 d := e.queue(t, &models.Download{})
339
340 done := make(chan error, 1)
341 go func() {
342 _, err := e.svc.ExecuteDownload(context.Background(), d)
343 done <- err
344 }()
345
346 waitForFile(t, filepath.Join(e.scratch, "started"))
347 e.svc.cancelDownload(d.ID)
348
349 select {
350 case err := <-done:
351 if err != ErrCancelled {
352 t.Errorf("error = %v, want ErrCancelled", err)
353 }
354 case <-time.After(20 * time.Second):
355 t.Fatal("ExecuteDownload did not return after cancel")
356 }
357
358 if got := e.status(t, d.ID); got.Status != "cancelled" {
359 t.Errorf("status = %q, want cancelled", got.Status)
360 }
361 if names := libraryEntries(t, e.cfg.LibraryDir); len(names) != 0 {
362 t.Errorf("library = %v, want empty", names)
363 }
364}
365
366// A shutdown is not a user cancel: the row must stay "downloading" so that
367// ResetStalledDownloads re-queues it on the next start instead of losing it.
368func TestExecuteDownloadShutdownLeavesRowResumable(t *testing.T) {
369 e := newExecEnv(t)
370 e.fakeYTDLP(t, `touch "$SCRATCH/started"; sleep 60`)
371
372 d := e.queue(t, &models.Download{})
373
374 // The parent context stands in for the worker pool's, which Stop cancels.
375 parent, stop := context.WithCancel(context.Background())
376 defer stop()
377
378 done := make(chan error, 1)
379 go func() {
380 _, err := e.svc.ExecuteDownload(parent, d)
381 done <- err
382 }()
383
384 waitForFile(t, filepath.Join(e.scratch, "started"))
385 stop()
386
387 select {
388 case err := <-done:
389 if err != ErrCancelled {
390 t.Errorf("error = %v, want ErrCancelled", err)
391 }
392 case <-time.After(20 * time.Second):
393 t.Fatal("ExecuteDownload did not return after shutdown")
394 }
395
396 if got := e.status(t, d.ID); got.Status != "downloading" {
397 t.Fatalf("status = %q, want downloading so the restart can resume it", got.Status)
398 }
399 if err := e.svc.ResetStalledDownloads(); err != nil {
400 t.Fatalf("ResetStalledDownloads: %v", err)
401 }
402 if got := e.status(t, d.ID); got.Status != "queued" {
403 t.Errorf("status after restart = %q, want queued", got.Status)
404 }
405}
406
407// A cancel that lands after yt-dlp exited 0 must not be recorded as a completed
408// download. The cancel here arrives during the import, so the yt-dlp error is
409// nil — the case that used to fall through to MarkCompleted.
410func TestExecuteDownloadCancelAfterSuccessIsNotCompleted(t *testing.T) {
411 e := newExecEnv(t)
412 e.fakeYTDLP(t, e.writeItem(t, "item-00001", "My Clip", "abc123"))
413 // ffprobe runs during the import, after yt-dlp has already succeeded. Block
414 // there so the cancel lands at exactly that point.
415 e.fakeFFprobe(t, `
416touch "$SCRATCH/probing"
417while [ ! -f "$SCRATCH/proceed" ]; do sleep 0.05; done`)
418
419 d := e.queue(t, &models.Download{})
420
421 done := make(chan error, 1)
422 go func() {
423 _, err := e.svc.ExecuteDownload(context.Background(), d)
424 done <- err
425 }()
426
427 waitForFile(t, filepath.Join(e.scratch, "probing"))
428 e.svc.cancelDownload(d.ID)
429 if err := os.WriteFile(filepath.Join(e.scratch, "proceed"), nil, 0o644); err != nil {
430 t.Fatal(err)
431 }
432
433 select {
434 case err := <-done:
435 if err != ErrCancelled {
436 t.Errorf("error = %v, want ErrCancelled", err)
437 }
438 case <-time.After(20 * time.Second):
439 t.Fatal("ExecuteDownload did not return after cancel")
440 }
441
442 if got := e.status(t, d.ID); got.Status != "cancelled" {
443 t.Errorf("status = %q, want cancelled (a cancelled run must not read as completed)", got.Status)
444 }
445}
446
447// The same rule for a metadata-mode subscription refresh, whose second pass
448// returns the yt-dlp error rather than the import error — so a cancel there also
449// leaves a nil error behind.
450func TestExecuteDownloadMetadataCancelIsNotCompleted(t *testing.T) {
451 e := newExecEnv(t)
452 // Pass 1 (--skip-download) writes metadata for an entry the library does not
453 // have; pass 2 downloads it as a full item.
454 e.fakeYTDLP(t, `
455if [ "$RUN" = "1" ]; then
456 mkdir -p "$DEST/item-00001"
457 printf '{"id":"new1","title":"Fresh","webpage_url":"https://example.com/new1"}' > "$DEST/item-00001/clip.info.json"
458else
459`+e.writeItem(t, "item-00001", "Fresh", "new1")+`
460fi`)
461 e.fakeFFprobe(t, `
462touch "$SCRATCH/probing"
463while [ ! -f "$SCRATCH/proceed" ]; do sleep 0.05; done`)
464
465 sub := &models.Subscription{
466 Name: "Channel",
467 URL: "https://example.com/channel",
468 Enabled: true,
469 RefreshMode: "metadata",
470 ScheduleKind: "daily",
471 }
472 if err := e.subRepo.Create(sub); err != nil {
473 t.Fatalf("create subscription: %v", err)
474 }
475
476 d := e.queue(t, &models.Download{SubscriptionID: sqlNullInt64(sub.ID)})
477
478 done := make(chan error, 1)
479 go func() {
480 _, err := e.svc.ExecuteDownload(context.Background(), d)
481 done <- err
482 }()
483
484 waitForFile(t, filepath.Join(e.scratch, "probing"))
485 e.svc.cancelDownload(d.ID)
486 if err := os.WriteFile(filepath.Join(e.scratch, "proceed"), nil, 0o644); err != nil {
487 t.Fatal(err)
488 }
489
490 select {
491 case err := <-done:
492 if err != ErrCancelled {
493 t.Errorf("error = %v, want ErrCancelled", err)
494 }
495 case <-time.After(20 * time.Second):
496 t.Fatal("ExecuteDownload did not return after cancel")
497 }
498
499 if got := e.status(t, d.ID); got.Status != "cancelled" {
500 t.Errorf("status = %q, want cancelled", got.Status)
501 }
502}
503
504// A restart must clear the temp dirs of downloads it re-queues: leaving them
505// behind makes the re-run import into a fresh uniqueDir and the library ends up
506// holding the same item twice.
507func TestResetStalledDownloadsClearsTempDirs(t *testing.T) {
508 e := newExecEnv(t)
509
510 d := e.queue(t, &models.Download{})
511 if _, err := e.repo.MarkStarted(d.ID); err != nil {
512 t.Fatal(err)
513 }
514
515 dirs := e.svc.tempDirsFor(d.ID)
516 if len(dirs) != 2 {
517 t.Fatalf("tempDirsFor returned %d dirs, want the main and second-pass dirs", len(dirs))
518 }
519 for _, dir := range dirs {
520 if err := os.MkdirAll(dir, 0o755); err != nil {
521 t.Fatal(err)
522 }
523 if err := os.WriteFile(filepath.Join(dir, "partial.mp4.part"), []byte("x"), 0o644); err != nil {
524 t.Fatal(err)
525 }
526 }
527
528 // A download that is not stalled must keep whatever it owns.
529 other := e.queue(t, &models.Download{})
530 keep := e.svc.tempDirFor(other.ID)
531 if err := os.MkdirAll(keep, 0o755); err != nil {
532 t.Fatal(err)
533 }
534
535 if err := e.svc.ResetStalledDownloads(); err != nil {
536 t.Fatalf("ResetStalledDownloads: %v", err)
537 }
538
539 if got := e.status(t, d.ID); got.Status != "queued" {
540 t.Errorf("status = %q, want queued", got.Status)
541 }
542 for _, dir := range dirs {
543 if _, err := os.Stat(dir); !os.IsNotExist(err) {
544 t.Errorf("stale temp dir %s was not removed", dir)
545 }
546 }
547 if _, err := os.Stat(keep); err != nil {
548 t.Errorf("temp dir of a queued download was removed: %v", err)
549 }
550}
551
552// Deleting a download from the queue stops the work it started, so yt-dlp does
553// not keep running for a row that no longer exists.
554func TestDeleteCancelsRunningDownload(t *testing.T) {
555 e := newExecEnv(t)
556 e.fakeYTDLP(t, `touch "$SCRATCH/started"; sleep 60`)
557
558 d := e.queue(t, &models.Download{})
559
560 done := make(chan error, 1)
561 go func() {
562 _, err := e.svc.ExecuteDownload(context.Background(), d)
563 done <- err
564 }()
565
566 waitForFile(t, filepath.Join(e.scratch, "started"))
567 if err := e.svc.Delete(d.ID); err != nil {
568 t.Fatalf("Delete: %v", err)
569 }
570
571 select {
572 case err := <-done:
573 if err != ErrCancelled {
574 t.Errorf("error = %v, want ErrCancelled", err)
575 }
576 case <-time.After(20 * time.Second):
577 t.Fatal("deleting the download did not stop it")
578 }
579
580 if _, err := e.repo.GetByID(d.ID); err == nil {
581 t.Error("download row still exists after Delete")
582 }
583 if names := libraryEntries(t, e.cfg.LibraryDir); len(names) != 0 {
584 t.Errorf("library = %v, want empty", names)
585 }
586}
587
588// Clearing the queue must also stop what is running, for the same reason.
589func TestDeleteAllCancelsRunningDownload(t *testing.T) {
590 e := newExecEnv(t)
591 e.fakeYTDLP(t, `touch "$SCRATCH/started"; sleep 60`)
592
593 d := e.queue(t, &models.Download{})
594
595 done := make(chan error, 1)
596 go func() {
597 _, err := e.svc.ExecuteDownload(context.Background(), d)
598 done <- err
599 }()
600
601 waitForFile(t, filepath.Join(e.scratch, "started"))
602 if err := e.svc.DeleteAll(); err != nil {
603 t.Fatalf("DeleteAll: %v", err)
604 }
605
606 select {
607 case err := <-done:
608 if err != ErrCancelled {
609 t.Errorf("error = %v, want ErrCancelled", err)
610 }
611 case <-time.After(20 * time.Second):
612 t.Fatal("clearing the queue did not stop the running download")
613 }
614}
615
616// yt-dlp rewrites the cookie file on exit with rotated session cookies. Those
617// must be saved back, or the stored snapshot goes stale and the session dies.
618func TestExecuteDownloadSavesRefreshedCookies(t *testing.T) {
619 e := newExecEnv(t)
620 if err := e.settings.SetCookies("# Netscape HTTP Cookie File\nold-session\n"); err != nil {
621 t.Fatalf("SetCookies: %v", err)
622 }
623
624 e.fakeYTDLP(t, `
625cookies=""
626prev=""
627for a in "$@"; do
628 if [ "$prev" = "--cookies" ]; then cookies="$a"; fi
629 prev="$a"
630done
631[ -n "$cookies" ] || { echo "no --cookies argument" >&2; exit 1; }
632printf '# Netscape HTTP Cookie File\nnew-session\n' > "$cookies"
633`+e.writeItem(t, "item-00001", "My Clip", "abc123"))
634
635 d := e.queue(t, &models.Download{})
636 if _, err := e.svc.ExecuteDownload(context.Background(), d); err != nil {
637 t.Fatalf("ExecuteDownload: %v", err)
638 }
639
640 got, err := e.settings.GetCookies()
641 if err != nil {
642 t.Fatalf("GetCookies: %v", err)
643 }
644 if want := "# Netscape HTTP Cookie File\nnew-session\n"; got != want {
645 t.Errorf("cookies = %q, want %q", got, want)
646 }
647}
648
649// An untouched cookie file must leave the stored cookies exactly as they were.
650func TestExecuteDownloadKeepsUntouchedCookies(t *testing.T) {
651 e := newExecEnv(t)
652 const stored = "# Netscape HTTP Cookie File\nold-session\n"
653 if err := e.settings.SetCookies(stored); err != nil {
654 t.Fatalf("SetCookies: %v", err)
655 }
656
657 e.fakeYTDLP(t, e.writeItem(t, "item-00001", "My Clip", "abc123"))
658
659 d := e.queue(t, &models.Download{})
660 if _, err := e.svc.ExecuteDownload(context.Background(), d); err != nil {
661 t.Fatalf("ExecuteDownload: %v", err)
662 }
663
664 got, err := e.settings.GetCookies()
665 if err != nil {
666 t.Fatalf("GetCookies: %v", err)
667 }
668 if got != stored {
669 t.Errorf("cookies = %q, want them unchanged (%q)", got, stored)
670 }
671}
672
673// A download whose scratch directory can't be created must be finalized as an
674// error, not left in "downloading" — which also keeps its subscription busy.
675func TestExecuteDownloadTempDirFailureIsError(t *testing.T) {
676 e := newExecEnv(t)
677 e.fakeYTDLP(t, `exit 0`)
678
679 // A regular file where the temp tree needs a directory makes MkdirAll fail
680 // with ENOTDIR.
681 blocker := filepath.Join(e.scratch, "blocker")
682 if err := os.WriteFile(blocker, nil, 0o644); err != nil {
683 t.Fatal(err)
684 }
685 e.cfg.TempDir = filepath.Join(blocker, "temp")
686
687 sub := &models.Subscription{Name: "s", URL: "https://example.com", OutputDir: "feed"}
688 if err := e.subRepo.Create(sub); err != nil {
689 t.Fatal(err)
690 }
691 d := e.queue(t, &models.Download{SubscriptionID: sqlNullInt64(sub.ID)})
692
693 if _, err := e.svc.ExecuteDownload(context.Background(), d); err == nil {
694 t.Fatal("ExecuteDownload succeeded, want an error")
695 }
696
697 got := e.status(t, d.ID)
698 if got.Status != "error" {
699 t.Errorf("status = %q, want error", got.Status)
700 }
701 if !got.ErrorMessage.Valid || !strings.Contains(got.ErrorMessage.String, "temp download dir") {
702 t.Errorf("error_message = %q, want it to name the temp dir", got.ErrorMessage.String)
703 }
704
705 active, err := e.svc.HasActiveForSubscription(sub.ID)
706 if err != nil {
707 t.Fatal(err)
708 }
709 if active {
710 t.Error("subscription still reported as having an active run")
711 }
712}
713