feat: notification service
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
// Package api reserves the transport-layer namespace of Notification Service.
|
||||
package api
|
||||
@@ -0,0 +1,147 @@
|
||||
// Package intentstream defines the frozen Redis Stream contract used for
|
||||
// Notification Service intent intake.
|
||||
package intentstream
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"galaxy/notification/internal/service/malformedintent"
|
||||
"galaxy/notificationintent"
|
||||
)
|
||||
|
||||
const (
|
||||
fieldNotificationType = "notification_type"
|
||||
fieldProducer = "producer"
|
||||
fieldAudienceKind = "audience_kind"
|
||||
fieldRecipientUserIDs = "recipient_user_ids_json"
|
||||
fieldIdempotencyKey = "idempotency_key"
|
||||
fieldOccurredAtMS = "occurred_at_ms"
|
||||
fieldRequestID = "request_id"
|
||||
fieldTraceID = "trace_id"
|
||||
fieldPayloadJSON = "payload_json"
|
||||
defaultResolvedLocale = "en"
|
||||
)
|
||||
|
||||
// NotificationType identifies one supported normalized notification type.
|
||||
type NotificationType = notificationintent.NotificationType
|
||||
|
||||
const (
|
||||
// NotificationTypeGeoReviewRecommended identifies the
|
||||
// `geo.review_recommended` notification.
|
||||
NotificationTypeGeoReviewRecommended = notificationintent.NotificationTypeGeoReviewRecommended
|
||||
|
||||
// NotificationTypeGameTurnReady identifies the `game.turn.ready`
|
||||
// notification.
|
||||
NotificationTypeGameTurnReady = notificationintent.NotificationTypeGameTurnReady
|
||||
|
||||
// NotificationTypeGameFinished identifies the `game.finished`
|
||||
// notification.
|
||||
NotificationTypeGameFinished = notificationintent.NotificationTypeGameFinished
|
||||
|
||||
// NotificationTypeGameGenerationFailed identifies the
|
||||
// `game.generation_failed` notification.
|
||||
NotificationTypeGameGenerationFailed = notificationintent.NotificationTypeGameGenerationFailed
|
||||
|
||||
// NotificationTypeLobbyRuntimePausedAfterStart identifies the
|
||||
// `lobby.runtime_paused_after_start` notification.
|
||||
NotificationTypeLobbyRuntimePausedAfterStart = notificationintent.NotificationTypeLobbyRuntimePausedAfterStart
|
||||
|
||||
// NotificationTypeLobbyApplicationSubmitted identifies the
|
||||
// `lobby.application.submitted` notification.
|
||||
NotificationTypeLobbyApplicationSubmitted = notificationintent.NotificationTypeLobbyApplicationSubmitted
|
||||
|
||||
// NotificationTypeLobbyMembershipApproved identifies the
|
||||
// `lobby.membership.approved` notification.
|
||||
NotificationTypeLobbyMembershipApproved = notificationintent.NotificationTypeLobbyMembershipApproved
|
||||
|
||||
// NotificationTypeLobbyMembershipRejected identifies the
|
||||
// `lobby.membership.rejected` notification.
|
||||
NotificationTypeLobbyMembershipRejected = notificationintent.NotificationTypeLobbyMembershipRejected
|
||||
|
||||
// NotificationTypeLobbyInviteCreated identifies the
|
||||
// `lobby.invite.created` notification.
|
||||
NotificationTypeLobbyInviteCreated = notificationintent.NotificationTypeLobbyInviteCreated
|
||||
|
||||
// NotificationTypeLobbyInviteRedeemed identifies the
|
||||
// `lobby.invite.redeemed` notification.
|
||||
NotificationTypeLobbyInviteRedeemed = notificationintent.NotificationTypeLobbyInviteRedeemed
|
||||
|
||||
// NotificationTypeLobbyInviteExpired identifies the
|
||||
// `lobby.invite.expired` notification.
|
||||
NotificationTypeLobbyInviteExpired = notificationintent.NotificationTypeLobbyInviteExpired
|
||||
)
|
||||
|
||||
// Producer identifies one supported upstream producer.
|
||||
type Producer = notificationintent.Producer
|
||||
|
||||
const (
|
||||
// ProducerGeoProfile identifies Geo Profile Service.
|
||||
ProducerGeoProfile = notificationintent.ProducerGeoProfile
|
||||
|
||||
// ProducerGameMaster identifies Game Master.
|
||||
ProducerGameMaster = notificationintent.ProducerGameMaster
|
||||
|
||||
// ProducerGameLobby identifies Game Lobby.
|
||||
ProducerGameLobby = notificationintent.ProducerGameLobby
|
||||
)
|
||||
|
||||
// AudienceKind identifies one supported target-audience kind.
|
||||
type AudienceKind = notificationintent.AudienceKind
|
||||
|
||||
const (
|
||||
// AudienceKindUser identifies user-targeted notifications.
|
||||
AudienceKindUser = notificationintent.AudienceKindUser
|
||||
|
||||
// AudienceKindAdminEmail identifies administrator-email notifications.
|
||||
AudienceKindAdminEmail = notificationintent.AudienceKindAdminEmail
|
||||
)
|
||||
|
||||
// Channel identifies one durable notification-delivery channel slot.
|
||||
type Channel = notificationintent.Channel
|
||||
|
||||
const (
|
||||
// ChannelPush identifies the push-delivery channel.
|
||||
ChannelPush = notificationintent.ChannelPush
|
||||
|
||||
// ChannelEmail identifies the email-delivery channel.
|
||||
ChannelEmail = notificationintent.ChannelEmail
|
||||
)
|
||||
|
||||
// Intent stores one normalized notification intent accepted from the Redis
|
||||
// Stream ingress contract.
|
||||
type Intent = notificationintent.Intent
|
||||
|
||||
// DecodeIntent validates one raw Redis Stream entry and returns the normalized
|
||||
// notification intent frozen by the shared producer contract.
|
||||
func DecodeIntent(fields map[string]any) (Intent, error) {
|
||||
return notificationintent.DecodeIntent(fields)
|
||||
}
|
||||
|
||||
// ClassifyDecodeError maps one intake decoding or validation error to the
|
||||
// stable malformed-intent failure surface.
|
||||
func ClassifyDecodeError(err error) malformedintent.FailureCode {
|
||||
if err == nil {
|
||||
return malformedintent.FailureCodeInvalidIntent
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
switch {
|
||||
case strings.Contains(message, "payload_json"),
|
||||
strings.Contains(message, "turn_number"),
|
||||
strings.Contains(message, "final_turn_number"),
|
||||
strings.Contains(message, "failure_reason"),
|
||||
strings.Contains(message, "applicant_name"),
|
||||
strings.Contains(message, "inviter_name"),
|
||||
strings.Contains(message, "invitee_name"),
|
||||
strings.Contains(message, "review_reason"):
|
||||
return malformedintent.FailureCodeInvalidPayload
|
||||
default:
|
||||
return malformedintent.FailureCodeInvalidIntent
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultResolvedLocale returns the frozen fallback locale assigned when the
|
||||
// current rollout has no supported exact user locale other than English.
|
||||
func DefaultResolvedLocale() string {
|
||||
return defaultResolvedLocale
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package intentstream
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDecodeIntentNormalizesUserRecipientsAndPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fields := map[string]any{
|
||||
fieldNotificationType: NotificationTypeGameTurnReady.String(),
|
||||
fieldProducer: ProducerGameMaster.String(),
|
||||
fieldAudienceKind: AudienceKindUser.String(),
|
||||
fieldRecipientUserIDs: `["user-2","user-1"]`,
|
||||
fieldIdempotencyKey: "game-123:turn-54",
|
||||
fieldOccurredAtMS: "1775121700000",
|
||||
fieldPayloadJSON: `{"turn_number":54,"game_name":"Nebula Clash","game_id":"game-123"}`,
|
||||
fieldRequestID: "request-123",
|
||||
fieldTraceID: "trace-123",
|
||||
}
|
||||
|
||||
intent, err := DecodeIntent(fields)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"user-1", "user-2"}, intent.RecipientUserIDs)
|
||||
require.Equal(t, `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, intent.PayloadJSON)
|
||||
require.Equal(t, time.UnixMilli(1775121700000).UTC(), intent.OccurredAt)
|
||||
}
|
||||
|
||||
func TestDecodeIntentCanonicalizesEquivalentPayloadJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fieldsA := map[string]any{
|
||||
fieldNotificationType: NotificationTypeGameFinished.String(),
|
||||
fieldProducer: ProducerGameMaster.String(),
|
||||
fieldAudienceKind: AudienceKindUser.String(),
|
||||
fieldRecipientUserIDs: `["user-1"]`,
|
||||
fieldIdempotencyKey: "game-123:finished",
|
||||
fieldOccurredAtMS: "1775121700001",
|
||||
fieldPayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","final_turn_number":54}`,
|
||||
}
|
||||
fieldsB := map[string]any{
|
||||
fieldNotificationType: NotificationTypeGameFinished.String(),
|
||||
fieldProducer: ProducerGameMaster.String(),
|
||||
fieldAudienceKind: AudienceKindUser.String(),
|
||||
fieldRecipientUserIDs: `["user-1"]`,
|
||||
fieldIdempotencyKey: "game-123:finished",
|
||||
fieldOccurredAtMS: "1775121709999",
|
||||
fieldPayloadJSON: `{"final_turn_number":54,"game_name":"Nebula Clash","game_id":"game-123"}`,
|
||||
}
|
||||
|
||||
intentA, err := DecodeIntent(fieldsA)
|
||||
require.NoError(t, err)
|
||||
intentB, err := DecodeIntent(fieldsB)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, intentA.PayloadJSON, intentB.PayloadJSON)
|
||||
}
|
||||
|
||||
func TestDecodeIntentRejectsUnsupportedTopLevelField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fields := map[string]any{
|
||||
fieldNotificationType: NotificationTypeGameTurnReady.String(),
|
||||
fieldProducer: ProducerGameMaster.String(),
|
||||
fieldAudienceKind: AudienceKindUser.String(),
|
||||
fieldRecipientUserIDs: `["user-1"]`,
|
||||
fieldIdempotencyKey: "game-123:turn-54",
|
||||
fieldOccurredAtMS: "1775121700000",
|
||||
fieldPayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
|
||||
"unexpected": "boom",
|
||||
}
|
||||
|
||||
_, err := DecodeIntent(fields)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unsupported fields")
|
||||
require.Equal(t, malformedFailureCodeInvalidIntent(), string(ClassifyDecodeError(err)))
|
||||
}
|
||||
|
||||
func TestDecodeIntentRejectsDuplicateRecipientUserIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fields := map[string]any{
|
||||
fieldNotificationType: NotificationTypeGameTurnReady.String(),
|
||||
fieldProducer: ProducerGameMaster.String(),
|
||||
fieldAudienceKind: AudienceKindUser.String(),
|
||||
fieldRecipientUserIDs: `["user-1","user-1"]`,
|
||||
fieldIdempotencyKey: "game-123:turn-54",
|
||||
fieldOccurredAtMS: "1775121700000",
|
||||
fieldPayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
|
||||
}
|
||||
|
||||
_, err := DecodeIntent(fields)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "duplicates user id")
|
||||
require.Equal(t, malformedFailureCodeInvalidIntent(), string(ClassifyDecodeError(err)))
|
||||
}
|
||||
|
||||
func TestDecodeIntentRejectsInvalidPayloadJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fields := map[string]any{
|
||||
fieldNotificationType: NotificationTypeLobbyInviteCreated.String(),
|
||||
fieldProducer: ProducerGameLobby.String(),
|
||||
fieldAudienceKind: AudienceKindUser.String(),
|
||||
fieldRecipientUserIDs: `["user-1"]`,
|
||||
fieldIdempotencyKey: "invite-created:user-1",
|
||||
fieldOccurredAtMS: "1775121700000",
|
||||
fieldPayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","inviter_user_id":"user-2"}`,
|
||||
}
|
||||
|
||||
_, err := DecodeIntent(fields)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "payload_json.inviter_name is required")
|
||||
require.Equal(t, malformedFailureCodeInvalidPayload(), string(ClassifyDecodeError(err)))
|
||||
}
|
||||
|
||||
func TestDecodeIntentRejectsAdminRecipientsField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fields := map[string]any{
|
||||
fieldNotificationType: NotificationTypeGeoReviewRecommended.String(),
|
||||
fieldProducer: ProducerGeoProfile.String(),
|
||||
fieldAudienceKind: AudienceKindAdminEmail.String(),
|
||||
fieldRecipientUserIDs: `["user-1"]`,
|
||||
fieldIdempotencyKey: "geo:user-1",
|
||||
fieldOccurredAtMS: "1775121700000",
|
||||
fieldPayloadJSON: `{"user_id":"user-1","user_email":"pilot@example.com","observed_country":"DE","usual_connection_country":"PL","review_reason":"country_mismatch"}`,
|
||||
}
|
||||
|
||||
_, err := DecodeIntent(fields)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "must not be present")
|
||||
require.Equal(t, malformedFailureCodeInvalidIntent(), string(ClassifyDecodeError(err)))
|
||||
}
|
||||
|
||||
func malformedFailureCodeInvalidIntent() string {
|
||||
return "invalid_intent"
|
||||
}
|
||||
|
||||
func malformedFailureCodeInvalidPayload() string {
|
||||
return "invalid_payload"
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Package internalhttp provides the private probe HTTP listener used by the
|
||||
// runnable Notification Service process.
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/telemetry"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
)
|
||||
|
||||
const jsonContentType = "application/json; charset=utf-8"
|
||||
|
||||
const (
|
||||
// HealthzPath is the private liveness probe route.
|
||||
HealthzPath = "/healthz"
|
||||
|
||||
// ReadyzPath is the private readiness probe route.
|
||||
ReadyzPath = "/readyz"
|
||||
)
|
||||
|
||||
// Config describes the private internal HTTP listener owned by Notification
|
||||
// Service.
|
||||
type Config struct {
|
||||
// Addr is the TCP listen address used by the private probe 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 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 private 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 private probe
|
||||
// transport layer.
|
||||
type Dependencies struct {
|
||||
// Logger writes structured listener lifecycle logs. When nil, slog.Default
|
||||
// is used.
|
||||
Logger *slog.Logger
|
||||
|
||||
// Telemetry records low-cardinality probe metrics and lifecycle events.
|
||||
Telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// Server owns the private probe HTTP listener exposed by Notification
|
||||
// Service.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
|
||||
handler http.Handler
|
||||
logger *slog.Logger
|
||||
metrics *telemetry.Runtime
|
||||
|
||||
stateMu sync.RWMutex
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// NewServer constructs one private probe 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(logger, deps.Telemetry),
|
||||
logger: logger.With("component", "internal_http"),
|
||||
metrics: deps.Telemetry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run binds the configured listener and serves the private probe 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("notification internal HTTP server started", "addr", listener.Addr().String())
|
||||
server.metrics.RecordInternalHTTPEvent(context.Background(), "started")
|
||||
|
||||
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("notification internal HTTP server stopped")
|
||||
server.metrics.RecordInternalHTTPEvent(context.Background(), "stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the private probe 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(logger *slog.Logger, metrics *telemetry.Runtime) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET "+HealthzPath, handleHealthz)
|
||||
mux.HandleFunc("GET "+ReadyzPath, handleReadyz)
|
||||
|
||||
return otelhttp.NewHandler(withObservability(mux, metrics), "notification.internal_http")
|
||||
}
|
||||
|
||||
func withObservability(next http.Handler, metrics *telemetry.Runtime) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
startedAt := time.Now()
|
||||
recorder := &statusRecorder{
|
||||
ResponseWriter: writer,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
next.ServeHTTP(recorder, request)
|
||||
|
||||
route := request.Pattern
|
||||
switch recorder.statusCode {
|
||||
case http.StatusMethodNotAllowed:
|
||||
route = "method_not_allowed"
|
||||
case http.StatusNotFound:
|
||||
route = "not_found"
|
||||
case 0:
|
||||
route = "unmatched"
|
||||
}
|
||||
if route == "" {
|
||||
route = "unmatched"
|
||||
}
|
||||
|
||||
metrics.RecordInternalHTTPRequest(
|
||||
request.Context(),
|
||||
[]attribute.KeyValue{
|
||||
attribute.String("route", route),
|
||||
attribute.String("method", request.Method),
|
||||
attribute.String("status_code", strconv.Itoa(recorder.statusCode)),
|
||||
},
|
||||
time.Since(startedAt),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func handleHealthz(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeStatusResponse(writer, http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
func handleReadyz(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeStatusResponse(writer, http.StatusOK, "ready")
|
||||
}
|
||||
|
||||
func writeStatusResponse(writer http.ResponseWriter, statusCode int, status string) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(statusResponse{Status: status})
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (recorder *statusRecorder) WriteHeader(statusCode int) {
|
||||
recorder.statusCode = statusCode
|
||||
recorder.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"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)
|
||||
waitForHealthzReady(t, client, cfg.Addr)
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, server.Shutdown(shutdownCtx))
|
||||
waitForServerRunResult(t, runErr)
|
||||
}
|
||||
|
||||
func TestProbeRoutesReturnStableJSON(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)
|
||||
waitForHealthzReady(t, client, cfg.Addr)
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
status string
|
||||
}{
|
||||
{path: HealthzPath, status: "ok"},
|
||||
{path: ReadyzPath, status: "ready"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
request, err := http.NewRequest(http.MethodGet, "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.StatusOK, response.StatusCode)
|
||||
require.Equal(t, "application/json; charset=utf-8", response.Header.Get("Content-Type"))
|
||||
|
||||
var payload statusResponse
|
||||
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
|
||||
require.Equal(t, tt.status, payload.Status)
|
||||
})
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, server.Shutdown(shutdownCtx))
|
||||
waitForServerRunResult(t, runErr)
|
||||
}
|
||||
|
||||
func TestServerDoesNotExposeMetricsOrUnknownRoutes(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)
|
||||
waitForHealthzReady(t, client, cfg.Addr)
|
||||
|
||||
for _, path := range []string{"/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 TestServerPreservesStandardHEADBehavior(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)
|
||||
waitForHealthzReady(t, client, cfg.Addr)
|
||||
|
||||
request, err := http.NewRequest(http.MethodHead, "http://"+cfg.Addr+HealthzPath, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
response, err := client.Do(request)
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, response.StatusCode)
|
||||
require.Empty(t, body)
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, server.Shutdown(shutdownCtx))
|
||||
waitForServerRunResult(t, runErr)
|
||||
}
|
||||
|
||||
func TestServerUsesStandardMethodNotAllowedBehavior(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)
|
||||
waitForHealthzReady(t, client, cfg.Addr)
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, "http://"+cfg.Addr+HealthzPath, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
response, err := client.Do(request)
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
_, _ = io.ReadAll(response.Body)
|
||||
|
||||
require.Equal(t, http.StatusMethodNotAllowed, response.StatusCode)
|
||||
require.Contains(t, response.Header.Get("Allow"), http.MethodGet)
|
||||
require.Contains(t, response.Header.Get("Allow"), http.MethodHead)
|
||||
|
||||
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 waitForHealthzReady(t *testing.T, client *http.Client, addr string) {
|
||||
t.Helper()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
request, err := http.NewRequest(http.MethodGet, "http://"+addr+HealthzPath, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return response.StatusCode == http.StatusOK && strings.Contains(string(payload), `"status":"ok"`)
|
||||
}, 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()
|
||||
}
|
||||
Reference in New Issue
Block a user