Files
honeyDueAPI/internal/worker/jobs/handler_test.go
Trey T bec880886b Coverage priorities 1-5: test pure functions, extract interfaces, mock-based handler tests
- Priority 1: Test NewSendEmailTask + NewSendPushTask (5 tests)
- Priority 2: Test customHTTPErrorHandler — all 15+ branches (21 tests)
- Priority 3: Extract Enqueuer interface + payload builders in worker pkg (5 tests)
- Priority 4: Extract ClassifyFile/ComputeRelPath in migrate-encrypt (6 tests)
- Priority 5: Define Handler interfaces, refactor to accept them, mock-based tests (14 tests)
- Fix .gitignore: /worker instead of worker to stop ignoring internal/worker/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 20:30:09 -05:00

389 lines
11 KiB
Go

package jobs
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/hibiken/asynq"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
)
// --- Mock implementations ---
type mockEmailSender struct {
sendFn func(to, subject, htmlBody, textBody string) error
}
func (m *mockEmailSender) SendEmail(to, subject, htmlBody, textBody string) error {
if m.sendFn != nil {
return m.sendFn(to, subject, htmlBody, textBody)
}
return nil
}
type mockPushSender struct {
sendFn func(ctx context.Context, iosTokens, androidTokens []string, title, message string, data map[string]string) error
}
func (m *mockPushSender) SendToAll(ctx context.Context, iosTokens, androidTokens []string, title, message string, data map[string]string) error {
if m.sendFn != nil {
return m.sendFn(ctx, iosTokens, androidTokens, title, message, data)
}
return nil
}
type mockNotificationRepo struct {
findPrefsFn func(userID uint) (*models.NotificationPreference, error)
getTokensFn func(userID uint) ([]string, []string, error)
}
func (m *mockNotificationRepo) FindPreferencesByUser(userID uint) (*models.NotificationPreference, error) {
if m.findPrefsFn != nil {
return m.findPrefsFn(userID)
}
return &models.NotificationPreference{}, nil
}
func (m *mockNotificationRepo) GetActiveTokensForUser(userID uint) ([]string, []string, error) {
if m.getTokensFn != nil {
return m.getTokensFn(userID)
}
return nil, nil, nil
}
type mockReminderRepo struct {
cleanupFn func(daysOld int) (int64, error)
batchFn func(keys []repositories.ReminderKey) (map[int]bool, error)
logFn func(taskID, userID uint, dueDate time.Time, stage models.ReminderStage, notificationID *uint) (*models.TaskReminderLog, error)
}
func (m *mockReminderRepo) HasSentReminderBatch(keys []repositories.ReminderKey) (map[int]bool, error) {
if m.batchFn != nil {
return m.batchFn(keys)
}
return map[int]bool{}, nil
}
func (m *mockReminderRepo) LogReminder(taskID, userID uint, dueDate time.Time, stage models.ReminderStage, notificationID *uint) (*models.TaskReminderLog, error) {
if m.logFn != nil {
return m.logFn(taskID, userID, dueDate, stage, notificationID)
}
return &models.TaskReminderLog{}, nil
}
func (m *mockReminderRepo) CleanupOldLogs(daysOld int) (int64, error) {
if m.cleanupFn != nil {
return m.cleanupFn(daysOld)
}
return 0, nil
}
type mockOnboardingSender struct {
noResFn func() (int, error)
noTasksFn func() (int, error)
}
func (m *mockOnboardingSender) CheckAndSendNoResidenceEmails() (int, error) {
if m.noResFn != nil {
return m.noResFn()
}
return 0, nil
}
func (m *mockOnboardingSender) CheckAndSendNoTasksEmails() (int, error) {
if m.noTasksFn != nil {
return m.noTasksFn()
}
return 0, nil
}
type mockNotificationSender struct {
sendFn func(ctx context.Context, userID uint, notificationType models.NotificationType, task *models.Task) error
}
func (m *mockNotificationSender) CreateAndSendTaskNotification(ctx context.Context, userID uint, notificationType models.NotificationType, task *models.Task) error {
if m.sendFn != nil {
return m.sendFn(ctx, userID, notificationType, task)
}
return nil
}
// --- Helper to build a handler with mocks ---
func newTestHandler(opts ...func(*Handler)) *Handler {
h := &Handler{
config: &config.Config{},
}
for _, opt := range opts {
opt(h)
}
return h
}
func makeTask(taskType string, payload interface{}) *asynq.Task {
data, _ := json.Marshal(payload)
return asynq.NewTask(taskType, data)
}
// --- HandleSendEmail tests ---
func TestHandleSendEmail_Success(t *testing.T) {
var called bool
h := newTestHandler(func(h *Handler) {
h.emailService = &mockEmailSender{
sendFn: func(to, subject, htmlBody, textBody string) error {
called = true
if to != "test@example.com" {
t.Errorf("to = %q, want %q", to, "test@example.com")
}
return nil
},
}
})
task := makeTask(TypeSendEmail, EmailPayload{
To: "test@example.com",
Subject: "Hello",
HTMLBody: "<b>Hi</b>",
TextBody: "Hi",
})
err := h.HandleSendEmail(context.Background(), task)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !called {
t.Error("expected email service to be called")
}
}
func TestHandleSendEmail_InvalidPayload(t *testing.T) {
h := newTestHandler(func(h *Handler) {
h.emailService = &mockEmailSender{}
})
task := asynq.NewTask(TypeSendEmail, []byte(`{invalid`))
err := h.HandleSendEmail(context.Background(), task)
if err == nil {
t.Error("expected error for invalid payload")
}
}
func TestHandleSendEmail_SendFails(t *testing.T) {
sendErr := errors.New("SMTP error")
h := newTestHandler(func(h *Handler) {
h.emailService = &mockEmailSender{
sendFn: func(_, _, _, _ string) error { return sendErr },
}
})
task := makeTask(TypeSendEmail, EmailPayload{To: "a@b.com", Subject: "S"})
err := h.HandleSendEmail(context.Background(), task)
if !errors.Is(err, sendErr) {
t.Errorf("err = %v, want %v", err, sendErr)
}
}
func TestHandleSendEmail_NilService_Noop(t *testing.T) {
h := newTestHandler() // emailService is nil
task := makeTask(TypeSendEmail, EmailPayload{To: "a@b.com", Subject: "S"})
err := h.HandleSendEmail(context.Background(), task)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// --- HandleSendPush tests ---
func TestHandleSendPush_Success(t *testing.T) {
var pushCalled bool
h := newTestHandler(func(h *Handler) {
h.notificationRepo = &mockNotificationRepo{
getTokensFn: func(userID uint) ([]string, []string, error) {
return []string{"ios-token"}, []string{"android-token"}, nil
},
}
h.pushClient = &mockPushSender{
sendFn: func(_ context.Context, ios, android []string, title, msg string, data map[string]string) error {
pushCalled = true
if len(ios) != 1 || ios[0] != "ios-token" {
t.Errorf("ios tokens = %v", ios)
}
return nil
},
}
})
task := makeTask(TypeSendPush, PushPayload{
UserID: 1,
Title: "Alert",
Message: "Hello",
Data: map[string]string{"type": "test"},
})
err := h.HandleSendPush(context.Background(), task)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !pushCalled {
t.Error("expected push client to be called")
}
}
func TestHandleSendPush_InvalidPayload(t *testing.T) {
h := newTestHandler(func(h *Handler) {
h.pushClient = &mockPushSender{}
})
task := asynq.NewTask(TypeSendPush, []byte(`{bad`))
err := h.HandleSendPush(context.Background(), task)
if err == nil {
t.Error("expected error for invalid payload")
}
}
func TestHandleSendPush_NoTokens(t *testing.T) {
h := newTestHandler(func(h *Handler) {
h.notificationRepo = &mockNotificationRepo{
getTokensFn: func(userID uint) ([]string, []string, error) {
return nil, nil, nil // no tokens
},
}
h.pushClient = &mockPushSender{
sendFn: func(_ context.Context, _, _ []string, _, _ string, _ map[string]string) error {
t.Error("push should not be called when no tokens")
return nil
},
}
})
task := makeTask(TypeSendPush, PushPayload{UserID: 1, Title: "T", Message: "M"})
err := h.HandleSendPush(context.Background(), task)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestHandleSendPush_NilClient_Noop(t *testing.T) {
h := newTestHandler() // pushClient is nil
task := makeTask(TypeSendPush, PushPayload{UserID: 1, Title: "T", Message: "M"})
err := h.HandleSendPush(context.Background(), task)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// --- HandleOnboardingEmails tests ---
func TestHandleOnboardingEmails_Disabled(t *testing.T) {
h := newTestHandler(func(h *Handler) {
h.config.Features.OnboardingEmailsEnabled = false
})
err := h.HandleOnboardingEmails(context.Background(), asynq.NewTask(TypeOnboardingEmails, nil))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestHandleOnboardingEmails_NilService(t *testing.T) {
h := newTestHandler(func(h *Handler) {
h.config.Features.OnboardingEmailsEnabled = true
// onboardingService is nil
})
err := h.HandleOnboardingEmails(context.Background(), asynq.NewTask(TypeOnboardingEmails, nil))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestHandleOnboardingEmails_Success(t *testing.T) {
h := newTestHandler(func(h *Handler) {
h.config.Features.OnboardingEmailsEnabled = true
h.onboardingService = &mockOnboardingSender{
noResFn: func() (int, error) { return 2, nil },
noTasksFn: func() (int, error) { return 3, nil },
}
})
err := h.HandleOnboardingEmails(context.Background(), asynq.NewTask(TypeOnboardingEmails, nil))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestHandleOnboardingEmails_BothFail(t *testing.T) {
h := newTestHandler(func(h *Handler) {
h.config.Features.OnboardingEmailsEnabled = true
h.onboardingService = &mockOnboardingSender{
noResFn: func() (int, error) { return 0, errors.New("fail1") },
noTasksFn: func() (int, error) { return 0, errors.New("fail2") },
}
})
err := h.HandleOnboardingEmails(context.Background(), asynq.NewTask(TypeOnboardingEmails, nil))
if err == nil {
t.Error("expected error when both sub-tasks fail")
}
}
func TestHandleOnboardingEmails_PartialFail(t *testing.T) {
h := newTestHandler(func(h *Handler) {
h.config.Features.OnboardingEmailsEnabled = true
h.onboardingService = &mockOnboardingSender{
noResFn: func() (int, error) { return 0, errors.New("fail") },
noTasksFn: func() (int, error) { return 1, nil },
}
})
err := h.HandleOnboardingEmails(context.Background(), asynq.NewTask(TypeOnboardingEmails, nil))
if err != nil {
t.Fatalf("partial failure should not return error, got: %v", err)
}
}
// --- HandleReminderLogCleanup tests ---
func TestHandleReminderLogCleanup_Success(t *testing.T) {
var calledDays int
h := newTestHandler(func(h *Handler) {
h.reminderRepo = &mockReminderRepo{
cleanupFn: func(daysOld int) (int64, error) {
calledDays = daysOld
return 42, nil
},
}
})
err := h.HandleReminderLogCleanup(context.Background(), asynq.NewTask(TypeReminderLogCleanup, nil))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if calledDays != 90 {
t.Errorf("cleanup called with %d days, want 90", calledDays)
}
}
func TestHandleReminderLogCleanup_Error(t *testing.T) {
cleanupErr := errors.New("db error")
h := newTestHandler(func(h *Handler) {
h.reminderRepo = &mockReminderRepo{
cleanupFn: func(daysOld int) (int64, error) { return 0, cleanupErr },
}
})
err := h.HandleReminderLogCleanup(context.Background(), asynq.NewTask(TypeReminderLogCleanup, nil))
if !errors.Is(err, cleanupErr) {
t.Errorf("err = %v, want %v", err, cleanupErr)
}
}