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>
This commit is contained in:
Trey T
2026-04-01 20:30:09 -05:00
parent 00fd674b56
commit bec880886b
83 changed files with 19569 additions and 730 deletions

View File

@@ -0,0 +1,44 @@
package worker
import "encoding/json"
// Enqueuer defines the interface for enqueuing background email tasks.
type Enqueuer interface {
EnqueueWelcomeEmail(to, firstName, code string) error
EnqueueVerificationEmail(to, firstName, code string) error
EnqueuePasswordResetEmail(to, firstName, code, resetToken string) error
EnqueuePasswordChangedEmail(to, firstName string) error
}
// Verify TaskClient satisfies the interface at compile time.
var _ Enqueuer = (*TaskClient)(nil)
// BuildWelcomeEmailPayload marshals a WelcomeEmailPayload to JSON bytes.
func BuildWelcomeEmailPayload(to, firstName, code string) ([]byte, error) {
return json.Marshal(WelcomeEmailPayload{
EmailPayload: EmailPayload{To: to, FirstName: firstName},
ConfirmationCode: code,
})
}
// BuildVerificationEmailPayload marshals a VerificationEmailPayload to JSON bytes.
func BuildVerificationEmailPayload(to, firstName, code string) ([]byte, error) {
return json.Marshal(VerificationEmailPayload{
EmailPayload: EmailPayload{To: to, FirstName: firstName},
Code: code,
})
}
// BuildPasswordResetEmailPayload marshals a PasswordResetEmailPayload to JSON bytes.
func BuildPasswordResetEmailPayload(to, firstName, code, resetToken string) ([]byte, error) {
return json.Marshal(PasswordResetEmailPayload{
EmailPayload: EmailPayload{To: to, FirstName: firstName},
Code: code,
ResetToken: resetToken,
})
}
// BuildPasswordChangedEmailPayload marshals an EmailPayload to JSON bytes.
func BuildPasswordChangedEmailPayload(to, firstName string) ([]byte, error) {
return json.Marshal(EmailPayload{To: to, FirstName: firstName})
}

View File

@@ -0,0 +1,79 @@
package worker
import (
"encoding/json"
"testing"
)
func TestBuildWelcomeEmailPayload_RoundTrip(t *testing.T) {
data, err := BuildWelcomeEmailPayload("a@b.com", "Alice", "CODE123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var p WelcomeEmailPayload
if err := json.Unmarshal(data, &p); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if p.To != "a@b.com" || p.FirstName != "Alice" || p.ConfirmationCode != "CODE123" {
t.Errorf("got %+v", p)
}
}
func TestBuildVerificationEmailPayload_RoundTrip(t *testing.T) {
data, err := BuildVerificationEmailPayload("b@c.com", "Bob", "VERIFY456")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var p VerificationEmailPayload
if err := json.Unmarshal(data, &p); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if p.To != "b@c.com" || p.FirstName != "Bob" || p.Code != "VERIFY456" {
t.Errorf("got %+v", p)
}
}
func TestBuildPasswordResetEmailPayload_RoundTrip(t *testing.T) {
data, err := BuildPasswordResetEmailPayload("c@d.com", "Carol", "RST789", "token-abc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var p PasswordResetEmailPayload
if err := json.Unmarshal(data, &p); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if p.To != "c@d.com" || p.FirstName != "Carol" || p.Code != "RST789" || p.ResetToken != "token-abc" {
t.Errorf("got %+v", p)
}
}
func TestBuildPasswordChangedEmailPayload_RoundTrip(t *testing.T) {
data, err := BuildPasswordChangedEmailPayload("d@e.com", "Dave")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var p EmailPayload
if err := json.Unmarshal(data, &p); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if p.To != "d@e.com" || p.FirstName != "Dave" {
t.Errorf("got %+v", p)
}
}
func TestBuildWelcomeEmailPayload_Fields(t *testing.T) {
data, err := BuildWelcomeEmailPayload("test@example.com", "Test", "ABC")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify raw JSON contains expected keys
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, key := range []string{"to", "first_name", "confirmation_code"} {
if _, ok := raw[key]; !ok {
t.Errorf("missing key %q in payload JSON", key)
}
}
}

View File

@@ -30,38 +30,43 @@ const (
// Handler handles background job processing
type Handler struct {
db *gorm.DB
taskRepo *repositories.TaskRepository
residenceRepo *repositories.ResidenceRepository
reminderRepo *repositories.ReminderRepository
notificationRepo *repositories.NotificationRepository
pushClient *push.Client
emailService *services.EmailService
notificationService *services.NotificationService
onboardingService *services.OnboardingEmailService
config *config.Config
db *gorm.DB
taskRepo TaskRepo
residenceRepo ResidenceRepo
reminderRepo ReminderRepo
notificationRepo NotificationRepo
pushClient PushSender
emailService EmailSender
notificationService NotificationSender
onboardingService OnboardingEmailSender
config *config.Config
}
// NewHandler creates a new job handler
func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.EmailService, notificationService *services.NotificationService, cfg *config.Config) *Handler {
// Create onboarding email service
var onboardingService *services.OnboardingEmailService
if emailService != nil {
onboardingService = services.NewOnboardingEmailService(db, emailService, cfg.Server.BaseURL)
h := &Handler{
db: db,
taskRepo: repositories.NewTaskRepository(db),
residenceRepo: repositories.NewResidenceRepository(db),
reminderRepo: repositories.NewReminderRepository(db),
notificationRepo: repositories.NewNotificationRepository(db),
config: cfg,
}
return &Handler{
db: db,
taskRepo: repositories.NewTaskRepository(db),
residenceRepo: repositories.NewResidenceRepository(db),
reminderRepo: repositories.NewReminderRepository(db),
notificationRepo: repositories.NewNotificationRepository(db),
pushClient: pushClient,
emailService: emailService,
notificationService: notificationService,
onboardingService: onboardingService,
config: cfg,
// Assign interface fields only when concrete values are non-nil
// to preserve correct nil checks on the interface values.
if pushClient != nil {
h.pushClient = pushClient
}
if emailService != nil {
h.emailService = emailService
h.onboardingService = services.NewOnboardingEmailService(db, emailService, cfg.Server.BaseURL)
}
if notificationService != nil {
h.notificationService = notificationService
}
return h
}
// HandleDailyDigest processes daily digest notifications with task statistics

View File

@@ -0,0 +1,39 @@
package jobs
import (
"fmt"
"strings"
"github.com/treytartt/honeydue-api/internal/models"
)
// BuildDigestMessage constructs the daily digest notification text.
func BuildDigestMessage(overdueCount, dueThisWeekCount int) (title, body string) {
title = "Daily Task Summary"
if overdueCount > 0 && dueThisWeekCount > 0 {
body = fmt.Sprintf("You have %d overdue task(s) and %d task(s) due this week", overdueCount, dueThisWeekCount)
} else if overdueCount > 0 {
body = fmt.Sprintf("You have %d overdue task(s) that need attention", overdueCount)
} else {
body = fmt.Sprintf("You have %d task(s) due this week", dueThisWeekCount)
}
return
}
// IsOverdueStage checks if a reminder stage string represents overdue.
func IsOverdueStage(stage string) bool {
return strings.HasPrefix(stage, "overdue")
}
// ExtractFrequencyDays gets interval days from a task's frequency.
func ExtractFrequencyDays(t *models.Task) *int {
if t.Frequency != nil && t.Frequency.Days != nil {
days := *t.Frequency.Days
return &days
}
if t.CustomIntervalDays != nil {
days := *t.CustomIntervalDays
return &days
}
return nil
}

View File

@@ -0,0 +1,226 @@
package jobs
import (
"encoding/json"
"testing"
"github.com/treytartt/honeydue-api/internal/models"
)
// --- BuildDigestMessage ---
func TestBuildDigestMessage_BothCounts(t *testing.T) {
title, body := BuildDigestMessage(3, 5)
if title != "Daily Task Summary" {
t.Errorf("title = %q, want %q", title, "Daily Task Summary")
}
want := "You have 3 overdue task(s) and 5 task(s) due this week"
if body != want {
t.Errorf("body = %q, want %q", body, want)
}
}
func TestBuildDigestMessage_OnlyOverdue(t *testing.T) {
_, body := BuildDigestMessage(2, 0)
want := "You have 2 overdue task(s) that need attention"
if body != want {
t.Errorf("body = %q, want %q", body, want)
}
}
func TestBuildDigestMessage_OnlyDueSoon(t *testing.T) {
_, body := BuildDigestMessage(0, 4)
want := "You have 4 task(s) due this week"
if body != want {
t.Errorf("body = %q, want %q", body, want)
}
}
func TestBuildDigestMessage_Title_AlwaysDailyTaskSummary(t *testing.T) {
cases := [][2]int{{1, 1}, {0, 1}, {1, 0}}
for _, c := range cases {
title, _ := BuildDigestMessage(c[0], c[1])
if title != "Daily Task Summary" {
t.Errorf("BuildDigestMessage(%d,%d) title = %q", c[0], c[1], title)
}
}
}
// --- IsOverdueStage ---
func TestIsOverdueStage_Overdue1_True(t *testing.T) {
if !IsOverdueStage("overdue_1") {
t.Error("expected true for overdue_1")
}
}
func TestIsOverdueStage_Overdue14_True(t *testing.T) {
if !IsOverdueStage("overdue_14") {
t.Error("expected true for overdue_14")
}
}
func TestIsOverdueStage_Reminder7d_False(t *testing.T) {
if IsOverdueStage("reminder_7d") {
t.Error("expected false for reminder_7d")
}
}
func TestIsOverdueStage_DayOf_False(t *testing.T) {
if IsOverdueStage("day_of") {
t.Error("expected false for day_of")
}
}
func TestIsOverdueStage_Empty_False(t *testing.T) {
if IsOverdueStage("") {
t.Error("expected false for empty string")
}
}
// --- ExtractFrequencyDays ---
func TestExtractFrequencyDays_WithFrequency(t *testing.T) {
days := 7
task := &models.Task{
Frequency: &models.TaskFrequency{Days: &days},
}
got := ExtractFrequencyDays(task)
if got == nil || *got != 7 {
t.Errorf("got %v, want 7", got)
}
}
func TestExtractFrequencyDays_WithCustomInterval(t *testing.T) {
custom := 14
task := &models.Task{
CustomIntervalDays: &custom,
}
got := ExtractFrequencyDays(task)
if got == nil || *got != 14 {
t.Errorf("got %v, want 14", got)
}
}
func TestExtractFrequencyDays_NilFrequency(t *testing.T) {
task := &models.Task{}
got := ExtractFrequencyDays(task)
if got != nil {
t.Errorf("got %v, want nil", got)
}
}
func TestExtractFrequencyDays_NilDays(t *testing.T) {
task := &models.Task{
Frequency: &models.TaskFrequency{},
}
got := ExtractFrequencyDays(task)
if got != nil {
t.Errorf("got %v, want nil", got)
}
}
// --- Email payload tests ---
func TestEmailPayload_Unmarshal_Valid(t *testing.T) {
data := []byte(`{"to":"a@b.com","subject":"hi","html_body":"<b>hi</b>","text_body":"hi"}`)
var p EmailPayload
if err := json.Unmarshal(data, &p); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if p.To != "a@b.com" || p.Subject != "hi" {
t.Errorf("got %+v", p)
}
}
func TestEmailPayload_Unmarshal_Invalid(t *testing.T) {
var p EmailPayload
if err := json.Unmarshal([]byte(`{invalid}`), &p); err == nil {
t.Error("expected error for invalid JSON")
}
}
// --- NewSendEmailTask ---
func TestNewSendEmailTask_ReturnsTask(t *testing.T) {
task, err := NewSendEmailTask("a@b.com", "Subject", "<b>hi</b>", "hi")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if task.Type() != TypeSendEmail {
t.Errorf("task type = %q, want %q", task.Type(), TypeSendEmail)
}
}
func TestNewSendEmailTask_PayloadFields(t *testing.T) {
task, err := NewSendEmailTask("user@example.com", "Welcome", "<h1>Hello</h1>", "Hello")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var p EmailPayload
if err := json.Unmarshal(task.Payload(), &p); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if p.To != "user@example.com" {
t.Errorf("To = %q, want %q", p.To, "user@example.com")
}
if p.Subject != "Welcome" {
t.Errorf("Subject = %q, want %q", p.Subject, "Welcome")
}
if p.HTMLBody != "<h1>Hello</h1>" {
t.Errorf("HTMLBody = %q, want %q", p.HTMLBody, "<h1>Hello</h1>")
}
if p.TextBody != "Hello" {
t.Errorf("TextBody = %q, want %q", p.TextBody, "Hello")
}
}
// --- NewSendPushTask ---
func TestNewSendPushTask_ReturnsTask(t *testing.T) {
task, err := NewSendPushTask(42, "Title", "Body", map[string]string{"key": "val"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if task.Type() != TypeSendPush {
t.Errorf("task type = %q, want %q", task.Type(), TypeSendPush)
}
}
func TestNewSendPushTask_PayloadFields(t *testing.T) {
data := map[string]string{"action": "open", "id": "123"}
task, err := NewSendPushTask(7, "Alert", "Something happened", data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var p PushPayload
if err := json.Unmarshal(task.Payload(), &p); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if p.UserID != 7 {
t.Errorf("UserID = %d, want 7", p.UserID)
}
if p.Title != "Alert" {
t.Errorf("Title = %q, want %q", p.Title, "Alert")
}
if p.Message != "Something happened" {
t.Errorf("Message = %q, want %q", p.Message, "Something happened")
}
if p.Data["action"] != "open" || p.Data["id"] != "123" {
t.Errorf("Data = %v, want map with action=open, id=123", p.Data)
}
}
func TestNewSendPushTask_NilData(t *testing.T) {
task, err := NewSendPushTask(1, "T", "M", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var p PushPayload
if err := json.Unmarshal(task.Payload(), &p); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if p.Data != nil {
t.Errorf("Data = %v, want nil", p.Data)
}
}

View File

@@ -0,0 +1,388 @@
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)
}
}

View File

@@ -0,0 +1,55 @@
package jobs
import (
"context"
"time"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
)
// TaskRepo defines task query operations needed by job handlers.
type TaskRepo interface {
GetOverdueTasks(now time.Time, opts repositories.TaskFilterOptions) ([]models.Task, error)
GetDueSoonTasks(now time.Time, daysThreshold int, opts repositories.TaskFilterOptions) ([]models.Task, error)
GetActiveTasksForUsers(now time.Time, opts repositories.TaskFilterOptions) ([]models.Task, error)
}
// ResidenceRepo defines residence query operations needed by job handlers.
type ResidenceRepo interface {
FindResidenceIDsByUser(userID uint) ([]uint, error)
}
// ReminderRepo defines reminder log operations needed by job handlers.
type ReminderRepo interface {
HasSentReminderBatch(keys []repositories.ReminderKey) (map[int]bool, error)
LogReminder(taskID, userID uint, dueDate time.Time, stage models.ReminderStage, notificationID *uint) (*models.TaskReminderLog, error)
CleanupOldLogs(daysOld int) (int64, error)
}
// NotificationRepo defines notification preference operations needed by job handlers.
type NotificationRepo interface {
FindPreferencesByUser(userID uint) (*models.NotificationPreference, error)
GetActiveTokensForUser(userID uint) ([]string, []string, error)
}
// NotificationSender creates and sends task notifications.
type NotificationSender interface {
CreateAndSendTaskNotification(ctx context.Context, userID uint, notificationType models.NotificationType, task *models.Task) error
}
// PushSender sends push notifications to device tokens.
type PushSender interface {
SendToAll(ctx context.Context, iosTokens, androidTokens []string, title, message string, data map[string]string) error
}
// EmailSender sends emails.
type EmailSender interface {
SendEmail(to, subject, htmlBody, textBody string) error
}
// OnboardingEmailSender sends onboarding campaign emails.
type OnboardingEmailSender interface {
CheckAndSendNoResidenceEmails() (int, error)
CheckAndSendNoTasksEmails() (int, error)
}

View File

@@ -1,8 +1,6 @@
package worker
import (
"encoding/json"
"github.com/hibiken/asynq"
"github.com/rs/zerolog/log"
)
@@ -58,10 +56,7 @@ func (c *TaskClient) Close() error {
// EnqueueWelcomeEmail enqueues a welcome email task
func (c *TaskClient) EnqueueWelcomeEmail(to, firstName, code string) error {
payload, err := json.Marshal(WelcomeEmailPayload{
EmailPayload: EmailPayload{To: to, FirstName: firstName},
ConfirmationCode: code,
})
payload, err := BuildWelcomeEmailPayload(to, firstName, code)
if err != nil {
return err
}
@@ -79,10 +74,7 @@ func (c *TaskClient) EnqueueWelcomeEmail(to, firstName, code string) error {
// EnqueueVerificationEmail enqueues a verification email task
func (c *TaskClient) EnqueueVerificationEmail(to, firstName, code string) error {
payload, err := json.Marshal(VerificationEmailPayload{
EmailPayload: EmailPayload{To: to, FirstName: firstName},
Code: code,
})
payload, err := BuildVerificationEmailPayload(to, firstName, code)
if err != nil {
return err
}
@@ -100,11 +92,7 @@ func (c *TaskClient) EnqueueVerificationEmail(to, firstName, code string) error
// EnqueuePasswordResetEmail enqueues a password reset email task
func (c *TaskClient) EnqueuePasswordResetEmail(to, firstName, code, resetToken string) error {
payload, err := json.Marshal(PasswordResetEmailPayload{
EmailPayload: EmailPayload{To: to, FirstName: firstName},
Code: code,
ResetToken: resetToken,
})
payload, err := BuildPasswordResetEmailPayload(to, firstName, code, resetToken)
if err != nil {
return err
}
@@ -122,7 +110,7 @@ func (c *TaskClient) EnqueuePasswordResetEmail(to, firstName, code, resetToken s
// EnqueuePasswordChangedEmail enqueues a password changed confirmation email
func (c *TaskClient) EnqueuePasswordChangedEmail(to, firstName string) error {
payload, err := json.Marshal(EmailPayload{To: to, FirstName: firstName})
payload, err := BuildPasswordChangedEmailPayload(to, firstName)
if err != nil {
return err
}

View File

@@ -0,0 +1,110 @@
package worker
import (
"encoding/json"
"testing"
)
// --- Payload roundtrip tests ---
func TestWelcomeEmailPayload_MarshalRoundtrip(t *testing.T) {
original := WelcomeEmailPayload{
EmailPayload: EmailPayload{To: "a@b.com", FirstName: "Alice"},
ConfirmationCode: "ABC123",
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got WelcomeEmailPayload
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.To != original.To || got.FirstName != original.FirstName || got.ConfirmationCode != original.ConfirmationCode {
t.Errorf("roundtrip mismatch: got %+v, want %+v", got, original)
}
}
func TestVerificationEmailPayload_MarshalRoundtrip(t *testing.T) {
original := VerificationEmailPayload{
EmailPayload: EmailPayload{To: "b@c.com", FirstName: "Bob"},
Code: "999888",
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got VerificationEmailPayload
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.To != original.To || got.FirstName != original.FirstName || got.Code != original.Code {
t.Errorf("roundtrip mismatch: got %+v, want %+v", got, original)
}
}
func TestPasswordResetEmailPayload_MarshalRoundtrip(t *testing.T) {
original := PasswordResetEmailPayload{
EmailPayload: EmailPayload{To: "c@d.com", FirstName: "Carol"},
Code: "XYZ",
ResetToken: "tok-abc-123",
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got PasswordResetEmailPayload
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.To != original.To || got.FirstName != original.FirstName || got.Code != original.Code || got.ResetToken != original.ResetToken {
t.Errorf("roundtrip mismatch: got %+v, want %+v", got, original)
}
}
func TestPasswordChangedEmailPayload_MarshalRoundtrip(t *testing.T) {
original := EmailPayload{To: "d@e.com", FirstName: "Dave"}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got EmailPayload
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.To != original.To || got.FirstName != original.FirstName {
t.Errorf("roundtrip mismatch: got %+v, want %+v", got, original)
}
}
// --- Task type constant tests ---
func TestTaskTypeConstants_Unique(t *testing.T) {
types := []string{
TypeWelcomeEmail,
TypeVerificationEmail,
TypePasswordResetEmail,
TypePasswordChangedEmail,
}
seen := make(map[string]bool)
for _, typ := range types {
if seen[typ] {
t.Errorf("duplicate task type: %q", typ)
}
seen[typ] = true
}
}
func TestTaskTypeConstants_EmailPrefix(t *testing.T) {
types := []string{
TypeWelcomeEmail,
TypeVerificationEmail,
TypePasswordResetEmail,
TypePasswordChangedEmail,
}
for _, typ := range types {
if len(typ) < 6 || typ[:6] != "email:" {
t.Errorf("task type %q does not have 'email:' prefix", typ)
}
}
}