test: add unit tests for warmup health evaluation, campaign state machine, and templates

This commit is contained in:
Matthew Meszaros
2026-04-09 15:59:27 +00:00
parent 23b5c925e4
commit 80358168ae
3 changed files with 307 additions and 0 deletions
+109
View File
@@ -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)
@@ -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)
}
}
+149
View File
@@ -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 := "<p>Hello <b>world</b></p><br><p>Second paragraph</p>"
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, "<p>") || strings.Contains(plain, "<b>") {
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)
}
}