feat: notification service

This commit is contained in:
Ilia Denisov
2026-04-22 08:49:45 +02:00
committed by GitHub
parent 5b7593e6f6
commit 32dc29359a
135 changed files with 21828 additions and 130 deletions
+24
View File
@@ -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
)
+31
View File
@@ -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=
+872
View File
@@ -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
}
+298
View File
@@ -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),
}
}
+162
View File
@@ -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)
}
+73
View File
@@ -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
}
+44
View File
@@ -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])
}
+38
View File
@@ -0,0 +1,38 @@
// notification contains shared FlatBuffers payloads published by
// Notification Service toward the gateway client event stream.
namespace notification;
table GameTurnReadyEvent {
game_id:string;
turn_number:int64;
}
table GameFinishedEvent {
game_id:string;
final_turn_number:int64;
}
table LobbyApplicationSubmittedEvent {
game_id:string;
applicant_user_id:string;
}
table LobbyMembershipApprovedEvent {
game_id:string;
}
table LobbyMembershipRejectedEvent {
game_id:string;
}
table LobbyInviteCreatedEvent {
game_id:string;
inviter_user_id:string;
}
table LobbyInviteRedeemedEvent {
game_id:string;
invitee_user_id:string;
}
root_type GameTurnReadyEvent;
@@ -0,0 +1,75 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package notification
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type GameFinishedEvent struct {
_tab flatbuffers.Table
}
func GetRootAsGameFinishedEvent(buf []byte, offset flatbuffers.UOffsetT) *GameFinishedEvent {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &GameFinishedEvent{}
x.Init(buf, n+offset)
return x
}
func FinishGameFinishedEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsGameFinishedEvent(buf []byte, offset flatbuffers.UOffsetT) *GameFinishedEvent {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &GameFinishedEvent{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedGameFinishedEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *GameFinishedEvent) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *GameFinishedEvent) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *GameFinishedEvent) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *GameFinishedEvent) FinalTurnNumber() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *GameFinishedEvent) MutateFinalTurnNumber(n int64) bool {
return rcv._tab.MutateInt64Slot(6, n)
}
func GameFinishedEventStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func GameFinishedEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func GameFinishedEventAddFinalTurnNumber(builder *flatbuffers.Builder, finalTurnNumber int64) {
builder.PrependInt64Slot(1, finalTurnNumber, 0)
}
func GameFinishedEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -0,0 +1,75 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package notification
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type GameTurnReadyEvent struct {
_tab flatbuffers.Table
}
func GetRootAsGameTurnReadyEvent(buf []byte, offset flatbuffers.UOffsetT) *GameTurnReadyEvent {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &GameTurnReadyEvent{}
x.Init(buf, n+offset)
return x
}
func FinishGameTurnReadyEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsGameTurnReadyEvent(buf []byte, offset flatbuffers.UOffsetT) *GameTurnReadyEvent {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &GameTurnReadyEvent{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedGameTurnReadyEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *GameTurnReadyEvent) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *GameTurnReadyEvent) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *GameTurnReadyEvent) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *GameTurnReadyEvent) TurnNumber() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *GameTurnReadyEvent) MutateTurnNumber(n int64) bool {
return rcv._tab.MutateInt64Slot(6, n)
}
func GameTurnReadyEventStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func GameTurnReadyEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func GameTurnReadyEventAddTurnNumber(builder *flatbuffers.Builder, turnNumber int64) {
builder.PrependInt64Slot(1, turnNumber, 0)
}
func GameTurnReadyEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -0,0 +1,71 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package notification
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type LobbyApplicationSubmittedEvent struct {
_tab flatbuffers.Table
}
func GetRootAsLobbyApplicationSubmittedEvent(buf []byte, offset flatbuffers.UOffsetT) *LobbyApplicationSubmittedEvent {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &LobbyApplicationSubmittedEvent{}
x.Init(buf, n+offset)
return x
}
func FinishLobbyApplicationSubmittedEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsLobbyApplicationSubmittedEvent(buf []byte, offset flatbuffers.UOffsetT) *LobbyApplicationSubmittedEvent {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &LobbyApplicationSubmittedEvent{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedLobbyApplicationSubmittedEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *LobbyApplicationSubmittedEvent) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *LobbyApplicationSubmittedEvent) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *LobbyApplicationSubmittedEvent) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *LobbyApplicationSubmittedEvent) ApplicantUserId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func LobbyApplicationSubmittedEventStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func LobbyApplicationSubmittedEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func LobbyApplicationSubmittedEventAddApplicantUserId(builder *flatbuffers.Builder, applicantUserId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(applicantUserId), 0)
}
func LobbyApplicationSubmittedEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -0,0 +1,71 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package notification
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type LobbyInviteCreatedEvent struct {
_tab flatbuffers.Table
}
func GetRootAsLobbyInviteCreatedEvent(buf []byte, offset flatbuffers.UOffsetT) *LobbyInviteCreatedEvent {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &LobbyInviteCreatedEvent{}
x.Init(buf, n+offset)
return x
}
func FinishLobbyInviteCreatedEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsLobbyInviteCreatedEvent(buf []byte, offset flatbuffers.UOffsetT) *LobbyInviteCreatedEvent {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &LobbyInviteCreatedEvent{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedLobbyInviteCreatedEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *LobbyInviteCreatedEvent) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *LobbyInviteCreatedEvent) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *LobbyInviteCreatedEvent) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *LobbyInviteCreatedEvent) InviterUserId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func LobbyInviteCreatedEventStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func LobbyInviteCreatedEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func LobbyInviteCreatedEventAddInviterUserId(builder *flatbuffers.Builder, inviterUserId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(inviterUserId), 0)
}
func LobbyInviteCreatedEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -0,0 +1,71 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package notification
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type LobbyInviteRedeemedEvent struct {
_tab flatbuffers.Table
}
func GetRootAsLobbyInviteRedeemedEvent(buf []byte, offset flatbuffers.UOffsetT) *LobbyInviteRedeemedEvent {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &LobbyInviteRedeemedEvent{}
x.Init(buf, n+offset)
return x
}
func FinishLobbyInviteRedeemedEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsLobbyInviteRedeemedEvent(buf []byte, offset flatbuffers.UOffsetT) *LobbyInviteRedeemedEvent {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &LobbyInviteRedeemedEvent{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedLobbyInviteRedeemedEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *LobbyInviteRedeemedEvent) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *LobbyInviteRedeemedEvent) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *LobbyInviteRedeemedEvent) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *LobbyInviteRedeemedEvent) InviteeUserId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func LobbyInviteRedeemedEventStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func LobbyInviteRedeemedEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func LobbyInviteRedeemedEventAddInviteeUserId(builder *flatbuffers.Builder, inviteeUserId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(inviteeUserId), 0)
}
func LobbyInviteRedeemedEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -0,0 +1,60 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package notification
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type LobbyMembershipApprovedEvent struct {
_tab flatbuffers.Table
}
func GetRootAsLobbyMembershipApprovedEvent(buf []byte, offset flatbuffers.UOffsetT) *LobbyMembershipApprovedEvent {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &LobbyMembershipApprovedEvent{}
x.Init(buf, n+offset)
return x
}
func FinishLobbyMembershipApprovedEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsLobbyMembershipApprovedEvent(buf []byte, offset flatbuffers.UOffsetT) *LobbyMembershipApprovedEvent {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &LobbyMembershipApprovedEvent{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedLobbyMembershipApprovedEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *LobbyMembershipApprovedEvent) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *LobbyMembershipApprovedEvent) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *LobbyMembershipApprovedEvent) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func LobbyMembershipApprovedEventStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func LobbyMembershipApprovedEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func LobbyMembershipApprovedEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -0,0 +1,60 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package notification
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type LobbyMembershipRejectedEvent struct {
_tab flatbuffers.Table
}
func GetRootAsLobbyMembershipRejectedEvent(buf []byte, offset flatbuffers.UOffsetT) *LobbyMembershipRejectedEvent {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &LobbyMembershipRejectedEvent{}
x.Init(buf, n+offset)
return x
}
func FinishLobbyMembershipRejectedEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsLobbyMembershipRejectedEvent(buf []byte, offset flatbuffers.UOffsetT) *LobbyMembershipRejectedEvent {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &LobbyMembershipRejectedEvent{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedLobbyMembershipRejectedEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *LobbyMembershipRejectedEvent) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *LobbyMembershipRejectedEvent) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *LobbyMembershipRejectedEvent) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func LobbyMembershipRejectedEventStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func LobbyMembershipRejectedEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func LobbyMembershipRejectedEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+390
View File
@@ -0,0 +1,390 @@
package transcoder
import (
"errors"
"fmt"
notificationfbs "galaxy/schema/fbs/notification"
flatbuffers "github.com/google/flatbuffers/go"
)
// GameTurnReadyEvent is the independent Go representation of
// `notification.GameTurnReadyEvent`.
type GameTurnReadyEvent struct {
GameID string
TurnNumber int64
}
// GameFinishedEvent is the independent Go representation of
// `notification.GameFinishedEvent`.
type GameFinishedEvent struct {
GameID string
FinalTurnNumber int64
}
// LobbyApplicationSubmittedEvent is the independent Go representation of
// `notification.LobbyApplicationSubmittedEvent`.
type LobbyApplicationSubmittedEvent struct {
GameID string
ApplicantUserID string
}
// LobbyMembershipApprovedEvent is the independent Go representation of
// `notification.LobbyMembershipApprovedEvent`.
type LobbyMembershipApprovedEvent struct {
GameID string
}
// LobbyMembershipRejectedEvent is the independent Go representation of
// `notification.LobbyMembershipRejectedEvent`.
type LobbyMembershipRejectedEvent struct {
GameID string
}
// LobbyInviteCreatedEvent is the independent Go representation of
// `notification.LobbyInviteCreatedEvent`.
type LobbyInviteCreatedEvent struct {
GameID string
InviterUserID string
}
// LobbyInviteRedeemedEvent is the independent Go representation of
// `notification.LobbyInviteRedeemedEvent`.
type LobbyInviteRedeemedEvent struct {
GameID string
InviteeUserID string
}
// GameTurnReadyEventToPayload converts GameTurnReadyEvent to FlatBuffers bytes
// suitable for the authenticated gateway push transport.
func GameTurnReadyEventToPayload(event *GameTurnReadyEvent) ([]byte, error) {
if event == nil {
return nil, errors.New("encode game turn ready payload: event is nil")
}
if event.GameID == "" {
return nil, errors.New("encode game turn ready payload: game_id is empty")
}
builder := flatbuffers.NewBuilder(64)
gameID := builder.CreateString(event.GameID)
notificationfbs.GameTurnReadyEventStart(builder)
notificationfbs.GameTurnReadyEventAddGameId(builder, gameID)
notificationfbs.GameTurnReadyEventAddTurnNumber(builder, event.TurnNumber)
offset := notificationfbs.GameTurnReadyEventEnd(builder)
notificationfbs.FinishGameTurnReadyEventBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToGameTurnReadyEvent converts FlatBuffers payload bytes into
// GameTurnReadyEvent.
func PayloadToGameTurnReadyEvent(data []byte) (result *GameTurnReadyEvent, err error) {
if len(data) == 0 {
return nil, errors.New("decode game turn ready payload: data is empty")
}
defer recoverNotificationDecodePanic("decode game turn ready payload", &result, &err)
event := notificationfbs.GetRootAsGameTurnReadyEvent(data, 0)
gameID, err := requiredNotificationString(event.GameId(), "game_id")
if err != nil {
return nil, fmt.Errorf("decode game turn ready payload: %w", err)
}
return &GameTurnReadyEvent{
GameID: gameID,
TurnNumber: event.TurnNumber(),
}, nil
}
// GameFinishedEventToPayload converts GameFinishedEvent to FlatBuffers bytes
// suitable for the authenticated gateway push transport.
func GameFinishedEventToPayload(event *GameFinishedEvent) ([]byte, error) {
if event == nil {
return nil, errors.New("encode game finished payload: event is nil")
}
if event.GameID == "" {
return nil, errors.New("encode game finished payload: game_id is empty")
}
builder := flatbuffers.NewBuilder(64)
gameID := builder.CreateString(event.GameID)
notificationfbs.GameFinishedEventStart(builder)
notificationfbs.GameFinishedEventAddGameId(builder, gameID)
notificationfbs.GameFinishedEventAddFinalTurnNumber(builder, event.FinalTurnNumber)
offset := notificationfbs.GameFinishedEventEnd(builder)
notificationfbs.FinishGameFinishedEventBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToGameFinishedEvent converts FlatBuffers payload bytes into
// GameFinishedEvent.
func PayloadToGameFinishedEvent(data []byte) (result *GameFinishedEvent, err error) {
if len(data) == 0 {
return nil, errors.New("decode game finished payload: data is empty")
}
defer recoverNotificationDecodePanic("decode game finished payload", &result, &err)
event := notificationfbs.GetRootAsGameFinishedEvent(data, 0)
gameID, err := requiredNotificationString(event.GameId(), "game_id")
if err != nil {
return nil, fmt.Errorf("decode game finished payload: %w", err)
}
return &GameFinishedEvent{
GameID: gameID,
FinalTurnNumber: event.FinalTurnNumber(),
}, nil
}
// LobbyApplicationSubmittedEventToPayload converts
// LobbyApplicationSubmittedEvent to FlatBuffers bytes suitable for the
// authenticated gateway push transport.
func LobbyApplicationSubmittedEventToPayload(event *LobbyApplicationSubmittedEvent) ([]byte, error) {
if event == nil {
return nil, errors.New("encode lobby application submitted payload: event is nil")
}
if event.GameID == "" {
return nil, errors.New("encode lobby application submitted payload: game_id is empty")
}
if event.ApplicantUserID == "" {
return nil, errors.New("encode lobby application submitted payload: applicant_user_id is empty")
}
builder := flatbuffers.NewBuilder(96)
gameID := builder.CreateString(event.GameID)
applicantUserID := builder.CreateString(event.ApplicantUserID)
notificationfbs.LobbyApplicationSubmittedEventStart(builder)
notificationfbs.LobbyApplicationSubmittedEventAddGameId(builder, gameID)
notificationfbs.LobbyApplicationSubmittedEventAddApplicantUserId(builder, applicantUserID)
offset := notificationfbs.LobbyApplicationSubmittedEventEnd(builder)
notificationfbs.FinishLobbyApplicationSubmittedEventBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToLobbyApplicationSubmittedEvent converts FlatBuffers payload bytes
// into LobbyApplicationSubmittedEvent.
func PayloadToLobbyApplicationSubmittedEvent(data []byte) (result *LobbyApplicationSubmittedEvent, err error) {
if len(data) == 0 {
return nil, errors.New("decode lobby application submitted payload: data is empty")
}
defer recoverNotificationDecodePanic("decode lobby application submitted payload", &result, &err)
event := notificationfbs.GetRootAsLobbyApplicationSubmittedEvent(data, 0)
gameID, err := requiredNotificationString(event.GameId(), "game_id")
if err != nil {
return nil, fmt.Errorf("decode lobby application submitted payload: %w", err)
}
applicantUserID, err := requiredNotificationString(event.ApplicantUserId(), "applicant_user_id")
if err != nil {
return nil, fmt.Errorf("decode lobby application submitted payload: %w", err)
}
return &LobbyApplicationSubmittedEvent{
GameID: gameID,
ApplicantUserID: applicantUserID,
}, nil
}
// LobbyMembershipApprovedEventToPayload converts LobbyMembershipApprovedEvent
// to FlatBuffers bytes suitable for the authenticated gateway push transport.
func LobbyMembershipApprovedEventToPayload(event *LobbyMembershipApprovedEvent) ([]byte, error) {
if event == nil {
return nil, errors.New("encode lobby membership approved payload: event is nil")
}
if event.GameID == "" {
return nil, errors.New("encode lobby membership approved payload: game_id is empty")
}
builder := flatbuffers.NewBuilder(48)
gameID := builder.CreateString(event.GameID)
notificationfbs.LobbyMembershipApprovedEventStart(builder)
notificationfbs.LobbyMembershipApprovedEventAddGameId(builder, gameID)
offset := notificationfbs.LobbyMembershipApprovedEventEnd(builder)
notificationfbs.FinishLobbyMembershipApprovedEventBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToLobbyMembershipApprovedEvent converts FlatBuffers payload bytes
// into LobbyMembershipApprovedEvent.
func PayloadToLobbyMembershipApprovedEvent(data []byte) (result *LobbyMembershipApprovedEvent, err error) {
if len(data) == 0 {
return nil, errors.New("decode lobby membership approved payload: data is empty")
}
defer recoverNotificationDecodePanic("decode lobby membership approved payload", &result, &err)
event := notificationfbs.GetRootAsLobbyMembershipApprovedEvent(data, 0)
gameID, err := requiredNotificationString(event.GameId(), "game_id")
if err != nil {
return nil, fmt.Errorf("decode lobby membership approved payload: %w", err)
}
return &LobbyMembershipApprovedEvent{GameID: gameID}, nil
}
// LobbyMembershipRejectedEventToPayload converts LobbyMembershipRejectedEvent
// to FlatBuffers bytes suitable for the authenticated gateway push transport.
func LobbyMembershipRejectedEventToPayload(event *LobbyMembershipRejectedEvent) ([]byte, error) {
if event == nil {
return nil, errors.New("encode lobby membership rejected payload: event is nil")
}
if event.GameID == "" {
return nil, errors.New("encode lobby membership rejected payload: game_id is empty")
}
builder := flatbuffers.NewBuilder(48)
gameID := builder.CreateString(event.GameID)
notificationfbs.LobbyMembershipRejectedEventStart(builder)
notificationfbs.LobbyMembershipRejectedEventAddGameId(builder, gameID)
offset := notificationfbs.LobbyMembershipRejectedEventEnd(builder)
notificationfbs.FinishLobbyMembershipRejectedEventBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToLobbyMembershipRejectedEvent converts FlatBuffers payload bytes
// into LobbyMembershipRejectedEvent.
func PayloadToLobbyMembershipRejectedEvent(data []byte) (result *LobbyMembershipRejectedEvent, err error) {
if len(data) == 0 {
return nil, errors.New("decode lobby membership rejected payload: data is empty")
}
defer recoverNotificationDecodePanic("decode lobby membership rejected payload", &result, &err)
event := notificationfbs.GetRootAsLobbyMembershipRejectedEvent(data, 0)
gameID, err := requiredNotificationString(event.GameId(), "game_id")
if err != nil {
return nil, fmt.Errorf("decode lobby membership rejected payload: %w", err)
}
return &LobbyMembershipRejectedEvent{GameID: gameID}, nil
}
// LobbyInviteCreatedEventToPayload converts LobbyInviteCreatedEvent to
// FlatBuffers bytes suitable for the authenticated gateway push transport.
func LobbyInviteCreatedEventToPayload(event *LobbyInviteCreatedEvent) ([]byte, error) {
if event == nil {
return nil, errors.New("encode lobby invite created payload: event is nil")
}
if event.GameID == "" {
return nil, errors.New("encode lobby invite created payload: game_id is empty")
}
if event.InviterUserID == "" {
return nil, errors.New("encode lobby invite created payload: inviter_user_id is empty")
}
builder := flatbuffers.NewBuilder(96)
gameID := builder.CreateString(event.GameID)
inviterUserID := builder.CreateString(event.InviterUserID)
notificationfbs.LobbyInviteCreatedEventStart(builder)
notificationfbs.LobbyInviteCreatedEventAddGameId(builder, gameID)
notificationfbs.LobbyInviteCreatedEventAddInviterUserId(builder, inviterUserID)
offset := notificationfbs.LobbyInviteCreatedEventEnd(builder)
notificationfbs.FinishLobbyInviteCreatedEventBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToLobbyInviteCreatedEvent converts FlatBuffers payload bytes into
// LobbyInviteCreatedEvent.
func PayloadToLobbyInviteCreatedEvent(data []byte) (result *LobbyInviteCreatedEvent, err error) {
if len(data) == 0 {
return nil, errors.New("decode lobby invite created payload: data is empty")
}
defer recoverNotificationDecodePanic("decode lobby invite created payload", &result, &err)
event := notificationfbs.GetRootAsLobbyInviteCreatedEvent(data, 0)
gameID, err := requiredNotificationString(event.GameId(), "game_id")
if err != nil {
return nil, fmt.Errorf("decode lobby invite created payload: %w", err)
}
inviterUserID, err := requiredNotificationString(event.InviterUserId(), "inviter_user_id")
if err != nil {
return nil, fmt.Errorf("decode lobby invite created payload: %w", err)
}
return &LobbyInviteCreatedEvent{
GameID: gameID,
InviterUserID: inviterUserID,
}, nil
}
// LobbyInviteRedeemedEventToPayload converts LobbyInviteRedeemedEvent to
// FlatBuffers bytes suitable for the authenticated gateway push transport.
func LobbyInviteRedeemedEventToPayload(event *LobbyInviteRedeemedEvent) ([]byte, error) {
if event == nil {
return nil, errors.New("encode lobby invite redeemed payload: event is nil")
}
if event.GameID == "" {
return nil, errors.New("encode lobby invite redeemed payload: game_id is empty")
}
if event.InviteeUserID == "" {
return nil, errors.New("encode lobby invite redeemed payload: invitee_user_id is empty")
}
builder := flatbuffers.NewBuilder(96)
gameID := builder.CreateString(event.GameID)
inviteeUserID := builder.CreateString(event.InviteeUserID)
notificationfbs.LobbyInviteRedeemedEventStart(builder)
notificationfbs.LobbyInviteRedeemedEventAddGameId(builder, gameID)
notificationfbs.LobbyInviteRedeemedEventAddInviteeUserId(builder, inviteeUserID)
offset := notificationfbs.LobbyInviteRedeemedEventEnd(builder)
notificationfbs.FinishLobbyInviteRedeemedEventBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToLobbyInviteRedeemedEvent converts FlatBuffers payload bytes into
// LobbyInviteRedeemedEvent.
func PayloadToLobbyInviteRedeemedEvent(data []byte) (result *LobbyInviteRedeemedEvent, err error) {
if len(data) == 0 {
return nil, errors.New("decode lobby invite redeemed payload: data is empty")
}
defer recoverNotificationDecodePanic("decode lobby invite redeemed payload", &result, &err)
event := notificationfbs.GetRootAsLobbyInviteRedeemedEvent(data, 0)
gameID, err := requiredNotificationString(event.GameId(), "game_id")
if err != nil {
return nil, fmt.Errorf("decode lobby invite redeemed payload: %w", err)
}
inviteeUserID, err := requiredNotificationString(event.InviteeUserId(), "invitee_user_id")
if err != nil {
return nil, fmt.Errorf("decode lobby invite redeemed payload: %w", err)
}
return &LobbyInviteRedeemedEvent{
GameID: gameID,
InviteeUserID: inviteeUserID,
}, nil
}
func requiredNotificationString(value []byte, field string) (string, error) {
if len(value) == 0 {
return "", fmt.Errorf("%s is missing", field)
}
return string(value), nil
}
func recoverNotificationDecodePanic[T any](message string, result **T, err *error) {
if recovered := recover(); recovered != nil {
*result = nil
*err = fmt.Errorf("%s: panic recovered: %v", message, recovered)
}
}
+387
View File
@@ -0,0 +1,387 @@
package transcoder
import (
"reflect"
"strings"
"testing"
notificationfbs "galaxy/schema/fbs/notification"
flatbuffers "github.com/google/flatbuffers/go"
)
func TestNotificationPayloadRoundTrips(t *testing.T) {
t.Parallel()
tests := []struct {
name string
source any
encode func(any) ([]byte, error)
decode func([]byte) (any, error)
}{
{
name: "game turn ready",
source: &GameTurnReadyEvent{GameID: "game-1", TurnNumber: 54},
encode: func(value any) ([]byte, error) { return GameTurnReadyEventToPayload(value.(*GameTurnReadyEvent)) },
decode: func(data []byte) (any, error) { return PayloadToGameTurnReadyEvent(data) },
},
{
name: "game finished",
source: &GameFinishedEvent{GameID: "game-2", FinalTurnNumber: 99},
encode: func(value any) ([]byte, error) { return GameFinishedEventToPayload(value.(*GameFinishedEvent)) },
decode: func(data []byte) (any, error) { return PayloadToGameFinishedEvent(data) },
},
{
name: "lobby application submitted",
source: &LobbyApplicationSubmittedEvent{GameID: "game-3", ApplicantUserID: "user-7"},
encode: func(value any) ([]byte, error) {
return LobbyApplicationSubmittedEventToPayload(value.(*LobbyApplicationSubmittedEvent))
},
decode: func(data []byte) (any, error) { return PayloadToLobbyApplicationSubmittedEvent(data) },
},
{
name: "lobby membership approved",
source: &LobbyMembershipApprovedEvent{GameID: "game-4"},
encode: func(value any) ([]byte, error) {
return LobbyMembershipApprovedEventToPayload(value.(*LobbyMembershipApprovedEvent))
},
decode: func(data []byte) (any, error) { return PayloadToLobbyMembershipApprovedEvent(data) },
},
{
name: "lobby membership rejected",
source: &LobbyMembershipRejectedEvent{GameID: "game-5"},
encode: func(value any) ([]byte, error) {
return LobbyMembershipRejectedEventToPayload(value.(*LobbyMembershipRejectedEvent))
},
decode: func(data []byte) (any, error) { return PayloadToLobbyMembershipRejectedEvent(data) },
},
{
name: "lobby invite created",
source: &LobbyInviteCreatedEvent{GameID: "game-6", InviterUserID: "user-8"},
encode: func(value any) ([]byte, error) {
return LobbyInviteCreatedEventToPayload(value.(*LobbyInviteCreatedEvent))
},
decode: func(data []byte) (any, error) { return PayloadToLobbyInviteCreatedEvent(data) },
},
{
name: "lobby invite redeemed",
source: &LobbyInviteRedeemedEvent{GameID: "game-7", InviteeUserID: "user-9"},
encode: func(value any) ([]byte, error) {
return LobbyInviteRedeemedEventToPayload(value.(*LobbyInviteRedeemedEvent))
},
decode: func(data []byte) (any, error) { return PayloadToLobbyInviteRedeemedEvent(data) },
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
payload, err := tt.encode(tt.source)
if err != nil {
t.Fatalf("encode payload: %v", err)
}
decoded, err := tt.decode(payload)
if err != nil {
t.Fatalf("decode payload: %v", err)
}
if !reflect.DeepEqual(tt.source, decoded) {
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", tt.source, decoded)
}
})
}
}
func TestNotificationPayloadEncodersRejectNilInputs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
call func() error
}{
{
name: "game turn ready",
call: func() error {
_, err := GameTurnReadyEventToPayload(nil)
return err
},
},
{
name: "game finished",
call: func() error {
_, err := GameFinishedEventToPayload(nil)
return err
},
},
{
name: "lobby application submitted",
call: func() error {
_, err := LobbyApplicationSubmittedEventToPayload(nil)
return err
},
},
{
name: "lobby membership approved",
call: func() error {
_, err := LobbyMembershipApprovedEventToPayload(nil)
return err
},
},
{
name: "lobby membership rejected",
call: func() error {
_, err := LobbyMembershipRejectedEventToPayload(nil)
return err
},
},
{
name: "lobby invite created",
call: func() error {
_, err := LobbyInviteCreatedEventToPayload(nil)
return err
},
},
{
name: "lobby invite redeemed",
call: func() error {
_, err := LobbyInviteRedeemedEventToPayload(nil)
return err
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := tt.call(); err == nil {
t.Fatal("expected error")
}
})
}
}
func TestNotificationPayloadDecodersRejectEmptyPayloads(t *testing.T) {
t.Parallel()
tests := []struct {
name string
call func() error
}{
{
name: "game turn ready",
call: func() error {
_, err := PayloadToGameTurnReadyEvent(nil)
return err
},
},
{
name: "game finished",
call: func() error {
_, err := PayloadToGameFinishedEvent(nil)
return err
},
},
{
name: "lobby application submitted",
call: func() error {
_, err := PayloadToLobbyApplicationSubmittedEvent(nil)
return err
},
},
{
name: "lobby membership approved",
call: func() error {
_, err := PayloadToLobbyMembershipApprovedEvent(nil)
return err
},
},
{
name: "lobby membership rejected",
call: func() error {
_, err := PayloadToLobbyMembershipRejectedEvent(nil)
return err
},
},
{
name: "lobby invite created",
call: func() error {
_, err := PayloadToLobbyInviteCreatedEvent(nil)
return err
},
},
{
name: "lobby invite redeemed",
call: func() error {
_, err := PayloadToLobbyInviteRedeemedEvent(nil)
return err
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := tt.call(); err == nil {
t.Fatal("expected error")
}
})
}
}
func TestNotificationPayloadEncodersRejectMissingRequiredStrings(t *testing.T) {
t.Parallel()
tests := []struct {
name string
call func() error
want string
}{
{
name: "game turn ready",
call: func() error {
_, err := GameTurnReadyEventToPayload(&GameTurnReadyEvent{})
return err
},
want: "game_id is empty",
},
{
name: "lobby application submitted",
call: func() error {
_, err := LobbyApplicationSubmittedEventToPayload(&LobbyApplicationSubmittedEvent{GameID: "game-1"})
return err
},
want: "applicant_user_id is empty",
},
{
name: "lobby invite created",
call: func() error {
_, err := LobbyInviteCreatedEventToPayload(&LobbyInviteCreatedEvent{GameID: "game-1"})
return err
},
want: "inviter_user_id is empty",
},
{
name: "lobby invite redeemed",
call: func() error {
_, err := LobbyInviteRedeemedEventToPayload(&LobbyInviteRedeemedEvent{GameID: "game-1"})
return err
},
want: "invitee_user_id is empty",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.call()
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestNotificationPayloadDecodersRejectMissingRequiredStrings(t *testing.T) {
t.Parallel()
tests := []struct {
name string
payload func() []byte
decode func([]byte) error
want string
}{
{
name: "game turn ready",
payload: func() []byte {
builder := flatbuffers.NewBuilder(32)
notificationfbs.GameTurnReadyEventStart(builder)
offset := notificationfbs.GameTurnReadyEventEnd(builder)
notificationfbs.FinishGameTurnReadyEventBuffer(builder, offset)
return builder.FinishedBytes()
},
decode: func(data []byte) error {
_, err := PayloadToGameTurnReadyEvent(data)
return err
},
want: "game_id is missing",
},
{
name: "lobby application submitted",
payload: func() []byte {
builder := flatbuffers.NewBuilder(32)
gameID := builder.CreateString("game-1")
notificationfbs.LobbyApplicationSubmittedEventStart(builder)
notificationfbs.LobbyApplicationSubmittedEventAddGameId(builder, gameID)
offset := notificationfbs.LobbyApplicationSubmittedEventEnd(builder)
notificationfbs.FinishLobbyApplicationSubmittedEventBuffer(builder, offset)
return builder.FinishedBytes()
},
decode: func(data []byte) error {
_, err := PayloadToLobbyApplicationSubmittedEvent(data)
return err
},
want: "applicant_user_id is missing",
},
{
name: "lobby invite created",
payload: func() []byte {
builder := flatbuffers.NewBuilder(32)
gameID := builder.CreateString("game-1")
notificationfbs.LobbyInviteCreatedEventStart(builder)
notificationfbs.LobbyInviteCreatedEventAddGameId(builder, gameID)
offset := notificationfbs.LobbyInviteCreatedEventEnd(builder)
notificationfbs.FinishLobbyInviteCreatedEventBuffer(builder, offset)
return builder.FinishedBytes()
},
decode: func(data []byte) error {
_, err := PayloadToLobbyInviteCreatedEvent(data)
return err
},
want: "inviter_user_id is missing",
},
{
name: "lobby invite redeemed",
payload: func() []byte {
builder := flatbuffers.NewBuilder(32)
gameID := builder.CreateString("game-1")
notificationfbs.LobbyInviteRedeemedEventStart(builder)
notificationfbs.LobbyInviteRedeemedEventAddGameId(builder, gameID)
offset := notificationfbs.LobbyInviteRedeemedEventEnd(builder)
notificationfbs.FinishLobbyInviteRedeemedEventBuffer(builder, offset)
return builder.FinishedBytes()
},
decode: func(data []byte) error {
_, err := PayloadToLobbyInviteRedeemedEvent(data)
return err
},
want: "invitee_user_id is missing",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.decode(tt.payload())
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("unexpected error: %v", err)
}
})
}
}