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.Save(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 completed.
408// This one arrives during the import, so the yt-dlp error is nil.
409func TestExecuteDownloadCancelAfterSuccessIsNotCompleted(t *testing.T) {
410 e := newExecEnv(t)
411 e.fakeYTDLP(t, e.writeItem(t, "item-00001", "My Clip", "abc123"))
412 // ffprobe runs during the import, after yt-dlp has already succeeded. Block
413 // there so the cancel lands at exactly that point.
414 e.fakeFFprobe(t, `
415touch "$SCRATCH/probing"
416while [ ! -f "$SCRATCH/proceed" ]; do sleep 0.05; done`)
417
418 d := e.queue(t, &models.Download{})
419
420 done := make(chan error, 1)
421 go func() {
422 _, err := e.svc.ExecuteDownload(context.Background(), d)
423 done <- err
424 }()
425
426 waitForFile(t, filepath.Join(e.scratch, "probing"))
427 e.svc.cancelDownload(d.ID)
428 if err := os.WriteFile(filepath.Join(e.scratch, "proceed"), nil, 0o644); err != nil {
429 t.Fatal(err)
430 }
431
432 select {
433 case err := <-done:
434 if err != ErrCancelled {
435 t.Errorf("error = %v, want ErrCancelled", err)
436 }
437 case <-time.After(20 * time.Second):
438 t.Fatal("ExecuteDownload did not return after cancel")
439 }
440
441 if got := e.status(t, d.ID); got.Status != "cancelled" {
442 t.Errorf("status = %q, want cancelled (a cancelled run must not read as completed)", got.Status)
443 }
444}
445
446// The same rule for a metadata-mode subscription refresh, whose second pass
447// returns the yt-dlp error rather than the import error — so a cancel there also
448// leaves a nil error behind.
449func TestExecuteDownloadMetadataCancelIsNotCompleted(t *testing.T) {
450 e := newExecEnv(t)
451 // Pass 1 (--skip-download) writes metadata for an entry the library does not
452 // have; pass 2 downloads it as a full item.
453 e.fakeYTDLP(t, `
454if [ "$RUN" = "1" ]; then
455 mkdir -p "$DEST/item-00001"
456 printf '{"id":"new1","title":"Fresh","webpage_url":"https://example.com/new1"}' > "$DEST/item-00001/clip.info.json"
457else
458`+e.writeItem(t, "item-00001", "Fresh", "new1")+`
459fi`)
460 e.fakeFFprobe(t, `
461touch "$SCRATCH/probing"
462while [ ! -f "$SCRATCH/proceed" ]; do sleep 0.05; done`)
463
464 sub := &models.Subscription{
465 Name: "Channel",
466 URL: "https://example.com/channel",
467 Enabled: true,
468 RefreshMode: "metadata",
469 ScheduleKind: "daily",
470 }
471 if err := e.subRepo.Create(sub); err != nil {
472 t.Fatalf("create subscription: %v", err)
473 }
474
475 d := e.queue(t, &models.Download{SubscriptionID: sqlNullInt64(sub.ID)})
476
477 done := make(chan error, 1)
478 go func() {
479 _, err := e.svc.ExecuteDownload(context.Background(), d)
480 done <- err
481 }()
482
483 waitForFile(t, filepath.Join(e.scratch, "probing"))
484 e.svc.cancelDownload(d.ID)
485 if err := os.WriteFile(filepath.Join(e.scratch, "proceed"), nil, 0o644); err != nil {
486 t.Fatal(err)
487 }
488
489 select {
490 case err := <-done:
491 if err != ErrCancelled {
492 t.Errorf("error = %v, want ErrCancelled", err)
493 }
494 case <-time.After(20 * time.Second):
495 t.Fatal("ExecuteDownload did not return after cancel")
496 }
497
498 if got := e.status(t, d.ID); got.Status != "cancelled" {
499 t.Errorf("status = %q, want cancelled", got.Status)
500 }
501}
502
503// A restart must clear the temp dirs of downloads it re-queues: leaving them
504// behind makes the re-run import into a fresh uniqueDir and the library ends up
505// holding the same item twice.
506func TestResetStalledDownloadsClearsTempDirs(t *testing.T) {
507 e := newExecEnv(t)
508
509 d := e.queue(t, &models.Download{})
510 if _, err := e.repo.MarkStarted(d.ID); err != nil {
511 t.Fatal(err)
512 }
513
514 dirs := e.svc.tempDirsFor(d.ID)
515 if len(dirs) != 2 {
516 t.Fatalf("tempDirsFor returned %d dirs, want the main and second-pass dirs", len(dirs))
517 }
518 for _, dir := range dirs {
519 if err := os.MkdirAll(dir, 0o755); err != nil {
520 t.Fatal(err)
521 }
522 if err := os.WriteFile(filepath.Join(dir, "partial.mp4.part"), []byte("x"), 0o644); err != nil {
523 t.Fatal(err)
524 }
525 }
526
527 // A download that is not stalled must keep whatever it owns.
528 other := e.queue(t, &models.Download{})
529 keep := e.svc.tempDirFor(other.ID)
530 if err := os.MkdirAll(keep, 0o755); err != nil {
531 t.Fatal(err)
532 }
533
534 if err := e.svc.ResetStalledDownloads(); err != nil {
535 t.Fatalf("ResetStalledDownloads: %v", err)
536 }
537
538 if got := e.status(t, d.ID); got.Status != "queued" {
539 t.Errorf("status = %q, want queued", got.Status)
540 }
541 for _, dir := range dirs {
542 if _, err := os.Stat(dir); !os.IsNotExist(err) {
543 t.Errorf("stale temp dir %s was not removed", dir)
544 }
545 }
546 if _, err := os.Stat(keep); err != nil {
547 t.Errorf("temp dir of a queued download was removed: %v", err)
548 }
549}
550
551// Deleting a download from the queue stops the work it started, so yt-dlp does
552// not keep running for a row that no longer exists.
553func TestDeleteCancelsRunningDownload(t *testing.T) {
554 e := newExecEnv(t)
555 e.fakeYTDLP(t, `touch "$SCRATCH/started"; sleep 60`)
556
557 d := e.queue(t, &models.Download{})
558
559 done := make(chan error, 1)
560 go func() {
561 _, err := e.svc.ExecuteDownload(context.Background(), d)
562 done <- err
563 }()
564
565 waitForFile(t, filepath.Join(e.scratch, "started"))
566 if err := e.svc.Delete(d.ID); err != nil {
567 t.Fatalf("Delete: %v", err)
568 }
569
570 select {
571 case err := <-done:
572 if err != ErrCancelled {
573 t.Errorf("error = %v, want ErrCancelled", err)
574 }
575 case <-time.After(20 * time.Second):
576 t.Fatal("deleting the download did not stop it")
577 }
578
579 if _, err := e.repo.GetByID(d.ID); err == nil {
580 t.Error("download row still exists after Delete")
581 }
582 if names := libraryEntries(t, e.cfg.LibraryDir); len(names) != 0 {
583 t.Errorf("library = %v, want empty", names)
584 }
585}
586
587// Clearing the queue must also stop what is running, for the same reason.
588func TestDeleteAllCancelsRunningDownload(t *testing.T) {
589 e := newExecEnv(t)
590 e.fakeYTDLP(t, `touch "$SCRATCH/started"; sleep 60`)
591
592 d := e.queue(t, &models.Download{})
593
594 done := make(chan error, 1)
595 go func() {
596 _, err := e.svc.ExecuteDownload(context.Background(), d)
597 done <- err
598 }()
599
600 waitForFile(t, filepath.Join(e.scratch, "started"))
601 if err := e.svc.DeleteAll(); err != nil {
602 t.Fatalf("DeleteAll: %v", err)
603 }
604
605 select {
606 case err := <-done:
607 if err != ErrCancelled {
608 t.Errorf("error = %v, want ErrCancelled", err)
609 }
610 case <-time.After(20 * time.Second):
611 t.Fatal("clearing the queue did not stop the running download")
612 }
613}
614
615// yt-dlp rewrites the cookie file on exit with rotated session cookies. Those
616// must be saved back, or the stored snapshot goes stale and the session dies.
617func TestExecuteDownloadSavesRefreshedCookies(t *testing.T) {
618 e := newExecEnv(t)
619 if err := e.settings.SetCookies("# Netscape HTTP Cookie File\nold-session\n"); err != nil {
620 t.Fatalf("SetCookies: %v", err)
621 }
622
623 e.fakeYTDLP(t, `
624cookies=""
625prev=""
626for a in "$@"; do
627 if [ "$prev" = "--cookies" ]; then cookies="$a"; fi
628 prev="$a"
629done
630[ -n "$cookies" ] || { echo "no --cookies argument" >&2; exit 1; }
631printf '# Netscape HTTP Cookie File\nnew-session\n' > "$cookies"
632`+e.writeItem(t, "item-00001", "My Clip", "abc123"))
633
634 d := e.queue(t, &models.Download{})
635 if _, err := e.svc.ExecuteDownload(context.Background(), d); err != nil {
636 t.Fatalf("ExecuteDownload: %v", err)
637 }
638
639 got, err := e.settings.GetCookies()
640 if err != nil {
641 t.Fatalf("GetCookies: %v", err)
642 }
643 if want := "# Netscape HTTP Cookie File\nnew-session\n"; got != want {
644 t.Errorf("cookies = %q, want %q", got, want)
645 }
646}
647
648// An untouched cookie file must leave the stored cookies exactly as they were.
649func TestExecuteDownloadKeepsUntouchedCookies(t *testing.T) {
650 e := newExecEnv(t)
651 const stored = "# Netscape HTTP Cookie File\nold-session\n"
652 if err := e.settings.SetCookies(stored); err != nil {
653 t.Fatalf("SetCookies: %v", err)
654 }
655
656 e.fakeYTDLP(t, e.writeItem(t, "item-00001", "My Clip", "abc123"))
657
658 d := e.queue(t, &models.Download{})
659 if _, err := e.svc.ExecuteDownload(context.Background(), d); err != nil {
660 t.Fatalf("ExecuteDownload: %v", err)
661 }
662
663 got, err := e.settings.GetCookies()
664 if err != nil {
665 t.Fatalf("GetCookies: %v", err)
666 }
667 if got != stored {
668 t.Errorf("cookies = %q, want them unchanged (%q)", got, stored)
669 }
670}
671
672// A download whose scratch directory can't be created must be finalized as an
673// error, not left in "downloading" — which also keeps its subscription busy.
674func TestExecuteDownloadTempDirFailureIsError(t *testing.T) {
675 e := newExecEnv(t)
676 e.fakeYTDLP(t, `exit 0`)
677
678 // A regular file where the temp tree needs a directory makes MkdirAll fail
679 // with ENOTDIR.
680 blocker := filepath.Join(e.scratch, "blocker")
681 if err := os.WriteFile(blocker, nil, 0o644); err != nil {
682 t.Fatal(err)
683 }
684 e.cfg.TempDir = filepath.Join(blocker, "temp")
685
686 sub := &models.Subscription{Name: "s", URL: "https://example.com", OutputDir: "feed"}
687 if err := e.subRepo.Create(sub); err != nil {
688 t.Fatal(err)
689 }
690 d := e.queue(t, &models.Download{SubscriptionID: sqlNullInt64(sub.ID)})
691
692 if _, err := e.svc.ExecuteDownload(context.Background(), d); err == nil {
693 t.Fatal("ExecuteDownload succeeded, want an error")
694 }
695
696 got := e.status(t, d.ID)
697 if got.Status != "error" {
698 t.Errorf("status = %q, want error", got.Status)
699 }
700 if !got.ErrorMessage.Valid || !strings.Contains(got.ErrorMessage.String, "temp download dir") {
701 t.Errorf("error_message = %q, want it to name the temp dir", got.ErrorMessage.String)
702 }
703
704 active, err := e.svc.HasActiveForSubscription(sub.ID)
705 if err != nil {
706 t.Fatal(err)
707 }
708 if active {
709 t.Error("subscription still reported as having an active run")
710 }
711}
712