mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-13 16:04:44 +00:00
* feat: make send-time optimization actually schedule sends, instead of being a documented setting with no caller: advanced.OptimizeSendTime had zero call sites anywhere in the codebase, so an org that enabled send_time_optimization through PATCH /outreach/settings changed nothing about when its campaign mail left, and the API reference said so in a callout; the campaign scheduler now resolves the recipient's timezone from the contact's timezone custom field, then the country-code suffix of its email domain, then the org fallback, and holds the slot until that clock reaches a preferred hour, raising hardFloor so the task handler reschedules rather than sending immediately, the snap is forward-only and never crosses the campaign end date, the default flips to off so no existing workspace silently re-times its sends, and Settings > Sending gives the block its first UI * feat: stop the recipient-hour gate deferring a send forever, and fix the window parser that silently disabled every campaign schedule: recipientSlot now searches for a moment BOTH calendars accept and yields when they never meet, because raising a hard floor at an hour the sender cannot serve made every tick re-derive it, defer, wake in the sender's window and defer again; separately parseTimeOfDay accepted only the 15:04 layout while start_time, end_time, warmup_start_time and warmup_end_time are Postgres time columns pgx renders as 09:00:00.000000, so every read parsed to 0 and effectiveWindows read that as unconstrained, leaving both the campaign sending window and its day-of-week gate off for every campaign on the legacy fields and pinning warmup to its 08:00-20:00 fallbacks; Settings > Sending also gains the MANAGE_SETTINGS gate a direct visit needs
167 lines
6.0 KiB
Go
167 lines
6.0 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
func TestTimezoneForEmailDomain(t *testing.T) {
|
|
tests := []struct {
|
|
email string
|
|
want string
|
|
}{
|
|
{"a@example.de", "Europe/Berlin"},
|
|
{"a@sub.example.co.uk", "Europe/London"},
|
|
{"a@example.jp", "Asia/Tokyo"},
|
|
{"a@example.com", ""}, // gTLD carries no location signal
|
|
{"a@example.io", ""}, // ditto
|
|
{"not-an-email", ""}, // no @
|
|
{"a@localhost", ""}, // single label
|
|
{"A@Example.DE", "Europe/Berlin"}, // case-insensitive
|
|
}
|
|
for _, tt := range tests {
|
|
if got := timezoneForEmailDomain(tt.email); got != tt.want {
|
|
t.Errorf("timezoneForEmailDomain(%q) = %q, want %q", tt.email, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeHoursRejectsGarbageAndNeverEmpties(t *testing.T) {
|
|
// An empty result would mean "no hour is acceptable", which would stall
|
|
// every campaign on the org, so it must fall back to business hours.
|
|
for _, in := range [][]int{nil, {}, {-1, 24, 99}} {
|
|
got := normalizeHours(in)
|
|
if len(got) != len(defaultPreferredHours) {
|
|
t.Errorf("normalizeHours(%v) = %v, want the business-hours default", in, got)
|
|
}
|
|
}
|
|
if got := normalizeHours([]int{16, 9, 9, 30, 11}); len(got) != 3 || got[0] != 9 || got[1] != 11 || got[2] != 16 {
|
|
t.Errorf("normalizeHours() = %v, want sorted+deduped [9 11 16]", got)
|
|
}
|
|
}
|
|
|
|
func TestSendTimePreferenceFromDisabled(t *testing.T) {
|
|
if p := sendTimePreferenceFrom(nil); p.enabled {
|
|
t.Error("nil settings must not enable optimization")
|
|
}
|
|
s := models.DefaultAdvancedOutreachSettings()
|
|
s.SendTimeOptimization.Enabled = false
|
|
if p := sendTimePreferenceFrom(&s); p.enabled {
|
|
t.Error("disabled settings must not enable optimization")
|
|
}
|
|
s.SendTimeOptimization.Enabled = true
|
|
p := sendTimePreferenceFrom(&s)
|
|
if !p.enabled || !p.useContactTZ || !p.avoidWeekends {
|
|
t.Errorf("defaults not carried through: %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestRecipientLocationPrefersContactFieldThenDomain(t *testing.T) {
|
|
pref := sendTimePreference{enabled: true, useContactTZ: true, defaultTZ: "UTC"}
|
|
|
|
explicit := &models.Contact{Email: "a@example.de", CustomFields: map[string]string{"timezone": "Asia/Tokyo"}}
|
|
if got := recipientLocation(explicit, pref); got.String() != "Asia/Tokyo" {
|
|
t.Errorf("explicit contact timezone ignored: got %s", got)
|
|
}
|
|
|
|
byDomain := &models.Contact{Email: "a@example.de"}
|
|
if got := recipientLocation(byDomain, pref); got.String() != "Europe/Berlin" {
|
|
t.Errorf("domain inference failed: got %s", got)
|
|
}
|
|
|
|
// A junk timezone field must fall through, not fail the send.
|
|
junk := &models.Contact{Email: "a@example.de", CustomFields: map[string]string{"timezone": "Mars/Olympus"}}
|
|
if got := recipientLocation(junk, pref); got.String() != "Europe/Berlin" {
|
|
t.Errorf("junk timezone should fall through to the domain: got %s", got)
|
|
}
|
|
|
|
// gTLD with no contact field lands on the org default.
|
|
pref.defaultTZ = "America/Denver"
|
|
if got := recipientLocation(&models.Contact{Email: "a@example.com"}, pref); got.String() != "America/Denver" {
|
|
t.Errorf("expected org default, got %s", got)
|
|
}
|
|
|
|
// use_contact_timezone off means the org default always wins.
|
|
pref.useContactTZ = false
|
|
if got := recipientLocation(explicit, pref); got.String() != "America/Denver" {
|
|
t.Errorf("use_contact_timezone=false must ignore the contact: got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestSnapToPreferredHourIsForwardOnly(t *testing.T) {
|
|
loc := time.UTC
|
|
hours := []int{9, 10, 14}
|
|
|
|
// Already inside a preferred hour: unchanged, so optimization never
|
|
// perturbs a slot the rest of the scheduler already agreed on.
|
|
at := time.Date(2026, 3, 4, 9, 30, 0, 0, loc)
|
|
if got := snapToPreferredHour(at, loc, hours, false); !got.Equal(at) {
|
|
t.Errorf("in-window slot moved: got %s want %s", got, at)
|
|
}
|
|
|
|
// Before the first hour: forward to it, same minute.
|
|
early := time.Date(2026, 3, 4, 6, 17, 0, 0, loc)
|
|
got := snapToPreferredHour(early, loc, hours, false)
|
|
if got.Hour() != 9 || got.Minute() != 17 || got.Day() != 4 {
|
|
t.Errorf("early slot = %s, want 09:17 same day", got)
|
|
}
|
|
|
|
// Between preferred hours: forward to the next one, not backward.
|
|
between := time.Date(2026, 3, 4, 11, 5, 0, 0, loc)
|
|
got = snapToPreferredHour(between, loc, hours, false)
|
|
if got.Hour() != 14 || got.Day() != 4 {
|
|
t.Errorf("mid-gap slot = %s, want 14:xx same day", got)
|
|
}
|
|
if got.Before(between) {
|
|
t.Errorf("snap moved backwards: %s < %s", got, between)
|
|
}
|
|
|
|
// After the last hour: first preferred hour tomorrow.
|
|
late := time.Date(2026, 3, 4, 22, 0, 0, 0, loc)
|
|
got = snapToPreferredHour(late, loc, hours, false)
|
|
if got.Hour() != 9 || got.Day() != 5 {
|
|
t.Errorf("late slot = %s, want 09:00 next day", got)
|
|
}
|
|
}
|
|
|
|
func TestSnapToPreferredHourSkipsWeekend(t *testing.T) {
|
|
loc := time.UTC
|
|
// 2026-03-07 is a Saturday.
|
|
sat := time.Date(2026, 3, 7, 22, 0, 0, 0, loc)
|
|
got := snapToPreferredHour(sat, loc, []int{9}, true)
|
|
if got.Weekday() != time.Monday || got.Day() != 9 {
|
|
t.Errorf("weekend slot = %s (%s), want Monday the 9th", got, got.Weekday())
|
|
}
|
|
// With weekend sending allowed it stays on Sunday.
|
|
got = snapToPreferredHour(sat, loc, []int{9}, false)
|
|
if got.Weekday() != time.Sunday {
|
|
t.Errorf("non-avoiding slot = %s (%s), want Sunday", got, got.Weekday())
|
|
}
|
|
}
|
|
|
|
func TestSnapToPreferredHourNoHoursIsNoop(t *testing.T) {
|
|
at := time.Date(2026, 3, 4, 6, 0, 0, 0, time.UTC)
|
|
if got := snapToPreferredHour(at, time.UTC, nil, false); !got.Equal(at) {
|
|
t.Errorf("empty hour list must not move the slot: got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestSnapToPreferredHourCrossesDSTForward(t *testing.T) {
|
|
// US DST starts 2026-03-08. A slot the evening before must still resolve
|
|
// to a real 09:00 local the next morning, not an hour that does not exist.
|
|
loc, err := time.LoadLocation("America/New_York")
|
|
if err != nil {
|
|
t.Skip("tzdata unavailable")
|
|
}
|
|
before := time.Date(2026, 3, 7, 23, 0, 0, 0, loc)
|
|
got := snapToPreferredHour(before, loc, []int{9}, false)
|
|
if got.Hour() != 9 || got.Day() != 8 {
|
|
t.Errorf("DST slot = %s, want 09:00 on the 8th", got)
|
|
}
|
|
if got.Before(before) {
|
|
t.Errorf("DST snap moved backwards: %s < %s", got, before)
|
|
}
|
|
}
|