package services import ( "context" "errors" "testing" ) // A job cancelled while still queued must never execute. processJob // sees the pending-cancel marker at dequeue and leaves the result // untouched; a scan without params would otherwise fail with // "library_id required" and flip the status to failed — so an empty // error field proves the job body never ran. func TestCancelQueuedJobNeverExecutes(t *testing.T) { // Zero workers: the job stays queued until processJob is driven by // hand below. w := NewWorkerWithConfig(0, 10, nil) job := &Job{ ID: "queued-cancel", Type: JobTypeScan, Context: context.Background(), Params: map[string]interface{}{}, } if err := w.EnqueueJob(job); err != nil { t.Fatalf("enqueue failed: %v", err) } // The pending result must exist before any worker picks the job up, // so status polling can see (and cancel) queued jobs. result, ok := w.GetJobStatus("queued-cancel") if !ok { t.Fatal("queued job has no result entry") } if result.Status != JobStatusPending { t.Fatalf("expected pending status, got %q", result.Status) } if err := w.CancelJob("queued-cancel"); err != nil { t.Fatalf("CancelJob failed: %v", err) } // Simulate the dequeue that races the cancellation. w.processJob(job) result, _ = w.GetJobStatus("queued-cancel") if result.Status != JobStatusCancelled { t.Fatalf("expected cancelled status after dequeue, got %q", result.Status) } if result.Error != "" { t.Errorf("skipped job must carry no processing error, got %q", result.Error) } } // Cancelling a running job must invoke its per-job context cancel func // — the old CancelJob only flipped a map status and the job kept // running to completion. func TestCancelRunningJobCancelsContext(t *testing.T) { w := NewWorker(1, nil) ctx, cancel := context.WithCancel(context.Background()) job := &Job{ID: "running-cancel", Type: JobTypeScan, Context: ctx} // Seed the state processJob establishes while a job runs. w.mu.Lock() w.results[job.ID] = &JobResult{JobID: job.ID, Status: JobStatusRunning} w.cancels[job.ID] = cancel w.mu.Unlock() if err := w.CancelJob(job.ID); err != nil { t.Fatalf("CancelJob failed: %v", err) } if ctx.Err() == nil { t.Fatal("CancelJob must cancel the job's context") } result, _ := w.GetJobStatus(job.ID) if result.Status != JobStatusCancelled { t.Fatalf("expected cancelled status, got %q", result.Status) } } func TestCancelJobErrors(t *testing.T) { w := NewWorker(1, nil) if err := w.CancelJob("missing-job"); !errors.Is(err, ErrJobNotFound) { t.Errorf("unknown job: expected ErrJobNotFound, got %v", err) } w.mu.Lock() w.results["done-job"] = &JobResult{JobID: "done-job", Status: JobStatusCompleted} w.results["failed-job"] = &JobResult{JobID: "failed-job", Status: JobStatusFailed} w.results["cancelled-job"] = &JobResult{JobID: "cancelled-job", Status: JobStatusCancelled} w.mu.Unlock() for _, id := range []string{"done-job", "failed-job", "cancelled-job"} { if err := w.CancelJob(id); !errors.Is(err, ErrJobNotCancellable) { t.Errorf("terminal job %q: expected ErrJobNotCancellable, got %v", id, err) } } } // Both enqueue paths must register a pending result: auto-scans arrive // via Enqueue (fire-and-forget) and manual scans via EnqueueJob, and a // queued job must be visible to status polling and cancellable before // a worker dequeues it. func TestEnqueueRecordsPendingResult(t *testing.T) { w := NewWorkerWithConfig(0, 10, nil) fireAndForget := &Job{ ID: "via-enqueue", Type: JobTypeScan, Context: context.Background(), Params: map[string]interface{}{"library_id": "lib-auto"}, } w.Enqueue(fireAndForget) tracked := &Job{ ID: "via-enqueue-job", Type: JobTypeScan, Context: context.Background(), Params: map[string]interface{}{"library_id": "lib-manual"}, } if err := w.EnqueueJob(tracked); err != nil { t.Fatalf("EnqueueJob failed: %v", err) } for id, libID := range map[string]string{ "via-enqueue": "lib-auto", "via-enqueue-job": "lib-manual", } { result, ok := w.GetJobStatus(id) if !ok { t.Fatalf("%s: no pending result recorded", id) } if result.Status != JobStatusPending { t.Errorf("%s: expected pending, got %q", id, result.Status) } if result.LibraryID != libID { t.Errorf("%s: expected library_id %q, got %q", id, libID, result.LibraryID) } } } // A scan whose context is already cancelled must abort immediately // with context.Canceled — before counting, walking, or running the // archive pass. processJob classifies that error as a cancelled job. func TestScanFoldersCancelledContext(t *testing.T) { s := NewMediaScanner(nil) ctx, cancel := context.WithCancel(context.Background()) cancel() if err := s.ScanFolders(ctx); !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } }