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: "Hi", 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) } }