feat: authsession service
This commit is contained in:
@@ -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",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user