package mail import ( "strings" "testing" "time" ) func TestRenderLoginCode(t *testing.T) { t.Parallel() subject, body := renderLoginCode("123456", 10*time.Minute) if !strings.Contains(subject, "123456") { t.Fatalf("subject must include code, got %q", subject) } if !strings.Contains(body, "123456") { t.Fatalf("body must include code, got %q", body) } if !strings.Contains(body, "10 minutes") { t.Fatalf("body must include human-readable TTL, got %q", body) } } func TestRenderLoginCode_RoundsTTL(t *testing.T) { t.Parallel() cases := map[string]struct { ttl time.Duration expect string }{ "sub-minute": {ttl: 30 * time.Second, expect: "1 minutes"}, "exact": {ttl: 10 * time.Minute, expect: "10 minutes"}, "with secs": {ttl: 5*time.Minute + 29*time.Second, expect: "5 minutes"}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { t.Parallel() _, body := renderLoginCode("000000", tc.ttl) if !strings.Contains(body, tc.expect) { t.Fatalf("body missing %q for ttl=%s, got %q", tc.expect, tc.ttl, body) } }) } } func TestNormaliseRecipient(t *testing.T) { t.Parallel() cases := map[string]struct { input string want string err bool }{ "plain": {input: "alice@example.test", want: "alice@example.test"}, "trims": {input: " bob@example.test ", want: "bob@example.test"}, "display-stripped": {input: "Alice ", want: "alice@example.test"}, "empty": {input: "", err: true}, "whitespace": {input: " ", err: true}, "malformed": {input: "not-an-email", err: true}, "with-spaces": {input: "ali ce@example.test", err: true}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { t.Parallel() got, err := normaliseRecipient(tc.input) if tc.err { if err == nil { t.Fatalf("expected error, got %q", got) } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if got != tc.want { t.Fatalf("got %q want %q", got, tc.want) } }) } } func TestTemplateRendererLoginCode(t *testing.T) { t.Parallel() render := templateRenderers[TemplateLoginCode] if render == nil { t.Fatal("TemplateLoginCode renderer must be registered") } subject, body, err := render(map[string]any{"code": "654321", "ttl": 7 * time.Minute}) if err != nil { t.Fatalf("render: %v", err) } if !strings.Contains(subject, "654321") || !strings.Contains(body, "654321") { t.Fatalf("subject=%q body=%q must mention code", subject, body) } if _, _, err := render(map[string]any{"ttl": 7 * time.Minute}); err == nil { t.Fatal("missing code must error") } } func TestNextBackoffMonotonicAndCapped(t *testing.T) { t.Parallel() // Sample many runs per attempt so jitter does not flake the // invariant: median of attempt N is below median of attempt N+1 // up to the cap. prev := time.Duration(0) for n := 1; n <= 12; n++ { var sum time.Duration runs := 32 for range runs { sum += nextBackoff(n) } avg := sum / time.Duration(runs) if avg > backoffMax+backoffMax/4 { // generous upper bound t.Fatalf("attempt %d avg %s exceeds capped budget", n, avg) } if avg < backoffBase/2 { t.Fatalf("attempt %d avg %s below base/2", n, avg) } if n > 1 && avg < prev/2 { t.Fatalf("backoff decreased dramatically between attempts %d and %d (%s vs %s)", n-1, n, prev, avg) } prev = avg } } func TestIsPermanent(t *testing.T) { t.Parallel() if IsPermanent(nil) { t.Fatal("nil must not be permanent") } transient := &SendError{Err: errSentinel("transient")} if IsPermanent(transient) { t.Fatal("default SendError must not be permanent") } permanent := &SendError{Err: errSentinel("permanent"), Permanent: true} if !IsPermanent(permanent) { t.Fatal("Permanent=true must report true") } } // errSentinel is a tiny sentinel error helper used only in tests. type errSentinel string func (e errSentinel) Error() string { return string(e) }