package infra import ( "context" "database/sql" "fmt" "sync" "sync/atomic" "testing" "time" _ "github.com/mattn/go-sqlite3" ) // openTestDB opens an in-memory SQLite database for testing. // MaxOpenConns is set to 1 to ensure all goroutines share the same connection // (required for in-memory SQLite which is per-connection otherwise). func openTestDB(t *testing.T) *sql.DB { t.Helper() db, err := sql.Open("sqlite3", ":memory:?_journal_mode=WAL") if err != nil { t.Fatalf("open test db: %v", err) } db.SetMaxOpenConns(1) t.Cleanup(func() { db.Close() }) return db } func TestJobQueueCreate(t *testing.T) { t.Run("test_queue_create", func(t *testing.T) { db := openTestDB(t) q, err := JobQueueCreate(db, "jobs") if err != nil { t.Fatalf("JobQueueCreate: %v", err) } if q == nil { t.Fatal("expected non-nil JobQueue") } if q.TableName != "jobs" { t.Errorf("TableName = %q, want %q", q.TableName, "jobs") } // Verify table exists by inserting a row directly. _, err = db.Exec(`INSERT INTO jobs (id, type, scheduled_at) VALUES ('x', 'test', ?)`, time.Now().UTC().Format(time.RFC3339)) if err != nil { t.Fatalf("table not created: %v", err) } }) t.Run("test_queue_create_default_table_name", func(t *testing.T) { db := openTestDB(t) q, err := JobQueueCreate(db, "") if err != nil { t.Fatalf("JobQueueCreate with empty name: %v", err) } if q.TableName != "jobs" { t.Errorf("TableName = %q, want %q", q.TableName, "jobs") } }) t.Run("test_queue_create_nil_db", func(t *testing.T) { _, err := JobQueueCreate(nil, "jobs") if err == nil { t.Fatal("expected error for nil db") } }) } func TestJobEnqueueDequeue(t *testing.T) { t.Run("enqueue_dequeue_atomicidad", func(t *testing.T) { db := openTestDB(t) q, err := JobQueueCreate(db, "jobs") if err != nil { t.Fatalf("create: %v", err) } id, err := JobEnqueue(q, "send_email", `{"to":"a@b.com"}`) if err != nil { t.Fatalf("enqueue: %v", err) } if id == "" { t.Fatal("expected non-empty job ID") } job, err := JobDequeue(q, nil) if err != nil { t.Fatalf("dequeue: %v", err) } if job == nil { t.Fatal("expected a job, got nil") } if job.ID != id { t.Errorf("job ID = %q, want %q", job.ID, id) } if job.Type != "send_email" { t.Errorf("job Type = %q, want %q", job.Type, "send_email") } if job.Status != JobStatusRunning { t.Errorf("job Status = %q, want %q", job.Status, JobStatusRunning) } // Second dequeue should return nil (job is now running, not pending). job2, err := JobDequeue(q, nil) if err != nil { t.Fatalf("second dequeue: %v", err) } if job2 != nil { t.Errorf("expected nil second dequeue, got job %q", job2.ID) } }) t.Run("dequeue_empty_queue_returns_nil", func(t *testing.T) { db := openTestDB(t) q, _ := JobQueueCreate(db, "jobs") job, err := JobDequeue(q, nil) if err != nil { t.Fatalf("dequeue empty: %v", err) } if job != nil { t.Errorf("expected nil, got job %q", job.ID) } }) t.Run("dequeue_priority_order", func(t *testing.T) { db := openTestDB(t) q, _ := JobQueueCreate(db, "jobs") _, _ = JobEnqueue(q, "low", "{}", WithPriority(0)) _, _ = JobEnqueue(q, "high", "{}", WithPriority(10)) _, _ = JobEnqueue(q, "mid", "{}", WithPriority(5)) job, _ := JobDequeue(q, nil) if job == nil || job.Type != "high" { t.Errorf("expected high-priority job first, got %v", job) } }) t.Run("dequeue_jobtype_filter", func(t *testing.T) { db := openTestDB(t) q, _ := JobQueueCreate(db, "jobs") _, _ = JobEnqueue(q, "email", "{}") _, _ = JobEnqueue(q, "sms", "{}") // Only dequeue "sms" jobs. job, err := JobDequeue(q, []string{"sms"}) if err != nil { t.Fatalf("dequeue with filter: %v", err) } if job == nil || job.Type != "sms" { t.Errorf("expected sms job, got %v", job) } }) t.Run("dequeue_scheduled_in_future_waits", func(t *testing.T) { db := openTestDB(t) q, _ := JobQueueCreate(db, "jobs") future := time.Now().Add(1 * time.Hour) _, _ = JobEnqueue(q, "future", "{}", WithScheduledAt(future)) job, err := JobDequeue(q, nil) if err != nil { t.Fatalf("dequeue future: %v", err) } if job != nil { t.Errorf("expected nil (future job not yet due), got job %q", job.ID) } }) } func TestJobCompleteAndFail(t *testing.T) { t.Run("complete_fail_transitions", func(t *testing.T) { db := openTestDB(t) q, _ := JobQueueCreate(db, "jobs") id, _ := JobEnqueue(q, "work", "{}", WithMaxAttempts(3)) job, _ := JobDequeue(q, nil) if job == nil { t.Fatal("expected job") } // Complete it. if err := JobComplete(q, id, `{"ok":true}`); err != nil { t.Fatalf("complete: %v", err) } // Verify status in DB. var status string _ = db.QueryRow(`SELECT status FROM jobs WHERE id = ?`, id).Scan(&status) if status != "completed" { t.Errorf("status = %q, want completed", status) } }) t.Run("fail_increments_attempts", func(t *testing.T) { db := openTestDB(t) q, _ := JobQueueCreate(db, "jobs") id, _ := JobEnqueue(q, "work", "{}", WithMaxAttempts(3)) _, _ = JobDequeue(q, nil) // First failure → status = failed (attempts=1 < max=3). if err := JobFail(q, id, "oops"); err != nil { t.Fatalf("fail: %v", err) } var status string var attempts int _ = db.QueryRow(`SELECT status, attempts FROM jobs WHERE id = ?`, id).Scan(&status, &attempts) if status != "failed" { t.Errorf("status = %q, want failed", status) } if attempts != 1 { t.Errorf("attempts = %d, want 1", attempts) } }) t.Run("fail_transitions_to_dead", func(t *testing.T) { db := openTestDB(t) q, _ := JobQueueCreate(db, "jobs") id, _ := JobEnqueue(q, "work", "{}", WithMaxAttempts(2)) // Fail twice → dead. for i := 0; i < 2; i++ { // Re-set to pending so we can dequeue again (simulate retry logic). if i > 0 { _, _ = db.Exec(`UPDATE jobs SET status = 'pending' WHERE id = ?`, id) } _, _ = JobDequeue(q, nil) _ = JobFail(q, id, fmt.Sprintf("error %d", i+1)) } var status string _ = db.QueryRow(`SELECT status FROM jobs WHERE id = ?`, id).Scan(&status) if status != "dead" { t.Errorf("status = %q, want dead after max_attempts reached", status) } }) } func TestJobStatusSummaryFn(t *testing.T) { t.Run("job_status_summary_format", func(t *testing.T) { counts := map[string]int{ "pending": 5, "running": 2, "completed": 10, "failed": 1, "dead": 0, } got := JobStatusSummary(counts) want := "pending: 5, running: 2, completed: 10, failed: 1, dead: 0" if got != want { t.Errorf("got %q, want %q", got, want) } }) t.Run("job_status_summary_empty_map", func(t *testing.T) { got := JobStatusSummary(map[string]int{}) want := "pending: 0, running: 0, completed: 0, failed: 0, dead: 0" if got != want { t.Errorf("got %q, want %q", got, want) } }) } func TestJobWorkerGracefulShutdown(t *testing.T) { t.Run("worker_graceful_shutdown", func(t *testing.T) { db := openTestDB(t) q, _ := JobQueueCreate(db, "jobs") ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) _ = JobWorker(ctx, q, func(j Job) error { return nil }, WithPollInterval(10*time.Millisecond)) }() // Let the worker poll a couple of times. time.Sleep(50 * time.Millisecond) cancel() select { case <-done: // OK — worker exited cleanly. case <-time.After(2 * time.Second): t.Fatal("worker did not stop within timeout after context cancellation") } }) t.Run("worker_processes_jobs", func(t *testing.T) { db := openTestDB(t) q, _ := JobQueueCreate(db, "jobs") var processed atomic.Int32 for i := 0; i < 3; i++ { _, _ = JobEnqueue(q, "task", `{}`) } ctx, cancel := context.WithCancel(context.Background()) go func() { _ = JobWorker(ctx, q, func(j Job) error { processed.Add(1) return nil }, WithPollInterval(10*time.Millisecond)) }() // Wait for all 3 to be processed (with timeout). deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if processed.Load() >= 3 { break } time.Sleep(10 * time.Millisecond) } cancel() if n := processed.Load(); n < 3 { t.Errorf("processed %d jobs, want 3", n) } }) } func TestJobConcurrency(t *testing.T) { t.Run("concurrency_multiples_goroutines_dequeue", func(t *testing.T) { db := openTestDB(t) q, _ := JobQueueCreate(db, "jobs") const numJobs = 50 for i := 0; i < numJobs; i++ { _, _ = JobEnqueue(q, "task", fmt.Sprintf(`{"i":%d}`, i)) } var processed atomic.Int32 var wg sync.WaitGroup ctx, cancel := context.WithCancel(context.Background()) const numWorkers = 5 for i := 0; i < numWorkers; i++ { wg.Add(1) go func() { defer wg.Done() _ = JobWorker(ctx, q, func(j Job) error { processed.Add(1) return nil }, WithPollInterval(5*time.Millisecond)) }() } // Wait until all jobs are processed. deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if processed.Load() >= numJobs { break } time.Sleep(10 * time.Millisecond) } cancel() wg.Wait() if n := processed.Load(); n != numJobs { t.Errorf("processed %d, want %d (no double-processing with concurrent workers)", n, numJobs) } }) } func TestJobCleanupFn(t *testing.T) { t.Run("job_cleanup_removes_old_terminal_jobs", func(t *testing.T) { db := openTestDB(t) q, _ := JobQueueCreate(db, "jobs") id, _ := JobEnqueue(q, "work", "{}") _, _ = JobDequeue(q, nil) _ = JobComplete(q, id, "") // Make the job look old (set created_at in the past, always UTC to match JobCleanup). past := time.Now().UTC().Add(-25 * time.Hour).Format(time.RFC3339) _, _ = db.Exec(`UPDATE jobs SET created_at = ? WHERE id = ?`, past, id) n, err := JobCleanup(q, 24*time.Hour) if err != nil { t.Fatalf("cleanup: %v", err) } if n != 1 { t.Errorf("deleted %d rows, want 1", n) } // Job should be gone. var count int _ = db.QueryRow(`SELECT COUNT(*) FROM jobs WHERE id = ?`, id).Scan(&count) if count != 0 { t.Errorf("job still in DB after cleanup") } }) t.Run("job_cleanup_keeps_recent_jobs", func(t *testing.T) { db := openTestDB(t) q, _ := JobQueueCreate(db, "jobs") id, _ := JobEnqueue(q, "work", "{}") _, _ = JobDequeue(q, nil) _ = JobComplete(q, id, "") // Job was just created — should not be cleaned up. n, err := JobCleanup(q, 24*time.Hour) if err != nil { t.Fatalf("cleanup: %v", err) } if n != 0 { t.Errorf("deleted %d rows, want 0 (job is recent)", n) } }) }