feat: notification service
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
module galaxy/notificationintent
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.37.0
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,872 @@
|
||||
// Package notificationintent defines the shared producer-facing contract for
|
||||
// publishing normalized notification intents into Notification Service.
|
||||
package notificationintent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
// DefaultIntentsStream stores the frozen Redis Stream name consumed by
|
||||
// Notification Service.
|
||||
DefaultIntentsStream = "notification:intents"
|
||||
)
|
||||
|
||||
var (
|
||||
requiredFieldNames = map[string]struct{}{
|
||||
fieldNotificationType: {},
|
||||
fieldProducer: {},
|
||||
fieldAudienceKind: {},
|
||||
fieldIdempotencyKey: {},
|
||||
fieldOccurredAtMS: {},
|
||||
fieldPayloadJSON: {},
|
||||
}
|
||||
optionalFieldNames = map[string]struct{}{
|
||||
fieldRecipientUserIDs: {},
|
||||
fieldRequestID: {},
|
||||
fieldTraceID: {},
|
||||
}
|
||||
)
|
||||
|
||||
// NotificationType identifies one supported normalized notification type.
|
||||
type NotificationType string
|
||||
|
||||
const (
|
||||
// NotificationTypeGeoReviewRecommended identifies the
|
||||
// `geo.review_recommended` notification.
|
||||
NotificationTypeGeoReviewRecommended NotificationType = "geo.review_recommended"
|
||||
|
||||
// NotificationTypeGameTurnReady identifies the `game.turn.ready`
|
||||
// notification.
|
||||
NotificationTypeGameTurnReady NotificationType = "game.turn.ready"
|
||||
|
||||
// NotificationTypeGameFinished identifies the `game.finished`
|
||||
// notification.
|
||||
NotificationTypeGameFinished NotificationType = "game.finished"
|
||||
|
||||
// NotificationTypeGameGenerationFailed identifies the
|
||||
// `game.generation_failed` notification.
|
||||
NotificationTypeGameGenerationFailed NotificationType = "game.generation_failed"
|
||||
|
||||
// NotificationTypeLobbyRuntimePausedAfterStart identifies the
|
||||
// `lobby.runtime_paused_after_start` notification.
|
||||
NotificationTypeLobbyRuntimePausedAfterStart NotificationType = "lobby.runtime_paused_after_start"
|
||||
|
||||
// NotificationTypeLobbyApplicationSubmitted identifies the
|
||||
// `lobby.application.submitted` notification.
|
||||
NotificationTypeLobbyApplicationSubmitted NotificationType = "lobby.application.submitted"
|
||||
|
||||
// NotificationTypeLobbyMembershipApproved identifies the
|
||||
// `lobby.membership.approved` notification.
|
||||
NotificationTypeLobbyMembershipApproved NotificationType = "lobby.membership.approved"
|
||||
|
||||
// NotificationTypeLobbyMembershipRejected identifies the
|
||||
// `lobby.membership.rejected` notification.
|
||||
NotificationTypeLobbyMembershipRejected NotificationType = "lobby.membership.rejected"
|
||||
|
||||
// NotificationTypeLobbyInviteCreated identifies the
|
||||
// `lobby.invite.created` notification.
|
||||
NotificationTypeLobbyInviteCreated NotificationType = "lobby.invite.created"
|
||||
|
||||
// NotificationTypeLobbyInviteRedeemed identifies the
|
||||
// `lobby.invite.redeemed` notification.
|
||||
NotificationTypeLobbyInviteRedeemed NotificationType = "lobby.invite.redeemed"
|
||||
|
||||
// NotificationTypeLobbyInviteExpired identifies the
|
||||
// `lobby.invite.expired` notification.
|
||||
NotificationTypeLobbyInviteExpired NotificationType = "lobby.invite.expired"
|
||||
)
|
||||
|
||||
// String returns the wire value for notificationType.
|
||||
func (notificationType NotificationType) String() string {
|
||||
return string(notificationType)
|
||||
}
|
||||
|
||||
// IsKnown reports whether notificationType belongs to the frozen catalog.
|
||||
func (notificationType NotificationType) IsKnown() bool {
|
||||
switch notificationType {
|
||||
case NotificationTypeGeoReviewRecommended,
|
||||
NotificationTypeGameTurnReady,
|
||||
NotificationTypeGameFinished,
|
||||
NotificationTypeGameGenerationFailed,
|
||||
NotificationTypeLobbyRuntimePausedAfterStart,
|
||||
NotificationTypeLobbyApplicationSubmitted,
|
||||
NotificationTypeLobbyMembershipApproved,
|
||||
NotificationTypeLobbyMembershipRejected,
|
||||
NotificationTypeLobbyInviteCreated,
|
||||
NotificationTypeLobbyInviteRedeemed,
|
||||
NotificationTypeLobbyInviteExpired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ExpectedProducer returns the frozen producer for notificationType.
|
||||
func (notificationType NotificationType) ExpectedProducer() Producer {
|
||||
switch notificationType {
|
||||
case NotificationTypeGeoReviewRecommended:
|
||||
return ProducerGeoProfile
|
||||
case NotificationTypeGameTurnReady,
|
||||
NotificationTypeGameFinished,
|
||||
NotificationTypeGameGenerationFailed:
|
||||
return ProducerGameMaster
|
||||
case NotificationTypeLobbyRuntimePausedAfterStart,
|
||||
NotificationTypeLobbyApplicationSubmitted,
|
||||
NotificationTypeLobbyMembershipApproved,
|
||||
NotificationTypeLobbyMembershipRejected,
|
||||
NotificationTypeLobbyInviteCreated,
|
||||
NotificationTypeLobbyInviteRedeemed,
|
||||
NotificationTypeLobbyInviteExpired:
|
||||
return ProducerGameLobby
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// SupportsAudience reports whether notificationType supports audienceKind.
|
||||
func (notificationType NotificationType) SupportsAudience(audienceKind AudienceKind) bool {
|
||||
switch notificationType {
|
||||
case NotificationTypeGeoReviewRecommended,
|
||||
NotificationTypeGameGenerationFailed,
|
||||
NotificationTypeLobbyRuntimePausedAfterStart:
|
||||
return audienceKind == AudienceKindAdminEmail
|
||||
case NotificationTypeLobbyApplicationSubmitted:
|
||||
return audienceKind == AudienceKindUser || audienceKind == AudienceKindAdminEmail
|
||||
default:
|
||||
return audienceKind == AudienceKindUser
|
||||
}
|
||||
}
|
||||
|
||||
// SupportsChannel reports whether notificationType uses channel for
|
||||
// audienceKind.
|
||||
func (notificationType NotificationType) SupportsChannel(audienceKind AudienceKind, channel Channel) bool {
|
||||
switch notificationType {
|
||||
case NotificationTypeGeoReviewRecommended,
|
||||
NotificationTypeGameGenerationFailed,
|
||||
NotificationTypeLobbyRuntimePausedAfterStart:
|
||||
return audienceKind == AudienceKindAdminEmail && channel == ChannelEmail
|
||||
case NotificationTypeLobbyApplicationSubmitted:
|
||||
if audienceKind == AudienceKindAdminEmail {
|
||||
return channel == ChannelEmail
|
||||
}
|
||||
return channel == ChannelPush || channel == ChannelEmail
|
||||
case NotificationTypeLobbyInviteExpired:
|
||||
return audienceKind == AudienceKindUser && channel == ChannelEmail
|
||||
default:
|
||||
return audienceKind == AudienceKindUser && (channel == ChannelPush || channel == ChannelEmail)
|
||||
}
|
||||
}
|
||||
|
||||
// Producer identifies one supported upstream producer.
|
||||
type Producer string
|
||||
|
||||
const (
|
||||
// ProducerGeoProfile identifies Geo Profile Service.
|
||||
ProducerGeoProfile Producer = "geoprofile"
|
||||
|
||||
// ProducerGameMaster identifies Game Master.
|
||||
ProducerGameMaster Producer = "game_master"
|
||||
|
||||
// ProducerGameLobby identifies Game Lobby.
|
||||
ProducerGameLobby Producer = "game_lobby"
|
||||
)
|
||||
|
||||
// String returns the wire value for producer.
|
||||
func (producer Producer) String() string {
|
||||
return string(producer)
|
||||
}
|
||||
|
||||
// IsKnown reports whether producer belongs to the frozen producer set.
|
||||
func (producer Producer) IsKnown() bool {
|
||||
switch producer {
|
||||
case ProducerGeoProfile, ProducerGameMaster, ProducerGameLobby:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AudienceKind identifies one supported target-audience kind.
|
||||
type AudienceKind string
|
||||
|
||||
const (
|
||||
// AudienceKindUser identifies user-targeted notifications.
|
||||
AudienceKindUser AudienceKind = "user"
|
||||
|
||||
// AudienceKindAdminEmail identifies administrator-email notifications.
|
||||
AudienceKindAdminEmail AudienceKind = "admin_email"
|
||||
)
|
||||
|
||||
// String returns the wire value for audienceKind.
|
||||
func (audienceKind AudienceKind) String() string {
|
||||
return string(audienceKind)
|
||||
}
|
||||
|
||||
// IsKnown reports whether audienceKind belongs to the frozen audience set.
|
||||
func (audienceKind AudienceKind) IsKnown() bool {
|
||||
switch audienceKind {
|
||||
case AudienceKindUser, AudienceKindAdminEmail:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Channel identifies one durable notification-delivery channel slot.
|
||||
type Channel string
|
||||
|
||||
const (
|
||||
// ChannelPush identifies the push-delivery channel.
|
||||
ChannelPush Channel = "push"
|
||||
|
||||
// ChannelEmail identifies the email-delivery channel.
|
||||
ChannelEmail Channel = "email"
|
||||
)
|
||||
|
||||
// String returns the wire value for channel.
|
||||
func (channel Channel) String() string {
|
||||
return string(channel)
|
||||
}
|
||||
|
||||
// IsKnown reports whether channel belongs to the frozen channel vocabulary.
|
||||
func (channel Channel) IsKnown() bool {
|
||||
switch channel {
|
||||
case ChannelPush, ChannelEmail:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata stores producer-owned envelope fields shared by every notification
|
||||
// intent.
|
||||
type Metadata struct {
|
||||
// IdempotencyKey stores the producer-owned idempotency key scoped together
|
||||
// with the producer name.
|
||||
IdempotencyKey string
|
||||
|
||||
// OccurredAt stores when the producer says the underlying business event
|
||||
// happened. Constructors normalize the value to UTC millisecond precision.
|
||||
OccurredAt time.Time
|
||||
|
||||
// RequestID stores the optional producer-side request identifier.
|
||||
RequestID string
|
||||
|
||||
// TraceID stores the optional producer-side trace identifier.
|
||||
TraceID string
|
||||
}
|
||||
|
||||
// Intent stores one normalized notification intent accepted by Notification
|
||||
// Service.
|
||||
type Intent struct {
|
||||
// NotificationType stores the frozen notification vocabulary value.
|
||||
NotificationType NotificationType
|
||||
|
||||
// Producer stores the frozen producer identifier.
|
||||
Producer Producer
|
||||
|
||||
// AudienceKind stores the normalized target audience kind.
|
||||
AudienceKind AudienceKind
|
||||
|
||||
// RecipientUserIDs stores the normalized sorted unique user-recipient set
|
||||
// when AudienceKind is AudienceKindUser.
|
||||
RecipientUserIDs []string
|
||||
|
||||
// IdempotencyKey stores the producer-owned idempotency key.
|
||||
IdempotencyKey string
|
||||
|
||||
// OccurredAt stores when the producer says the underlying business event
|
||||
// happened.
|
||||
OccurredAt time.Time
|
||||
|
||||
// RequestID stores the optional producer-side request identifier.
|
||||
RequestID string
|
||||
|
||||
// TraceID stores the optional producer-side trace identifier.
|
||||
TraceID string
|
||||
|
||||
// PayloadJSON stores the canonical normalized payload JSON string used for
|
||||
// duplicate detection.
|
||||
PayloadJSON string
|
||||
}
|
||||
|
||||
// Validate reports whether intent contains a complete normalized intake
|
||||
// request.
|
||||
func (intent Intent) Validate() error {
|
||||
if !intent.NotificationType.IsKnown() {
|
||||
return fmt.Errorf("intent notification type %q is unsupported", intent.NotificationType)
|
||||
}
|
||||
if !intent.Producer.IsKnown() {
|
||||
return fmt.Errorf("intent producer %q is unsupported", intent.Producer)
|
||||
}
|
||||
if expected := intent.NotificationType.ExpectedProducer(); intent.Producer != expected {
|
||||
return fmt.Errorf(
|
||||
"intent producer %q does not match notification type %q",
|
||||
intent.Producer,
|
||||
intent.NotificationType,
|
||||
)
|
||||
}
|
||||
if !intent.AudienceKind.IsKnown() {
|
||||
return fmt.Errorf("intent audience kind %q is unsupported", intent.AudienceKind)
|
||||
}
|
||||
if !intent.NotificationType.SupportsAudience(intent.AudienceKind) {
|
||||
return fmt.Errorf(
|
||||
"intent notification type %q does not support audience kind %q",
|
||||
intent.NotificationType,
|
||||
intent.AudienceKind,
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(intent.IdempotencyKey) == "" {
|
||||
return errors.New("intent idempotency key must not be empty")
|
||||
}
|
||||
if err := validateTimestamp("intent occurred at", intent.OccurredAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(intent.PayloadJSON) == "" {
|
||||
return errors.New("intent payload json must not be empty")
|
||||
}
|
||||
|
||||
switch intent.AudienceKind {
|
||||
case AudienceKindUser:
|
||||
if len(intent.RecipientUserIDs) == 0 {
|
||||
return errors.New("intent recipient user ids must not be empty for audience kind user")
|
||||
}
|
||||
for index, userID := range intent.RecipientUserIDs {
|
||||
if userID == "" {
|
||||
return fmt.Errorf("intent recipient user ids[%d] must not be empty", index)
|
||||
}
|
||||
if index > 0 && intent.RecipientUserIDs[index-1] >= userID {
|
||||
return errors.New("intent recipient user ids must be sorted strictly ascending")
|
||||
}
|
||||
}
|
||||
case AudienceKindAdminEmail:
|
||||
if len(intent.RecipientUserIDs) > 0 {
|
||||
return errors.New("intent recipient user ids must be empty for audience kind admin_email")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Values returns Redis Stream field values for intent. It validates and
|
||||
// normalizes the recipient set, event timestamp, and payload before building
|
||||
// the field map.
|
||||
func (intent Intent) Values() (map[string]any, error) {
|
||||
normalized, err := normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values := map[string]any{
|
||||
fieldNotificationType: normalized.NotificationType.String(),
|
||||
fieldProducer: normalized.Producer.String(),
|
||||
fieldAudienceKind: normalized.AudienceKind.String(),
|
||||
fieldIdempotencyKey: normalized.IdempotencyKey,
|
||||
fieldOccurredAtMS: strconv.FormatInt(normalized.OccurredAt.UnixMilli(), 10),
|
||||
fieldPayloadJSON: normalized.PayloadJSON,
|
||||
}
|
||||
if normalized.AudienceKind == AudienceKindUser {
|
||||
recipientUserIDs, err := json.Marshal(normalized.RecipientUserIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal recipient_user_ids_json: %w", err)
|
||||
}
|
||||
values[fieldRecipientUserIDs] = string(recipientUserIDs)
|
||||
}
|
||||
if normalized.RequestID != "" {
|
||||
values[fieldRequestID] = normalized.RequestID
|
||||
}
|
||||
if normalized.TraceID != "" {
|
||||
values[fieldTraceID] = normalized.TraceID
|
||||
}
|
||||
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// DecodeIntent validates one raw Redis Stream entry and returns the normalized
|
||||
// notification intent frozen by the producer contract.
|
||||
func DecodeIntent(fields map[string]any) (Intent, error) {
|
||||
if fields == nil {
|
||||
return Intent{}, errors.New("intent fields must not be nil")
|
||||
}
|
||||
|
||||
if err := validateFieldSet(fields); err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
|
||||
notificationTypeValue, err := requiredString(fields, fieldNotificationType)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
producerValue, err := requiredString(fields, fieldProducer)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
audienceKindValue, err := requiredString(fields, fieldAudienceKind)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
idempotencyKeyValue, err := requiredString(fields, fieldIdempotencyKey)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
occurredAtValue, err := requiredString(fields, fieldOccurredAtMS)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
payloadJSONValue, err := requiredString(fields, fieldPayloadJSON)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
|
||||
intent := Intent{
|
||||
NotificationType: NotificationType(notificationTypeValue),
|
||||
Producer: Producer(producerValue),
|
||||
AudienceKind: AudienceKind(audienceKindValue),
|
||||
IdempotencyKey: idempotencyKeyValue,
|
||||
}
|
||||
|
||||
if requestIDValue, ok, err := optionalString(fields, fieldRequestID); err != nil {
|
||||
return Intent{}, err
|
||||
} else if ok {
|
||||
intent.RequestID = requestIDValue
|
||||
}
|
||||
if traceIDValue, ok, err := optionalString(fields, fieldTraceID); err != nil {
|
||||
return Intent{}, err
|
||||
} else if ok {
|
||||
intent.TraceID = traceIDValue
|
||||
}
|
||||
|
||||
occurredAt, err := parseUnixMilliseconds(occurredAtValue)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
intent.OccurredAt = occurredAt
|
||||
|
||||
if !intent.NotificationType.IsKnown() {
|
||||
return Intent{}, fmt.Errorf("stream field %q value %q is unsupported", fieldNotificationType, notificationTypeValue)
|
||||
}
|
||||
if !intent.Producer.IsKnown() {
|
||||
return Intent{}, fmt.Errorf("stream field %q value %q is unsupported", fieldProducer, producerValue)
|
||||
}
|
||||
if !intent.AudienceKind.IsKnown() {
|
||||
return Intent{}, fmt.Errorf("stream field %q value %q is unsupported", fieldAudienceKind, audienceKindValue)
|
||||
}
|
||||
if intent.NotificationType.ExpectedProducer() != intent.Producer {
|
||||
return Intent{}, fmt.Errorf(
|
||||
"stream field %q value %q does not match notification type %q",
|
||||
fieldProducer,
|
||||
producerValue,
|
||||
intent.NotificationType,
|
||||
)
|
||||
}
|
||||
if !intent.NotificationType.SupportsAudience(intent.AudienceKind) {
|
||||
return Intent{}, fmt.Errorf(
|
||||
"stream field %q value %q is unsupported for notification type %q",
|
||||
fieldAudienceKind,
|
||||
audienceKindValue,
|
||||
intent.NotificationType,
|
||||
)
|
||||
}
|
||||
|
||||
switch intent.AudienceKind {
|
||||
case AudienceKindUser:
|
||||
recipientUserIDsValue, err := requiredString(fields, fieldRecipientUserIDs)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
recipientUserIDs, err := normalizeRecipientUserIDs(recipientUserIDsValue)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
intent.RecipientUserIDs = recipientUserIDs
|
||||
case AudienceKindAdminEmail:
|
||||
if _, found := fields[fieldRecipientUserIDs]; found {
|
||||
return Intent{}, fmt.Errorf("stream field %q must not be present for audience kind %q", fieldRecipientUserIDs, intent.AudienceKind)
|
||||
}
|
||||
}
|
||||
|
||||
canonicalPayloadJSON, err := validateAndNormalizePayload(intent.NotificationType, payloadJSONValue)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
intent.PayloadJSON = canonicalPayloadJSON
|
||||
|
||||
if err := intent.Validate(); err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
|
||||
return intent, nil
|
||||
}
|
||||
|
||||
func newIntent(
|
||||
notificationType NotificationType,
|
||||
producer Producer,
|
||||
audienceKind AudienceKind,
|
||||
recipientUserIDs []string,
|
||||
metadata Metadata,
|
||||
payload any,
|
||||
) (Intent, error) {
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return Intent{}, fmt.Errorf("marshal payload_json: %w", err)
|
||||
}
|
||||
|
||||
return normalizeIntent(Intent{
|
||||
NotificationType: notificationType,
|
||||
Producer: producer,
|
||||
AudienceKind: audienceKind,
|
||||
RecipientUserIDs: append([]string(nil), recipientUserIDs...),
|
||||
IdempotencyKey: metadata.IdempotencyKey,
|
||||
OccurredAt: normalizeTimestamp(metadata.OccurredAt),
|
||||
RequestID: metadata.RequestID,
|
||||
TraceID: metadata.TraceID,
|
||||
PayloadJSON: string(payloadJSON),
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeIntent(intent Intent) (Intent, error) {
|
||||
normalized := intent
|
||||
normalized.OccurredAt = normalizeTimestamp(intent.OccurredAt)
|
||||
|
||||
switch normalized.AudienceKind {
|
||||
case AudienceKindUser:
|
||||
recipientUserIDs, err := normalizeRecipientUserIDValues(normalized.RecipientUserIDs)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
normalized.RecipientUserIDs = recipientUserIDs
|
||||
case AudienceKindAdminEmail:
|
||||
if len(normalized.RecipientUserIDs) > 0 {
|
||||
return Intent{}, errors.New("intent recipient user ids must be empty for audience kind admin_email")
|
||||
}
|
||||
default:
|
||||
if len(normalized.RecipientUserIDs) > 0 {
|
||||
recipientUserIDs, err := normalizeRecipientUserIDValues(normalized.RecipientUserIDs)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
normalized.RecipientUserIDs = recipientUserIDs
|
||||
}
|
||||
}
|
||||
|
||||
canonicalPayloadJSON, err := validateAndNormalizePayload(normalized.NotificationType, normalized.PayloadJSON)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
normalized.PayloadJSON = canonicalPayloadJSON
|
||||
|
||||
if err := normalized.Validate(); err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeTimestamp(value time.Time) time.Time {
|
||||
if value.IsZero() {
|
||||
return value
|
||||
}
|
||||
|
||||
return value.UTC().Truncate(time.Millisecond)
|
||||
}
|
||||
|
||||
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("intent 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("intent 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 := rawString(value)
|
||||
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 := rawString(value)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("stream field %q must be a string", name)
|
||||
}
|
||||
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func rawString(value any) (string, bool) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed, true
|
||||
case []byte:
|
||||
return string(typed), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func parseUnixMilliseconds(raw string) (time.Time, error) {
|
||||
if raw == "" {
|
||||
return time.Time{}, fmt.Errorf("stream field %q must be a base-10 Unix milliseconds string", fieldOccurredAtMS)
|
||||
}
|
||||
for _, r := range raw {
|
||||
if r < '0' || r > '9' {
|
||||
return time.Time{}, fmt.Errorf("stream field %q must be a base-10 Unix milliseconds string", fieldOccurredAtMS)
|
||||
}
|
||||
}
|
||||
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("stream field %q must be a base-10 Unix milliseconds string", fieldOccurredAtMS)
|
||||
}
|
||||
|
||||
return time.UnixMilli(value).UTC(), nil
|
||||
}
|
||||
|
||||
func normalizeRecipientUserIDs(raw string) ([]string, error) {
|
||||
var values []string
|
||||
if err := decodeStrictJSON("decode recipient_user_ids_json", raw, &values, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return normalizeRecipientUserIDValues(values)
|
||||
}
|
||||
|
||||
func normalizeRecipientUserIDValues(values []string) ([]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, errors.New("recipient_user_ids_json must contain at least one user id")
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
normalized := make([]string, 0, len(values))
|
||||
for index, value := range values {
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("recipient_user_ids_json[%d] must not be empty", index)
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
return nil, fmt.Errorf("recipient_user_ids_json[%d] duplicates user id %q", index, value)
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
|
||||
sort.Strings(normalized)
|
||||
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func validateAndNormalizePayload(notificationType NotificationType, raw string) (string, error) {
|
||||
payloadObject, err := decodeJSONObjectRaw("decode payload_json", raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validatePayloadObject(notificationType, payloadObject); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
normalizedValue, err := decodeNormalizedJSONValue("decode payload_json", raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
normalizedPayload, err := json.Marshal(normalizedValue)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("normalize payload_json: %w", err)
|
||||
}
|
||||
|
||||
return string(normalizedPayload), nil
|
||||
}
|
||||
|
||||
func validatePayloadObject(notificationType NotificationType, payload map[string]json.RawMessage) error {
|
||||
switch notificationType {
|
||||
case NotificationTypeGeoReviewRecommended:
|
||||
return validateStringFields(payload, "user_id", "user_email", "observed_country", "usual_connection_country", "review_reason")
|
||||
case NotificationTypeGameTurnReady:
|
||||
if err := validateStringFields(payload, "game_id", "game_name"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePositiveIntFields(payload, "turn_number")
|
||||
case NotificationTypeGameFinished:
|
||||
if err := validateStringFields(payload, "game_id", "game_name"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePositiveIntFields(payload, "final_turn_number")
|
||||
case NotificationTypeGameGenerationFailed:
|
||||
return validateStringFields(payload, "game_id", "game_name", "failure_reason")
|
||||
case NotificationTypeLobbyRuntimePausedAfterStart:
|
||||
return validateStringFields(payload, "game_id", "game_name")
|
||||
case NotificationTypeLobbyApplicationSubmitted:
|
||||
return validateStringFields(payload, "game_id", "game_name", "applicant_user_id", "applicant_name")
|
||||
case NotificationTypeLobbyMembershipApproved, NotificationTypeLobbyMembershipRejected:
|
||||
return validateStringFields(payload, "game_id", "game_name")
|
||||
case NotificationTypeLobbyInviteCreated:
|
||||
return validateStringFields(payload, "game_id", "game_name", "inviter_user_id", "inviter_name")
|
||||
case NotificationTypeLobbyInviteRedeemed, NotificationTypeLobbyInviteExpired:
|
||||
return validateStringFields(payload, "game_id", "game_name", "invitee_user_id", "invitee_name")
|
||||
default:
|
||||
return fmt.Errorf("payload_json notification type %q is unsupported", notificationType)
|
||||
}
|
||||
}
|
||||
|
||||
func validateStringFields(payload map[string]json.RawMessage, names ...string) error {
|
||||
for _, name := range names {
|
||||
var value string
|
||||
if err := decodeRequiredJSONField(payload, name, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
if value == "" {
|
||||
return fmt.Errorf("payload_json.%s must not be empty", name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePositiveIntFields(payload map[string]json.RawMessage, names ...string) error {
|
||||
for _, name := range names {
|
||||
var value int64
|
||||
if err := decodeRequiredJSONField(payload, name, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
if value < 1 {
|
||||
return fmt.Errorf("payload_json.%s must be at least 1", name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeRequiredJSONField(payload map[string]json.RawMessage, name string, target any) error {
|
||||
raw, ok := payload[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("payload_json.%s is required", name)
|
||||
}
|
||||
|
||||
if err := decodeStrictJSON("decode payload_json."+name, string(raw), target, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeJSONObjectRaw(label string, raw string) (map[string]json.RawMessage, error) {
|
||||
var value map[string]json.RawMessage
|
||||
if err := decodeStrictJSON(label, raw, &value, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value == nil {
|
||||
return nil, errors.New("payload_json must be a JSON object")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func decodeNormalizedJSONValue(label string, raw string) (any, error) {
|
||||
decoder := json.NewDecoder(bytes.NewBufferString(raw))
|
||||
decoder.UseNumber()
|
||||
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("%s: unexpected trailing JSON input", label)
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok || object == nil {
|
||||
return nil, errors.New("payload_json must be a JSON object")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func decodeStrictJSON(label string, raw string, target any, useNumber bool) error {
|
||||
decoder := json.NewDecoder(bytes.NewBufferString(raw))
|
||||
if useNumber {
|
||||
decoder.UseNumber()
|
||||
}
|
||||
|
||||
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 validateTimestamp(name string, value time.Time) error {
|
||||
if value.IsZero() {
|
||||
return fmt.Errorf("%s must not be zero", name)
|
||||
}
|
||||
if !value.Equal(value.UTC()) {
|
||||
return fmt.Errorf("%s must be UTC", name)
|
||||
}
|
||||
if !value.Equal(value.Truncate(time.Millisecond)) {
|
||||
return fmt.Errorf("%s must use millisecond precision", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package notificationintent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConstructorsBuildExpectedIntentValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metadata := Metadata{
|
||||
IdempotencyKey: "idempotency-1",
|
||||
OccurredAt: time.UnixMilli(1775121700000).Add(123 * time.Nanosecond),
|
||||
RequestID: "request-1",
|
||||
TraceID: "trace-1",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
build func() (Intent, error)
|
||||
notificationType NotificationType
|
||||
producer Producer
|
||||
audienceKind AudienceKind
|
||||
recipientUserIDs []string
|
||||
payloadJSON string
|
||||
}{
|
||||
{
|
||||
name: "geo review recommended",
|
||||
build: func() (Intent, error) {
|
||||
return NewGeoReviewRecommendedIntent(metadata, GeoReviewRecommendedPayload{
|
||||
UserID: "user-1",
|
||||
UserEmail: "pilot@example.com",
|
||||
ObservedCountry: "DE",
|
||||
UsualConnectionCountry: "PL",
|
||||
ReviewReason: "country_mismatch",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeGeoReviewRecommended,
|
||||
producer: ProducerGeoProfile,
|
||||
audienceKind: AudienceKindAdminEmail,
|
||||
payloadJSON: `{"user_id":"user-1","user_email":"pilot@example.com","observed_country":"DE","usual_connection_country":"PL","review_reason":"country_mismatch"}`,
|
||||
},
|
||||
{
|
||||
name: "game turn ready",
|
||||
build: func() (Intent, error) {
|
||||
return NewGameTurnReadyIntent(metadata, []string{"user-2", "user-1"}, GameTurnReadyPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
TurnNumber: 54,
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeGameTurnReady,
|
||||
producer: ProducerGameMaster,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"user-1", "user-2"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","turn_number":54}`,
|
||||
},
|
||||
{
|
||||
name: "game finished",
|
||||
build: func() (Intent, error) {
|
||||
return NewGameFinishedIntent(metadata, []string{"user-1", "user-2"}, GameFinishedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
FinalTurnNumber: 55,
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeGameFinished,
|
||||
producer: ProducerGameMaster,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"user-1", "user-2"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","final_turn_number":55}`,
|
||||
},
|
||||
{
|
||||
name: "game generation failed",
|
||||
build: func() (Intent, error) {
|
||||
return NewGameGenerationFailedIntent(metadata, GameGenerationFailedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
FailureReason: "engine_timeout",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeGameGenerationFailed,
|
||||
producer: ProducerGameMaster,
|
||||
audienceKind: AudienceKindAdminEmail,
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","failure_reason":"engine_timeout"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby runtime paused after start",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyRuntimePausedAfterStartIntent(metadata, LobbyRuntimePausedAfterStartPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyRuntimePausedAfterStart,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindAdminEmail,
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash"}`,
|
||||
},
|
||||
{
|
||||
name: "private lobby application submitted",
|
||||
build: func() (Intent, error) {
|
||||
return NewPrivateLobbyApplicationSubmittedIntent(metadata, "owner-1", LobbyApplicationSubmittedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
ApplicantUserID: "user-2",
|
||||
ApplicantName: "Nova Pilot",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyApplicationSubmitted,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"owner-1"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","applicant_user_id":"user-2","applicant_name":"Nova Pilot"}`,
|
||||
},
|
||||
{
|
||||
name: "public lobby application submitted",
|
||||
build: func() (Intent, error) {
|
||||
return NewPublicLobbyApplicationSubmittedIntent(metadata, LobbyApplicationSubmittedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
ApplicantUserID: "user-2",
|
||||
ApplicantName: "Nova Pilot",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyApplicationSubmitted,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindAdminEmail,
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","applicant_user_id":"user-2","applicant_name":"Nova Pilot"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby membership approved",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyMembershipApprovedIntent(metadata, "applicant-1", LobbyMembershipApprovedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyMembershipApproved,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"applicant-1"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby membership rejected",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyMembershipRejectedIntent(metadata, "applicant-1", LobbyMembershipRejectedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyMembershipRejected,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"applicant-1"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby invite created",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyInviteCreatedIntent(metadata, "invited-1", LobbyInviteCreatedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
InviterUserID: "owner-1",
|
||||
InviterName: "Owner Pilot",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyInviteCreated,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"invited-1"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","inviter_user_id":"owner-1","inviter_name":"Owner Pilot"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby invite redeemed",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyInviteRedeemedIntent(metadata, "owner-1", LobbyInviteRedeemedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
InviteeUserID: "invitee-1",
|
||||
InviteeName: "Nova Pilot",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyInviteRedeemed,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"owner-1"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","invitee_user_id":"invitee-1","invitee_name":"Nova Pilot"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby invite expired",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyInviteExpiredIntent(metadata, "owner-1", LobbyInviteExpiredPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
InviteeUserID: "invitee-1",
|
||||
InviteeName: "Nova Pilot",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyInviteExpired,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"owner-1"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","invitee_user_id":"invitee-1","invitee_name":"Nova Pilot"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
intent, err := tt.build()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.notificationType, intent.NotificationType)
|
||||
require.Equal(t, tt.producer, intent.Producer)
|
||||
require.Equal(t, tt.audienceKind, intent.AudienceKind)
|
||||
require.Equal(t, tt.recipientUserIDs, intent.RecipientUserIDs)
|
||||
|
||||
values, err := intent.Values()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.notificationType.String(), values[fieldNotificationType])
|
||||
require.Equal(t, tt.producer.String(), values[fieldProducer])
|
||||
require.Equal(t, tt.audienceKind.String(), values[fieldAudienceKind])
|
||||
require.Equal(t, metadata.IdempotencyKey, values[fieldIdempotencyKey])
|
||||
require.Equal(t, "1775121700000", values[fieldOccurredAtMS])
|
||||
require.Equal(t, metadata.RequestID, values[fieldRequestID])
|
||||
require.Equal(t, metadata.TraceID, values[fieldTraceID])
|
||||
require.JSONEq(t, tt.payloadJSON, values[fieldPayloadJSON].(string))
|
||||
|
||||
if len(tt.recipientUserIDs) == 0 {
|
||||
require.NotContains(t, values, fieldRecipientUserIDs)
|
||||
return
|
||||
}
|
||||
|
||||
var recipientUserIDs []string
|
||||
require.NoError(t, json.Unmarshal([]byte(values[fieldRecipientUserIDs].(string)), &recipientUserIDs))
|
||||
require.Equal(t, tt.recipientUserIDs, recipientUserIDs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRecipientConstructorsRejectDuplicates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewGameTurnReadyIntent(defaultMetadata(), []string{"user-1", "user-1"}, GameTurnReadyPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
TurnNumber: 54,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "duplicates user id")
|
||||
}
|
||||
|
||||
func TestConstructorsRejectInvalidPayloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewGameTurnReadyIntent(defaultMetadata(), []string{"user-1"}, GameTurnReadyPayload{
|
||||
GameName: "Nebula Clash",
|
||||
TurnNumber: 54,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "payload_json.game_id must not be empty")
|
||||
|
||||
_, err = NewGameTurnReadyIntent(defaultMetadata(), []string{"user-1"}, GameTurnReadyPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
TurnNumber: 0,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "payload_json.turn_number must be at least 1")
|
||||
}
|
||||
|
||||
func TestDecodeIntentRejectsMissingRequiredTopLevelField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := DecodeIntent(map[string]any{
|
||||
fieldNotificationType: NotificationTypeGameTurnReady.String(),
|
||||
fieldProducer: ProducerGameMaster.String(),
|
||||
fieldAudienceKind: AudienceKindUser.String(),
|
||||
fieldRecipientUserIDs: `["user-1"]`,
|
||||
fieldIdempotencyKey: "game-1:turn-54",
|
||||
fieldOccurredAtMS: "1775121700000",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), fieldPayloadJSON)
|
||||
}
|
||||
|
||||
func defaultMetadata() Metadata {
|
||||
return Metadata{
|
||||
IdempotencyKey: "idempotency-1",
|
||||
OccurredAt: time.UnixMilli(1775121700000),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package notificationintent
|
||||
|
||||
// GeoReviewRecommendedPayload stores the normalized payload for
|
||||
// `geo.review_recommended`.
|
||||
type GeoReviewRecommendedPayload struct {
|
||||
UserID string `json:"user_id"`
|
||||
UserEmail string `json:"user_email"`
|
||||
ObservedCountry string `json:"observed_country"`
|
||||
UsualConnectionCountry string `json:"usual_connection_country"`
|
||||
ReviewReason string `json:"review_reason"`
|
||||
}
|
||||
|
||||
// GameTurnReadyPayload stores the normalized payload for `game.turn.ready`.
|
||||
type GameTurnReadyPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
TurnNumber int64 `json:"turn_number"`
|
||||
}
|
||||
|
||||
// GameFinishedPayload stores the normalized payload for `game.finished`.
|
||||
type GameFinishedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
FinalTurnNumber int64 `json:"final_turn_number"`
|
||||
}
|
||||
|
||||
// GameGenerationFailedPayload stores the normalized payload for
|
||||
// `game.generation_failed`.
|
||||
type GameGenerationFailedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
FailureReason string `json:"failure_reason"`
|
||||
}
|
||||
|
||||
// LobbyRuntimePausedAfterStartPayload stores the normalized payload for
|
||||
// `lobby.runtime_paused_after_start`.
|
||||
type LobbyRuntimePausedAfterStartPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
}
|
||||
|
||||
// LobbyApplicationSubmittedPayload stores the normalized payload for
|
||||
// `lobby.application.submitted`.
|
||||
type LobbyApplicationSubmittedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
ApplicantUserID string `json:"applicant_user_id"`
|
||||
ApplicantName string `json:"applicant_name"`
|
||||
}
|
||||
|
||||
// LobbyMembershipApprovedPayload stores the normalized payload for
|
||||
// `lobby.membership.approved`.
|
||||
type LobbyMembershipApprovedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
}
|
||||
|
||||
// LobbyMembershipRejectedPayload stores the normalized payload for
|
||||
// `lobby.membership.rejected`.
|
||||
type LobbyMembershipRejectedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
}
|
||||
|
||||
// LobbyInviteCreatedPayload stores the normalized payload for
|
||||
// `lobby.invite.created`.
|
||||
type LobbyInviteCreatedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
InviterUserID string `json:"inviter_user_id"`
|
||||
InviterName string `json:"inviter_name"`
|
||||
}
|
||||
|
||||
// LobbyInviteRedeemedPayload stores the normalized payload for
|
||||
// `lobby.invite.redeemed`.
|
||||
type LobbyInviteRedeemedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
InviteeUserID string `json:"invitee_user_id"`
|
||||
InviteeName string `json:"invitee_name"`
|
||||
}
|
||||
|
||||
// LobbyInviteExpiredPayload stores the normalized payload for
|
||||
// `lobby.invite.expired`.
|
||||
type LobbyInviteExpiredPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
InviteeUserID string `json:"invitee_user_id"`
|
||||
InviteeName string `json:"invitee_name"`
|
||||
}
|
||||
|
||||
// NewGeoReviewRecommendedIntent builds the admin-email intent published by Geo
|
||||
// Profile Service when a user becomes review-worthy.
|
||||
func NewGeoReviewRecommendedIntent(metadata Metadata, payload GeoReviewRecommendedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeGeoReviewRecommended, ProducerGeoProfile, AudienceKindAdminEmail, nil, metadata, payload)
|
||||
}
|
||||
|
||||
// NewGameTurnReadyIntent builds the user-targeted intent published by Game
|
||||
// Master when a new turn is ready for active accepted participants.
|
||||
func NewGameTurnReadyIntent(metadata Metadata, recipientUserIDs []string, payload GameTurnReadyPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeGameTurnReady, ProducerGameMaster, AudienceKindUser, recipientUserIDs, metadata, payload)
|
||||
}
|
||||
|
||||
// NewGameFinishedIntent builds the user-targeted intent published by Game
|
||||
// Master when a running game finishes.
|
||||
func NewGameFinishedIntent(metadata Metadata, recipientUserIDs []string, payload GameFinishedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeGameFinished, ProducerGameMaster, AudienceKindUser, recipientUserIDs, metadata, payload)
|
||||
}
|
||||
|
||||
// NewGameGenerationFailedIntent builds the admin-email intent published by
|
||||
// Game Master when turn generation fails.
|
||||
func NewGameGenerationFailedIntent(metadata Metadata, payload GameGenerationFailedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeGameGenerationFailed, ProducerGameMaster, AudienceKindAdminEmail, nil, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyRuntimePausedAfterStartIntent builds the admin-email intent
|
||||
// published by Game Lobby when a game is paused after runtime startup.
|
||||
func NewLobbyRuntimePausedAfterStartIntent(metadata Metadata, payload LobbyRuntimePausedAfterStartPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyRuntimePausedAfterStart, ProducerGameLobby, AudienceKindAdminEmail, nil, metadata, payload)
|
||||
}
|
||||
|
||||
// NewPrivateLobbyApplicationSubmittedIntent builds the private-game owner
|
||||
// intent published by Game Lobby when an application is submitted.
|
||||
func NewPrivateLobbyApplicationSubmittedIntent(metadata Metadata, ownerUserID string, payload LobbyApplicationSubmittedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyApplicationSubmitted, ProducerGameLobby, AudienceKindUser, []string{ownerUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewPublicLobbyApplicationSubmittedIntent builds the public-game admin-email
|
||||
// intent published by Game Lobby when an application is submitted.
|
||||
func NewPublicLobbyApplicationSubmittedIntent(metadata Metadata, payload LobbyApplicationSubmittedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyApplicationSubmitted, ProducerGameLobby, AudienceKindAdminEmail, nil, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyMembershipApprovedIntent builds the applicant-user intent published
|
||||
// by Game Lobby when membership is approved.
|
||||
func NewLobbyMembershipApprovedIntent(metadata Metadata, applicantUserID string, payload LobbyMembershipApprovedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyMembershipApproved, ProducerGameLobby, AudienceKindUser, []string{applicantUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyMembershipRejectedIntent builds the applicant-user intent published
|
||||
// by Game Lobby when membership is rejected.
|
||||
func NewLobbyMembershipRejectedIntent(metadata Metadata, applicantUserID string, payload LobbyMembershipRejectedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyMembershipRejected, ProducerGameLobby, AudienceKindUser, []string{applicantUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyInviteCreatedIntent builds the invited-user intent published by Game
|
||||
// Lobby when a private-game invite is created.
|
||||
func NewLobbyInviteCreatedIntent(metadata Metadata, invitedUserID string, payload LobbyInviteCreatedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyInviteCreated, ProducerGameLobby, AudienceKindUser, []string{invitedUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyInviteRedeemedIntent builds the private-game owner intent published
|
||||
// by Game Lobby when an invite is redeemed.
|
||||
func NewLobbyInviteRedeemedIntent(metadata Metadata, ownerUserID string, payload LobbyInviteRedeemedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyInviteRedeemed, ProducerGameLobby, AudienceKindUser, []string{ownerUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyInviteExpiredIntent builds the private-game owner intent published
|
||||
// by Game Lobby when an invite expires.
|
||||
func NewLobbyInviteExpiredIntent(metadata Metadata, ownerUserID string, payload LobbyInviteExpiredPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyInviteExpired, ProducerGameLobby, AudienceKindUser, []string{ownerUserID}, metadata, payload)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package notificationintent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisClient stores the minimal Redis command surface required by Publisher.
|
||||
type RedisClient interface {
|
||||
// XAdd appends one entry to a Redis Stream.
|
||||
XAdd(context.Context, *redis.XAddArgs) *redis.StringCmd
|
||||
}
|
||||
|
||||
// PublisherConfig stores the dependencies and stream name used by Publisher.
|
||||
type PublisherConfig struct {
|
||||
// Client appends normalized intents to Redis Streams.
|
||||
Client RedisClient
|
||||
|
||||
// Stream stores the Redis Stream name. When empty, DefaultIntentsStream is
|
||||
// used.
|
||||
Stream string
|
||||
}
|
||||
|
||||
// Publisher publishes normalized notification intents into the Notification
|
||||
// Service ingress stream.
|
||||
type Publisher struct {
|
||||
client RedisClient
|
||||
stream string
|
||||
}
|
||||
|
||||
// NewPublisher constructs a Publisher from cfg.
|
||||
func NewPublisher(cfg PublisherConfig) (*Publisher, error) {
|
||||
if cfg.Client == nil {
|
||||
return nil, errors.New("new notification intent publisher: nil redis client")
|
||||
}
|
||||
if cfg.Stream == "" {
|
||||
cfg.Stream = DefaultIntentsStream
|
||||
}
|
||||
|
||||
return &Publisher{
|
||||
client: cfg.Client,
|
||||
stream: cfg.Stream,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Publish validates intent and appends it with plain XADD. It does not trim
|
||||
// the stream and does not perform hidden retries.
|
||||
func (publisher *Publisher) Publish(ctx context.Context, intent Intent) (string, error) {
|
||||
if ctx == nil {
|
||||
return "", errors.New("publish notification intent: nil context")
|
||||
}
|
||||
if publisher == nil || publisher.client == nil {
|
||||
return "", errors.New("publish notification intent: nil publisher")
|
||||
}
|
||||
|
||||
values, err := intent.Values()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("publish notification intent: %w", err)
|
||||
}
|
||||
|
||||
entryID, err := publisher.client.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: publisher.stream,
|
||||
Values: values,
|
||||
}).Result()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("publish notification intent: xadd: %w", err)
|
||||
}
|
||||
|
||||
return entryID, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package notificationintent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPublisherPublishAppendsIntentToDefaultStream(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
redisServer := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, redisClient.Close())
|
||||
})
|
||||
|
||||
publisher, err := NewPublisher(PublisherConfig{Client: redisClient})
|
||||
require.NoError(t, err)
|
||||
|
||||
intent, err := NewGameTurnReadyIntent(defaultMetadata(), []string{"user-1"}, GameTurnReadyPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
TurnNumber: 54,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
entryID, err := publisher.Publish(context.Background(), intent)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, entryID)
|
||||
|
||||
messages, err := redisClient.XRange(context.Background(), DefaultIntentsStream, "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
require.Equal(t, entryID, messages[0].ID)
|
||||
require.Equal(t, NotificationTypeGameTurnReady.String(), messages[0].Values[fieldNotificationType])
|
||||
require.Equal(t, ProducerGameMaster.String(), messages[0].Values[fieldProducer])
|
||||
require.Equal(t, AudienceKindUser.String(), messages[0].Values[fieldAudienceKind])
|
||||
require.Equal(t, `["user-1"]`, messages[0].Values[fieldRecipientUserIDs])
|
||||
require.Equal(t, `{"game_id":"game-1","game_name":"Nebula Clash","turn_number":54}`, messages[0].Values[fieldPayloadJSON])
|
||||
}
|
||||
Reference in New Issue
Block a user