diff --git a/internal/app/warmup/service_test.go b/internal/app/warmup/service_test.go index baed3c6a..de591180 100644 --- a/internal/app/warmup/service_test.go +++ b/internal/app/warmup/service_test.go @@ -64,6 +64,115 @@ func TestEvaluateMetricsSpamThresholds(t *testing.T) { } } +func TestEvaluateMetricsThrottleBand(t *testing.T) { + now := time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC) + + decision := evaluateMetrics(&models.WarmupHealthMetrics{ + SentLast7d: 25, + SpamPlacementRate: 16.0, // between 15% (throttle) and 20% (quarantine) + }, now) + + if decision.State != models.WarmupHealthThrottled { + t.Fatalf("expected throttled, got %s", decision.State) + } + if decision.BlockedUntil == nil { + t.Fatal("throttled should have blocked_until set") + } + if !decision.BlockedUntil.Equal(now.Add(warmupThrottleDuration)) { + t.Fatalf("expected 3-day throttle, got %v", decision.BlockedUntil.Sub(now)) + } +} + +func TestEvaluateMetricsComplaintRateWatch(t *testing.T) { + now := time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC) + + decision := evaluateMetrics(&models.WarmupHealthMetrics{ + SentLast7d: 5, + DeliveredLast30d: 200, + ComplaintsLast30d: 1, + ComplaintRate: 0.05, // between 0.03% (watch) and 0.10% (quarantine) + }, now) + + if decision.State != models.WarmupHealthWatch { + t.Fatalf("expected watch from complaint rate, got %s", decision.State) + } +} + +func TestEvaluateMetricsComplaintRateQuarantine(t *testing.T) { + now := time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC) + + decision := evaluateMetrics(&models.WarmupHealthMetrics{ + SentLast7d: 5, + DeliveredLast30d: 200, + ComplaintsLast30d: 2, + ComplaintRate: 0.15, // > 0.10% quarantine + }, now) + + if decision.State != models.WarmupHealthQuarantined { + t.Fatalf("expected quarantined from complaint rate, got %s", decision.State) + } +} + +func TestEvaluateMetricsComplaintRateBlock(t *testing.T) { + now := time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC) + + decision := evaluateMetrics(&models.WarmupHealthMetrics{ + SentLast7d: 5, + DeliveredLast30d: 200, + ComplaintsLast30d: 10, + ComplaintRate: 0.5, // > 0.30% block + }, now) + + if decision.State != models.WarmupHealthBlocked { + t.Fatalf("expected blocked from complaint rate, got %s", decision.State) + } +} + +func TestEvaluateMetricsBounceRateQuarantine(t *testing.T) { + now := time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC) + + decision := evaluateMetrics(&models.WarmupHealthMetrics{ + SentLast7d: 5, + DeliveredLast30d: 200, + BouncesLast30d: 12, + BounceRate: 6.0, // > 5% quarantine + }, now) + + if decision.State != models.WarmupHealthQuarantined { + t.Fatalf("expected quarantined from bounce rate, got %s", decision.State) + } +} + +func TestEvaluateMetricsBounceRateBlock(t *testing.T) { + now := time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC) + + decision := evaluateMetrics(&models.WarmupHealthMetrics{ + SentLast7d: 5, + DeliveredLast30d: 200, + BouncesLast30d: 25, + BounceRate: 12.5, // > 10% block + }, now) + + if decision.State != models.WarmupHealthBlocked { + t.Fatalf("expected blocked from bounce rate, got %s", decision.State) + } +} + +func TestEvaluateMetricsComplaintBelowSampleIgnored(t *testing.T) { + now := time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC) + + decision := evaluateMetrics(&models.WarmupHealthMetrics{ + SentLast7d: 5, + DeliveredLast30d: 50, // below 100 minimum + ComplaintsLast30d: 5, + ComplaintRate: 10.0, + }, now) + + if decision.State == models.WarmupHealthQuarantined || decision.State == models.WarmupHealthBlocked { + t.Fatalf("should not quarantine/block with insufficient sample, got %s", decision.State) + } +} + func TestEvaluateMetricsIgnoresSmallSamples(t *testing.T) { now := time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC) diff --git a/internal/repository/campaign_state_test.go b/internal/repository/campaign_state_test.go new file mode 100644 index 00000000..f778f4d3 --- /dev/null +++ b/internal/repository/campaign_state_test.go @@ -0,0 +1,49 @@ +package repository + +import ( + "testing" +) + +func TestValidCampaignTransitions(t *testing.T) { + tests := []struct { + from string + to string + allowed bool + }{ + // Valid transitions + {"draft", "active", true}, + {"active", "paused", true}, + {"active", "completed", true}, + {"active", "paused_no_accounts", true}, + {"active", "paused_trial_expired", true}, + {"paused", "active", true}, + {"paused", "draft", true}, + {"paused_no_accounts", "active", true}, + {"paused_trial_expired", "active", true}, + + // Invalid transitions + {"completed", "active", false}, + {"completed", "draft", false}, + {"completed", "paused", false}, + {"draft", "completed", false}, + {"draft", "paused", false}, + {"active", "draft", false}, + } + + for _, tc := range tests { + t.Run(tc.from+"_to_"+tc.to, func(t *testing.T) { + allowed, ok := validCampaignTransitions[tc.from] + result := ok && allowed[tc.to] + if result != tc.allowed { + t.Errorf("transition %s -> %s: expected allowed=%v, got %v", tc.from, tc.to, tc.allowed, result) + } + }) + } +} + +func TestCompletedIsTerminal(t *testing.T) { + allowed := validCampaignTransitions["completed"] + if len(allowed) != 0 { + t.Errorf("completed should be terminal state with no valid transitions, got %v", allowed) + } +} diff --git a/internal/tasks/template_test.go b/internal/tasks/template_test.go new file mode 100644 index 00000000..37c41c02 --- /dev/null +++ b/internal/tasks/template_test.go @@ -0,0 +1,149 @@ +package tasks + +import ( + "strings" + "testing" + + "github.com/warmbly/warmbly/internal/models" +) + +func TestRenderTemplate_BasicVariables(t *testing.T) { + contact := models.Contact{ + FirstName: "Alice", + LastName: "Smith", + Email: "alice@example.com", + Company: "Acme Corp", + Phone: "+1234567890", + } + + tmpl := "Hi {{.FirstName}} {{.LastName}}, welcome from {{.Company}}!" + result := RenderTemplate(tmpl, contact) + + expected := "Hi Alice Smith, welcome from Acme Corp!" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestRenderTemplate_CustomFields(t *testing.T) { + contact := models.Contact{ + FirstName: "Bob", + CustomFields: map[string]string{"role": "Engineer", "city": "Berlin"}, + } + + tmpl := "Hey {{.FirstName}}, you work as a {{.role}} in {{.city}}" + result := RenderTemplate(tmpl, contact) + + if !strings.Contains(result, "Engineer") || !strings.Contains(result, "Berlin") { + t.Errorf("custom fields not rendered: %q", result) + } +} + +func TestRenderTemplate_EmptyContact(t *testing.T) { + contact := models.Contact{} + tmpl := "Hello {{.FirstName}}" + result := RenderTemplate(tmpl, contact) + + if result != "Hello " { + t.Errorf("expected empty first name, got %q", result) + } +} + +func TestRenderTemplate_NoPlaceholders(t *testing.T) { + contact := models.Contact{FirstName: "Test"} + tmpl := "Just a plain text email with no variables." + result := RenderTemplate(tmpl, contact) + + if result != tmpl { + t.Errorf("expected unchanged text, got %q", result) + } +} + +func TestGenerateConversationEmail_NewEmail(t *testing.T) { + conv := Conversation{ + Theme: "test", + Description: "This is a test conversation.", + Messages: []string{"What do you think?"}, + } + account := models.Email{Name: "John Doe", Email: "john@test.com"} + + body := GenerateConversationEmail(conv, account, false) + + if !strings.Contains(body, "This is a test conversation.") { + t.Errorf("body should contain description: %q", body) + } + if !strings.Contains(body, "John Doe") { + t.Errorf("body should contain signature: %q", body) + } +} + +func TestGenerateConversationEmail_Reply(t *testing.T) { + conv := Conversation{ + Theme: "test", + Description: "Test desc.", + Messages: []string{"Sure thing!"}, + } + account := models.Email{Name: "Jane", Email: "jane@test.com"} + + body := GenerateConversationEmail(conv, account, true) + + if strings.Contains(body, "Test desc.") { + t.Errorf("reply should not contain description: %q", body) + } + if !strings.Contains(body, "Jane") { + t.Errorf("reply should contain signature: %q", body) + } +} + +func TestGenerateConversationEmail_FallbackSignature(t *testing.T) { + conv := Conversation{Description: "Hello.", Messages: []string{"Hi"}} + account := models.Email{Email: "anon@test.com"} // No Name set + + body := GenerateConversationEmail(conv, account, false) + + if !strings.Contains(body, "anon@test.com") { + t.Errorf("should fall back to email as signature: %q", body) + } +} + +func TestExtractPlainTextFromHTML(t *testing.T) { + html := "
Hello world
Second paragraph
" + plain := ExtractPlainTextFromHTML(html) + + if !strings.Contains(plain, "Hello") || !strings.Contains(plain, "world") { + t.Errorf("plain text should contain content: %q", plain) + } + if strings.Contains(plain, "") || strings.Contains(plain, "") { + t.Errorf("plain text should not contain HTML tags: %q", plain) + } +} + +func TestGenerateWarmupSubject_NotEmpty(t *testing.T) { + subject := generateWarmupSubject() + if subject == "" { + t.Error("warmup subject should not be empty") + } +} + +func TestRandomWarmupConversation_HasContent(t *testing.T) { + conv := randomWarmupConversation() + if conv.Theme == "" { + t.Error("conversation should have a theme") + } + if conv.Description == "" { + t.Error("conversation should have a description") + } + if len(conv.Messages) == 0 { + t.Error("conversation should have at least one message") + } +} + +func TestGenerateMessageID_Format(t *testing.T) { + mid := generateMessageID("user@example.com") + if !strings.HasSuffix(mid, "@example.com>") { + t.Errorf("message ID should end with domain, got %q", mid) + } + if !strings.HasPrefix(mid, "<") { + t.Errorf("message ID should start with <, got %q", mid) + } +}