coda/internal/worker/worker_test.go

474 lines
11 KiB
Go

package worker
import (
"context"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
"time"
"coda/internal/config"
"coda/internal/jobs"
"coda/internal/models"
"coda/internal/queue"
"coda/internal/scheduler"
"coda/internal/store"
"coda/internal/transcoder"
"coda/internal/validator"
)
type mockStore struct {
data map[string]string
}
func (m *mockStore) GetAll(_ context.Context) (map[string]string, error) {
result := make(map[string]string, len(m.data))
for k, v := range m.data {
result[k] = v
}
return result, nil
}
func (m *mockStore) Set(_ context.Context, key, value string) error {
if m.data == nil {
m.data = map[string]string{}
}
m.data[key] = value
return nil
}
func (m *mockStore) Delete(_ context.Context, key string) error {
delete(m.data, key)
return nil
}
type countingRescanner struct {
mu sync.Mutex
calls int
paths []string
}
func (r *countingRescanner) Rescan(_ context.Context, jobPath string) error {
r.mu.Lock()
r.calls++
r.paths = append(r.paths, jobPath)
r.mu.Unlock()
return nil
}
func (r *countingRescanner) Calls() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.calls
}
type countingNotifier struct {
mu sync.Mutex
calls int
statuses []models.JobStatus
storedStatus []models.JobStatus
store *store.Store
}
func (n *countingNotifier) Notify(ctx context.Context, job *models.Job) error {
n.mu.Lock()
defer n.mu.Unlock()
n.calls++
n.statuses = append(n.statuses, job.Status)
if n.store != nil {
stored, err := n.store.Jobs.Get(ctx, job.ID)
if err == nil {
n.storedStatus = append(n.storedStatus, stored.Status)
}
}
return nil
}
func (n *countingNotifier) Calls() int {
n.mu.Lock()
defer n.mu.Unlock()
return n.calls
}
func setupPool(t *testing.T, overrides map[string]string) (*store.Store, *queue.Queue, *jobs.Service, *Pool, *countingRescanner) {
t.Helper()
st := openTestStore(t)
q := queue.New(10)
jobSvc := jobs.New(st, q)
svc, err := config.NewService(context.Background(), &mockStore{data: overrides})
if err != nil {
t.Fatalf("NewService: %v", err)
}
sched := scheduler.New(svc)
tr := transcoder.New(svc)
val := validator.New(svc)
rs := &countingRescanner{}
pool := New(svc, st, q, sched, tr, val, rs, NoopNotifier{})
return st, q, jobSvc, pool, rs
}
func TestNotifierCalledBeforeFinishOnSuccess(t *testing.T) {
dir := t.TempDir()
albumDir := filepath.Join(dir, "Test Artist", "Notify Album")
if err := os.MkdirAll(albumDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(albumDir, "01 track.flac"), []byte("fake flac"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
st, _, jobSvc, pool, _ := setupPool(t, map[string]string{
"DRY_RUN": "true",
"WORK_WINDOW_ENABLED": "false",
"MUSIC_PATH": dir,
})
notifier := &countingNotifier{store: st}
pool.Notifier = notifier
ctx := context.Background()
id, err := jobSvc.Enqueue(ctx, jobs.EnqueueInput{
Artist: "Test Artist",
Album: "Notify Album",
Path: albumDir,
Source: models.SourceManual,
})
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
pool.process(ctx, id)
if notifier.Calls() != 1 {
t.Fatalf("notifier calls = %d, want 1", notifier.Calls())
}
if notifier.statuses[0] != models.StatusDone {
t.Errorf("notified status = %q, want done", notifier.statuses[0])
}
if notifier.storedStatus[0] != models.StatusProcessing {
t.Errorf("stored status during notify = %q, want processing", notifier.storedStatus[0])
}
job, err := st.Jobs.Get(ctx, id)
if err != nil {
t.Fatalf("Get: %v", err)
}
if job.Status != models.StatusDone {
t.Errorf("final status = %q, want done", job.Status)
}
}
func TestNotifierCalledBeforeFinishOnError(t *testing.T) {
dir := t.TempDir()
albumDir := filepath.Join(dir, "Test Artist", "Empty Album")
if err := os.MkdirAll(albumDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
st, _, jobSvc, pool, _ := setupPool(t, map[string]string{
"DRY_RUN": "true",
"WORK_WINDOW_ENABLED": "false",
"MUSIC_PATH": dir,
})
notifier := &countingNotifier{store: st}
pool.Notifier = notifier
ctx := context.Background()
id, err := jobSvc.Enqueue(ctx, jobs.EnqueueInput{
Artist: "Test Artist",
Album: "Empty Album",
Path: albumDir,
Source: models.SourceManual,
})
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
pool.process(ctx, id)
if notifier.Calls() != 1 {
t.Fatalf("notifier calls = %d, want 1", notifier.Calls())
}
if notifier.statuses[0] != models.StatusError {
t.Errorf("notified status = %q, want error", notifier.statuses[0])
}
if notifier.storedStatus[0] != models.StatusProcessing {
t.Errorf("stored status during notify = %q, want processing", notifier.storedStatus[0])
}
job, err := st.Jobs.Get(ctx, id)
if err != nil {
t.Fatalf("Get: %v", err)
}
if job.Status != models.StatusError {
t.Errorf("final status = %q, want error", job.Status)
}
}
func TestNoopNotifier(t *testing.T) {
if err := (NoopNotifier{}).Notify(context.Background(), &models.Job{}); err != nil {
t.Errorf("NoopNotifier.Notify returned error: %v", err)
}
}
func openTestStore(t *testing.T) *store.Store {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("store.New: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestPoolDryRun(t *testing.T) {
dir := t.TempDir()
albumDir := filepath.Join(dir, "Test Artist", "Test Album")
if err := os.MkdirAll(albumDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
for _, name := range []string{"01 track.flac", "02 track.flac"} {
if err := os.WriteFile(filepath.Join(albumDir, name), []byte("fake flac"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
st, q, jobSvc, pool, rs := setupPool(t, map[string]string{
"DRY_RUN": "true",
"WORK_WINDOW_ENABLED": "false",
"MUSIC_PATH": dir,
})
_ = st
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
id, err := jobSvc.Enqueue(ctx, jobs.EnqueueInput{
Artist: "Test Artist",
Album: "Test Album",
Path: albumDir,
Source: models.SourceManual,
})
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
pool.Start(ctx)
time.Sleep(200 * time.Millisecond)
q.Close()
pool.Wait()
job, err := st.Jobs.Get(ctx, id)
if err != nil {
t.Fatalf("Get: %v", err)
}
if job.Status != models.StatusDone {
t.Errorf("Status = %q, want done", job.Status)
}
for _, name := range []string{"01 track.flac", "02 track.flac"} {
if _, err := os.Stat(filepath.Join(albumDir, name)); os.IsNotExist(err) {
t.Errorf("%s should not be removed in dry run", name)
}
}
if rs.Calls() > 0 {
t.Errorf("rescanner should not be called in dry run, got %d calls", rs.Calls())
}
}
func requireFFmpegFFprobe(t *testing.T) (string, string) {
t.Helper()
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("ffmpeg not found")
}
ffprobe, err := exec.LookPath("ffprobe")
if err != nil {
t.Skip("ffprobe not found")
}
return ffmpeg, ffprobe
}
func generateTestFLAC(t *testing.T, dir, name, ffmpegPath string) string {
t.Helper()
flacPath := filepath.Join(dir, name)
cmd := exec.Command(ffmpegPath,
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
"-c:a", "flac",
"-y",
flacPath,
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("generate flac: %v\n%s", err, out)
}
return flacPath
}
func TestPoolRealConversion(t *testing.T) {
ffmpegPath, ffprobePath := requireFFmpegFFprobe(t)
t.Setenv("FFMPEG_BIN", ffmpegPath)
t.Setenv("FFPROBE_BIN", ffprobePath)
dir := t.TempDir()
albumDir := filepath.Join(dir, "Test Artist", "Real Album")
if err := os.MkdirAll(albumDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
flacPath := generateTestFLAC(t, albumDir, "01 real.flac", ffmpegPath)
st, q, jobSvc, pool, rs := setupPool(t, map[string]string{
"DRY_RUN": "false",
"WORK_WINDOW_ENABLED": "false",
"MUSIC_PATH": dir,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
id, err := jobSvc.Enqueue(ctx, jobs.EnqueueInput{
Artist: "Test Artist",
Album: "Real Album",
Path: albumDir,
Source: models.SourceManual,
})
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
pool.Start(ctx)
time.Sleep(1 * time.Second)
q.Close()
pool.Wait()
job, err := st.Jobs.Get(ctx, id)
if err != nil {
t.Fatalf("Get: %v", err)
}
if job.Status != models.StatusDone {
t.Errorf("Status = %q, want done (error: %q)", job.Status, job.ErrorMsg)
}
if _, err := os.Stat(flacPath); !os.IsNotExist(err) {
t.Error("flac should be removed after successful conversion")
}
mp3Path := flacPath[:len(flacPath)-len(".flac")] + ".mp3"
info, err := os.Stat(mp3Path)
if err != nil {
t.Fatalf("stat mp3: %v", err)
}
if info.Size() == 0 {
t.Error("mp3 is empty")
}
if rs.Calls() != 1 {
t.Errorf("rescanner should be called once, got %d", rs.Calls())
}
}
func TestPoolValidationFails(t *testing.T) {
ffmpegPath, _ := requireFFmpegFFprobe(t)
t.Setenv("FFMPEG_BIN", ffmpegPath)
t.Setenv("FFPROBE_BIN", "/nonexistent/ffprobe")
dir := t.TempDir()
albumDir := filepath.Join(dir, "Test Artist", "Fail Album")
if err := os.MkdirAll(albumDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
flacPath := generateTestFLAC(t, albumDir, "01 fail.flac", ffmpegPath)
st, q, jobSvc, pool, _ := setupPool(t, map[string]string{
"DRY_RUN": "false",
"WORK_WINDOW_ENABLED": "false",
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
id, err := jobSvc.Enqueue(ctx, jobs.EnqueueInput{
Artist: "Test Artist",
Album: "Fail Album",
Path: albumDir,
Source: models.SourceManual,
})
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
pool.Start(ctx)
time.Sleep(1 * time.Second)
q.Close()
pool.Wait()
job, err := st.Jobs.Get(ctx, id)
if err != nil {
t.Fatalf("Get: %v", err)
}
if job.Status != models.StatusError {
t.Errorf("Status = %q, want error", job.Status)
}
if _, err := os.Stat(flacPath); os.IsNotExist(err) {
t.Error("flac should be preserved when validation fails")
}
}
func TestWorkerRecordsProgress(t *testing.T) {
dir := t.TempDir()
albumDir := filepath.Join(dir, "Test Artist", "Progress Album")
if err := os.MkdirAll(albumDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
for _, name := range []string{"01 track.flac", "02 track.flac", "03 track.flac"} {
if err := os.WriteFile(filepath.Join(albumDir, name), []byte("fake flac"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
st, _, jobSvc, pool, _ := setupPool(t, map[string]string{
"DRY_RUN": "true",
"WORK_WINDOW_ENABLED": "false",
"MUSIC_PATH": dir,
})
ctx := context.Background()
id, err := jobSvc.Enqueue(ctx, jobs.EnqueueInput{
Artist: "Test Artist",
Album: "Progress Album",
Path: albumDir,
Source: models.SourceManual,
})
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
pool.process(ctx, id)
job, err := st.Jobs.Get(ctx, id)
if err != nil {
t.Fatalf("Get: %v", err)
}
if job.FilesTotal != 3 {
t.Errorf("FilesTotal = %d, want 3", job.FilesTotal)
}
if job.FilesProcessed != 3 {
t.Errorf("FilesProcessed = %d, want 3", job.FilesProcessed)
}
if job.Status != models.StatusDone {
t.Errorf("Status = %q, want done", job.Status)
}
}