- 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>
389 lines
11 KiB
Go
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)
|
|
}
|
|
}
|