feat: mail service
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
// Package internalhttp defines the frozen trusted internal HTTP contract used
|
||||
// by Mail Service.
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
)
|
||||
|
||||
const (
|
||||
// LoginCodeDeliveriesPath is the dedicated trusted route used by
|
||||
// Auth / Session Service for auth login-code delivery intake.
|
||||
LoginCodeDeliveriesPath = "/api/v1/internal/login-code-deliveries"
|
||||
|
||||
// IdempotencyKeyHeader is the required header that scopes auth-delivery
|
||||
// deduplication.
|
||||
IdempotencyKeyHeader = "Idempotency-Key"
|
||||
|
||||
// ErrorCodeInvalidRequest identifies trusted validation failures.
|
||||
ErrorCodeInvalidRequest = "invalid_request"
|
||||
|
||||
// ErrorCodeInternalError identifies trusted invariant failures.
|
||||
ErrorCodeInternalError = "internal_error"
|
||||
|
||||
// ErrorCodeServiceUnavailable identifies trusted availability failures.
|
||||
ErrorCodeServiceUnavailable = "service_unavailable"
|
||||
|
||||
// ErrorCodeConflict identifies conflicting idempotency replays.
|
||||
ErrorCodeConflict = "conflict"
|
||||
|
||||
jsonMediaType = "application/json"
|
||||
)
|
||||
|
||||
// LoginCodeDeliveryRequest stores the strict JSON body accepted on the frozen
|
||||
// auth-delivery route before normalization.
|
||||
type LoginCodeDeliveryRequest struct {
|
||||
// Email stores the destination e-mail address.
|
||||
Email string `json:"email"`
|
||||
|
||||
// Code stores the exact login code generated by Auth / Session Service.
|
||||
Code string `json:"code"`
|
||||
|
||||
// Locale stores the caller-selected BCP 47 language tag.
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
// LoginCodeDeliveryCommand stores the normalized auth-delivery request shape
|
||||
// that later Mail Service handlers and services can consume directly.
|
||||
type LoginCodeDeliveryCommand struct {
|
||||
// IdempotencyKey stores the caller-owned stable deduplication key.
|
||||
IdempotencyKey common.IdempotencyKey
|
||||
|
||||
// Email stores the normalized recipient address.
|
||||
Email common.Email
|
||||
|
||||
// Code stores the exact login code after boundary validation.
|
||||
Code string
|
||||
|
||||
// Locale stores the canonical BCP 47 language tag.
|
||||
Locale common.Locale
|
||||
}
|
||||
|
||||
// Validate reports whether command satisfies the frozen auth-delivery
|
||||
// contract.
|
||||
func (command LoginCodeDeliveryCommand) Validate() error {
|
||||
if err := command.IdempotencyKey.Validate(); err != nil {
|
||||
return fmt.Errorf("idempotency key: %w", err)
|
||||
}
|
||||
if err := command.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("email: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(command.Code) == "" {
|
||||
return errors.New("code must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(command.Code) != command.Code {
|
||||
return errors.New("code must not contain surrounding whitespace")
|
||||
}
|
||||
if err := command.Locale.Validate(); err != nil {
|
||||
return fmt.Errorf("locale: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fingerprint returns the stable auth-delivery idempotency fingerprint of
|
||||
// command.
|
||||
func (command LoginCodeDeliveryCommand) Fingerprint() (string, error) {
|
||||
if err := command.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
normalized := struct {
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
Locale string `json:"locale"`
|
||||
}{
|
||||
IdempotencyKey: command.IdempotencyKey.String(),
|
||||
Email: command.Email.String(),
|
||||
Code: command.Code,
|
||||
Locale: command.Locale.String(),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal login code delivery fingerprint: %w", err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(payload)
|
||||
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// LoginCodeDeliveryOutcome identifies the stable successful auth-delivery
|
||||
// intake outcomes.
|
||||
type LoginCodeDeliveryOutcome string
|
||||
|
||||
const (
|
||||
// LoginCodeDeliveryOutcomeSent reports durable acceptance into the internal
|
||||
// mail-delivery pipeline.
|
||||
LoginCodeDeliveryOutcomeSent LoginCodeDeliveryOutcome = "sent"
|
||||
|
||||
// LoginCodeDeliveryOutcomeSuppressed reports intentional outward delivery
|
||||
// suppression while keeping the auth flow success-shaped.
|
||||
LoginCodeDeliveryOutcomeSuppressed LoginCodeDeliveryOutcome = "suppressed"
|
||||
)
|
||||
|
||||
// IsKnown reports whether outcome belongs to the frozen auth success surface.
|
||||
func (outcome LoginCodeDeliveryOutcome) IsKnown() bool {
|
||||
switch outcome {
|
||||
case LoginCodeDeliveryOutcomeSent, LoginCodeDeliveryOutcomeSuppressed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// LoginCodeDeliveryResponse stores the stable successful auth-delivery
|
||||
// response body.
|
||||
type LoginCodeDeliveryResponse struct {
|
||||
// Outcome stores the stable coarse acceptance result.
|
||||
Outcome LoginCodeDeliveryOutcome `json:"outcome"`
|
||||
}
|
||||
|
||||
// Validate reports whether response satisfies the frozen success contract.
|
||||
func (response LoginCodeDeliveryResponse) Validate() error {
|
||||
if !response.Outcome.IsKnown() {
|
||||
return fmt.Errorf("login code delivery outcome %q is unsupported", response.Outcome)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrorResponse stores the stable trusted error envelope used by Mail Service.
|
||||
type ErrorResponse struct {
|
||||
// Error stores the stable trusted error body.
|
||||
Error ErrorBody `json:"error"`
|
||||
}
|
||||
|
||||
// Validate reports whether response satisfies the frozen trusted error
|
||||
// envelope contract.
|
||||
func (response ErrorResponse) Validate() error {
|
||||
return response.Error.Validate()
|
||||
}
|
||||
|
||||
// ErrorBody stores the stable trusted error shape returned by Mail Service.
|
||||
type ErrorBody struct {
|
||||
// Code stores the stable machine-readable error code.
|
||||
Code string `json:"code"`
|
||||
|
||||
// Message stores the trusted human-readable error message.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Validate reports whether body contains a complete trusted error payload.
|
||||
func (body ErrorBody) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(body.Code) == "":
|
||||
return errors.New("error code must not be empty")
|
||||
case strings.TrimSpace(body.Code) != body.Code:
|
||||
return errors.New("error code must not contain surrounding whitespace")
|
||||
case strings.TrimSpace(body.Message) == "":
|
||||
return errors.New("error message must not be empty")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// DecodeLoginCodeDeliveryCommand validates one trusted HTTP request and
|
||||
// returns the normalized auth-delivery command shape frozen by Stage 04.
|
||||
func DecodeLoginCodeDeliveryCommand(request *http.Request) (LoginCodeDeliveryCommand, error) {
|
||||
if request == nil {
|
||||
return LoginCodeDeliveryCommand{}, errors.New("login code delivery request must not be nil")
|
||||
}
|
||||
|
||||
if err := validateJSONContentType(request.Header.Get("Content-Type")); err != nil {
|
||||
return LoginCodeDeliveryCommand{}, err
|
||||
}
|
||||
|
||||
idempotencyKey, err := parseIdempotencyKey(request.Header.Get(IdempotencyKeyHeader))
|
||||
if err != nil {
|
||||
return LoginCodeDeliveryCommand{}, err
|
||||
}
|
||||
|
||||
body, err := decodeLoginCodeDeliveryRequest(request.Body)
|
||||
if err != nil {
|
||||
return LoginCodeDeliveryCommand{}, err
|
||||
}
|
||||
|
||||
command := LoginCodeDeliveryCommand{
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Email: common.Email(strings.TrimSpace(body.Email)),
|
||||
Code: body.Code,
|
||||
}
|
||||
|
||||
locale, err := common.ParseLocale(strings.TrimSpace(body.Locale))
|
||||
if err != nil {
|
||||
return LoginCodeDeliveryCommand{}, fmt.Errorf("locale: %w", err)
|
||||
}
|
||||
command.Locale = locale
|
||||
|
||||
if err := command.Validate(); err != nil {
|
||||
return LoginCodeDeliveryCommand{}, err
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func decodeLoginCodeDeliveryRequest(body io.ReadCloser) (LoginCodeDeliveryRequest, error) {
|
||||
if body == nil {
|
||||
return LoginCodeDeliveryRequest{}, errors.New("request body must not be nil")
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
payload, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return LoginCodeDeliveryRequest{}, fmt.Errorf("read request body: %w", err)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
var request LoginCodeDeliveryRequest
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
return LoginCodeDeliveryRequest{}, fmt.Errorf("decode request body: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return LoginCodeDeliveryRequest{}, errors.New("decode request body: unexpected trailing JSON input")
|
||||
}
|
||||
|
||||
return LoginCodeDeliveryRequest{}, fmt.Errorf("decode request body: %w", err)
|
||||
}
|
||||
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func parseIdempotencyKey(value string) (common.IdempotencyKey, error) {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return "", errors.New("Idempotency-Key header must not be empty")
|
||||
case strings.TrimSpace(value) != value:
|
||||
return "", errors.New("Idempotency-Key header must not contain surrounding whitespace")
|
||||
default:
|
||||
key := common.IdempotencyKey(value)
|
||||
if err := key.Validate(); err != nil {
|
||||
return "", fmt.Errorf("idempotency key: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateJSONContentType(value string) error {
|
||||
mediaType, _, err := mime.ParseMediaType(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Content-Type must be %s", jsonMediaType)
|
||||
}
|
||||
if mediaType != jsonMediaType {
|
||||
return fmt.Errorf("Content-Type must be %s", jsonMediaType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDecodeLoginCodeDeliveryCommandSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, LoginCodeDeliveriesPath, strings.NewReader(`{"email":" pilot@example.com ","code":"123456","locale":" en "}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set(IdempotencyKeyHeader, "challenge-1")
|
||||
|
||||
command, err := DecodeLoginCodeDeliveryCommand(request)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, LoginCodeDeliveryCommand{
|
||||
IdempotencyKey: common.IdempotencyKey("challenge-1"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
Code: "123456",
|
||||
Locale: common.Locale("en"),
|
||||
}, command)
|
||||
}
|
||||
|
||||
func TestDecodeLoginCodeDeliveryCommandRejectsInvalidRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
contentType string
|
||||
headerValue string
|
||||
body string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing content type",
|
||||
headerValue: "challenge-1",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}`,
|
||||
wantErr: "Content-Type must be application/json",
|
||||
},
|
||||
{
|
||||
name: "missing idempotency key",
|
||||
contentType: "application/json",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}`,
|
||||
wantErr: "Idempotency-Key header must not be empty",
|
||||
},
|
||||
{
|
||||
name: "idempotency key surrounding whitespace",
|
||||
contentType: "application/json",
|
||||
headerValue: " challenge-1 ",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}`,
|
||||
wantErr: "Idempotency-Key header must not contain surrounding whitespace",
|
||||
},
|
||||
{
|
||||
name: "unknown field",
|
||||
contentType: "application/json",
|
||||
headerValue: "challenge-1",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en","extra":true}`,
|
||||
wantErr: "decode request body",
|
||||
},
|
||||
{
|
||||
name: "trailing json",
|
||||
contentType: "application/json",
|
||||
headerValue: "challenge-1",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}{}`,
|
||||
wantErr: "unexpected trailing JSON input",
|
||||
},
|
||||
{
|
||||
name: "code surrounding whitespace",
|
||||
contentType: "application/json",
|
||||
headerValue: "challenge-1",
|
||||
body: `{"email":"pilot@example.com","code":" 123456 ","locale":"en"}`,
|
||||
wantErr: "code must not contain surrounding whitespace",
|
||||
},
|
||||
{
|
||||
name: "invalid locale",
|
||||
contentType: "application/json",
|
||||
headerValue: "challenge-1",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"english"}`,
|
||||
wantErr: "locale:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, LoginCodeDeliveriesPath, strings.NewReader(tt.body))
|
||||
if tt.contentType != "" {
|
||||
request.Header.Set("Content-Type", tt.contentType)
|
||||
}
|
||||
if tt.headerValue != "" {
|
||||
request.Header.Set(IdempotencyKeyHeader, tt.headerValue)
|
||||
}
|
||||
|
||||
_, err := DecodeLoginCodeDeliveryCommand(request)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeLoginCodeDeliveryCommandRepeatedEquivalentRequestsMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := httptest.NewRequest(http.MethodPost, LoginCodeDeliveriesPath, strings.NewReader(`{"email":"pilot@example.com","code":"123456","locale":"en"}`))
|
||||
first.Header.Set("Content-Type", "application/json")
|
||||
first.Header.Set(IdempotencyKeyHeader, "challenge-1")
|
||||
|
||||
second := httptest.NewRequest(http.MethodPost, LoginCodeDeliveriesPath, strings.NewReader(`{"email":" pilot@example.com ","code":"123456","locale":" en "}`))
|
||||
second.Header.Set("Content-Type", "application/json")
|
||||
second.Header.Set(IdempotencyKeyHeader, "challenge-1")
|
||||
|
||||
firstCommand, err := DecodeLoginCodeDeliveryCommand(first)
|
||||
require.NoError(t, err)
|
||||
secondCommand, err := DecodeLoginCodeDeliveryCommand(second)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, firstCommand, secondCommand)
|
||||
}
|
||||
|
||||
func TestLoginCodeDeliveryCommandFingerprintStableForEquivalentRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := LoginCodeDeliveryCommand{
|
||||
IdempotencyKey: common.IdempotencyKey("challenge-1"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
Code: "123456",
|
||||
Locale: common.Locale("en"),
|
||||
}
|
||||
second := LoginCodeDeliveryCommand{
|
||||
IdempotencyKey: common.IdempotencyKey("challenge-1"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
Code: "123456",
|
||||
Locale: common.Locale("en"),
|
||||
}
|
||||
|
||||
firstFingerprint, err := first.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
secondFingerprint, err := second.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, firstFingerprint, secondFingerprint)
|
||||
}
|
||||
|
||||
func TestLoginCodeDeliveryResponseValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.NoError(t, LoginCodeDeliveryResponse{Outcome: LoginCodeDeliveryOutcomeSent}.Validate())
|
||||
require.NoError(t, LoginCodeDeliveryResponse{Outcome: LoginCodeDeliveryOutcomeSuppressed}.Validate())
|
||||
|
||||
err := LoginCodeDeliveryResponse{Outcome: LoginCodeDeliveryOutcome("queued")}.Validate()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "unsupported")
|
||||
}
|
||||
|
||||
func TestErrorResponseValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.NoError(t, ErrorResponse{
|
||||
Error: ErrorBody{
|
||||
Code: ErrorCodeInvalidRequest,
|
||||
Message: "field-specific validation detail",
|
||||
},
|
||||
}.Validate())
|
||||
|
||||
err := ErrorResponse{
|
||||
Error: ErrorBody{
|
||||
Code: " invalid_request ",
|
||||
Message: "",
|
||||
},
|
||||
}.Validate()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "error code")
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"galaxy/mail/internal/service/acceptauthdelivery"
|
||||
)
|
||||
|
||||
// AcceptLoginCodeDeliveryUseCase accepts one auth login-code delivery request.
|
||||
type AcceptLoginCodeDeliveryUseCase interface {
|
||||
// Execute durably accepts one normalized auth login-code delivery command.
|
||||
Execute(context.Context, acceptauthdelivery.Input) (acceptauthdelivery.Result, error)
|
||||
}
|
||||
|
||||
func newAcceptLoginCodeDeliveryHandler(useCase AcceptLoginCodeDeliveryUseCase) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
ctx := request.Context()
|
||||
|
||||
command, err := DecodeLoginCodeDeliveryCommand(request)
|
||||
if err != nil {
|
||||
writeErrorResponse(writer, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := useCase.Execute(ctx, acceptauthdelivery.Input{
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
Email: command.Email,
|
||||
Code: command.Code,
|
||||
Locale: command.Locale,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, acceptauthdelivery.ErrConflict):
|
||||
writeErrorResponse(writer, http.StatusConflict, ErrorCodeConflict, "request conflicts with current state")
|
||||
case errors.Is(err, acceptauthdelivery.ErrServiceUnavailable):
|
||||
writeErrorResponse(writer, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "service is unavailable")
|
||||
default:
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := result.Validate(); err != nil {
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
response := LoginCodeDeliveryResponse{
|
||||
Outcome: LoginCodeDeliveryOutcome(result.Outcome),
|
||||
}
|
||||
if err := response.Validate(); err != nil {
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(writer).Encode(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"galaxy/mail/internal/service/acceptauthdelivery"
|
||||
mailtelemetry "galaxy/mail/internal/telemetry"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
func TestLoginCodeDeliveryHandlerReturnsSuccessOutcomes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
result acceptauthdelivery.Result
|
||||
wantOutcome string
|
||||
}{
|
||||
{name: "sent", result: acceptauthdelivery.Result{Outcome: acceptauthdelivery.OutcomeSent}, wantOutcome: "sent"},
|
||||
{name: "suppressed", result: acceptauthdelivery.Result{Outcome: acceptauthdelivery.OutcomeSuppressed}, wantOutcome: "suppressed"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newHandler(Dependencies{
|
||||
Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
|
||||
AcceptLoginCodeDelivery: acceptLoginCodeDeliveryFunc(func(context.Context, acceptauthdelivery.Input) (acceptauthdelivery.Result, error) {
|
||||
return tt.result, nil
|
||||
}),
|
||||
})
|
||||
|
||||
response := doLoginCodeDeliveryRequest(t, handler, `{"email":"pilot@example.com","code":"123456","locale":"en"}`, "challenge-1")
|
||||
defer response.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, response.StatusCode)
|
||||
require.Equal(t, "application/json", response.Header.Get("Content-Type"))
|
||||
|
||||
var payload LoginCodeDeliveryResponse
|
||||
require.NoError(t, decodeJSONBody(response, &payload))
|
||||
require.Equal(t, LoginCodeDeliveryOutcome(tt.wantOutcome), payload.Outcome)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCodeDeliveryHandlerMapsErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
header string
|
||||
useCaseErr error
|
||||
wantCode int
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "invalid request",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
wantErr: ErrorCodeInvalidRequest,
|
||||
},
|
||||
{
|
||||
name: "conflict",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}`,
|
||||
header: "challenge-1",
|
||||
useCaseErr: acceptauthdelivery.ErrConflict,
|
||||
wantCode: http.StatusConflict,
|
||||
wantErr: ErrorCodeConflict,
|
||||
},
|
||||
{
|
||||
name: "service unavailable",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}`,
|
||||
header: "challenge-1",
|
||||
useCaseErr: acceptauthdelivery.ErrServiceUnavailable,
|
||||
wantCode: http.StatusServiceUnavailable,
|
||||
wantErr: ErrorCodeServiceUnavailable,
|
||||
},
|
||||
{
|
||||
name: "internal error",
|
||||
body: `{"email":"pilot@example.com","code":"123456","locale":"en"}`,
|
||||
header: "challenge-1",
|
||||
useCaseErr: context.DeadlineExceeded,
|
||||
wantCode: http.StatusInternalServerError,
|
||||
wantErr: ErrorCodeInternalError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newHandler(Dependencies{
|
||||
Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
|
||||
AcceptLoginCodeDelivery: acceptLoginCodeDeliveryFunc(func(context.Context, acceptauthdelivery.Input) (acceptauthdelivery.Result, error) {
|
||||
if tt.useCaseErr != nil {
|
||||
return acceptauthdelivery.Result{}, tt.useCaseErr
|
||||
}
|
||||
|
||||
return acceptauthdelivery.Result{Outcome: acceptauthdelivery.OutcomeSent}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
response := doLoginCodeDeliveryRequest(t, handler, tt.body, tt.header)
|
||||
defer response.Body.Close()
|
||||
|
||||
require.Equal(t, tt.wantCode, response.StatusCode)
|
||||
|
||||
var payload ErrorResponse
|
||||
require.NoError(t, decodeJSONBody(response, &payload))
|
||||
require.Equal(t, tt.wantErr, payload.Error.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCodeDeliveryHandlerEmitsMetricsAndSpan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
|
||||
telemetryRuntime, err := mailtelemetry.NewWithProviders(meterProvider, tracerProvider)
|
||||
require.NoError(t, err)
|
||||
|
||||
loggerBuffer := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewJSONHandler(loggerBuffer, nil))
|
||||
handler := newHandler(Dependencies{
|
||||
Logger: logger,
|
||||
Telemetry: telemetryRuntime,
|
||||
AcceptLoginCodeDelivery: acceptLoginCodeDeliveryFunc(func(context.Context, acceptauthdelivery.Input) (acceptauthdelivery.Result, error) {
|
||||
return acceptauthdelivery.Result{Outcome: acceptauthdelivery.OutcomeSent}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
response := doLoginCodeDeliveryRequest(t, handler, `{"email":"pilot@example.com","code":"123456","locale":"en"}`, "challenge-1")
|
||||
defer response.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, response.StatusCode)
|
||||
require.Len(t, recorder.Ended(), 1)
|
||||
assert.Equal(t, LoginCodeDeliveriesPath, recorder.Ended()[0].Name())
|
||||
assert.Contains(t, loggerBuffer.String(), "otel_trace_id")
|
||||
assert.Contains(t, loggerBuffer.String(), "otel_span_id")
|
||||
|
||||
assertMetricCount(t, reader, "mail.internal_http.requests", map[string]string{
|
||||
"route": LoginCodeDeliveriesPath,
|
||||
"method": http.MethodPost,
|
||||
"edge_outcome": "success",
|
||||
}, 1)
|
||||
}
|
||||
|
||||
type acceptLoginCodeDeliveryFunc func(context.Context, acceptauthdelivery.Input) (acceptauthdelivery.Result, error)
|
||||
|
||||
func (fn acceptLoginCodeDeliveryFunc) Execute(ctx context.Context, input acceptauthdelivery.Input) (acceptauthdelivery.Result, error) {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
func doLoginCodeDeliveryRequest(t *testing.T, handler http.Handler, body string, idempotencyKey string) *http.Response {
|
||||
t.Helper()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, LoginCodeDeliveriesPath, bytes.NewBufferString(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if idempotencyKey != "" {
|
||||
request.Header.Set(IdempotencyKeyHeader, idempotencyKey)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
return recorder.Result()
|
||||
}
|
||||
|
||||
func decodeJSONBody(response *http.Response, target any) error {
|
||||
return json.NewDecoder(response.Body).Decode(target)
|
||||
}
|
||||
|
||||
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
|
||||
t.Helper()
|
||||
|
||||
var resourceMetrics metricdata.ResourceMetrics
|
||||
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
|
||||
|
||||
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
|
||||
for _, metric := range scopeMetrics.Metrics {
|
||||
if metric.Name != metricName {
|
||||
continue
|
||||
}
|
||||
|
||||
sum, ok := metric.Data.(metricdata.Sum[int64])
|
||||
require.True(t, ok)
|
||||
|
||||
for _, point := range sum.DataPoints {
|
||||
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
|
||||
assert.Equal(t, wantValue, point.Value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
|
||||
}
|
||||
|
||||
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if want[string(value.Key)] != value.Value.AsString() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/logging"
|
||||
"galaxy/mail/internal/telemetry"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
)
|
||||
|
||||
type edgeOutcome string
|
||||
|
||||
const (
|
||||
edgeOutcomeSuccess edgeOutcome = "success"
|
||||
edgeOutcomeRejected edgeOutcome = "rejected"
|
||||
edgeOutcomeFailed edgeOutcome = "failed"
|
||||
)
|
||||
|
||||
func instrumentRoute(route string, logger *slog.Logger, telemetryRuntime *telemetry.Runtime, next http.Handler) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
startedAt := time.Now()
|
||||
recorder := &observedResponseWriter{
|
||||
ResponseWriter: writer,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
next.ServeHTTP(recorder, request)
|
||||
|
||||
duration := time.Since(startedAt)
|
||||
outcome := outcomeFromStatusCode(recorder.statusCode)
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String("route", route),
|
||||
attribute.String("method", request.Method),
|
||||
attribute.String("edge_outcome", string(outcome)),
|
||||
}
|
||||
if recorder.errorCode != "" {
|
||||
attrs = append(attrs, attribute.String("error_code", recorder.errorCode))
|
||||
}
|
||||
if telemetryRuntime != nil {
|
||||
telemetryRuntime.RecordInternalHTTPRequest(request.Context(), attrs, duration)
|
||||
}
|
||||
|
||||
logArgs := []any{
|
||||
"component", "internal_http",
|
||||
"transport", "http",
|
||||
"route", route,
|
||||
"method", request.Method,
|
||||
"status_code", recorder.statusCode,
|
||||
"duration_ms", float64(duration.Microseconds()) / 1000,
|
||||
"edge_outcome", string(outcome),
|
||||
}
|
||||
if recorder.errorCode != "" {
|
||||
logArgs = append(logArgs, "error_code", recorder.errorCode)
|
||||
}
|
||||
logArgs = append(logArgs, logging.TraceAttrsFromContext(request.Context())...)
|
||||
|
||||
switch outcome {
|
||||
case edgeOutcomeSuccess:
|
||||
logger.Info("internal request completed", logArgs...)
|
||||
case edgeOutcomeFailed:
|
||||
logger.Error("internal request failed", logArgs...)
|
||||
default:
|
||||
logger.Warn("internal request rejected", logArgs...)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type observedResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
statusCode int
|
||||
errorCode string
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (writer *observedResponseWriter) WriteHeader(statusCode int) {
|
||||
if writer.wroteHeader {
|
||||
return
|
||||
}
|
||||
|
||||
writer.statusCode = statusCode
|
||||
writer.wroteHeader = true
|
||||
writer.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (writer *observedResponseWriter) Write(payload []byte) (int, error) {
|
||||
if !writer.wroteHeader {
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
return writer.ResponseWriter.Write(payload)
|
||||
}
|
||||
|
||||
func (writer *observedResponseWriter) SetErrorCode(code string) {
|
||||
writer.errorCode = code
|
||||
}
|
||||
|
||||
func outcomeFromStatusCode(statusCode int) edgeOutcome {
|
||||
switch {
|
||||
case statusCode >= 500:
|
||||
return edgeOutcomeFailed
|
||||
case statusCode >= 400:
|
||||
return edgeOutcomeRejected
|
||||
default:
|
||||
return edgeOutcomeSuccess
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/service/listdeliveries"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrorCodeDeliveryNotFound identifies a missing trusted delivery lookup
|
||||
// target.
|
||||
ErrorCodeDeliveryNotFound = "delivery_not_found"
|
||||
|
||||
// ErrorCodeResendNotAllowed identifies resend requests against non-terminal
|
||||
// deliveries.
|
||||
ErrorCodeResendNotAllowed = "resend_not_allowed"
|
||||
|
||||
deliveryIDPathValue = "delivery_id"
|
||||
)
|
||||
|
||||
// DeliveryListQuery stores the raw trusted query-string values accepted by the
|
||||
// operator delivery-list route before normalization.
|
||||
type DeliveryListQuery struct {
|
||||
// Recipient stores the optional recipient filter covering `to`, `cc`, and
|
||||
// `bcc`.
|
||||
Recipient string
|
||||
|
||||
// Status stores the optional delivery-status filter.
|
||||
Status string
|
||||
|
||||
// Source stores the optional delivery-source filter.
|
||||
Source string
|
||||
|
||||
// TemplateID stores the optional template-family filter.
|
||||
TemplateID string
|
||||
|
||||
// IdempotencyKey stores the optional idempotency-key filter.
|
||||
IdempotencyKey string
|
||||
|
||||
// FromCreatedAtMS stores the optional inclusive lower creation-time bound.
|
||||
FromCreatedAtMS string
|
||||
|
||||
// ToCreatedAtMS stores the optional inclusive upper creation-time bound.
|
||||
ToCreatedAtMS string
|
||||
|
||||
// Limit stores the optional page size.
|
||||
Limit string
|
||||
|
||||
// Cursor stores the optional opaque continuation cursor.
|
||||
Cursor string
|
||||
}
|
||||
|
||||
// DeliverySummaryResponse stores one brief operator-facing delivery record.
|
||||
type DeliverySummaryResponse struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
ResendParentDeliveryID string `json:"resend_parent_delivery_id,omitempty"`
|
||||
Source string `json:"source"`
|
||||
PayloadMode string `json:"payload_mode"`
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
To []string `json:"to"`
|
||||
Cc []string `json:"cc"`
|
||||
Bcc []string `json:"bcc"`
|
||||
ReplyTo []string `json:"reply_to"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
LocaleFallbackUsed bool `json:"locale_fallback_used"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
Status string `json:"status"`
|
||||
AttemptCount int `json:"attempt_count"`
|
||||
LastAttemptStatus string `json:"last_attempt_status,omitempty"`
|
||||
ProviderSummary string `json:"provider_summary,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
SentAtMS *int64 `json:"sent_at_ms,omitempty"`
|
||||
SuppressedAtMS *int64 `json:"suppressed_at_ms,omitempty"`
|
||||
FailedAtMS *int64 `json:"failed_at_ms,omitempty"`
|
||||
DeadLetteredAtMS *int64 `json:"dead_lettered_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
// DeliveryListResponse stores one deterministic page of brief delivery
|
||||
// summaries.
|
||||
type DeliveryListResponse struct {
|
||||
Items []DeliverySummaryResponse `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
|
||||
// AttachmentResponse stores one durable attachment audit record.
|
||||
type AttachmentResponse struct {
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// DeadLetterResponse stores one operator-visible dead-letter entry.
|
||||
type DeadLetterResponse struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
FinalAttemptNo int `json:"final_attempt_no"`
|
||||
FailureClassification string `json:"failure_classification"`
|
||||
ProviderSummary string `json:"provider_summary,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
RecoveryHint string `json:"recovery_hint,omitempty"`
|
||||
}
|
||||
|
||||
// DeliveryDetailResponse stores one full operator-facing delivery view.
|
||||
type DeliveryDetailResponse struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
ResendParentDeliveryID string `json:"resend_parent_delivery_id,omitempty"`
|
||||
Source string `json:"source"`
|
||||
PayloadMode string `json:"payload_mode"`
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
TemplateVariables map[string]any `json:"template_variables,omitempty"`
|
||||
To []string `json:"to"`
|
||||
Cc []string `json:"cc"`
|
||||
Bcc []string `json:"bcc"`
|
||||
ReplyTo []string `json:"reply_to"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
TextBody string `json:"text_body,omitempty"`
|
||||
HTMLBody string `json:"html_body,omitempty"`
|
||||
Attachments []AttachmentResponse `json:"attachments"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
LocaleFallbackUsed bool `json:"locale_fallback_used"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
Status string `json:"status"`
|
||||
AttemptCount int `json:"attempt_count"`
|
||||
LastAttemptStatus string `json:"last_attempt_status,omitempty"`
|
||||
ProviderSummary string `json:"provider_summary,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
SentAtMS *int64 `json:"sent_at_ms,omitempty"`
|
||||
SuppressedAtMS *int64 `json:"suppressed_at_ms,omitempty"`
|
||||
FailedAtMS *int64 `json:"failed_at_ms,omitempty"`
|
||||
DeadLetteredAtMS *int64 `json:"dead_lettered_at_ms,omitempty"`
|
||||
DeadLetter *DeadLetterResponse `json:"dead_letter,omitempty"`
|
||||
}
|
||||
|
||||
// AttemptResponse stores one operator-facing delivery-attempt record.
|
||||
type AttemptResponse struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
AttemptNo int `json:"attempt_no"`
|
||||
ScheduledForMS int64 `json:"scheduled_for_ms"`
|
||||
StartedAtMS *int64 `json:"started_at_ms,omitempty"`
|
||||
FinishedAtMS *int64 `json:"finished_at_ms,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ProviderClassification string `json:"provider_classification,omitempty"`
|
||||
ProviderSummary string `json:"provider_summary,omitempty"`
|
||||
}
|
||||
|
||||
// DeliveryAttemptsResponse stores the attempt history of one accepted
|
||||
// delivery.
|
||||
type DeliveryAttemptsResponse struct {
|
||||
Items []AttemptResponse `json:"items"`
|
||||
}
|
||||
|
||||
// DeliveryResendResponse stores the identifier of the clone delivery created
|
||||
// by one resend request.
|
||||
type DeliveryResendResponse struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
}
|
||||
|
||||
// DecodeDeliveryListInput validates one trusted operator delivery-list
|
||||
// request and returns the normalized list input.
|
||||
func DecodeDeliveryListInput(request *http.Request) (listdeliveries.Input, error) {
|
||||
if request == nil {
|
||||
return listdeliveries.Input{}, errors.New("delivery list request must not be nil")
|
||||
}
|
||||
|
||||
query, err := decodeDeliveryListQuery(request)
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
|
||||
input, err := query.Normalize()
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// DecodeDeliveryIDFromPath validates one trusted path delivery identifier.
|
||||
func DecodeDeliveryIDFromPath(request *http.Request) (common.DeliveryID, error) {
|
||||
if request == nil {
|
||||
return "", errors.New("delivery lookup request must not be nil")
|
||||
}
|
||||
|
||||
return parseDeliveryID(request.PathValue(deliveryIDPathValue))
|
||||
}
|
||||
|
||||
// EncodeDeliveryListCursor encodes cursor into the frozen opaque base64url
|
||||
// format `created_at_ms:delivery_id`.
|
||||
func EncodeDeliveryListCursor(cursor listdeliveries.Cursor) (string, error) {
|
||||
if err := cursor.Validate(); err != nil {
|
||||
return "", fmt.Errorf("encode delivery list cursor: %w", err)
|
||||
}
|
||||
|
||||
payload := fmt.Sprintf("%d:%s", cursor.CreatedAt.UTC().UnixMilli(), cursor.DeliveryID.String())
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(payload)), nil
|
||||
}
|
||||
|
||||
// DecodeDeliveryListCursor decodes raw from the frozen opaque cursor format.
|
||||
func DecodeDeliveryListCursor(raw string) (listdeliveries.Cursor, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return listdeliveries.Cursor{}, errors.New("cursor must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(raw) != raw {
|
||||
return listdeliveries.Cursor{}, errors.New("cursor must not contain surrounding whitespace")
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return listdeliveries.Cursor{}, fmt.Errorf("decode cursor: %w", err)
|
||||
}
|
||||
|
||||
createdAtRaw, deliveryIDRaw, ok := strings.Cut(string(payload), ":")
|
||||
if !ok {
|
||||
return listdeliveries.Cursor{}, errors.New("decode cursor: invalid cursor payload")
|
||||
}
|
||||
|
||||
createdAtMS, err := strconv.ParseInt(createdAtRaw, 10, 64)
|
||||
if err != nil {
|
||||
return listdeliveries.Cursor{}, fmt.Errorf("decode cursor created_at_ms: %w", err)
|
||||
}
|
||||
|
||||
cursor := listdeliveries.Cursor{
|
||||
CreatedAt: time.UnixMilli(createdAtMS).UTC(),
|
||||
DeliveryID: common.DeliveryID(deliveryIDRaw),
|
||||
}
|
||||
if err := cursor.Validate(); err != nil {
|
||||
return listdeliveries.Cursor{}, fmt.Errorf("decode cursor: %w", err)
|
||||
}
|
||||
|
||||
return cursor, nil
|
||||
}
|
||||
|
||||
// Normalize converts the raw trusted query-string shape into the operator list
|
||||
// input consumed by the service layer.
|
||||
func (query DeliveryListQuery) Normalize() (listdeliveries.Input, error) {
|
||||
var input listdeliveries.Input
|
||||
|
||||
recipient, err := parseOptionalEmail(query.Recipient, "recipient")
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
status, err := parseOptionalStatus(query.Status)
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
source, err := parseOptionalSource(query.Source)
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
templateID, err := parseOptionalTemplateID(query.TemplateID)
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
idempotencyKey, err := parseOptionalIdempotencyKey(query.IdempotencyKey)
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
fromCreatedAt, err := parseOptionalUnixMilli(query.FromCreatedAtMS, "from_created_at_ms")
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
toCreatedAt, err := parseOptionalUnixMilli(query.ToCreatedAtMS, "to_created_at_ms")
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
limit, err := parseOptionalLimit(query.Limit)
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
cursor, err := parseOptionalCursor(query.Cursor)
|
||||
if err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
|
||||
input = listdeliveries.Input{
|
||||
Limit: limit,
|
||||
Cursor: cursor,
|
||||
Filters: listdeliveries.Filters{
|
||||
Recipient: recipient,
|
||||
Status: status,
|
||||
Source: source,
|
||||
TemplateID: templateID,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
FromCreatedAt: fromCreatedAt,
|
||||
ToCreatedAt: toCreatedAt,
|
||||
},
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return listdeliveries.Input{}, err
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func decodeDeliveryListQuery(request *http.Request) (DeliveryListQuery, error) {
|
||||
values := request.URL.Query()
|
||||
|
||||
recipient, err := singleQueryValue(values, "recipient")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
status, err := singleQueryValue(values, "status")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
source, err := singleQueryValue(values, "source")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
templateID, err := singleQueryValue(values, "template_id")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
idempotencyKey, err := singleQueryValue(values, "idempotency_key")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
fromCreatedAtMS, err := singleQueryValue(values, "from_created_at_ms")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
toCreatedAtMS, err := singleQueryValue(values, "to_created_at_ms")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
limit, err := singleQueryValue(values, "limit")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
cursor, err := singleQueryValue(values, "cursor")
|
||||
if err != nil {
|
||||
return DeliveryListQuery{}, err
|
||||
}
|
||||
|
||||
return DeliveryListQuery{
|
||||
Recipient: recipient,
|
||||
Status: status,
|
||||
Source: source,
|
||||
TemplateID: templateID,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
FromCreatedAtMS: fromCreatedAtMS,
|
||||
ToCreatedAtMS: toCreatedAtMS,
|
||||
Limit: limit,
|
||||
Cursor: cursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func singleQueryValue(values map[string][]string, key string) (string, error) {
|
||||
rawValues := values[key]
|
||||
switch len(rawValues) {
|
||||
case 0:
|
||||
return "", nil
|
||||
case 1:
|
||||
return rawValues[0], nil
|
||||
default:
|
||||
return "", fmt.Errorf("query parameter %q must appear at most once", key)
|
||||
}
|
||||
}
|
||||
|
||||
func parseDeliveryID(raw string) (common.DeliveryID, error) {
|
||||
deliveryID := common.DeliveryID(raw)
|
||||
if err := deliveryID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("delivery id: %w", err)
|
||||
}
|
||||
|
||||
return deliveryID, nil
|
||||
}
|
||||
|
||||
func parseOptionalEmail(raw string, name string) (common.Email, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
email := common.Email(strings.TrimSpace(raw))
|
||||
if err := email.Validate(); err != nil {
|
||||
return "", fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func parseOptionalStatus(raw string) (deliverydomain.Status, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
status := deliverydomain.Status(strings.TrimSpace(raw))
|
||||
if !status.IsKnown() {
|
||||
return "", fmt.Errorf("status %q is unsupported", raw)
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func parseOptionalSource(raw string) (deliverydomain.Source, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
source := deliverydomain.Source(strings.TrimSpace(raw))
|
||||
if !source.IsKnown() {
|
||||
return "", fmt.Errorf("source %q is unsupported", raw)
|
||||
}
|
||||
|
||||
return source, nil
|
||||
}
|
||||
|
||||
func parseOptionalTemplateID(raw string) (common.TemplateID, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
templateID := common.TemplateID(strings.TrimSpace(raw))
|
||||
if err := templateID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("template id: %w", err)
|
||||
}
|
||||
|
||||
return templateID, nil
|
||||
}
|
||||
|
||||
func parseOptionalIdempotencyKey(raw string) (common.IdempotencyKey, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
key := common.IdempotencyKey(strings.TrimSpace(raw))
|
||||
if err := key.Validate(); err != nil {
|
||||
return "", fmt.Errorf("idempotency key: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func parseOptionalUnixMilli(raw string, name string) (*time.Time, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
timestamp := time.UnixMilli(value).UTC()
|
||||
if err := common.ValidateTimestamp(name, timestamp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ×tamp, nil
|
||||
}
|
||||
|
||||
func parseOptionalLimit(raw string) (int, error) {
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("limit: %w", err)
|
||||
}
|
||||
if value < 1 {
|
||||
return 0, errors.New("limit must be at least 1")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseOptionalCursor(raw string) (*listdeliveries.Cursor, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cursor, err := DecodeDeliveryListCursor(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cursor, nil
|
||||
}
|
||||
|
||||
func summaryResponseFromDelivery(record deliverydomain.Delivery) DeliverySummaryResponse {
|
||||
return DeliverySummaryResponse{
|
||||
DeliveryID: record.DeliveryID.String(),
|
||||
ResendParentDeliveryID: record.ResendParentDeliveryID.String(),
|
||||
Source: string(record.Source),
|
||||
PayloadMode: string(record.PayloadMode),
|
||||
TemplateID: record.TemplateID.String(),
|
||||
To: emailStrings(record.Envelope.To),
|
||||
Cc: emailStrings(record.Envelope.Cc),
|
||||
Bcc: emailStrings(record.Envelope.Bcc),
|
||||
ReplyTo: emailStrings(record.Envelope.ReplyTo),
|
||||
Locale: record.Locale.String(),
|
||||
LocaleFallbackUsed: record.LocaleFallbackUsed,
|
||||
IdempotencyKey: record.IdempotencyKey.String(),
|
||||
Status: string(record.Status),
|
||||
AttemptCount: record.AttemptCount,
|
||||
LastAttemptStatus: string(record.LastAttemptStatus),
|
||||
ProviderSummary: record.ProviderSummary,
|
||||
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAtMS: record.UpdatedAt.UTC().UnixMilli(),
|
||||
SentAtMS: unixMilliPtr(record.SentAt),
|
||||
SuppressedAtMS: unixMilliPtr(record.SuppressedAt),
|
||||
FailedAtMS: unixMilliPtr(record.FailedAt),
|
||||
DeadLetteredAtMS: unixMilliPtr(record.DeadLetteredAt),
|
||||
}
|
||||
}
|
||||
|
||||
func detailResponseFromDelivery(record deliverydomain.Delivery, deadLetter *deliverydomain.DeadLetterEntry) DeliveryDetailResponse {
|
||||
response := DeliveryDetailResponse{
|
||||
DeliveryID: record.DeliveryID.String(),
|
||||
ResendParentDeliveryID: record.ResendParentDeliveryID.String(),
|
||||
Source: string(record.Source),
|
||||
PayloadMode: string(record.PayloadMode),
|
||||
TemplateID: record.TemplateID.String(),
|
||||
TemplateVariables: cloneJSONObject(record.TemplateVariables),
|
||||
To: emailStrings(record.Envelope.To),
|
||||
Cc: emailStrings(record.Envelope.Cc),
|
||||
Bcc: emailStrings(record.Envelope.Bcc),
|
||||
ReplyTo: emailStrings(record.Envelope.ReplyTo),
|
||||
Subject: record.Content.Subject,
|
||||
TextBody: record.Content.TextBody,
|
||||
HTMLBody: record.Content.HTMLBody,
|
||||
Attachments: attachmentResponses(record.Attachments),
|
||||
Locale: record.Locale.String(),
|
||||
LocaleFallbackUsed: record.LocaleFallbackUsed,
|
||||
IdempotencyKey: record.IdempotencyKey.String(),
|
||||
Status: string(record.Status),
|
||||
AttemptCount: record.AttemptCount,
|
||||
LastAttemptStatus: string(record.LastAttemptStatus),
|
||||
ProviderSummary: record.ProviderSummary,
|
||||
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAtMS: record.UpdatedAt.UTC().UnixMilli(),
|
||||
SentAtMS: unixMilliPtr(record.SentAt),
|
||||
SuppressedAtMS: unixMilliPtr(record.SuppressedAt),
|
||||
FailedAtMS: unixMilliPtr(record.FailedAt),
|
||||
DeadLetteredAtMS: unixMilliPtr(record.DeadLetteredAt),
|
||||
}
|
||||
if deadLetter != nil {
|
||||
response.DeadLetter = &DeadLetterResponse{
|
||||
DeliveryID: deadLetter.DeliveryID.String(),
|
||||
FinalAttemptNo: deadLetter.FinalAttemptNo,
|
||||
FailureClassification: deadLetter.FailureClassification,
|
||||
ProviderSummary: deadLetter.ProviderSummary,
|
||||
CreatedAtMS: deadLetter.CreatedAt.UTC().UnixMilli(),
|
||||
RecoveryHint: deadLetter.RecoveryHint,
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func attemptResponseFromRecord(record attempt.Attempt) AttemptResponse {
|
||||
return AttemptResponse{
|
||||
DeliveryID: record.DeliveryID.String(),
|
||||
AttemptNo: record.AttemptNo,
|
||||
ScheduledForMS: record.ScheduledFor.UTC().UnixMilli(),
|
||||
StartedAtMS: unixMilliPtr(record.StartedAt),
|
||||
FinishedAtMS: unixMilliPtr(record.FinishedAt),
|
||||
Status: string(record.Status),
|
||||
ProviderClassification: record.ProviderClassification,
|
||||
ProviderSummary: record.ProviderSummary,
|
||||
}
|
||||
}
|
||||
|
||||
func attachmentResponses(attachments []common.AttachmentMetadata) []AttachmentResponse {
|
||||
if len(attachments) == 0 {
|
||||
return []AttachmentResponse{}
|
||||
}
|
||||
|
||||
result := make([]AttachmentResponse, len(attachments))
|
||||
for index, attachment := range attachments {
|
||||
result[index] = AttachmentResponse{
|
||||
Filename: attachment.Filename,
|
||||
ContentType: attachment.ContentType,
|
||||
SizeBytes: attachment.SizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func emailStrings(values []common.Email) []string {
|
||||
if len(values) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
result := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = value.String()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func unixMilliPtr(value *time.Time) *int64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
encoded := value.UTC().UnixMilli()
|
||||
return &encoded
|
||||
}
|
||||
|
||||
func cloneJSONObject(value map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make(map[string]any, len(value))
|
||||
for key, entry := range value {
|
||||
cloned[key] = entry
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
"galaxy/mail/internal/service/listdeliveries"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDecodeDeliveryListInputSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cursor, err := EncodeDeliveryListCursor(listdeliveries.Cursor{
|
||||
CreatedAt: time.Unix(1_775_122_000, 0).UTC(),
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
DeliveriesPath+"?recipient=pilot@example.com&status=sent&source=notification&template_id=template.welcome&idempotency_key=notification:delivery-123&from_created_at_ms=1775122000000&to_created_at_ms=1775122600000&limit=25&cursor="+cursor,
|
||||
nil,
|
||||
)
|
||||
|
||||
input, err := DecodeDeliveryListInput(request)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 25, input.Limit)
|
||||
require.Equal(t, common.Email("pilot@example.com"), input.Filters.Recipient)
|
||||
require.Equal(t, common.TemplateID("template.welcome"), input.Filters.TemplateID)
|
||||
require.Equal(t, common.IdempotencyKey("notification:delivery-123"), input.Filters.IdempotencyKey)
|
||||
require.NotNil(t, input.Cursor)
|
||||
require.Equal(t, common.DeliveryID("delivery-123"), input.Cursor.DeliveryID)
|
||||
}
|
||||
|
||||
func TestDecodeDeliveryListInputRejectsInvalidCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, DeliveriesPath+"?cursor=bad", nil)
|
||||
|
||||
_, err := DecodeDeliveryListInput(request)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "decode cursor")
|
||||
}
|
||||
|
||||
func TestDeliveryListCursorRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cursor := listdeliveries.Cursor{
|
||||
CreatedAt: time.Unix(1_775_122_500, 0).UTC(),
|
||||
DeliveryID: common.DeliveryID("delivery-xyz"),
|
||||
}
|
||||
|
||||
encoded, err := EncodeDeliveryListCursor(cursor)
|
||||
require.NoError(t, err)
|
||||
|
||||
decoded, err := DecodeDeliveryListCursor(encoded)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, cursor, decoded)
|
||||
}
|
||||
|
||||
func TestDecodeDeliveryIDFromPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/deliveries/delivery-123", nil)
|
||||
request.SetPathValue(deliveryIDPathValue, "delivery-123")
|
||||
|
||||
deliveryID, err := DecodeDeliveryIDFromPath(request)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.DeliveryID("delivery-123"), deliveryID)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/service/getdelivery"
|
||||
"galaxy/mail/internal/service/listattempts"
|
||||
"galaxy/mail/internal/service/listdeliveries"
|
||||
"galaxy/mail/internal/service/resenddelivery"
|
||||
)
|
||||
|
||||
const defaultOperatorRequestTimeout = 5 * time.Second
|
||||
|
||||
// ListDeliveriesUseCase lists accepted deliveries for trusted operators.
|
||||
type ListDeliveriesUseCase interface {
|
||||
// Execute returns one filtered deterministic ordered page of deliveries.
|
||||
Execute(context.Context, listdeliveries.Input) (listdeliveries.Result, error)
|
||||
}
|
||||
|
||||
// GetDeliveryUseCase resolves one accepted delivery for trusted operators.
|
||||
type GetDeliveryUseCase interface {
|
||||
// Execute returns one exact delivery view and its optional dead-letter
|
||||
// entry.
|
||||
Execute(context.Context, getdelivery.Input) (getdelivery.Result, error)
|
||||
}
|
||||
|
||||
// ListAttemptsUseCase resolves one delivery-attempt history for trusted
|
||||
// operators.
|
||||
type ListAttemptsUseCase interface {
|
||||
// Execute returns the full attempt history of one accepted delivery.
|
||||
Execute(context.Context, listattempts.Input) (listattempts.Result, error)
|
||||
}
|
||||
|
||||
// ResendDeliveryUseCase clones one accepted terminal delivery for trusted
|
||||
// operator resend.
|
||||
type ResendDeliveryUseCase interface {
|
||||
// Execute creates one new clone delivery and returns its identifier.
|
||||
Execute(context.Context, resenddelivery.Input) (resenddelivery.Result, error)
|
||||
}
|
||||
|
||||
func newListDeliveriesHandler(useCase ListDeliveriesUseCase, timeout time.Duration) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
input, err := DecodeDeliveryListInput(request)
|
||||
if err != nil {
|
||||
writeErrorResponse(writer, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(request.Context(), effectiveOperatorTimeout(timeout))
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, input)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, listdeliveries.ErrInvalidCursor):
|
||||
writeErrorResponse(writer, http.StatusBadRequest, ErrorCodeInvalidRequest, "cursor is invalid")
|
||||
case errors.Is(err, listdeliveries.ErrServiceUnavailable):
|
||||
writeErrorResponse(writer, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "service is unavailable")
|
||||
default:
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response := DeliveryListResponse{
|
||||
Items: make([]DeliverySummaryResponse, len(result.Items)),
|
||||
}
|
||||
for index, record := range result.Items {
|
||||
response.Items[index] = summaryResponseFromDelivery(record)
|
||||
}
|
||||
if result.NextCursor != nil {
|
||||
encodedCursor, err := EncodeDeliveryListCursor(*result.NextCursor)
|
||||
if err != nil {
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
return
|
||||
}
|
||||
response.NextCursor = encodedCursor
|
||||
}
|
||||
|
||||
writeJSONResponse(writer, http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
func newGetDeliveryHandler(useCase GetDeliveryUseCase, timeout time.Duration) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
deliveryID, err := DecodeDeliveryIDFromPath(request)
|
||||
if err != nil {
|
||||
writeErrorResponse(writer, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(request.Context(), effectiveOperatorTimeout(timeout))
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, getdelivery.Input{DeliveryID: deliveryID})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, getdelivery.ErrNotFound):
|
||||
writeErrorResponse(writer, http.StatusNotFound, ErrorCodeDeliveryNotFound, "delivery not found")
|
||||
case errors.Is(err, getdelivery.ErrServiceUnavailable):
|
||||
writeErrorResponse(writer, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "service is unavailable")
|
||||
default:
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONResponse(writer, http.StatusOK, detailResponseFromDelivery(result.Delivery, result.DeadLetter))
|
||||
}
|
||||
}
|
||||
|
||||
func newListAttemptsHandler(useCase ListAttemptsUseCase, timeout time.Duration) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
deliveryID, err := DecodeDeliveryIDFromPath(request)
|
||||
if err != nil {
|
||||
writeErrorResponse(writer, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(request.Context(), effectiveOperatorTimeout(timeout))
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, listattempts.Input{DeliveryID: deliveryID})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, listattempts.ErrNotFound):
|
||||
writeErrorResponse(writer, http.StatusNotFound, ErrorCodeDeliveryNotFound, "delivery not found")
|
||||
case errors.Is(err, listattempts.ErrServiceUnavailable):
|
||||
writeErrorResponse(writer, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "service is unavailable")
|
||||
default:
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response := DeliveryAttemptsResponse{
|
||||
Items: make([]AttemptResponse, len(result.Attempts)),
|
||||
}
|
||||
for index, record := range result.Attempts {
|
||||
response.Items[index] = attemptResponseFromRecord(record)
|
||||
}
|
||||
|
||||
writeJSONResponse(writer, http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
func newResendDeliveryHandler(useCase ResendDeliveryUseCase, timeout time.Duration) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
deliveryID, err := DecodeDeliveryIDFromPath(request)
|
||||
if err != nil {
|
||||
writeErrorResponse(writer, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(request.Context(), effectiveOperatorTimeout(timeout))
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, resenddelivery.Input{DeliveryID: deliveryID})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, resenddelivery.ErrNotFound):
|
||||
writeErrorResponse(writer, http.StatusNotFound, ErrorCodeDeliveryNotFound, "delivery not found")
|
||||
case errors.Is(err, resenddelivery.ErrNotAllowed):
|
||||
writeErrorResponse(writer, http.StatusConflict, ErrorCodeResendNotAllowed, "delivery status does not allow resend")
|
||||
case errors.Is(err, resenddelivery.ErrServiceUnavailable):
|
||||
writeErrorResponse(writer, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "service is unavailable")
|
||||
default:
|
||||
writeErrorResponse(writer, http.StatusInternalServerError, ErrorCodeInternalError, "internal server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONResponse(writer, http.StatusOK, DeliveryResendResponse{
|
||||
DeliveryID: result.DeliveryID.String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveOperatorTimeout(timeout time.Duration) time.Duration {
|
||||
if timeout <= 0 {
|
||||
return defaultOperatorRequestTimeout
|
||||
}
|
||||
|
||||
return timeout
|
||||
}
|
||||
|
||||
func writeJSONResponse(writer http.ResponseWriter, statusCode int, payload any) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(payload)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/service/getdelivery"
|
||||
"galaxy/mail/internal/service/listattempts"
|
||||
"galaxy/mail/internal/service/listdeliveries"
|
||||
"galaxy/mail/internal/service/resenddelivery"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOperatorHandlersReturnSuccessResponses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listDelivery := validOperatorDelivery("delivery-list", deliverydomain.StatusSent)
|
||||
getDeliveryRecord := validOperatorDelivery("delivery-get", deliverydomain.StatusDeadLetter)
|
||||
deadLetter := validOperatorDeadLetter(getDeliveryRecord.DeliveryID)
|
||||
attemptRecord := validOperatorAttempt(getDeliveryRecord.DeliveryID, 1, attempt.StatusProviderRejected)
|
||||
|
||||
handler := newHandler(Dependencies{
|
||||
Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
|
||||
OperatorRequestTimeout: time.Second,
|
||||
ListDeliveries: listDeliveriesFunc(func(context.Context, listdeliveries.Input) (listdeliveries.Result, error) {
|
||||
return listdeliveries.Result{
|
||||
Items: []deliverydomain.Delivery{listDelivery},
|
||||
NextCursor: &listdeliveries.Cursor{
|
||||
CreatedAt: listDelivery.CreatedAt,
|
||||
DeliveryID: listDelivery.DeliveryID,
|
||||
},
|
||||
}, nil
|
||||
}),
|
||||
GetDelivery: getDeliveryFunc(func(context.Context, getdelivery.Input) (getdelivery.Result, error) {
|
||||
return getdelivery.Result{
|
||||
Delivery: getDeliveryRecord,
|
||||
DeadLetter: &deadLetter,
|
||||
}, nil
|
||||
}),
|
||||
ListAttempts: listAttemptsFunc(func(context.Context, listattempts.Input) (listattempts.Result, error) {
|
||||
return listattempts.Result{
|
||||
Delivery: getDeliveryRecord,
|
||||
Attempts: []attempt.Attempt{attemptRecord},
|
||||
}, nil
|
||||
}),
|
||||
ResendDelivery: resendDeliveryFunc(func(context.Context, resenddelivery.Input) (resenddelivery.Result, error) {
|
||||
return resenddelivery.Result{DeliveryID: common.DeliveryID("delivery-clone")}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, DeliveriesPath+"?limit=1", nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
var payload DeliveryListResponse
|
||||
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
|
||||
require.Len(t, payload.Items, 1)
|
||||
require.Equal(t, "delivery-list", payload.Items[0].DeliveryID)
|
||||
require.NotEmpty(t, payload.NextCursor)
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/deliveries/delivery-get", nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
var payload DeliveryDetailResponse
|
||||
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
|
||||
require.Equal(t, "delivery-get", payload.DeliveryID)
|
||||
require.NotNil(t, payload.DeadLetter)
|
||||
})
|
||||
|
||||
t.Run("attempts", func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/deliveries/delivery-get/attempts", nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
var payload DeliveryAttemptsResponse
|
||||
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
|
||||
require.Len(t, payload.Items, 1)
|
||||
require.Equal(t, 1, payload.Items[0].AttemptNo)
|
||||
})
|
||||
|
||||
t.Run("resend", func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/internal/deliveries/delivery-get/resend", nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
var payload DeliveryResendResponse
|
||||
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
|
||||
require.Equal(t, "delivery-clone", payload.DeliveryID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOperatorHandlersMapErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
deps Dependencies
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
name: "list bad request",
|
||||
method: http.MethodGet,
|
||||
path: DeliveriesPath + "?limit=0",
|
||||
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), ListDeliveries: listDeliveriesFunc(func(context.Context, listdeliveries.Input) (listdeliveries.Result, error) {
|
||||
return listdeliveries.Result{}, nil
|
||||
})},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantCode: ErrorCodeInvalidRequest,
|
||||
},
|
||||
{
|
||||
name: "get not found",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/deliveries/missing",
|
||||
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), GetDelivery: getDeliveryFunc(func(context.Context, getdelivery.Input) (getdelivery.Result, error) {
|
||||
return getdelivery.Result{}, getdelivery.ErrNotFound
|
||||
})},
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantCode: ErrorCodeDeliveryNotFound,
|
||||
},
|
||||
{
|
||||
name: "attempts unavailable",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/deliveries/missing/attempts",
|
||||
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), ListAttempts: listAttemptsFunc(func(context.Context, listattempts.Input) (listattempts.Result, error) {
|
||||
return listattempts.Result{}, listattempts.ErrServiceUnavailable
|
||||
})},
|
||||
wantStatus: http.StatusServiceUnavailable,
|
||||
wantCode: ErrorCodeServiceUnavailable,
|
||||
},
|
||||
{
|
||||
name: "resend not allowed",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/deliveries/missing/resend",
|
||||
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), ResendDelivery: resendDeliveryFunc(func(context.Context, resenddelivery.Input) (resenddelivery.Result, error) {
|
||||
return resenddelivery.Result{}, resenddelivery.ErrNotAllowed
|
||||
})},
|
||||
wantStatus: http.StatusConflict,
|
||||
wantCode: ErrorCodeResendNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "resend internal error",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/deliveries/missing/resend",
|
||||
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), ResendDelivery: resendDeliveryFunc(func(context.Context, resenddelivery.Input) (resenddelivery.Result, error) {
|
||||
return resenddelivery.Result{}, errors.New("boom")
|
||||
})},
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantCode: ErrorCodeInternalError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tt.deps.OperatorRequestTimeout = time.Second
|
||||
handler := newHandler(tt.deps)
|
||||
request := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
require.Equal(t, tt.wantStatus, response.Code)
|
||||
var payload ErrorResponse
|
||||
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
|
||||
require.Equal(t, tt.wantCode, payload.Error.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorHandlersApplyRequestTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deadlineObserved := make(chan struct{}, 1)
|
||||
handler := newHandler(Dependencies{
|
||||
Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
|
||||
OperatorRequestTimeout: 50 * time.Millisecond,
|
||||
ListDeliveries: listDeliveriesFunc(func(ctx context.Context, input listdeliveries.Input) (listdeliveries.Result, error) {
|
||||
_ = input
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
deadlineObserved <- struct{}{}
|
||||
}
|
||||
return listdeliveries.Result{}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, DeliveriesPath, nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
select {
|
||||
case <-deadlineObserved:
|
||||
default:
|
||||
t.Fatal("expected operator handler to apply request timeout")
|
||||
}
|
||||
}
|
||||
|
||||
type listDeliveriesFunc func(context.Context, listdeliveries.Input) (listdeliveries.Result, error)
|
||||
|
||||
func (fn listDeliveriesFunc) Execute(ctx context.Context, input listdeliveries.Input) (listdeliveries.Result, error) {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
type getDeliveryFunc func(context.Context, getdelivery.Input) (getdelivery.Result, error)
|
||||
|
||||
func (fn getDeliveryFunc) Execute(ctx context.Context, input getdelivery.Input) (getdelivery.Result, error) {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
type listAttemptsFunc func(context.Context, listattempts.Input) (listattempts.Result, error)
|
||||
|
||||
func (fn listAttemptsFunc) Execute(ctx context.Context, input listattempts.Input) (listattempts.Result, error) {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
type resendDeliveryFunc func(context.Context, resenddelivery.Input) (resenddelivery.Result, error)
|
||||
|
||||
func (fn resendDeliveryFunc) Execute(ctx context.Context, input resenddelivery.Input) (resenddelivery.Result, error) {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
func validOperatorDelivery(id string, status deliverydomain.Status) deliverydomain.Delivery {
|
||||
createdAt := time.Unix(1_775_122_000, 0).UTC()
|
||||
updatedAt := createdAt.Add(time.Minute)
|
||||
record := deliverydomain.Delivery{
|
||||
DeliveryID: common.DeliveryID(id),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
Envelope: deliverydomain.Envelope{To: []common.Email{common.Email("pilot@example.com")}},
|
||||
Content: deliverydomain.Content{Subject: "Turn ready", TextBody: "Turn ready"},
|
||||
IdempotencyKey: common.IdempotencyKey("notification:" + id),
|
||||
Status: status,
|
||||
AttemptCount: 1,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
|
||||
switch status {
|
||||
case deliverydomain.StatusSent:
|
||||
sentAt := updatedAt
|
||||
record.SentAt = &sentAt
|
||||
record.LastAttemptStatus = attempt.StatusProviderAccepted
|
||||
case deliverydomain.StatusDeadLetter:
|
||||
deadLetteredAt := updatedAt
|
||||
record.DeadLetteredAt = &deadLetteredAt
|
||||
record.LastAttemptStatus = attempt.StatusTimedOut
|
||||
}
|
||||
|
||||
if err := record.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func validOperatorDeadLetter(deliveryID common.DeliveryID) deliverydomain.DeadLetterEntry {
|
||||
entry := deliverydomain.DeadLetterEntry{
|
||||
DeliveryID: deliveryID,
|
||||
FinalAttemptNo: 1,
|
||||
FailureClassification: "retry_exhausted",
|
||||
ProviderSummary: "smtp timeout",
|
||||
CreatedAt: time.Unix(1_775_122_100, 0).UTC(),
|
||||
RecoveryHint: "check SMTP connectivity",
|
||||
}
|
||||
if err := entry.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func validOperatorAttempt(deliveryID common.DeliveryID, attemptNo int, status attempt.Status) attempt.Attempt {
|
||||
scheduledFor := time.Unix(1_775_122_050, 0).UTC()
|
||||
startedAt := scheduledFor.Add(time.Second)
|
||||
finishedAt := startedAt.Add(time.Second)
|
||||
record := attempt.Attempt{
|
||||
DeliveryID: deliveryID,
|
||||
AttemptNo: attemptNo,
|
||||
ScheduledFor: scheduledFor,
|
||||
StartedAt: &startedAt,
|
||||
FinishedAt: &finishedAt,
|
||||
Status: status,
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// Package internalhttp provides the trusted internal HTTP listener used by the
|
||||
// runnable Mail Service process.
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/telemetry"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
const (
|
||||
// DeliveriesPath is the trusted operator list route reserved by the Stage 6
|
||||
// runnable skeleton.
|
||||
DeliveriesPath = "/api/v1/internal/deliveries"
|
||||
|
||||
// DeliveryByIDPath is the trusted operator get-delivery route reserved by
|
||||
// the Stage 6 runnable skeleton.
|
||||
DeliveryByIDPath = "/api/v1/internal/deliveries/{delivery_id}"
|
||||
|
||||
// DeliveryAttemptsPath is the trusted operator list-attempts route reserved
|
||||
// by the Stage 6 runnable skeleton.
|
||||
DeliveryAttemptsPath = "/api/v1/internal/deliveries/{delivery_id}/attempts"
|
||||
|
||||
// DeliveryResendPath is the trusted operator resend route reserved by the
|
||||
// Stage 6 runnable skeleton.
|
||||
DeliveryResendPath = "/api/v1/internal/deliveries/{delivery_id}/resend"
|
||||
)
|
||||
|
||||
// Config describes the trusted internal HTTP listener owned by Mail Service.
|
||||
type Config struct {
|
||||
// Addr is the TCP listen address used by the trusted internal HTTP server.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds how long the listener may spend reading request
|
||||
// headers before the server rejects the connection.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds how long the listener may spend reading one trusted
|
||||
// internal request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long the listener keeps an idle keep-alive
|
||||
// connection open.
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a usable internal HTTP listener
|
||||
// configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.Addr == "":
|
||||
return errors.New("internal HTTP addr must not be empty")
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return errors.New("internal HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return errors.New("internal HTTP read timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return errors.New("internal HTTP idle timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Dependencies describes the collaborators used by the trusted internal HTTP
|
||||
// transport layer.
|
||||
type Dependencies struct {
|
||||
// Logger writes structured transport logs. When nil, slog.Default is used.
|
||||
Logger *slog.Logger
|
||||
|
||||
// Telemetry records low-cardinality transport and auth-delivery metrics.
|
||||
Telemetry *telemetry.Runtime
|
||||
|
||||
// AcceptLoginCodeDelivery handles the dedicated auth-delivery route when
|
||||
// provided.
|
||||
AcceptLoginCodeDelivery AcceptLoginCodeDeliveryUseCase
|
||||
|
||||
// ListDeliveries handles the trusted operator delivery-list route when
|
||||
// provided.
|
||||
ListDeliveries ListDeliveriesUseCase
|
||||
|
||||
// GetDelivery handles the trusted operator exact delivery-read route when
|
||||
// provided.
|
||||
GetDelivery GetDeliveryUseCase
|
||||
|
||||
// ListAttempts handles the trusted operator attempt-history route when
|
||||
// provided.
|
||||
ListAttempts ListAttemptsUseCase
|
||||
|
||||
// ResendDelivery handles the trusted operator resend route when provided.
|
||||
ResendDelivery ResendDeliveryUseCase
|
||||
|
||||
// OperatorRequestTimeout bounds one trusted operator use-case execution.
|
||||
OperatorRequestTimeout time.Duration
|
||||
}
|
||||
|
||||
// Server owns the trusted internal HTTP listener exposed by Mail Service.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
|
||||
handler http.Handler
|
||||
logger *slog.Logger
|
||||
|
||||
stateMu sync.RWMutex
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// NewServer constructs one trusted internal HTTP server for cfg and deps.
|
||||
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
handler: newHandler(deps),
|
||||
logger: logger.With("component", "internal_http"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run binds the configured listener and serves the trusted internal HTTP
|
||||
// surface until Shutdown closes the server.
|
||||
func (server *Server) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run internal HTTP server: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", server.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run internal HTTP server: listen on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Handler: server.handler,
|
||||
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
|
||||
ReadTimeout: server.cfg.ReadTimeout,
|
||||
IdleTimeout: server.cfg.IdleTimeout,
|
||||
}
|
||||
|
||||
server.stateMu.Lock()
|
||||
server.server = httpServer
|
||||
server.listener = listener
|
||||
server.stateMu.Unlock()
|
||||
|
||||
server.logger.Info("internal HTTP server started", "addr", listener.Addr().String())
|
||||
|
||||
defer func() {
|
||||
server.stateMu.Lock()
|
||||
server.server = nil
|
||||
server.listener = nil
|
||||
server.stateMu.Unlock()
|
||||
}()
|
||||
|
||||
err = httpServer.Serve(listener)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, http.ErrServerClosed):
|
||||
server.logger.Info("internal HTTP server stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the trusted internal HTTP server within ctx.
|
||||
func (server *Server) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown internal HTTP server: nil context")
|
||||
}
|
||||
|
||||
server.stateMu.RLock()
|
||||
httpServer := server.server
|
||||
server.stateMu.RUnlock()
|
||||
|
||||
if httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("shutdown internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newHandler(deps Dependencies) http.Handler {
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
loginCodeHandler := http.HandlerFunc(serviceUnavailableHandler)
|
||||
if deps.AcceptLoginCodeDelivery != nil {
|
||||
loginCodeHandler = newAcceptLoginCodeDeliveryHandler(deps.AcceptLoginCodeDelivery)
|
||||
}
|
||||
listDeliveriesHandler := http.HandlerFunc(serviceUnavailableHandler)
|
||||
if deps.ListDeliveries != nil {
|
||||
listDeliveriesHandler = newListDeliveriesHandler(deps.ListDeliveries, deps.OperatorRequestTimeout)
|
||||
}
|
||||
getDeliveryHandler := http.HandlerFunc(serviceUnavailableHandler)
|
||||
if deps.GetDelivery != nil {
|
||||
getDeliveryHandler = newGetDeliveryHandler(deps.GetDelivery, deps.OperatorRequestTimeout)
|
||||
}
|
||||
listAttemptsHandler := http.HandlerFunc(serviceUnavailableHandler)
|
||||
if deps.ListAttempts != nil {
|
||||
listAttemptsHandler = newListAttemptsHandler(deps.ListAttempts, deps.OperatorRequestTimeout)
|
||||
}
|
||||
resendDeliveryHandler := http.HandlerFunc(serviceUnavailableHandler)
|
||||
if deps.ResendDelivery != nil {
|
||||
resendDeliveryHandler = newResendDeliveryHandler(deps.ResendDelivery, deps.OperatorRequestTimeout)
|
||||
}
|
||||
|
||||
mux.Handle("POST "+LoginCodeDeliveriesPath, wrapObservedRoute(LoginCodeDeliveriesPath, logger, deps.Telemetry, loginCodeHandler))
|
||||
mux.Handle("GET "+DeliveriesPath, wrapObservedRoute(DeliveriesPath, logger, deps.Telemetry, listDeliveriesHandler))
|
||||
mux.Handle("GET "+DeliveryByIDPath, wrapObservedRoute(DeliveryByIDPath, logger, deps.Telemetry, getDeliveryHandler))
|
||||
mux.Handle("GET "+DeliveryAttemptsPath, wrapObservedRoute(DeliveryAttemptsPath, logger, deps.Telemetry, listAttemptsHandler))
|
||||
mux.Handle("POST "+DeliveryResendPath, wrapObservedRoute(DeliveryResendPath, logger, deps.Telemetry, resendDeliveryHandler))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func wrapObservedRoute(route string, logger *slog.Logger, telemetryRuntime *telemetry.Runtime, next http.Handler) http.Handler {
|
||||
handler := instrumentRoute(route, logger, telemetryRuntime, next)
|
||||
|
||||
options := []otelhttp.Option{}
|
||||
if telemetryRuntime != nil {
|
||||
options = append(options,
|
||||
otelhttp.WithTracerProvider(telemetryRuntime.TracerProvider()),
|
||||
otelhttp.WithMeterProvider(telemetryRuntime.MeterProvider()),
|
||||
)
|
||||
}
|
||||
|
||||
return otelhttp.NewHandler(handler, route, options...)
|
||||
}
|
||||
|
||||
func serviceUnavailableHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
_ = request
|
||||
writeErrorResponse(writer, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "service is unavailable")
|
||||
}
|
||||
|
||||
func writeErrorResponse(writer http.ResponseWriter, statusCode int, code string, message string) {
|
||||
if recorder, ok := writer.(*observedResponseWriter); ok {
|
||||
recorder.SetErrorCode(code)
|
||||
}
|
||||
|
||||
payload := ErrorResponse{
|
||||
Error: ErrorBody{
|
||||
Code: code,
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(payload)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewServerRejectsInvalidConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := Config{
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}
|
||||
|
||||
_, err := NewServer(cfg, Dependencies{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "addr")
|
||||
}
|
||||
|
||||
func TestServerRunAndShutdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := testConfig(t)
|
||||
server, err := NewServer(cfg, Dependencies{})
|
||||
require.NoError(t, err)
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
runErr <- server.Run(context.Background())
|
||||
}()
|
||||
|
||||
client := newTestHTTPClient(t)
|
||||
waitForReservedRouteReady(t, client, cfg.Addr)
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, server.Shutdown(shutdownCtx))
|
||||
waitForServerRunResult(t, runErr)
|
||||
}
|
||||
|
||||
func TestReservedRoutesReturnStableServiceUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := testConfig(t)
|
||||
server, err := NewServer(cfg, Dependencies{})
|
||||
require.NoError(t, err)
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
runErr <- server.Run(context.Background())
|
||||
}()
|
||||
|
||||
client := newTestHTTPClient(t)
|
||||
waitForReservedRouteReady(t, client, cfg.Addr)
|
||||
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{method: http.MethodPost, path: LoginCodeDeliveriesPath},
|
||||
{method: http.MethodGet, path: DeliveriesPath},
|
||||
{method: http.MethodGet, path: "/api/v1/internal/deliveries/delivery-123"},
|
||||
{method: http.MethodGet, path: "/api/v1/internal/deliveries/delivery-123/attempts"},
|
||||
{method: http.MethodPost, path: "/api/v1/internal/deliveries/delivery-123/resend"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||
request, err := http.NewRequest(tt.method, "http://"+cfg.Addr+tt.path, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
response, err := client.Do(request)
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, response.StatusCode)
|
||||
require.Equal(t, "application/json", response.Header.Get("Content-Type"))
|
||||
|
||||
var payload ErrorResponse
|
||||
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
|
||||
require.Equal(t, ErrorCodeServiceUnavailable, payload.Error.Code)
|
||||
require.Equal(t, "service is unavailable", payload.Error.Message)
|
||||
})
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, server.Shutdown(shutdownCtx))
|
||||
waitForServerRunResult(t, runErr)
|
||||
}
|
||||
|
||||
func TestServerDoesNotExposeProbeOrUnknownRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := testConfig(t)
|
||||
server, err := NewServer(cfg, Dependencies{})
|
||||
require.NoError(t, err)
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
runErr <- server.Run(context.Background())
|
||||
}()
|
||||
|
||||
client := newTestHTTPClient(t)
|
||||
waitForReservedRouteReady(t, client, cfg.Addr)
|
||||
|
||||
for _, path := range []string{"/healthz", "/readyz", "/metrics", "/unknown"} {
|
||||
request, err := http.NewRequest(http.MethodGet, "http://"+cfg.Addr+path, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
response, err := client.Do(request)
|
||||
require.NoError(t, err)
|
||||
_, _ = io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
|
||||
assert.Equalf(t, http.StatusNotFound, response.StatusCode, "path %s", path)
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, server.Shutdown(shutdownCtx))
|
||||
waitForServerRunResult(t, runErr)
|
||||
}
|
||||
|
||||
func testConfig(t *testing.T) Config {
|
||||
t.Helper()
|
||||
|
||||
return Config{
|
||||
Addr: mustFreeAddr(t),
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: 2 * time.Second,
|
||||
IdleTimeout: time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
func newTestHTTPClient(t *testing.T) *http.Client {
|
||||
t.Helper()
|
||||
|
||||
transport := &http.Transport{DisableKeepAlives: true}
|
||||
t.Cleanup(transport.CloseIdleConnections)
|
||||
|
||||
return &http.Client{
|
||||
Timeout: 250 * time.Millisecond,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
func waitForReservedRouteReady(t *testing.T, client *http.Client, addr string) {
|
||||
t.Helper()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
request, err := http.NewRequest(http.MethodPost, "http://"+addr+LoginCodeDeliveriesPath, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.ReadAll(response.Body)
|
||||
|
||||
return response.StatusCode == http.StatusServiceUnavailable
|
||||
}, 5*time.Second, 25*time.Millisecond, "internal HTTP server did not become reachable")
|
||||
}
|
||||
|
||||
func waitForServerRunResult(t *testing.T, runErr <-chan error) {
|
||||
t.Helper()
|
||||
|
||||
var err error
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case err = <-runErr:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, 5*time.Second, 10*time.Millisecond, "internal HTTP server did not stop")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func mustFreeAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
assert.NoError(t, listener.Close())
|
||||
}()
|
||||
|
||||
return listener.Addr().String()
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
// Package streamcommand defines the frozen Redis Streams command contract used
|
||||
// by Mail Service for generic asynchronous delivery intake.
|
||||
package streamcommand
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
"galaxy/mail/internal/domain/malformedcommand"
|
||||
)
|
||||
|
||||
const (
|
||||
// DeliveryCommandsStream is the frozen Redis Stream name used for generic
|
||||
// asynchronous delivery commands.
|
||||
DeliveryCommandsStream = "mail:delivery_commands"
|
||||
|
||||
// MaxAttachments is the frozen attachment-count limit for one generic
|
||||
// asynchronous command.
|
||||
MaxAttachments = 5
|
||||
|
||||
// MaxEncodedAttachmentPayloadBytes is the frozen limit for the total
|
||||
// encoded attachment payload, measured as the sum of attachment
|
||||
// `content_base64` string lengths.
|
||||
MaxEncodedAttachmentPayloadBytes = 2 * 1024 * 1024
|
||||
)
|
||||
|
||||
const (
|
||||
fieldDeliveryID = "delivery_id"
|
||||
fieldSource = "source"
|
||||
fieldPayloadMode = "payload_mode"
|
||||
fieldIdempotency = "idempotency_key"
|
||||
fieldRequestedAtMS = "requested_at_ms"
|
||||
fieldPayloadJSON = "payload_json"
|
||||
fieldRequestID = "request_id"
|
||||
fieldTraceID = "trace_id"
|
||||
)
|
||||
|
||||
var (
|
||||
requiredFieldNames = map[string]struct{}{
|
||||
fieldDeliveryID: {},
|
||||
fieldSource: {},
|
||||
fieldPayloadMode: {},
|
||||
fieldIdempotency: {},
|
||||
fieldRequestedAtMS: {},
|
||||
fieldPayloadJSON: {},
|
||||
}
|
||||
optionalFieldNames = map[string]struct{}{
|
||||
fieldRequestID: {},
|
||||
fieldTraceID: {},
|
||||
}
|
||||
)
|
||||
|
||||
// ClassifyDecodeError maps one command-decoding or command-validation error to
|
||||
// the stable malformed-command failure code surface.
|
||||
func ClassifyDecodeError(err error) malformedcommand.FailureCode {
|
||||
if err == nil {
|
||||
return malformedcommand.FailureCodeInvalidCommand
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
switch {
|
||||
case strings.Contains(message, "delivery envelope"),
|
||||
strings.Contains(message, "must contain at least one recipient"):
|
||||
return malformedcommand.FailureCodeInvalidEnvelope
|
||||
case strings.Contains(message, "payload_json"),
|
||||
strings.Contains(message, "stream command attachments"),
|
||||
strings.Contains(message, "delivery content"),
|
||||
strings.Contains(message, "template id"),
|
||||
strings.Contains(message, "locale"),
|
||||
strings.Contains(message, "variables"):
|
||||
return malformedcommand.FailureCodeInvalidPayload
|
||||
default:
|
||||
return malformedcommand.FailureCodeInvalidCommand
|
||||
}
|
||||
}
|
||||
|
||||
// Command stores one normalized generic asynchronous command accepted from the
|
||||
// Redis Streams contract.
|
||||
type Command struct {
|
||||
// DeliveryID stores the publisher-owned logical delivery identifier.
|
||||
DeliveryID common.DeliveryID
|
||||
|
||||
// Source stores the frozen async source vocabulary value.
|
||||
Source deliverydomain.Source
|
||||
|
||||
// PayloadMode stores whether the command contains final rendered content or
|
||||
// template-selection data.
|
||||
PayloadMode deliverydomain.PayloadMode
|
||||
|
||||
// IdempotencyKey stores the caller-owned stable deduplication key.
|
||||
IdempotencyKey common.IdempotencyKey
|
||||
|
||||
// RequestedAt stores when the publisher originally requested the generic
|
||||
// delivery.
|
||||
RequestedAt time.Time
|
||||
|
||||
// RequestID stores the optional tracing request identifier.
|
||||
RequestID string
|
||||
|
||||
// TraceID stores the optional tracing trace identifier.
|
||||
TraceID string
|
||||
|
||||
// Envelope stores the SMTP addressing information frozen by the stream
|
||||
// payload contract.
|
||||
Envelope deliverydomain.Envelope
|
||||
|
||||
// Attachments stores the normalized attachment list including computed
|
||||
// decoded sizes.
|
||||
Attachments []Attachment
|
||||
|
||||
// Subject stores the required final subject for rendered-mode commands.
|
||||
Subject string
|
||||
|
||||
// TextBody stores the required plaintext body for rendered-mode commands.
|
||||
TextBody string
|
||||
|
||||
// HTMLBody stores the optional HTML body for rendered-mode commands.
|
||||
HTMLBody string
|
||||
|
||||
// TemplateID stores the required template family for template-mode
|
||||
// commands.
|
||||
TemplateID common.TemplateID
|
||||
|
||||
// Locale stores the required canonical BCP 47 locale for template-mode
|
||||
// commands.
|
||||
Locale common.Locale
|
||||
|
||||
// Variables stores the arbitrary template variables object for
|
||||
// template-mode commands.
|
||||
Variables map[string]any
|
||||
}
|
||||
|
||||
// Validate reports whether Command satisfies the frozen Stage 05 stream
|
||||
// contract.
|
||||
func (command Command) Validate() error {
|
||||
if err := command.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("stream command delivery id: %w", err)
|
||||
}
|
||||
if command.Source != deliverydomain.SourceNotification {
|
||||
return fmt.Errorf("stream command source %q is unsupported", command.Source)
|
||||
}
|
||||
if !command.PayloadMode.IsKnown() {
|
||||
return fmt.Errorf("stream command payload mode %q is unsupported", command.PayloadMode)
|
||||
}
|
||||
if err := command.IdempotencyKey.Validate(); err != nil {
|
||||
return fmt.Errorf("stream command idempotency key: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("stream command requested at", command.RequestedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := command.Envelope.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(command.Attachments) > MaxAttachments {
|
||||
return fmt.Errorf("stream command attachments must contain at most %d entries", MaxAttachments)
|
||||
}
|
||||
|
||||
totalEncodedPayloadBytes := 0
|
||||
for index, attachment := range command.Attachments {
|
||||
if err := attachment.Validate(); err != nil {
|
||||
return fmt.Errorf("stream command attachments[%d]: %w", index, err)
|
||||
}
|
||||
totalEncodedPayloadBytes += len(attachment.ContentBase64)
|
||||
}
|
||||
if totalEncodedPayloadBytes > MaxEncodedAttachmentPayloadBytes {
|
||||
return fmt.Errorf(
|
||||
"stream command encoded attachment payload must not exceed %d bytes",
|
||||
MaxEncodedAttachmentPayloadBytes,
|
||||
)
|
||||
}
|
||||
|
||||
switch command.PayloadMode {
|
||||
case deliverydomain.PayloadModeRendered:
|
||||
if err := (deliverydomain.Content{
|
||||
Subject: command.Subject,
|
||||
TextBody: command.TextBody,
|
||||
HTMLBody: command.HTMLBody,
|
||||
}).ValidateMaterialized(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !command.TemplateID.IsZero() {
|
||||
return errors.New("rendered stream command must not contain template id")
|
||||
}
|
||||
if !command.Locale.IsZero() {
|
||||
return errors.New("rendered stream command must not contain locale")
|
||||
}
|
||||
if len(command.Variables) != 0 {
|
||||
return errors.New("rendered stream command must not contain template variables")
|
||||
}
|
||||
case deliverydomain.PayloadModeTemplate:
|
||||
if err := command.TemplateID.Validate(); err != nil {
|
||||
return fmt.Errorf("stream command template id: %w", err)
|
||||
}
|
||||
if err := command.Locale.Validate(); err != nil {
|
||||
return fmt.Errorf("stream command locale: %w", err)
|
||||
}
|
||||
if command.Variables == nil {
|
||||
return errors.New("template stream command variables must not be nil")
|
||||
}
|
||||
if command.Subject != "" {
|
||||
return errors.New("template stream command must not contain subject")
|
||||
}
|
||||
if command.TextBody != "" {
|
||||
return errors.New("template stream command must not contain text body")
|
||||
}
|
||||
if command.HTMLBody != "" {
|
||||
return errors.New("template stream command must not contain html body")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fingerprint returns the stable Stage 05 request fingerprint used by later
|
||||
// idempotency handling. The fingerprint excludes tracing-only metadata
|
||||
// (`request_id`, `trace_id`) but includes the normalized business fields of
|
||||
// the command.
|
||||
func (command Command) Fingerprint() (string, error) {
|
||||
if err := command.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
normalized := fingerprintCommand{
|
||||
DeliveryID: command.DeliveryID.String(),
|
||||
Source: command.Source,
|
||||
PayloadMode: command.PayloadMode,
|
||||
IdempotencyKey: command.IdempotencyKey.String(),
|
||||
RequestedAtMS: command.RequestedAt.UTC().UnixMilli(),
|
||||
Envelope: fingerprintEnvelope{
|
||||
To: cloneEmails(command.Envelope.To),
|
||||
Cc: cloneEmails(command.Envelope.Cc),
|
||||
Bcc: cloneEmails(command.Envelope.Bcc),
|
||||
ReplyTo: cloneEmails(command.Envelope.ReplyTo),
|
||||
},
|
||||
Attachments: cloneAttachments(command.Attachments),
|
||||
Subject: command.Subject,
|
||||
TextBody: command.TextBody,
|
||||
HTMLBody: command.HTMLBody,
|
||||
TemplateID: command.TemplateID.String(),
|
||||
Locale: command.Locale.String(),
|
||||
Variables: command.Variables,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal stream command fingerprint: %w", err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(payload)
|
||||
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// Attachment stores one inline base64 attachment accepted by the asynchronous
|
||||
// generic stream contract.
|
||||
type Attachment struct {
|
||||
// Filename stores the user-facing attachment filename.
|
||||
Filename string
|
||||
|
||||
// ContentType stores the MIME media type of the attachment.
|
||||
ContentType string
|
||||
|
||||
// ContentBase64 stores the exact inline base64 payload published on the
|
||||
// stream.
|
||||
ContentBase64 string
|
||||
|
||||
// SizeBytes stores the computed decoded attachment size in bytes.
|
||||
SizeBytes int64
|
||||
}
|
||||
|
||||
// Validate reports whether Attachment contains a valid inline base64 payload
|
||||
// and a complete metadata header.
|
||||
func (attachment Attachment) Validate() error {
|
||||
if _, err := base64.StdEncoding.DecodeString(attachment.ContentBase64); err != nil {
|
||||
return fmt.Errorf("attachment content_base64 must be valid base64: %w", err)
|
||||
}
|
||||
|
||||
metadata := common.AttachmentMetadata{
|
||||
Filename: attachment.Filename,
|
||||
ContentType: attachment.ContentType,
|
||||
SizeBytes: attachment.SizeBytes,
|
||||
}
|
||||
if err := metadata.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecodeCommand validates one raw Redis Streams entry and returns the
|
||||
// normalized asynchronous generic command frozen by Stage 05.
|
||||
func DecodeCommand(fields map[string]any) (Command, error) {
|
||||
if fields == nil {
|
||||
return Command{}, errors.New("stream command fields must not be nil")
|
||||
}
|
||||
|
||||
if err := validateFieldSet(fields); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
|
||||
deliveryIDValue, err := requiredString(fields, fieldDeliveryID)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
sourceValue, err := requiredString(fields, fieldSource)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
payloadModeValue, err := requiredString(fields, fieldPayloadMode)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
idempotencyValue, err := requiredString(fields, fieldIdempotency)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
requestedAtValue, err := requiredString(fields, fieldRequestedAtMS)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
payloadJSONValue, err := requiredString(fields, fieldPayloadJSON)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
|
||||
requestedAtMS, err := strconv.ParseInt(requestedAtValue, 10, 64)
|
||||
if err != nil {
|
||||
return Command{}, fmt.Errorf("stream field %q must be a base-10 Unix milliseconds string", fieldRequestedAtMS)
|
||||
}
|
||||
|
||||
command := Command{
|
||||
DeliveryID: common.DeliveryID(deliveryIDValue),
|
||||
Source: deliverydomain.Source(sourceValue),
|
||||
PayloadMode: deliverydomain.PayloadMode(payloadModeValue),
|
||||
IdempotencyKey: common.IdempotencyKey(idempotencyValue),
|
||||
RequestedAt: time.UnixMilli(requestedAtMS).UTC(),
|
||||
}
|
||||
|
||||
if requestIDValue, ok, err := optionalString(fields, fieldRequestID); err != nil {
|
||||
return Command{}, err
|
||||
} else if ok {
|
||||
command.RequestID = requestIDValue
|
||||
}
|
||||
if traceIDValue, ok, err := optionalString(fields, fieldTraceID); err != nil {
|
||||
return Command{}, err
|
||||
} else if ok {
|
||||
command.TraceID = traceIDValue
|
||||
}
|
||||
|
||||
switch command.PayloadMode {
|
||||
case deliverydomain.PayloadModeRendered:
|
||||
if err := decodeRenderedPayload(payloadJSONValue, &command); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
case deliverydomain.PayloadModeTemplate:
|
||||
if err := decodeTemplatePayload(payloadJSONValue, &command); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
default:
|
||||
return Command{}, fmt.Errorf("stream field %q value %q is unsupported", fieldPayloadMode, payloadModeValue)
|
||||
}
|
||||
|
||||
if err := command.Validate(); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
type renderedPayloadJSON struct {
|
||||
To *[]string `json:"to"`
|
||||
Cc *[]string `json:"cc"`
|
||||
Bcc *[]string `json:"bcc"`
|
||||
ReplyTo *[]string `json:"reply_to"`
|
||||
Subject *string `json:"subject"`
|
||||
TextBody *string `json:"text_body"`
|
||||
HTMLBody *string `json:"html_body,omitempty"`
|
||||
Attachments *[]attachmentJSON `json:"attachments"`
|
||||
}
|
||||
|
||||
type templatePayloadJSON struct {
|
||||
To *[]string `json:"to"`
|
||||
Cc *[]string `json:"cc"`
|
||||
Bcc *[]string `json:"bcc"`
|
||||
ReplyTo *[]string `json:"reply_to"`
|
||||
TemplateID *string `json:"template_id"`
|
||||
Locale *string `json:"locale"`
|
||||
Variables *json.RawMessage `json:"variables"`
|
||||
Attachments *[]attachmentJSON `json:"attachments"`
|
||||
}
|
||||
|
||||
type attachmentJSON struct {
|
||||
Filename *string `json:"filename"`
|
||||
ContentType *string `json:"content_type"`
|
||||
ContentBase64 *string `json:"content_base64"`
|
||||
}
|
||||
|
||||
type fingerprintCommand struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
Source deliverydomain.Source `json:"source"`
|
||||
PayloadMode deliverydomain.PayloadMode `json:"payload_mode"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
RequestedAtMS int64 `json:"requested_at_ms"`
|
||||
Envelope fingerprintEnvelope `json:"envelope"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
TextBody string `json:"text_body,omitempty"`
|
||||
HTMLBody string `json:"html_body,omitempty"`
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Variables map[string]any `json:"variables,omitempty"`
|
||||
}
|
||||
|
||||
type fingerprintEnvelope struct {
|
||||
To []string `json:"to"`
|
||||
Cc []string `json:"cc"`
|
||||
Bcc []string `json:"bcc"`
|
||||
ReplyTo []string `json:"reply_to"`
|
||||
}
|
||||
|
||||
func validateFieldSet(fields map[string]any) error {
|
||||
missing := make([]string, 0, len(requiredFieldNames))
|
||||
for name := range requiredFieldNames {
|
||||
if _, ok := fields[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("stream command is missing required fields: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
unexpected := make([]string, 0)
|
||||
for name := range fields {
|
||||
if _, ok := requiredFieldNames[name]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := optionalFieldNames[name]; ok {
|
||||
continue
|
||||
}
|
||||
unexpected = append(unexpected, name)
|
||||
}
|
||||
sort.Strings(unexpected)
|
||||
if len(unexpected) > 0 {
|
||||
return fmt.Errorf("stream command contains unsupported fields: %s", strings.Join(unexpected, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiredString(fields map[string]any, name string) (string, error) {
|
||||
value, ok := fields[name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("stream field %q is required", name)
|
||||
}
|
||||
|
||||
result, ok := value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("stream field %q must be a string", name)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func optionalString(fields map[string]any, name string) (string, bool, error) {
|
||||
value, ok := fields[name]
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
result, ok := value.(string)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("stream field %q must be a string", name)
|
||||
}
|
||||
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func decodeRenderedPayload(payload string, command *Command) error {
|
||||
var raw renderedPayloadJSON
|
||||
if err := decodeStrictJSON("decode payload_json", payload, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
envelope, attachments, err := decodeCommonPayloadFields(
|
||||
raw.To,
|
||||
raw.Cc,
|
||||
raw.Bcc,
|
||||
raw.ReplyTo,
|
||||
raw.Attachments,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw.Subject == nil {
|
||||
return errors.New("payload_json.subject is required")
|
||||
}
|
||||
if raw.TextBody == nil {
|
||||
return errors.New("payload_json.text_body is required")
|
||||
}
|
||||
|
||||
command.Envelope = envelope
|
||||
command.Attachments = attachments
|
||||
command.Subject = *raw.Subject
|
||||
command.TextBody = *raw.TextBody
|
||||
if raw.HTMLBody != nil {
|
||||
command.HTMLBody = *raw.HTMLBody
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeTemplatePayload(payload string, command *Command) error {
|
||||
var raw templatePayloadJSON
|
||||
if err := decodeStrictJSON("decode payload_json", payload, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
envelope, attachments, err := decodeCommonPayloadFields(
|
||||
raw.To,
|
||||
raw.Cc,
|
||||
raw.Bcc,
|
||||
raw.ReplyTo,
|
||||
raw.Attachments,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw.TemplateID == nil {
|
||||
return errors.New("payload_json.template_id is required")
|
||||
}
|
||||
if raw.Locale == nil {
|
||||
return errors.New("payload_json.locale is required")
|
||||
}
|
||||
if raw.Variables == nil {
|
||||
return errors.New("payload_json.variables is required")
|
||||
}
|
||||
|
||||
variables, err := decodeVariables(*raw.Variables)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
locale, err := common.ParseLocale(*raw.Locale)
|
||||
if err != nil {
|
||||
return fmt.Errorf("payload_json.locale: %w", err)
|
||||
}
|
||||
|
||||
command.Envelope = envelope
|
||||
command.Attachments = attachments
|
||||
command.TemplateID = common.TemplateID(*raw.TemplateID)
|
||||
command.Locale = locale
|
||||
command.Variables = variables
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeCommonPayloadFields(
|
||||
to *[]string,
|
||||
cc *[]string,
|
||||
bcc *[]string,
|
||||
replyTo *[]string,
|
||||
attachments *[]attachmentJSON,
|
||||
) (deliverydomain.Envelope, []Attachment, error) {
|
||||
if to == nil {
|
||||
return deliverydomain.Envelope{}, nil, errors.New("payload_json.to is required")
|
||||
}
|
||||
if cc == nil {
|
||||
return deliverydomain.Envelope{}, nil, errors.New("payload_json.cc is required")
|
||||
}
|
||||
if bcc == nil {
|
||||
return deliverydomain.Envelope{}, nil, errors.New("payload_json.bcc is required")
|
||||
}
|
||||
if replyTo == nil {
|
||||
return deliverydomain.Envelope{}, nil, errors.New("payload_json.reply_to is required")
|
||||
}
|
||||
if attachments == nil {
|
||||
return deliverydomain.Envelope{}, nil, errors.New("payload_json.attachments is required")
|
||||
}
|
||||
|
||||
envelope := deliverydomain.Envelope{
|
||||
To: inflateEmails(*to),
|
||||
Cc: inflateEmails(*cc),
|
||||
Bcc: inflateEmails(*bcc),
|
||||
ReplyTo: inflateEmails(*replyTo),
|
||||
}
|
||||
inflatedAttachments, err := inflateAttachments(*attachments)
|
||||
if err != nil {
|
||||
return deliverydomain.Envelope{}, nil, err
|
||||
}
|
||||
|
||||
return envelope, inflatedAttachments, nil
|
||||
}
|
||||
|
||||
func inflateAttachments(raw []attachmentJSON) ([]Attachment, error) {
|
||||
attachments := make([]Attachment, 0, len(raw))
|
||||
for index, entry := range raw {
|
||||
if entry.Filename == nil {
|
||||
return nil, fmt.Errorf("payload_json.attachments[%d].filename is required", index)
|
||||
}
|
||||
if entry.ContentType == nil {
|
||||
return nil, fmt.Errorf("payload_json.attachments[%d].content_type is required", index)
|
||||
}
|
||||
if entry.ContentBase64 == nil {
|
||||
return nil, fmt.Errorf("payload_json.attachments[%d].content_base64 is required", index)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(*entry.ContentBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"payload_json.attachments[%d].content_base64 must be valid base64: %w",
|
||||
index,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
attachments = append(attachments, Attachment{
|
||||
Filename: *entry.Filename,
|
||||
ContentType: *entry.ContentType,
|
||||
ContentBase64: *entry.ContentBase64,
|
||||
SizeBytes: int64(len(decoded)),
|
||||
})
|
||||
}
|
||||
|
||||
return attachments, nil
|
||||
}
|
||||
|
||||
func inflateEmails(values []string) []common.Email {
|
||||
emails := make([]common.Email, len(values))
|
||||
for index, value := range values {
|
||||
emails[index] = common.Email(value)
|
||||
}
|
||||
|
||||
return emails
|
||||
}
|
||||
|
||||
func decodeVariables(raw json.RawMessage) (map[string]any, error) {
|
||||
var variables map[string]any
|
||||
if err := decodeStrictJSON("decode payload_json.variables", string(raw), &variables); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if variables == nil {
|
||||
return nil, errors.New("payload_json.variables must be a JSON object")
|
||||
}
|
||||
|
||||
return variables, nil
|
||||
}
|
||||
|
||||
func decodeStrictJSON(label string, raw string, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewBufferString(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("%s: unexpected trailing JSON input", label)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneEmails(values []common.Email) []string {
|
||||
result := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = value.String()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneAttachments(values []Attachment) []Attachment {
|
||||
result := make([]Attachment, len(values))
|
||||
copy(result, values)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package streamcommand
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDecodeCommandSuccessRendered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
command, err := DecodeCommand(validRenderedFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, Command{
|
||||
DeliveryID: common.DeliveryID("mail-123"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeRendered,
|
||||
IdempotencyKey: common.IdempotencyKey("notification:mail-123"),
|
||||
RequestedAt: mustUnixMilli(1_775_121_700_000),
|
||||
RequestID: "req-123",
|
||||
TraceID: "trace-123",
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{"pilot@example.com"},
|
||||
Cc: []common.Email{},
|
||||
Bcc: []common.Email{},
|
||||
ReplyTo: []common.Email{"noreply@example.com"},
|
||||
},
|
||||
Attachments: []Attachment{
|
||||
{
|
||||
Filename: "report.txt",
|
||||
ContentType: "text/plain",
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("report")),
|
||||
SizeBytes: 6,
|
||||
},
|
||||
},
|
||||
Subject: "Turn ready",
|
||||
TextBody: "Turn 54 is ready.",
|
||||
HTMLBody: "<p>Turn 54 is ready.</p>",
|
||||
}, command)
|
||||
}
|
||||
|
||||
func TestDecodeCommandSuccessTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
command, err := DecodeCommand(validTemplateFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, common.TemplateID("game.turn_ready"), command.TemplateID)
|
||||
require.Equal(t, common.Locale("fr-FR"), command.Locale)
|
||||
require.Equal(t, map[string]any{
|
||||
"turn_number": float64(54),
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
}, command.Variables)
|
||||
require.Empty(t, command.Subject)
|
||||
require.Empty(t, command.TextBody)
|
||||
}
|
||||
|
||||
func TestDecodeCommandRejectsInvalidEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields map[string]any
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing required field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
delete(fields, fieldDeliveryID)
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "missing required fields: delivery_id",
|
||||
},
|
||||
{
|
||||
name: "unsupported field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields["extra"] = "value"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "unsupported fields: extra",
|
||||
},
|
||||
{
|
||||
name: "non string field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldDeliveryID] = 42
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream field "delivery_id" must be a string`,
|
||||
},
|
||||
{
|
||||
name: "invalid requested at",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldRequestedAtMS] = "not-a-timestamp"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream field "requested_at_ms" must be a base-10 Unix milliseconds string`,
|
||||
},
|
||||
{
|
||||
name: "unsupported source",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldSource] = "operator_resend"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream command source "operator_resend" is unsupported`,
|
||||
},
|
||||
{
|
||||
name: "unsupported payload mode",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadMode] = "unknown"
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: `stream field "payload_mode" value "unknown" is unsupported`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := DecodeCommand(tt.fields)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCommandRejectsInvalidPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields map[string]any
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "payload must be object",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = `[]`
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "decode payload_json",
|
||||
},
|
||||
{
|
||||
name: "rendered payload unknown field",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{},
|
||||
"template_id": "game.turn_ready",
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "unknown field",
|
||||
},
|
||||
{
|
||||
name: "trailing json input",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = validRenderedPayloadJSON(t) + `{}`
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "unexpected trailing JSON input",
|
||||
},
|
||||
{
|
||||
name: "empty recipients",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "must contain at least one recipient",
|
||||
},
|
||||
{
|
||||
name: "invalid locale",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validTemplateFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"locale": "english",
|
||||
"variables": map[string]any{},
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "payload_json.locale:",
|
||||
},
|
||||
{
|
||||
name: "variables must be object",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validTemplateFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"locale": "fr-FR",
|
||||
"variables": []string{"not", "object"},
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "decode payload_json.variables",
|
||||
},
|
||||
{
|
||||
name: "invalid attachment base64",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "!@#",
|
||||
},
|
||||
},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "content_base64 must be valid base64",
|
||||
},
|
||||
{
|
||||
name: "too many attachments",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
attachments := make([]map[string]any, 0, MaxAttachments+1)
|
||||
for index := 0; index < MaxAttachments+1; index++ {
|
||||
attachments = append(attachments, map[string]any{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": base64.StdEncoding.EncodeToString([]byte("a")),
|
||||
})
|
||||
}
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": attachments,
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "must contain at most 5 entries",
|
||||
},
|
||||
{
|
||||
name: "encoded attachment payload limit exceeded",
|
||||
fields: func(t *testing.T) map[string]any {
|
||||
fields := validRenderedFields(t)
|
||||
fields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": oversizedBase64(),
|
||||
}},
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
wantErr: "encoded attachment payload must not exceed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := DecodeCommand(tt.fields)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandFingerprintIgnoresTracingFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := DecodeCommand(validRenderedFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
secondFields := validRenderedFields(t)
|
||||
secondFields[fieldRequestID] = "req-456"
|
||||
secondFields[fieldTraceID] = "trace-456"
|
||||
second, err := DecodeCommand(secondFields)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstFingerprint, err := first.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
secondFingerprint, err := second.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, firstFingerprint, secondFingerprint)
|
||||
}
|
||||
|
||||
func TestCommandFingerprintChangesForBusinessFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := DecodeCommand(validRenderedFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
secondFields := validRenderedFields(t)
|
||||
secondFields[fieldPayloadJSON] = mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{"noreply@example.com"},
|
||||
"subject": "Different subject",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"html_body": "<p>Turn 54 is ready.</p>",
|
||||
"attachments": []map[string]any{{"filename": "report.txt", "content_type": "text/plain", "content_base64": base64.StdEncoding.EncodeToString([]byte("report"))}},
|
||||
})
|
||||
second, err := DecodeCommand(secondFields)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstFingerprint, err := first.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
secondFingerprint, err := second.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotEqual(t, firstFingerprint, secondFingerprint)
|
||||
}
|
||||
|
||||
func validRenderedFields(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
return map[string]any{
|
||||
fieldDeliveryID: "mail-123",
|
||||
fieldSource: "notification",
|
||||
fieldPayloadMode: "rendered",
|
||||
fieldIdempotency: "notification:mail-123",
|
||||
fieldRequestedAtMS: "1775121700000",
|
||||
fieldRequestID: "req-123",
|
||||
fieldTraceID: "trace-123",
|
||||
fieldPayloadJSON: validRenderedPayloadJSON(t),
|
||||
}
|
||||
}
|
||||
|
||||
func validTemplateFields(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
return map[string]any{
|
||||
fieldDeliveryID: "mail-124",
|
||||
fieldSource: "notification",
|
||||
fieldPayloadMode: "template",
|
||||
fieldIdempotency: "notification:mail-124",
|
||||
fieldRequestedAtMS: "1775121700001",
|
||||
fieldPayloadJSON: validTemplatePayloadJSON(t),
|
||||
}
|
||||
}
|
||||
|
||||
func validRenderedPayloadJSON(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{"noreply@example.com"},
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"html_body": "<p>Turn 54 is ready.</p>",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"filename": "report.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": base64.StdEncoding.EncodeToString([]byte("report")),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func validTemplatePayloadJSON(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return mustJSONString(t, map[string]any{
|
||||
"to": []string{"pilot@example.com"},
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"locale": "fr-FR",
|
||||
"variables": map[string]any{
|
||||
"turn_number": 54,
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
},
|
||||
"attachments": []map[string]any{},
|
||||
})
|
||||
}
|
||||
|
||||
func mustJSONString(t *testing.T, value any) string {
|
||||
t.Helper()
|
||||
|
||||
payload, err := json.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
func oversizedBase64() string {
|
||||
return string(bytesOf('A', MaxEncodedAttachmentPayloadBytes+4))
|
||||
}
|
||||
|
||||
func bytesOf(value byte, size int) []byte {
|
||||
result := make([]byte, size)
|
||||
for index := range result {
|
||||
result[index] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mustUnixMilli(value int64) time.Time {
|
||||
return time.UnixMilli(value).UTC()
|
||||
}
|
||||
Reference in New Issue
Block a user