feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
+7
View File
@@ -80,6 +80,13 @@ type EntitlementSnapshot struct {
// UpdatedAt stores when the snapshot was last recomputed.
UpdatedAt time.Time `json:"updated_at"`
// MaxRegisteredRaceNames mirrors the per-tier quota carried in the
// backend HTTP response (`backend.EntitlementSnapshot`). Gateway
// re-validates the response shape with strict-unknown-field
// decoding, so the field must be present here even when the
// FlatBuffers schema does not yet carry it.
MaxRegisteredRaceNames int32 `json:"max_registered_race_names"`
}
// ActiveSanction stores one transport-ready active sanction returned in the
-24
View File
@@ -1,24 +0,0 @@
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.43.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-31
View File
@@ -1,31 +0,0 @@
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
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=
-958
View File
@@ -1,958 +0,0 @@
// 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"
// NotificationTypeLobbyMembershipBlocked identifies the
// `lobby.membership.blocked` notification published by Game Lobby
// to the private-game owner when an active membership is blocked
// by the user-lifecycle cascade reacting to a `permanent_block` or
// `DeleteUser` event.
NotificationTypeLobbyMembershipBlocked NotificationType = "lobby.membership.blocked"
// 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"
// NotificationTypeLobbyRaceNameRegistrationEligible identifies the
// `lobby.race_name.registration_eligible` notification published by
// Game Lobby when capability evaluation at game finish promotes a
// reservation to `pending_registration`.
NotificationTypeLobbyRaceNameRegistrationEligible NotificationType = "lobby.race_name.registration_eligible"
// NotificationTypeLobbyRaceNameRegistered identifies the
// `lobby.race_name.registered` notification published by Game Lobby
// when a user converts a `pending_registration` into a permanent
// registered race name.
NotificationTypeLobbyRaceNameRegistered NotificationType = "lobby.race_name.registered"
// NotificationTypeLobbyRaceNameRegistrationDenied identifies the
// `lobby.race_name.registration_denied` notification published by
// Game Lobby when capability evaluation at game finish releases a
// reservation because the member did not meet the capability rule.
NotificationTypeLobbyRaceNameRegistrationDenied NotificationType = "lobby.race_name.registration_denied"
// NotificationTypeRuntimeImagePullFailed identifies the
// `runtime.image_pull_failed` administrator notification published by
// Runtime Manager when the engine image cannot be pulled during a
// start operation.
NotificationTypeRuntimeImagePullFailed NotificationType = "runtime.image_pull_failed"
// NotificationTypeRuntimeContainerStartFailed identifies the
// `runtime.container_start_failed` administrator notification published
// by Runtime Manager when `docker create` or `docker start` returns an
// error during a start operation.
NotificationTypeRuntimeContainerStartFailed NotificationType = "runtime.container_start_failed"
// NotificationTypeRuntimeStartConfigInvalid identifies the
// `runtime.start_config_invalid` administrator notification published by
// Runtime Manager when start configuration validation fails (invalid
// `image_ref`, missing Docker network, unwritable state directory).
NotificationTypeRuntimeStartConfigInvalid NotificationType = "runtime.start_config_invalid"
)
// 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,
NotificationTypeLobbyMembershipBlocked,
NotificationTypeLobbyInviteCreated,
NotificationTypeLobbyInviteRedeemed,
NotificationTypeLobbyInviteExpired,
NotificationTypeLobbyRaceNameRegistrationEligible,
NotificationTypeLobbyRaceNameRegistered,
NotificationTypeLobbyRaceNameRegistrationDenied,
NotificationTypeRuntimeImagePullFailed,
NotificationTypeRuntimeContainerStartFailed,
NotificationTypeRuntimeStartConfigInvalid:
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,
NotificationTypeLobbyMembershipBlocked,
NotificationTypeLobbyInviteCreated,
NotificationTypeLobbyInviteRedeemed,
NotificationTypeLobbyInviteExpired,
NotificationTypeLobbyRaceNameRegistrationEligible,
NotificationTypeLobbyRaceNameRegistered,
NotificationTypeLobbyRaceNameRegistrationDenied:
return ProducerGameLobby
case NotificationTypeRuntimeImagePullFailed,
NotificationTypeRuntimeContainerStartFailed,
NotificationTypeRuntimeStartConfigInvalid:
return ProducerRuntimeManager
default:
return ""
}
}
// SupportsAudience reports whether notificationType supports audienceKind.
func (notificationType NotificationType) SupportsAudience(audienceKind AudienceKind) bool {
switch notificationType {
case NotificationTypeGeoReviewRecommended,
NotificationTypeGameGenerationFailed,
NotificationTypeLobbyRuntimePausedAfterStart,
NotificationTypeRuntimeImagePullFailed,
NotificationTypeRuntimeContainerStartFailed,
NotificationTypeRuntimeStartConfigInvalid:
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,
NotificationTypeRuntimeImagePullFailed,
NotificationTypeRuntimeContainerStartFailed,
NotificationTypeRuntimeStartConfigInvalid:
return audienceKind == AudienceKindAdminEmail && channel == ChannelEmail
case NotificationTypeLobbyApplicationSubmitted:
if audienceKind == AudienceKindAdminEmail {
return channel == ChannelEmail
}
return channel == ChannelPush || channel == ChannelEmail
case NotificationTypeLobbyInviteExpired,
NotificationTypeLobbyRaceNameRegistrationDenied:
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"
// ProducerRuntimeManager identifies Runtime Manager.
ProducerRuntimeManager Producer = "runtime_manager"
)
// 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, ProducerRuntimeManager:
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 NotificationTypeLobbyMembershipBlocked:
return validateStringFields(payload, "game_id", "game_name", "membership_user_id", "membership_user_name", "reason")
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")
case NotificationTypeLobbyRaceNameRegistrationEligible:
if err := validateStringFields(payload, "game_id", "game_name", "race_name"); err != nil {
return err
}
return validatePositiveIntFields(payload, "eligible_until_ms")
case NotificationTypeLobbyRaceNameRegistered:
return validateStringFields(payload, "race_name")
case NotificationTypeLobbyRaceNameRegistrationDenied:
return validateStringFields(payload, "game_id", "game_name", "race_name", "reason")
case NotificationTypeRuntimeImagePullFailed,
NotificationTypeRuntimeContainerStartFailed,
NotificationTypeRuntimeStartConfigInvalid:
if err := validateStringFields(payload, "game_id", "image_ref", "error_code", "error_message"); err != nil {
return err
}
return validatePositiveIntFields(payload, "attempted_at_ms")
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
}
-428
View File
@@ -1,428 +0,0 @@
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 membership blocked",
build: func() (Intent, error) {
return NewLobbyMembershipBlockedIntent(metadata, "owner-1", LobbyMembershipBlockedPayload{
GameID: "game-1",
GameName: "Nebula Clash",
MembershipUserID: "user-2",
MembershipUserName: "player-aabbccdd",
Reason: "permanent_blocked",
})
},
notificationType: NotificationTypeLobbyMembershipBlocked,
producer: ProducerGameLobby,
audienceKind: AudienceKindUser,
recipientUserIDs: []string{"owner-1"},
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","membership_user_id":"user-2","membership_user_name":"player-aabbccdd","reason":"permanent_blocked"}`,
},
{
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"}`,
},
{
name: "lobby race name registration eligible",
build: func() (Intent, error) {
return NewLobbyRaceNameRegistrationEligibleIntent(metadata, "user-7", LobbyRaceNameRegistrationEligiblePayload{
GameID: "game-1",
GameName: "Nebula Clash",
RaceName: "Skylancer",
EligibleUntilMs: 1775208100000,
})
},
notificationType: NotificationTypeLobbyRaceNameRegistrationEligible,
producer: ProducerGameLobby,
audienceKind: AudienceKindUser,
recipientUserIDs: []string{"user-7"},
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","race_name":"Skylancer","eligible_until_ms":1775208100000}`,
},
{
name: "lobby race name registered",
build: func() (Intent, error) {
return NewLobbyRaceNameRegisteredIntent(metadata, "user-8", LobbyRaceNameRegisteredPayload{
RaceName: "Skylancer",
})
},
notificationType: NotificationTypeLobbyRaceNameRegistered,
producer: ProducerGameLobby,
audienceKind: AudienceKindUser,
recipientUserIDs: []string{"user-8"},
payloadJSON: `{"race_name":"Skylancer"}`,
},
{
name: "lobby race name registration denied",
build: func() (Intent, error) {
return NewLobbyRaceNameRegistrationDeniedIntent(metadata, "user-9", LobbyRaceNameRegistrationDeniedPayload{
GameID: "game-1",
GameName: "Nebula Clash",
RaceName: "Skylancer",
Reason: "capability_not_met",
})
},
notificationType: NotificationTypeLobbyRaceNameRegistrationDenied,
producer: ProducerGameLobby,
audienceKind: AudienceKindUser,
recipientUserIDs: []string{"user-9"},
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","race_name":"Skylancer","reason":"capability_not_met"}`,
},
{
name: "runtime image pull failed",
build: func() (Intent, error) {
return NewRuntimeImagePullFailedIntent(metadata, RuntimeImagePullFailedPayload{
GameID: "game-1",
ImageRef: "galaxy/game:1.4.7",
ErrorCode: "image_pull_failed",
ErrorMessage: "manifest unknown",
AttemptedAtMs: 1775121700000,
})
},
notificationType: NotificationTypeRuntimeImagePullFailed,
producer: ProducerRuntimeManager,
audienceKind: AudienceKindAdminEmail,
payloadJSON: `{"game_id":"game-1","image_ref":"galaxy/game:1.4.7","error_code":"image_pull_failed","error_message":"manifest unknown","attempted_at_ms":1775121700000}`,
},
{
name: "runtime container start failed",
build: func() (Intent, error) {
return NewRuntimeContainerStartFailedIntent(metadata, RuntimeContainerStartFailedPayload{
GameID: "game-1",
ImageRef: "galaxy/game:1.4.7",
ErrorCode: "container_start_failed",
ErrorMessage: "OCI runtime create failed",
AttemptedAtMs: 1775121700001,
})
},
notificationType: NotificationTypeRuntimeContainerStartFailed,
producer: ProducerRuntimeManager,
audienceKind: AudienceKindAdminEmail,
payloadJSON: `{"game_id":"game-1","image_ref":"galaxy/game:1.4.7","error_code":"container_start_failed","error_message":"OCI runtime create failed","attempted_at_ms":1775121700001}`,
},
{
name: "runtime start config invalid",
build: func() (Intent, error) {
return NewRuntimeStartConfigInvalidIntent(metadata, RuntimeStartConfigInvalidPayload{
GameID: "game-1",
ImageRef: "galaxy/game:1.4.7",
ErrorCode: "start_config_invalid",
ErrorMessage: "docker network galaxy-net not found",
AttemptedAtMs: 1775121700002,
})
},
notificationType: NotificationTypeRuntimeStartConfigInvalid,
producer: ProducerRuntimeManager,
audienceKind: AudienceKindAdminEmail,
payloadJSON: `{"game_id":"game-1","image_ref":"galaxy/game:1.4.7","error_code":"start_config_invalid","error_message":"docker network galaxy-net not found","attempted_at_ms":1775121700002}`,
},
}
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")
_, err = NewRuntimeImagePullFailedIntent(defaultMetadata(), RuntimeImagePullFailedPayload{
GameID: "game-1",
ImageRef: "galaxy/game:1.4.7",
ErrorCode: "",
ErrorMessage: "manifest unknown",
AttemptedAtMs: 1775121700000,
})
require.Error(t, err)
require.Contains(t, err.Error(), "payload_json.error_code must not be empty")
_, err = NewRuntimeContainerStartFailedIntent(defaultMetadata(), RuntimeContainerStartFailedPayload{
GameID: "game-1",
ImageRef: "galaxy/game:1.4.7",
ErrorCode: "container_start_failed",
ErrorMessage: "OCI runtime create failed",
AttemptedAtMs: 0,
})
require.Error(t, err)
require.Contains(t, err.Error(), "payload_json.attempted_at_ms 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),
}
}
-283
View File
@@ -1,283 +0,0 @@
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"`
}
// LobbyMembershipBlockedPayload stores the normalized payload for
// `lobby.membership.blocked` published by the user-lifecycle cascade
// when an active membership is blocked because the underlying user was
// permanently blocked or deleted.
type LobbyMembershipBlockedPayload struct {
GameID string `json:"game_id"`
GameName string `json:"game_name"`
MembershipUserID string `json:"membership_user_id"`
MembershipUserName string `json:"membership_user_name"`
// Reason captures the upstream lifecycle event that triggered the
// cascade. Frozen vocabulary: `permanent_blocked`, `deleted`.
Reason string `json:"reason"`
}
// 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"`
}
// LobbyRaceNameRegistrationEligiblePayload stores the normalized payload
// for `lobby.race_name.registration_eligible`.
type LobbyRaceNameRegistrationEligiblePayload struct {
GameID string `json:"game_id"`
GameName string `json:"game_name"`
RaceName string `json:"race_name"`
EligibleUntilMs int64 `json:"eligible_until_ms"`
}
// LobbyRaceNameRegisteredPayload stores the normalized payload for
// `lobby.race_name.registered`.
type LobbyRaceNameRegisteredPayload struct {
RaceName string `json:"race_name"`
}
// LobbyRaceNameRegistrationDeniedPayload stores the normalized payload for
// `lobby.race_name.registration_denied`.
type LobbyRaceNameRegistrationDeniedPayload struct {
GameID string `json:"game_id"`
GameName string `json:"game_name"`
RaceName string `json:"race_name"`
Reason string `json:"reason"`
}
// RuntimeImagePullFailedPayload stores the normalized payload for
// `runtime.image_pull_failed`. AttemptedAtMs carries Unix milliseconds in
// UTC of the failed pull attempt.
type RuntimeImagePullFailedPayload struct {
GameID string `json:"game_id"`
ImageRef string `json:"image_ref"`
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
AttemptedAtMs int64 `json:"attempted_at_ms"`
}
// RuntimeContainerStartFailedPayload stores the normalized payload for
// `runtime.container_start_failed`. AttemptedAtMs carries Unix milliseconds
// in UTC of the failed start attempt.
type RuntimeContainerStartFailedPayload struct {
GameID string `json:"game_id"`
ImageRef string `json:"image_ref"`
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
AttemptedAtMs int64 `json:"attempted_at_ms"`
}
// RuntimeStartConfigInvalidPayload stores the normalized payload for
// `runtime.start_config_invalid`. AttemptedAtMs carries Unix milliseconds
// in UTC of the rejected start attempt.
type RuntimeStartConfigInvalidPayload struct {
GameID string `json:"game_id"`
ImageRef string `json:"image_ref"`
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
AttemptedAtMs int64 `json:"attempted_at_ms"`
}
// 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)
}
// NewLobbyMembershipBlockedIntent builds the private-game owner intent
// published by Game Lobby when an active membership is blocked by the
// user-lifecycle cascade. ownerUserID is the recipient (private-game
// owner whose roster lost the affected member).
func NewLobbyMembershipBlockedIntent(metadata Metadata, ownerUserID string, payload LobbyMembershipBlockedPayload) (Intent, error) {
return newIntent(NotificationTypeLobbyMembershipBlocked, ProducerGameLobby, AudienceKindUser, []string{ownerUserID}, 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)
}
// NewLobbyRaceNameRegistrationEligibleIntent builds the capable-member intent
// published by Game Lobby at game finish when a reservation is promoted to
// `pending_registration`.
func NewLobbyRaceNameRegistrationEligibleIntent(metadata Metadata, recipientUserID string, payload LobbyRaceNameRegistrationEligiblePayload) (Intent, error) {
return newIntent(NotificationTypeLobbyRaceNameRegistrationEligible, ProducerGameLobby, AudienceKindUser, []string{recipientUserID}, metadata, payload)
}
// NewLobbyRaceNameRegisteredIntent builds the registering-user intent
// published by Game Lobby on successful `lobby.race_name.register` commit.
func NewLobbyRaceNameRegisteredIntent(metadata Metadata, recipientUserID string, payload LobbyRaceNameRegisteredPayload) (Intent, error) {
return newIntent(NotificationTypeLobbyRaceNameRegistered, ProducerGameLobby, AudienceKindUser, []string{recipientUserID}, metadata, payload)
}
// NewLobbyRaceNameRegistrationDeniedIntent builds the incapable-member intent
// published by Game Lobby at game finish when a reservation is released
// without a pending-registration window.
func NewLobbyRaceNameRegistrationDeniedIntent(metadata Metadata, recipientUserID string, payload LobbyRaceNameRegistrationDeniedPayload) (Intent, error) {
return newIntent(NotificationTypeLobbyRaceNameRegistrationDenied, ProducerGameLobby, AudienceKindUser, []string{recipientUserID}, metadata, payload)
}
// NewRuntimeImagePullFailedIntent builds the administrator-email intent
// published by Runtime Manager when a start operation fails because the
// engine image cannot be pulled.
func NewRuntimeImagePullFailedIntent(metadata Metadata, payload RuntimeImagePullFailedPayload) (Intent, error) {
return newIntent(NotificationTypeRuntimeImagePullFailed, ProducerRuntimeManager, AudienceKindAdminEmail, nil, metadata, payload)
}
// NewRuntimeContainerStartFailedIntent builds the administrator-email
// intent published by Runtime Manager when a start operation fails because
// `docker create` or `docker start` returns an error.
func NewRuntimeContainerStartFailedIntent(metadata Metadata, payload RuntimeContainerStartFailedPayload) (Intent, error) {
return newIntent(NotificationTypeRuntimeContainerStartFailed, ProducerRuntimeManager, AudienceKindAdminEmail, nil, metadata, payload)
}
// NewRuntimeStartConfigInvalidIntent builds the administrator-email intent
// published by Runtime Manager when start configuration validation rejects
// the request (invalid image reference, missing Docker network, unwritable
// state directory).
func NewRuntimeStartConfigInvalidIntent(metadata Metadata, payload RuntimeStartConfigInvalidPayload) (Intent, error) {
return newIntent(NotificationTypeRuntimeStartConfigInvalid, ProducerRuntimeManager, AudienceKindAdminEmail, nil, metadata, payload)
}
-73
View File
@@ -1,73 +0,0 @@
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
}
-44
View File
@@ -1,44 +0,0 @@
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])
}
+2 -3
View File
@@ -26,9 +26,8 @@ const (
)
// Config stores the connection and pool tuning used to open a primary plus
// zero-or-more replica `*sql.DB` instances. Stage 1 wires only the primary;
// the replica list is preserved so future read-routing is a non-breaking
// change.
// zero-or-more replica `*sql.DB` instances. The replica list is preserved
// so future read-routing is a non-breaking change.
type Config struct {
// PrimaryDSN stores the DSN used by the primary connection. Required.
PrimaryDSN string
+2 -2
View File
@@ -25,8 +25,8 @@ const (
)
// Config stores the connection settings for one master plus zero-or-more
// replica Redis instances. Stage 1 wires only the master; the replica list is
// preserved so future read-routing is a non-breaking change.
// replica Redis instances. The replica list is preserved so future read-routing
// is a non-breaking change.
type Config struct {
// MasterAddr stores the Redis network address in host:port form. Required.
MasterAddr string