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:
44
internal/worker/enqueuer.go
Normal file
44
internal/worker/enqueuer.go
Normal 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})
|
||||
}
|
||||
79
internal/worker/enqueuer_test.go
Normal file
79
internal/worker/enqueuer_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
39
internal/worker/jobs/handler_helpers.go
Normal file
39
internal/worker/jobs/handler_helpers.go
Normal 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
|
||||
}
|
||||
226
internal/worker/jobs/handler_helpers_test.go
Normal file
226
internal/worker/jobs/handler_helpers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
388
internal/worker/jobs/handler_test.go
Normal file
388
internal/worker/jobs/handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
55
internal/worker/jobs/interfaces.go
Normal file
55
internal/worker/jobs/interfaces.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
110
internal/worker/scheduler_test.go
Normal file
110
internal/worker/scheduler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user