feat: notification service
This commit is contained in:
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user