mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-10 16:04:55 +00:00
test: add unit tests for warmup health evaluation, campaign state machine, and templates
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user