feat: authsession service

This commit is contained in:
Ilia Denisov
2026-04-08 16:23:07 +02:00
committed by GitHub
parent 28f04916af
commit 86a68ed9d0
174 changed files with 31732 additions and 112 deletions
@@ -0,0 +1,182 @@
// Package mail provides runtime mail-delivery adapters for the auth/session
// service.
package mail
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"galaxy/authsession/internal/ports"
)
const sendLoginCodePath = "/api/v1/internal/login-code-deliveries"
// Config configures one HTTP-based mail-delivery client.
type Config struct {
// BaseURL is the absolute base URL of the internal mail-service HTTP API.
BaseURL string
// RequestTimeout bounds each outbound mail-service request.
RequestTimeout time.Duration
}
// RESTClient implements ports.MailSender over the frozen internal REST mail
// contract.
type RESTClient struct {
baseURL string
requestTimeout time.Duration
httpClient *http.Client
}
// NewRESTClient constructs a REST-backed MailSender adapter from cfg.
func NewRESTClient(cfg Config) (*RESTClient, error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
return newRESTClient(cfg, &http.Client{Transport: transport})
}
func newRESTClient(cfg Config, httpClient *http.Client) (*RESTClient, error) {
switch {
case strings.TrimSpace(cfg.BaseURL) == "":
return nil, errors.New("new mail service REST client: base URL must not be empty")
case cfg.RequestTimeout <= 0:
return nil, errors.New("new mail service REST client: request timeout must be positive")
case httpClient == nil:
return nil, errors.New("new mail service REST client: http client must not be nil")
}
parsedBaseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"))
if err != nil {
return nil, fmt.Errorf("new mail service REST client: parse base URL: %w", err)
}
if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" {
return nil, errors.New("new mail service REST client: base URL must be absolute")
}
return &RESTClient{
baseURL: parsedBaseURL.String(),
requestTimeout: cfg.RequestTimeout,
httpClient: httpClient,
}, nil
}
// Close releases idle HTTP connections owned by the client transport.
func (c *RESTClient) Close() error {
if c == nil || c.httpClient == nil {
return nil
}
type idleCloser interface {
CloseIdleConnections()
}
if transport, ok := c.httpClient.Transport.(idleCloser); ok {
transport.CloseIdleConnections()
}
return nil
}
// SendLoginCode submits one delivery request to the internal mail service
// without retrying transport or upstream failures.
func (c *RESTClient) SendLoginCode(ctx context.Context, input ports.SendLoginCodeInput) (ports.SendLoginCodeResult, error) {
if err := validateRESTContext(ctx, "send login code"); err != nil {
return ports.SendLoginCodeResult{}, err
}
if err := input.Validate(); err != nil {
return ports.SendLoginCodeResult{}, fmt.Errorf("send login code: %w", err)
}
payload, statusCode, err := c.doRequest(ctx, "send login code", map[string]string{
"email": input.Email.String(),
"code": input.Code,
})
if err != nil {
return ports.SendLoginCodeResult{}, err
}
if statusCode != http.StatusOK {
return ports.SendLoginCodeResult{}, fmt.Errorf("send login code: unexpected HTTP status %d", statusCode)
}
var response struct {
Outcome ports.SendLoginCodeOutcome `json:"outcome"`
}
if err := decodeJSONPayload(payload, &response); err != nil {
return ports.SendLoginCodeResult{}, fmt.Errorf("send login code: %w", err)
}
result := ports.SendLoginCodeResult{Outcome: response.Outcome}
if err := result.Validate(); err != nil {
return ports.SendLoginCodeResult{}, fmt.Errorf("send login code: %w", err)
}
return result, nil
}
func (c *RESTClient) doRequest(ctx context.Context, operation string, requestBody any) ([]byte, int, error) {
bodyBytes, err := json.Marshal(requestBody)
if err != nil {
return nil, 0, fmt.Errorf("%s: marshal request body: %w", operation, err)
}
attemptCtx, cancel := context.WithTimeout(ctx, c.requestTimeout)
defer cancel()
request, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, c.baseURL+sendLoginCodePath, bytes.NewReader(bodyBytes))
if err != nil {
return nil, 0, fmt.Errorf("%s: build request: %w", operation, err)
}
request.Header.Set("Content-Type", "application/json")
response, err := c.httpClient.Do(request)
if err != nil {
return nil, 0, fmt.Errorf("%s: %w", operation, err)
}
defer response.Body.Close()
payload, err := io.ReadAll(response.Body)
if err != nil {
return nil, 0, fmt.Errorf("%s: read response body: %w", operation, err)
}
return payload, response.StatusCode, nil
}
func decodeJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("decode response body: %w", err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return errors.New("decode response body: unexpected trailing JSON input")
}
return fmt.Errorf("decode response body: %w", err)
}
return nil
}
func validateRESTContext(ctx context.Context, operation string) error {
if ctx == nil {
return fmt.Errorf("%s: nil context", operation)
}
if err := ctx.Err(); err != nil {
return fmt.Errorf("%s: %w", operation, err)
}
return nil
}
var _ ports.MailSender = (*RESTClient)(nil)
@@ -0,0 +1,394 @@
package mail
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/ports"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewRESTClient(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg Config
wantErr string
}{
{
name: "valid config",
cfg: Config{
BaseURL: "http://127.0.0.1:8080",
RequestTimeout: time.Second,
},
},
{
name: "empty base url",
cfg: Config{
RequestTimeout: time.Second,
},
wantErr: "base URL must not be empty",
},
{
name: "relative base url",
cfg: Config{
BaseURL: "/relative",
RequestTimeout: time.Second,
},
wantErr: "base URL must be absolute",
},
{
name: "non positive timeout",
cfg: Config{
BaseURL: "http://127.0.0.1:8080",
},
wantErr: "request timeout must be positive",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
client, err := NewRESTClient(tt.cfg)
if tt.wantErr != "" {
require.Error(t, err)
assert.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.NoError(t, client.Close())
})
}
}
func TestRESTClientSendLoginCodeSuccessCases(t *testing.T) {
t.Parallel()
tests := []struct {
name string
response string
wantOutcome ports.SendLoginCodeOutcome
}{
{
name: "sent",
response: `{"outcome":"sent"}`,
wantOutcome: ports.SendLoginCodeOutcomeSent,
},
{
name: "suppressed",
response: `{"outcome":"suppressed"}`,
wantOutcome: ports.SendLoginCodeOutcomeSuppressed,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var requestsMu sync.Mutex
var requests []capturedRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestsMu.Lock()
requests = append(requests, captureRequest(t, r))
requestsMu.Unlock()
writeJSON(t, w, http.StatusOK, json.RawMessage(tt.response))
}))
defer server.Close()
client := newTestRESTClient(t, server.URL, 250*time.Millisecond)
result, err := client.SendLoginCode(context.Background(), validInput())
require.NoError(t, err)
assert.Equal(t, tt.wantOutcome, result.Outcome)
requestsMu.Lock()
defer requestsMu.Unlock()
require.Len(t, requests, 1)
assert.Equal(t, http.MethodPost, requests[0].Method)
assert.Equal(t, sendLoginCodePath, requests[0].Path)
assert.Equal(t, "application/json", requests[0].ContentType)
assert.JSONEq(t, `{"email":"pilot@example.com","code":"654321"}`, requests[0].Body)
})
}
}
func TestRESTClientPreservesNormalizedEmailAndCodeExactly(t *testing.T) {
t.Parallel()
var captured string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = captureRequest(t, r).Body
writeJSON(t, w, http.StatusOK, map[string]string{"outcome": "sent"})
}))
defer server.Close()
client := newTestRESTClient(t, server.URL, 250*time.Millisecond)
result, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email("Pilot+Alias@Example.com"),
Code: "123456",
})
require.NoError(t, err)
assert.Equal(t, ports.SendLoginCodeOutcomeSent, result.Outcome)
assert.JSONEq(t, `{"email":"Pilot+Alias@Example.com","code":"123456"}`, captured)
}
func TestRESTClientSendLoginCodeDoesNotRetry(t *testing.T) {
t.Parallel()
t.Run("no retry on 503", func(t *testing.T) {
t.Parallel()
var calls atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
http.Error(w, "temporary", http.StatusServiceUnavailable)
}))
defer server.Close()
client := newTestRESTClient(t, server.URL, 250*time.Millisecond)
_, err := client.SendLoginCode(context.Background(), validInput())
require.Error(t, err)
assert.ErrorContains(t, err, "unexpected HTTP status 503")
assert.EqualValues(t, 1, calls.Load())
})
t.Run("no retry on transport failure", func(t *testing.T) {
t.Parallel()
var calls atomic.Int64
client, err := newRESTClient(Config{
BaseURL: "http://127.0.0.1:8080",
RequestTimeout: 250 * time.Millisecond,
}, &http.Client{
Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
calls.Add(1)
return nil, errors.New("temporary transport failure")
}),
})
require.NoError(t, err)
_, err = client.SendLoginCode(context.Background(), validInput())
require.Error(t, err)
assert.ErrorContains(t, err, "temporary transport failure")
assert.EqualValues(t, 1, calls.Load())
})
}
func TestRESTClientStrictDecodingAndUnexpectedStatuses(t *testing.T) {
t.Parallel()
tests := []struct {
name string
statusCode int
body string
wantErrText string
}{
{
name: "rejects unknown field",
statusCode: http.StatusOK,
body: `{"outcome":"sent","extra":true}`,
wantErrText: "decode response body",
},
{
name: "rejects unsupported outcome",
statusCode: http.StatusOK,
body: `{"outcome":"queued"}`,
wantErrText: "unsupported",
},
{
name: "rejects missing outcome",
statusCode: http.StatusOK,
body: `{}`,
wantErrText: "unsupported",
},
{
name: "rejects trailing json",
statusCode: http.StatusOK,
body: `{"outcome":"sent"}{}`,
wantErrText: "unexpected trailing JSON input",
},
{
name: "rejects unexpected status",
statusCode: http.StatusBadGateway,
body: `{"error":"temporary"}`,
wantErrText: "unexpected HTTP status 502",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tt.statusCode)
_, err := io.WriteString(w, tt.body)
require.NoError(t, err)
}))
defer server.Close()
client := newTestRESTClient(t, server.URL, 250*time.Millisecond)
_, err := client.SendLoginCode(context.Background(), validInput())
require.Error(t, err)
assert.ErrorContains(t, err, tt.wantErrText)
})
}
}
func TestRESTClientRequestTimeout(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(40 * time.Millisecond)
writeJSON(t, w, http.StatusOK, map[string]string{"outcome": "sent"})
}))
defer server.Close()
client := newTestRESTClient(t, server.URL, 10*time.Millisecond)
_, err := client.SendLoginCode(context.Background(), validInput())
require.Error(t, err)
assert.ErrorContains(t, err, "context deadline exceeded")
}
func TestRESTClientContextAndValidation(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected upstream call")
}))
defer server.Close()
client := newTestRESTClient(t, server.URL, 250*time.Millisecond)
cancelledCtx, cancel := context.WithCancel(context.Background())
cancel()
tests := []struct {
name string
run func() error
}{
{
name: "nil context",
run: func() error {
_, err := client.SendLoginCode(nil, validInput())
return err
},
},
{
name: "cancelled context",
run: func() error {
_, err := client.SendLoginCode(cancelledCtx, validInput())
return err
},
},
{
name: "invalid email",
run: func() error {
_, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email(" bad@example.com "),
Code: "123456",
})
return err
},
},
{
name: "invalid code",
run: func() error {
_, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
Code: " 123456 ",
})
return err
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
err := tt.run()
require.Error(t, err)
})
}
}
type capturedRequest struct {
Method string
Path string
ContentType string
Body string
}
func captureRequest(t *testing.T, request *http.Request) capturedRequest {
t.Helper()
body, err := io.ReadAll(request.Body)
require.NoError(t, err)
return capturedRequest{
Method: request.Method,
Path: request.URL.Path,
ContentType: request.Header.Get("Content-Type"),
Body: strings.TrimSpace(string(body)),
}
}
func writeJSON(t *testing.T, writer http.ResponseWriter, statusCode int, value any) {
t.Helper()
payload, err := json.Marshal(value)
require.NoError(t, err)
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(statusCode)
_, err = writer.Write(payload)
require.NoError(t, err)
}
func newTestRESTClient(t *testing.T, baseURL string, timeout time.Duration) *RESTClient {
t.Helper()
client, err := NewRESTClient(Config{
BaseURL: baseURL,
RequestTimeout: timeout,
})
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, client.Close())
})
return client
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (fn roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
@@ -0,0 +1,179 @@
// Package mail provides runtime mail-delivery adapters for the auth/session
// service.
package mail
import (
"context"
"errors"
"fmt"
"sync"
"galaxy/authsession/internal/ports"
)
var errForcedFailure = errors.New("stub mail sender: forced failure")
// StubMode identifies the deterministic outcome used by StubSender for one
// delivery attempt.
type StubMode string
const (
// StubModeSent reports that the adapter accepts delivery and returns the
// stable sent outcome expected by the auth flow.
StubModeSent StubMode = "sent"
// StubModeSuppressed reports that the adapter intentionally suppresses
// outward delivery while still returning a successful suppressed outcome.
StubModeSuppressed StubMode = "suppressed"
// StubModeFailed reports that the adapter returns an explicit delivery
// failure instead of a successful outcome.
StubModeFailed StubMode = "failed"
)
// IsKnown reports whether mode is one of the supported stub delivery modes.
func (mode StubMode) IsKnown() bool {
switch mode {
case StubModeSent, StubModeSuppressed, StubModeFailed:
return true
default:
return false
}
}
// StubStep overrides the default stub behavior for one queued delivery
// attempt.
type StubStep struct {
// Mode selects the delivery behavior for this queued step.
Mode StubMode
// Err optionally overrides the failure returned when Mode is StubModeFailed.
Err error
}
// Validate reports whether step contains one supported queued behavior.
func (step StubStep) Validate() error {
if !step.Mode.IsKnown() {
return fmt.Errorf("stub mail step mode %q is unsupported", step.Mode)
}
return nil
}
// Attempt records one validated delivery request handled by StubSender.
type Attempt struct {
// Input stores the validated cleartext mail-delivery request exactly as it
// was passed into SendLoginCode.
Input ports.SendLoginCodeInput
// Mode stores the resolved stub mode after queued overrides were applied.
Mode StubMode
}
// StubSender is a deterministic runtime MailSender implementation intended
// for development, local integration, and explicit stub-based tests.
//
// The zero value is ready to use and defaults to StubModeSent.
type StubSender struct {
// DefaultMode controls the fallback behavior when Script is empty. The zero
// value is treated as StubModeSent so the zero-value sender is usable
// without extra configuration.
DefaultMode StubMode
// DefaultError optionally overrides the failure returned when DefaultMode
// resolves to StubModeFailed.
DefaultError error
// Script stores queued one-shot overrides consumed in FIFO order before the
// default behavior is used.
Script []StubStep
mu sync.Mutex
attempts []Attempt
}
// SendLoginCode records one validated delivery request and returns the
// deterministic stub outcome selected by the queued script or the default
// mode.
func (s *StubSender) SendLoginCode(ctx context.Context, input ports.SendLoginCodeInput) (ports.SendLoginCodeResult, error) {
if ctx == nil {
return ports.SendLoginCodeResult{}, errors.New("stub mail sender: nil context")
}
if err := ctx.Err(); err != nil {
return ports.SendLoginCodeResult{}, err
}
if err := input.Validate(); err != nil {
return ports.SendLoginCodeResult{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
mode, errOverride, err := s.resolveNextStepLocked()
if err != nil {
return ports.SendLoginCodeResult{}, err
}
s.attempts = append(s.attempts, Attempt{
Input: input,
Mode: mode,
})
switch mode {
case StubModeSent:
return ports.SendLoginCodeResult{Outcome: ports.SendLoginCodeOutcomeSent}, nil
case StubModeSuppressed:
return ports.SendLoginCodeResult{Outcome: ports.SendLoginCodeOutcomeSuppressed}, nil
case StubModeFailed:
if errOverride != nil {
return ports.SendLoginCodeResult{}, errOverride
}
return ports.SendLoginCodeResult{}, errForcedFailure
default:
return ports.SendLoginCodeResult{}, fmt.Errorf("stub mail sender: unsupported resolved mode %q", mode)
}
}
// RecordedAttempts returns a stable defensive copy of every validated delivery
// attempt handled by the stub.
func (s *StubSender) RecordedAttempts() []Attempt {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Attempt(nil), s.attempts...)
}
func (s *StubSender) resolveNextStepLocked() (StubMode, error, error) {
if len(s.Script) > 0 {
step := s.Script[0]
s.Script = append([]StubStep(nil), s.Script[1:]...)
if err := step.Validate(); err != nil {
return "", nil, fmt.Errorf("stub mail sender: %w", err)
}
if step.Mode == StubModeFailed {
if step.Err != nil {
return step.Mode, step.Err, nil
}
return step.Mode, errForcedFailure, nil
}
return step.Mode, nil, nil
}
mode := s.DefaultMode
if mode == "" {
mode = StubModeSent
}
if !mode.IsKnown() {
return "", nil, fmt.Errorf("stub mail sender: default mode %q is unsupported", mode)
}
if mode == StubModeFailed {
if s.DefaultError != nil {
return mode, s.DefaultError, nil
}
return mode, errForcedFailure, nil
}
return mode, nil, nil
}
var _ ports.MailSender = (*StubSender)(nil)
@@ -0,0 +1,198 @@
package mail
import (
"context"
"errors"
"testing"
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/ports"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStubSenderSendLoginCode(t *testing.T) {
t.Parallel()
t.Run("zero value defaults to sent", func(t *testing.T) {
t.Parallel()
sender := &StubSender{}
result, err := sender.SendLoginCode(context.Background(), validInput())
require.NoError(t, err)
assert.Equal(t, ports.SendLoginCodeOutcomeSent, result.Outcome)
attempts := sender.RecordedAttempts()
require.Len(t, attempts, 1)
assert.Equal(t, StubModeSent, attempts[0].Mode)
assert.Equal(t, validInput(), attempts[0].Input)
})
t.Run("default suppressed", func(t *testing.T) {
t.Parallel()
sender := &StubSender{DefaultMode: StubModeSuppressed}
result, err := sender.SendLoginCode(context.Background(), validInput())
require.NoError(t, err)
assert.Equal(t, ports.SendLoginCodeOutcomeSuppressed, result.Outcome)
attempts := sender.RecordedAttempts()
require.Len(t, attempts, 1)
assert.Equal(t, StubModeSuppressed, attempts[0].Mode)
})
t.Run("default failed uses configured error", func(t *testing.T) {
t.Parallel()
wantErr := errors.New("delivery refused")
sender := &StubSender{
DefaultMode: StubModeFailed,
DefaultError: wantErr,
}
result, err := sender.SendLoginCode(context.Background(), validInput())
require.Error(t, err)
assert.ErrorIs(t, err, wantErr)
assert.Equal(t, ports.SendLoginCodeResult{}, result)
attempts := sender.RecordedAttempts()
require.Len(t, attempts, 1)
assert.Equal(t, StubModeFailed, attempts[0].Mode)
})
t.Run("default failed uses stable fallback error", func(t *testing.T) {
t.Parallel()
sender := &StubSender{DefaultMode: StubModeFailed}
_, err := sender.SendLoginCode(context.Background(), validInput())
require.Error(t, err)
assert.EqualError(t, err, "stub mail sender: forced failure")
})
t.Run("script overrides default and is consumed fifo", func(t *testing.T) {
t.Parallel()
wantErr := errors.New("step failed")
sender := &StubSender{
DefaultMode: StubModeSent,
Script: []StubStep{
{Mode: StubModeSuppressed},
{Mode: StubModeFailed, Err: wantErr},
},
}
first, err := sender.SendLoginCode(context.Background(), validInput())
require.NoError(t, err)
assert.Equal(t, ports.SendLoginCodeOutcomeSuppressed, first.Outcome)
second, err := sender.SendLoginCode(context.Background(), validInput())
require.Error(t, err)
assert.ErrorIs(t, err, wantErr)
assert.Equal(t, ports.SendLoginCodeResult{}, second)
third, err := sender.SendLoginCode(context.Background(), validInput())
require.NoError(t, err)
assert.Equal(t, ports.SendLoginCodeOutcomeSent, third.Outcome)
attempts := sender.RecordedAttempts()
require.Len(t, attempts, 3)
assert.Equal(t, []StubMode{StubModeSuppressed, StubModeFailed, StubModeSent}, []StubMode{
attempts[0].Mode,
attempts[1].Mode,
attempts[2].Mode,
})
assert.Empty(t, sender.Script)
})
t.Run("invalid default mode returns adapter error", func(t *testing.T) {
t.Parallel()
sender := &StubSender{DefaultMode: StubMode("queued")}
_, err := sender.SendLoginCode(context.Background(), validInput())
require.Error(t, err)
assert.ErrorContains(t, err, `default mode "queued" is unsupported`)
assert.Empty(t, sender.RecordedAttempts())
})
t.Run("invalid scripted mode returns adapter error", func(t *testing.T) {
t.Parallel()
sender := &StubSender{
Script: []StubStep{
{Mode: StubMode("queued")},
},
}
_, err := sender.SendLoginCode(context.Background(), validInput())
require.Error(t, err)
assert.ErrorContains(t, err, `mode "queued" is unsupported`)
assert.Empty(t, sender.RecordedAttempts())
assert.Empty(t, sender.Script)
})
}
func TestStubSenderRecordedAttemptsAreDefensive(t *testing.T) {
t.Parallel()
sender := &StubSender{}
_, err := sender.SendLoginCode(context.Background(), validInput())
require.NoError(t, err)
attempts := sender.RecordedAttempts()
require.Len(t, attempts, 1)
attempts[0].Mode = StubModeFailed
attempts[0].Input.Code = "000000"
again := sender.RecordedAttempts()
require.Len(t, again, 1)
assert.Equal(t, StubModeSent, again[0].Mode)
assert.Equal(t, "654321", again[0].Input.Code)
}
func TestStubSenderSendLoginCodeNilContext(t *testing.T) {
t.Parallel()
sender := &StubSender{}
_, err := sender.SendLoginCode(nil, validInput())
require.Error(t, err)
assert.ErrorContains(t, err, "nil context")
assert.Empty(t, sender.RecordedAttempts())
}
func TestStubSenderSendLoginCodeCancelledContext(t *testing.T) {
t.Parallel()
sender := &StubSender{}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := sender.SendLoginCode(ctx, validInput())
require.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
assert.Empty(t, sender.RecordedAttempts())
}
func TestStubSenderSendLoginCodeInvalidInput(t *testing.T) {
t.Parallel()
sender := &StubSender{}
_, err := sender.SendLoginCode(context.Background(), ports.SendLoginCodeInput{})
require.Error(t, err)
assert.ErrorContains(t, err, "send login code input email")
assert.Empty(t, sender.RecordedAttempts())
}
func validInput() ports.SendLoginCodeInput {
return ports.SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
Code: "654321",
}
}