Files
warmbly/internal/app/email/orphaned_worker_test.go
T
Matthew Meszaros bd8545a1c5 feat: stop a freshly connected mailbox being silently excluded from every campaign send, by making an unset mailbox timezone representable as the empty string the campaign scheduler already checks for, since email_accounts.timezone defaulted to 'UTC' while campaigns.timezone defaults to 'Europe/London' and nothing in the OAuth or SMTP onboarding paths ever set either, so a brand new mailbox looked deliberately placed in UTC, was compared against the differing campaign zone and dropped by the hardcoded 8am-8pm business-hours gate whenever the current UTC hour fell outside it, emptying the candidate pool and failing the campaign start, adding a migration that changes the column default and converts existing 'UTC' rows because until now no API field, dashboard control or onboarding path could set that column at all so every such row is the old default rather than a choice, adding the missing Timezone field to UpdateEmail with IANA validation so the setting the sending-behaviour UI already tells people to change is finally reachable and an unloadable zone is rejected instead of being silently coerced to UTC by the scheduler, and replacing the misleading 'no active email accounts found for campaign's email tags' response for a pool that exists but is entirely gated out with a distinct message naming the real cause, via an ErrNoEligibleMailbox that wraps ErrNoEmailAccounts so the three callers that pause a campaign on it are unaffected (#126) (#125) (#127)
2026-08-16 07:58:52 +02:00

116 lines
3.7 KiB
Go

package email
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/worker"
)
// stubAssignment records what releaseDeadWorker asked of the assignment
// service. Embedding the interface keeps the stub to the two methods under
// test; anything else would panic loudly rather than pass silently.
type stubAssignment struct {
worker.WorkerAssignmentService
live bool
liveErr error
unassignErr error
unassignCalls int
}
func (s *stubAssignment) IsWorkerLive(ctx context.Context, workerID uuid.UUID) (bool, error) {
return s.live, s.liveErr
}
func (s *stubAssignment) UnassignWorkerFromEmail(ctx context.Context, emailAccountID uuid.UUID) error {
s.unassignCalls++
return s.unassignErr
}
func TestReleaseDeadWorkerReleasesOrphanedMailbox(t *testing.T) {
dead := uuid.New()
st := &stubAssignment{live: false}
s := &emailService{workerAssignment: st}
got, err := s.releaseDeadWorker(context.Background(), uuid.New(), &dead)
if err != nil {
t.Fatalf("releaseDeadWorker: %v", err)
}
if got != nil {
t.Errorf("got %v, want nil so the caller places the mailbox on a live worker", got)
}
if st.unassignCalls != 1 {
t.Errorf("unassign called %d times, want 1", st.unassignCalls)
}
}
func TestReleaseDeadWorkerKeepsLiveWorker(t *testing.T) {
current := uuid.New()
st := &stubAssignment{live: true}
s := &emailService{workerAssignment: st}
got, err := s.releaseDeadWorker(context.Background(), uuid.New(), &current)
if err != nil {
t.Fatalf("releaseDeadWorker: %v", err)
}
if got == nil || *got != current {
t.Error("a live worker's assignment was not preserved")
}
if st.unassignCalls != 0 {
t.Errorf("a live worker was released (%d unassign calls)", st.unassignCalls)
}
}
// A database blip must not churn placements: moving a mailbox changes the IP it
// sends from, which is not something to do on a failed lookup.
func TestReleaseDeadWorkerKeepsAssignmentOnLookupFailure(t *testing.T) {
current := uuid.New()
st := &stubAssignment{liveErr: errors.New("db down")}
s := &emailService{workerAssignment: st}
got, err := s.releaseDeadWorker(context.Background(), uuid.New(), &current)
if err != nil {
t.Fatalf("releaseDeadWorker returned an error for a transient lookup failure: %v", err)
}
if got == nil || *got != current {
t.Error("the existing assignment was not preserved through a lookup failure")
}
if st.unassignCalls != 0 {
t.Errorf("mailbox was released on a lookup failure (%d unassign calls)", st.unassignCalls)
}
}
func TestReleaseDeadWorkerHandlesUnassignedAndUnwired(t *testing.T) {
// No worker assigned yet: nothing to check.
s := &emailService{workerAssignment: &stubAssignment{live: false}}
if got, err := s.releaseDeadWorker(context.Background(), uuid.New(), nil); err != nil || got != nil {
t.Errorf("got (%v, %v), want (nil, nil)", got, err)
}
// No assignment service wired (jobs, tests): leave the mailbox alone.
current := uuid.New()
bare := &emailService{}
got, err := bare.releaseDeadWorker(context.Background(), uuid.New(), &current)
if err != nil {
t.Fatalf("releaseDeadWorker: %v", err)
}
if got == nil || *got != current {
t.Error("assignment was dropped with no assignment service wired")
}
}
// A failed release must surface, not silently fall through to placing the
// mailbox a second time while the old row still counts it.
func TestReleaseDeadWorkerPropagatesUnassignFailure(t *testing.T) {
dead := uuid.New()
st := &stubAssignment{live: false, unassignErr: errors.New("write failed")}
s := &emailService{workerAssignment: st}
if _, err := s.releaseDeadWorker(context.Background(), uuid.New(), &dead); err == nil {
t.Fatal("a failed unassign was swallowed")
}
}