Add intake worker
Subsystem to monitor IMAP mailbox for new messages. Introduces: - intake: worker that uses IDLE or polling to detect new emails. - imap: client wrapper for connection management and IMAP commands. - filter: logic for IMAP search and sender allow-list. - tracker: concurrency control to prevent processing the same UID twice. - backoff: for handling connection retries with jitter.
This commit is contained in:
196
internal/backoff/backoff_test.go
Normal file
196
internal/backoff/backoff_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package backoff
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
var tests = []struct {
|
||||
name string
|
||||
cfg Config
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "initial zero",
|
||||
cfg: Config{Initial: 0, Max: time.Second},
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "initial negative",
|
||||
cfg: Config{Initial: -1, Max: time.Second},
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "max less than initial",
|
||||
cfg: Config{
|
||||
Initial: time.Second,
|
||||
Max: 500 * time.Millisecond,
|
||||
},
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "max equals maxint64",
|
||||
cfg: Config{
|
||||
Initial: time.Second,
|
||||
Max: math.MaxInt64,
|
||||
},
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "valid",
|
||||
cfg: Config{
|
||||
Initial: time.Second,
|
||||
Max: time.Second,
|
||||
},
|
||||
shouldError: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cfg.Validate()
|
||||
if tt.shouldError && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tt.shouldError && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInvalidConfig(t *testing.T) {
|
||||
_, err := New(Config{Initial: 0, Max: time.Second})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid config") {
|
||||
t.Fatalf("expected invalid config error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistribution uses a fixed seed to check the logic flow and jitter.
|
||||
func TestDistribution(t *testing.T) {
|
||||
// RNG with seed (1, 2)
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
|
||||
initial := 100 * time.Millisecond
|
||||
maxDur := 400 * time.Millisecond
|
||||
|
||||
b, _ := New(Config{
|
||||
Initial: initial,
|
||||
Max: maxDur,
|
||||
RNG: rng,
|
||||
})
|
||||
|
||||
// Generate reference numbers using the same seed to predict outcomes.
|
||||
refRng := rand.New(rand.NewPCG(1, 2))
|
||||
|
||||
expectedCaps := []time.Duration{
|
||||
100 * time.Millisecond,
|
||||
200 * time.Millisecond,
|
||||
400 * time.Millisecond,
|
||||
400 * time.Millisecond, // Clamped
|
||||
}
|
||||
|
||||
for i, cap := range expectedCaps {
|
||||
// Expect Next to pick rand(0, cap).
|
||||
expected := time.Duration(refRng.Int64N(int64(cap) + 1))
|
||||
got := b.Next()
|
||||
|
||||
if got != expected {
|
||||
t.Errorf(
|
||||
"step %d: expected %v, got %v (cap was %v)",
|
||||
i, expected, got, cap,
|
||||
)
|
||||
}
|
||||
if got > cap {
|
||||
t.Errorf(
|
||||
"step %d: got %v greater than cap %v",
|
||||
i, got, cap,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOverflowProtection ensures that large Max values do not cause overflow
|
||||
// during the doubling phase.
|
||||
func TestOverflowProtection(t *testing.T) {
|
||||
// Use a Max that is near MaxInt64.
|
||||
max := time.Duration(math.MaxInt64 - 1)
|
||||
start := max / 4
|
||||
|
||||
b, err := New(Config{
|
||||
Initial: start,
|
||||
Max: max,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Current = start.
|
||||
// Next returns [0, start]. state becomes start*2 (max/2).
|
||||
_ = b.Next()
|
||||
|
||||
// 2. Current = max/2.
|
||||
// Next returns [0, max/2].
|
||||
_ = b.Next()
|
||||
|
||||
// 3. Current = max.
|
||||
// Next returns [0, max].
|
||||
// Logic check: current >= max. state stays max.
|
||||
val := b.Next()
|
||||
if val < 0 {
|
||||
t.Errorf("got negative duration %v, likely overflow", val)
|
||||
}
|
||||
|
||||
// Verify clamp.
|
||||
// No panic implies success.
|
||||
for range 5 {
|
||||
b.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRNG(t *testing.T) {
|
||||
b, err := New(Config{
|
||||
Initial: time.Millisecond,
|
||||
Max: 10 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New failed: %v", err)
|
||||
}
|
||||
got := b.Next()
|
||||
if got < 0 || got > time.Millisecond {
|
||||
t.Errorf("out of bounds: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReset(t *testing.T) {
|
||||
b, _ := New(Config{
|
||||
Initial: 10 * time.Millisecond,
|
||||
Max: 100 * time.Millisecond,
|
||||
})
|
||||
|
||||
// Advance state
|
||||
b.Next() // 10
|
||||
b.Next() // 20
|
||||
b.Next() // 40
|
||||
|
||||
b.Reset()
|
||||
|
||||
// Verify that the cap is reset by checking the bounds of the next
|
||||
// call. If reset works, the next call is bounded by Initial (10ms).
|
||||
for range 10 {
|
||||
got := b.Next()
|
||||
if got > 10*time.Millisecond {
|
||||
t.Fatalf(
|
||||
"call after Reset returned %v; expected <= 10ms",
|
||||
got,
|
||||
)
|
||||
}
|
||||
b.Reset()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user