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
@@ -0,0 +1,946 @@
// Package acceptintent implements durable idempotent acceptance of normalized
// notification intents.
package acceptintent
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
netmail "net/mail"
"strings"
"time"
"galaxy/notification/internal/api/intentstream"
"galaxy/notification/internal/config"
"galaxy/notification/internal/logging"
)
var (
// ErrConflict reports that an idempotency scope already exists for
// different normalized content.
ErrConflict = errors.New("accept intent conflict")
// ErrRecipientNotFound reports that at least one user-targeted recipient
// does not exist in the trusted User Service directory.
ErrRecipientNotFound = errors.New("accept intent recipient not found")
// ErrServiceUnavailable reports that durable acceptance could not be
// completed or recovered safely.
ErrServiceUnavailable = errors.New("accept intent service unavailable")
)
// Outcome identifies the coarse intent-acceptance outcome.
type Outcome string
const (
// OutcomeAccepted reports that the intent was durably accepted into local
// notification state.
OutcomeAccepted Outcome = "accepted"
// OutcomeDuplicate reports that the intent matched already accepted
// normalized content and therefore became a replay no-op.
OutcomeDuplicate Outcome = "duplicate"
)
// RouteStatus identifies one stable notification-route state.
type RouteStatus string
const (
// RouteStatusPending reports that the route is ready for first publication.
RouteStatusPending RouteStatus = "pending"
// RouteStatusPublished reports that the route was durably handed off.
RouteStatusPublished RouteStatus = "published"
// RouteStatusFailed reports that the last publish attempt failed and a
// retry is scheduled.
RouteStatusFailed RouteStatus = "failed"
// RouteStatusDeadLetter reports that the route exhausted its retry budget.
RouteStatusDeadLetter RouteStatus = "dead_letter"
// RouteStatusSkipped reports that the route slot was durably materialized
// but intentionally not emitted.
RouteStatusSkipped RouteStatus = "skipped"
)
// Result stores the coarse outcome of one intent-acceptance attempt.
type Result struct {
// Outcome stores the stable intent-acceptance outcome.
Outcome Outcome
}
// NotificationRecord stores the primary durable notification record accepted
// from one normalized intent.
type NotificationRecord struct {
// NotificationID stores the stable notification identifier.
NotificationID string
// NotificationType stores the frozen notification vocabulary value.
NotificationType intentstream.NotificationType
// Producer stores the frozen producer identifier.
Producer intentstream.Producer
// AudienceKind stores the normalized audience selector.
AudienceKind intentstream.AudienceKind
// RecipientUserIDs stores the normalized recipient user set for
// user-targeted intents.
RecipientUserIDs []string
// PayloadJSON stores the canonical normalized payload JSON string.
PayloadJSON string
// IdempotencyKey stores the producer-owned idempotency key.
IdempotencyKey string
// RequestFingerprint stores the stable normalized request fingerprint.
RequestFingerprint string
// RequestID stores the optional tracing request identifier.
RequestID string
// TraceID stores the optional tracing trace identifier.
TraceID string
// OccurredAt stores when the producer says the event happened.
OccurredAt time.Time
// AcceptedAt stores when Notification Service durably accepted the intent.
AcceptedAt time.Time
// UpdatedAt stores the last notification-record mutation timestamp.
UpdatedAt time.Time
}
// NotificationRoute stores one durable route slot derived from an accepted
// notification.
type NotificationRoute struct {
// NotificationID stores the owning notification identifier.
NotificationID string
// RouteID stores the stable `<channel>:<recipient_ref>` identifier.
RouteID string
// Channel stores the route channel slot.
Channel intentstream.Channel
// RecipientRef stores the stable target slot identifier.
RecipientRef string
// Status stores the current route status.
Status RouteStatus
// AttemptCount stores how many publication attempts already ran.
AttemptCount int
// MaxAttempts stores the total retry budget for Channel.
MaxAttempts int
// NextAttemptAt stores the next scheduled publication time when Status is
// RouteStatusPending or RouteStatusFailed.
NextAttemptAt time.Time
// ResolvedEmail stores the already-known email target when available.
ResolvedEmail string
// ResolvedLocale stores the already-known locale when available.
ResolvedLocale string
// LastErrorClassification stores the optional last classified route error.
LastErrorClassification string
// LastErrorMessage stores the optional last route error message.
LastErrorMessage string
// LastErrorAt stores when the last route error happened.
LastErrorAt time.Time
// CreatedAt stores when the route was materialized.
CreatedAt time.Time
// UpdatedAt stores the last route mutation timestamp.
UpdatedAt time.Time
// PublishedAt stores when the route reached published.
PublishedAt time.Time
// DeadLetteredAt stores when the route reached dead_letter.
DeadLetteredAt time.Time
// SkippedAt stores when the route reached skipped.
SkippedAt time.Time
}
// IdempotencyRecord stores one durable `(producer, idempotency_key)`
// reservation.
type IdempotencyRecord struct {
// Producer stores the owning producer identifier.
Producer intentstream.Producer
// IdempotencyKey stores the producer-owned idempotency key.
IdempotencyKey string
// NotificationID stores the accepted notification identifier.
NotificationID string
// RequestFingerprint stores the stable normalized request fingerprint.
RequestFingerprint string
// CreatedAt stores when the reservation was created.
CreatedAt time.Time
// ExpiresAt stores when the reservation expires.
ExpiresAt time.Time
}
// AcceptInput stores one normalized intent plus its chosen notification
// identifier.
type AcceptInput struct {
// NotificationID stores the stable accepted notification identifier.
NotificationID string
// Intent stores the normalized decoded ingress intent.
Intent intentstream.Intent
}
// CreateAcceptanceInput stores the durable write set required to accept one
// notification intent.
type CreateAcceptanceInput struct {
// Notification stores the accepted notification record.
Notification NotificationRecord
// Routes stores every durable route slot derived from Notification.
Routes []NotificationRoute
// Idempotency stores the idempotency reservation bound to Notification.
Idempotency IdempotencyRecord
}
// Store describes the durable storage required by the intent-acceptance use
// case.
type Store interface {
// CreateAcceptance stores the complete durable write set for one intent
// acceptance attempt. Implementations must wrap ErrConflict when the write
// set races with already accepted state.
CreateAcceptance(context.Context, CreateAcceptanceInput) error
// GetIdempotency loads one existing idempotency reservation.
GetIdempotency(context.Context, intentstream.Producer, string) (IdempotencyRecord, bool, error)
// GetNotification loads one accepted notification by NotificationID.
GetNotification(context.Context, string) (NotificationRecord, bool, error)
}
// UserRecord stores the enrichment data resolved for one recipient user.
type UserRecord struct {
// Email stores the current user email address.
Email string
// PreferredLanguage stores the current user preferred language tag.
PreferredLanguage string
}
// Validate reports whether record contains usable recipient enrichment data.
func (record UserRecord) Validate() error {
if strings.TrimSpace(record.Email) == "" {
return errors.New("user record email must not be empty")
}
if _, err := netmail.ParseAddress(record.Email); err != nil {
return fmt.Errorf("user record email: %w", err)
}
return nil
}
// UserDirectory resolves trusted recipient data from User Service. Missing
// users must wrap ErrRecipientNotFound. Other failures are treated as
// dependency unavailability.
type UserDirectory interface {
// GetUserByID loads one user by stable user identifier.
GetUserByID(context.Context, string) (UserRecord, error)
}
// Telemetry records low-cardinality intent-acceptance and user-enrichment
// outcomes.
type Telemetry interface {
// RecordIntentOutcome records one accepted notification-intent outcome.
RecordIntentOutcome(context.Context, string, string, string, string)
// RecordUserEnrichmentAttempt records one User Service enrichment lookup
// outcome.
RecordUserEnrichmentAttempt(context.Context, string, string)
}
// Clock provides the current wall-clock time.
type Clock interface {
// Now returns the current time.
Now() time.Time
}
type systemClock struct{}
func (systemClock) Now() time.Time {
return time.Now()
}
// Config stores the dependencies and policies used by Service.
type Config struct {
// Store owns the durable accepted state.
Store Store
// UserDirectory resolves recipient email and locale from User Service.
UserDirectory UserDirectory
// Clock provides wall-clock timestamps.
Clock Clock
// Logger writes structured acceptance logs.
Logger *slog.Logger
// Telemetry records low-cardinality acceptance and enrichment outcomes.
Telemetry Telemetry
// PushMaxAttempts stores the retry budget for push routes.
PushMaxAttempts int
// EmailMaxAttempts stores the retry budget for email routes.
EmailMaxAttempts int
// IdempotencyTTL stores how long accepted idempotency scopes remain valid.
IdempotencyTTL time.Duration
// AdminRouting stores the type-specific administrator email lists.
AdminRouting config.AdminRoutingConfig
}
// Service durably accepts normalized notification intents.
type Service struct {
store Store
userDirectory UserDirectory
clock Clock
logger *slog.Logger
telemetry Telemetry
pushMaxAttempts int
emailMaxAttempts int
idempotencyTTL time.Duration
adminRouting config.AdminRoutingConfig
}
// New constructs Service from cfg.
func New(cfg Config) (*Service, error) {
if cfg.Store == nil {
return nil, errors.New("new accept intent service: nil store")
}
if cfg.UserDirectory == nil {
return nil, errors.New("new accept intent service: nil user directory")
}
if cfg.Clock == nil {
cfg.Clock = systemClock{}
}
if cfg.PushMaxAttempts <= 0 {
return nil, errors.New("new accept intent service: push max attempts must be positive")
}
if cfg.EmailMaxAttempts <= 0 {
return nil, errors.New("new accept intent service: email max attempts must be positive")
}
if cfg.IdempotencyTTL <= 0 {
return nil, errors.New("new accept intent service: idempotency ttl must be positive")
}
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
if err := cfg.AdminRouting.Validate(); err != nil {
return nil, fmt.Errorf("new accept intent service: %w", err)
}
return &Service{
store: cfg.Store,
userDirectory: cfg.UserDirectory,
clock: cfg.Clock,
logger: cfg.Logger.With("component", "accept_intent"),
telemetry: cfg.Telemetry,
pushMaxAttempts: cfg.PushMaxAttempts,
emailMaxAttempts: cfg.EmailMaxAttempts,
idempotencyTTL: cfg.IdempotencyTTL,
adminRouting: cfg.AdminRouting,
}, nil
}
// Execute durably accepts one normalized intent.
func (service *Service) Execute(ctx context.Context, input AcceptInput) (Result, error) {
if ctx == nil {
return Result{}, errors.New("accept intent: nil context")
}
if service == nil {
return Result{}, errors.New("accept intent: nil service")
}
if err := input.Validate(); err != nil {
return Result{}, fmt.Errorf("accept intent: %w", err)
}
fingerprint, err := requestFingerprint(input.Intent)
if err != nil {
return Result{}, fmt.Errorf("accept intent: %w", err)
}
if result, handled, err := service.resolveReplay(ctx, input, fingerprint); handled {
return result, err
}
createInput, result, err := service.buildCreateInput(ctx, input, fingerprint)
if err != nil {
switch {
case errors.Is(err, ErrRecipientNotFound):
return Result{}, err
case errors.Is(err, ErrServiceUnavailable):
return Result{}, err
default:
return Result{}, fmt.Errorf("accept intent: %w", err)
}
}
if err := service.store.CreateAcceptance(ctx, createInput); err != nil {
if !errors.Is(err, ErrConflict) {
return Result{}, fmt.Errorf("%w: create acceptance: %v", ErrServiceUnavailable, err)
}
if replayResult, handled, replayErr := service.resolveReplay(ctx, input, fingerprint); handled {
return replayResult, replayErr
}
return Result{}, fmt.Errorf("%w: create acceptance conflict without replay state", ErrServiceUnavailable)
}
service.recordIntentOutcome(ctx, createInput.Notification, string(result.Outcome))
logArgs := logging.NotificationAttrs(
createInput.Notification.NotificationID,
createInput.Notification.NotificationType,
createInput.Notification.Producer,
createInput.Notification.AudienceKind,
createInput.Notification.IdempotencyKey,
createInput.Notification.RequestID,
createInput.Notification.TraceID,
)
logArgs = append(logArgs,
"route_count", len(createInput.Routes),
"outcome", string(result.Outcome),
)
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
service.logger.Info("notification intent accepted", logArgs...)
return result, nil
}
// Validate reports whether result stores a supported intent-acceptance
// outcome.
func (result Result) Validate() error {
switch result.Outcome {
case OutcomeAccepted, OutcomeDuplicate:
return nil
default:
return fmt.Errorf("accept intent outcome %q is unsupported", result.Outcome)
}
}
// Validate reports whether input contains a usable acceptance request.
func (input AcceptInput) Validate() error {
if strings.TrimSpace(input.NotificationID) == "" {
return errors.New("accept input notification id must not be empty")
}
if err := input.Intent.Validate(); err != nil {
return fmt.Errorf("accept input intent: %w", err)
}
return nil
}
// Validate reports whether record contains a complete notification record.
func (record NotificationRecord) Validate() error {
if strings.TrimSpace(record.NotificationID) == "" {
return errors.New("notification record notification id must not be empty")
}
if !record.NotificationType.IsKnown() {
return fmt.Errorf("notification record type %q is unsupported", record.NotificationType)
}
if !record.Producer.IsKnown() {
return fmt.Errorf("notification record producer %q is unsupported", record.Producer)
}
if !record.AudienceKind.IsKnown() {
return fmt.Errorf("notification record audience kind %q is unsupported", record.AudienceKind)
}
if strings.TrimSpace(record.PayloadJSON) == "" {
return errors.New("notification record payload json must not be empty")
}
if strings.TrimSpace(record.IdempotencyKey) == "" {
return errors.New("notification record idempotency key must not be empty")
}
if strings.TrimSpace(record.RequestFingerprint) == "" {
return errors.New("notification record request fingerprint must not be empty")
}
if err := validateTimestamp("notification record occurred at", record.OccurredAt); err != nil {
return err
}
if err := validateTimestamp("notification record accepted at", record.AcceptedAt); err != nil {
return err
}
if err := validateTimestamp("notification record updated at", record.UpdatedAt); err != nil {
return err
}
if record.AudienceKind == intentstream.AudienceKindUser && len(record.RecipientUserIDs) == 0 {
return errors.New("notification record recipient user ids must not be empty for audience kind user")
}
if record.AudienceKind == intentstream.AudienceKindAdminEmail && len(record.RecipientUserIDs) > 0 {
return errors.New("notification record recipient user ids must be empty for audience kind admin_email")
}
return nil
}
// Validate reports whether route contains a complete route record.
func (route NotificationRoute) Validate() error {
if strings.TrimSpace(route.NotificationID) == "" {
return errors.New("notification route notification id must not be empty")
}
if strings.TrimSpace(route.RouteID) == "" {
return errors.New("notification route route id must not be empty")
}
if !route.Channel.IsKnown() {
return fmt.Errorf("notification route channel %q is unsupported", route.Channel)
}
if strings.TrimSpace(route.RecipientRef) == "" {
return errors.New("notification route recipient ref must not be empty")
}
if !route.Status.IsKnown() {
return fmt.Errorf("notification route status %q is unsupported", route.Status)
}
if route.AttemptCount < 0 {
return errors.New("notification route attempt count must not be negative")
}
if route.MaxAttempts <= 0 {
return errors.New("notification route max attempts must be positive")
}
if err := validateTimestamp("notification route created at", route.CreatedAt); err != nil {
return err
}
if err := validateTimestamp("notification route updated at", route.UpdatedAt); err != nil {
return err
}
switch route.Status {
case RouteStatusPending, RouteStatusFailed:
if err := validateTimestamp("notification route next attempt at", route.NextAttemptAt); err != nil {
return err
}
case RouteStatusSkipped:
if !route.NextAttemptAt.IsZero() {
return errors.New("notification route next attempt at must be zero for skipped routes")
}
if err := validateTimestamp("notification route skipped at", route.SkippedAt); err != nil {
return err
}
}
return nil
}
// IsKnown reports whether status belongs to the frozen route-status surface.
func (status RouteStatus) IsKnown() bool {
switch status {
case RouteStatusPending,
RouteStatusPublished,
RouteStatusFailed,
RouteStatusDeadLetter,
RouteStatusSkipped:
return true
default:
return false
}
}
// Validate reports whether record contains a complete idempotency record.
func (record IdempotencyRecord) Validate() error {
if !record.Producer.IsKnown() {
return fmt.Errorf("idempotency record producer %q is unsupported", record.Producer)
}
if strings.TrimSpace(record.IdempotencyKey) == "" {
return errors.New("idempotency record idempotency key must not be empty")
}
if strings.TrimSpace(record.NotificationID) == "" {
return errors.New("idempotency record notification id must not be empty")
}
if strings.TrimSpace(record.RequestFingerprint) == "" {
return errors.New("idempotency record request fingerprint must not be empty")
}
if err := validateTimestamp("idempotency record created at", record.CreatedAt); err != nil {
return err
}
if err := validateTimestamp("idempotency record expires at", record.ExpiresAt); err != nil {
return err
}
if !record.ExpiresAt.After(record.CreatedAt) {
return errors.New("idempotency record expires at must be after created at")
}
return nil
}
// Validate reports whether input contains a consistent durable write set.
func (input CreateAcceptanceInput) Validate() error {
if err := input.Notification.Validate(); err != nil {
return fmt.Errorf("notification: %w", err)
}
if err := input.Idempotency.Validate(); err != nil {
return fmt.Errorf("idempotency: %w", err)
}
if input.Idempotency.NotificationID != input.Notification.NotificationID {
return errors.New("idempotency notification id must match notification record")
}
if input.Idempotency.Producer != input.Notification.Producer {
return errors.New("idempotency producer must match notification record")
}
if input.Idempotency.IdempotencyKey != input.Notification.IdempotencyKey {
return errors.New("idempotency key must match notification record")
}
if input.Idempotency.RequestFingerprint != input.Notification.RequestFingerprint {
return errors.New("idempotency request fingerprint must match notification record")
}
seenRouteIDs := make(map[string]struct{}, len(input.Routes))
for index, route := range input.Routes {
if err := route.Validate(); err != nil {
return fmt.Errorf("routes[%d]: %w", index, err)
}
if route.NotificationID != input.Notification.NotificationID {
return fmt.Errorf("routes[%d]: notification id must match notification record", index)
}
if _, ok := seenRouteIDs[route.RouteID]; ok {
return fmt.Errorf("routes[%d]: route id %q is duplicated", index, route.RouteID)
}
seenRouteIDs[route.RouteID] = struct{}{}
if input.Notification.AudienceKind == intentstream.AudienceKindUser {
if !strings.HasPrefix(route.RecipientRef, "user:") {
return fmt.Errorf("routes[%d]: recipient ref must use user: prefix for audience kind user", index)
}
if strings.TrimSpace(route.ResolvedEmail) == "" {
return fmt.Errorf("routes[%d]: resolved email must not be empty for audience kind user", index)
}
if strings.TrimSpace(route.ResolvedLocale) == "" {
return fmt.Errorf("routes[%d]: resolved locale must not be empty for audience kind user", index)
}
}
}
return nil
}
func (service *Service) buildCreateInput(ctx context.Context, input AcceptInput, fingerprint string) (CreateAcceptanceInput, Result, error) {
now := service.clock.Now().UTC().Truncate(time.Millisecond)
record := NotificationRecord{
NotificationID: input.NotificationID,
NotificationType: input.Intent.NotificationType,
Producer: input.Intent.Producer,
AudienceKind: input.Intent.AudienceKind,
RecipientUserIDs: append([]string(nil), input.Intent.RecipientUserIDs...),
PayloadJSON: input.Intent.PayloadJSON,
IdempotencyKey: input.Intent.IdempotencyKey,
RequestFingerprint: fingerprint,
RequestID: input.Intent.RequestID,
TraceID: input.Intent.TraceID,
OccurredAt: input.Intent.OccurredAt,
AcceptedAt: now,
UpdatedAt: now,
}
routes, err := service.materializeRoutes(ctx, record, now)
if err != nil {
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("materialize routes: %w", err)
}
createInput := CreateAcceptanceInput{
Notification: record,
Routes: routes,
Idempotency: IdempotencyRecord{
Producer: record.Producer,
IdempotencyKey: record.IdempotencyKey,
NotificationID: record.NotificationID,
RequestFingerprint: fingerprint,
CreatedAt: now,
ExpiresAt: now.Add(service.idempotencyTTL),
},
}
if err := createInput.Validate(); err != nil {
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build create acceptance input: %w", err)
}
result := Result{Outcome: OutcomeAccepted}
if err := result.Validate(); err != nil {
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build acceptance result: %w", err)
}
return createInput, result, nil
}
func (service *Service) materializeRoutes(ctx context.Context, record NotificationRecord, now time.Time) ([]NotificationRoute, error) {
switch record.AudienceKind {
case intentstream.AudienceKindUser:
recipients, err := service.resolveRecipients(ctx, record.NotificationType, record.RecipientUserIDs)
if err != nil {
return nil, err
}
routes := make([]NotificationRoute, 0, len(record.RecipientUserIDs)*2)
for _, userID := range record.RecipientUserIDs {
recipient := recipients[userID]
recipientRef := "user:" + userID
routes = append(routes,
service.newRoute(record, now, intentstream.ChannelPush, recipientRef, recipient.Email, resolveLocale(recipient.PreferredLanguage)),
service.newRoute(record, now, intentstream.ChannelEmail, recipientRef, recipient.Email, resolveLocale(recipient.PreferredLanguage)),
)
}
return routes, nil
case intentstream.AudienceKindAdminEmail:
adminEmails := service.adminEmailsFor(record.NotificationType)
if len(adminEmails) == 0 {
return []NotificationRoute{
service.newSyntheticAdminConfigRoute(record, now),
}, nil
}
routes := make([]NotificationRoute, 0, len(adminEmails)*2)
for _, email := range adminEmails {
recipientRef := "email:" + email
routes = append(routes,
service.newRoute(record, now, intentstream.ChannelPush, recipientRef, email, intentstream.DefaultResolvedLocale()),
service.newRoute(record, now, intentstream.ChannelEmail, recipientRef, email, intentstream.DefaultResolvedLocale()),
)
}
return routes, nil
default:
return nil, fmt.Errorf("unsupported audience kind %q", record.AudienceKind)
}
}
func (service *Service) resolveRecipients(ctx context.Context, notificationType intentstream.NotificationType, userIDs []string) (map[string]UserRecord, error) {
recipients := make(map[string]UserRecord, len(userIDs))
for _, userID := range userIDs {
record, err := service.userDirectory.GetUserByID(ctx, userID)
switch {
case err == nil:
if err := record.Validate(); err != nil {
service.recordUserEnrichmentAttempt(ctx, notificationType, "service_unavailable")
return nil, fmt.Errorf("%w: resolve recipient %q: %v", ErrServiceUnavailable, userID, err)
}
service.recordUserEnrichmentAttempt(ctx, notificationType, "success")
recipients[userID] = record
case errors.Is(err, ErrRecipientNotFound):
service.recordUserEnrichmentAttempt(ctx, notificationType, "recipient_not_found")
return nil, fmt.Errorf("%w: resolve recipient %q: %v", ErrRecipientNotFound, userID, err)
default:
service.recordUserEnrichmentAttempt(ctx, notificationType, "service_unavailable")
return nil, fmt.Errorf("%w: resolve recipient %q: %v", ErrServiceUnavailable, userID, err)
}
}
return recipients, nil
}
func (service *Service) newRoute(
record NotificationRecord,
now time.Time,
channel intentstream.Channel,
recipientRef string,
resolvedEmail string,
resolvedLocale string,
) NotificationRoute {
route := NotificationRoute{
NotificationID: record.NotificationID,
RouteID: string(channel) + ":" + recipientRef,
Channel: channel,
RecipientRef: recipientRef,
AttemptCount: 0,
MaxAttempts: service.maxAttempts(channel),
ResolvedEmail: resolvedEmail,
ResolvedLocale: resolvedLocale,
CreatedAt: now,
UpdatedAt: now,
}
if record.NotificationType.SupportsChannel(record.AudienceKind, channel) {
route.Status = RouteStatusPending
route.NextAttemptAt = now
return route
}
route.Status = RouteStatusSkipped
route.SkippedAt = now
return route
}
func (service *Service) newSyntheticAdminConfigRoute(record NotificationRecord, now time.Time) NotificationRoute {
recipientRef := "config:" + string(record.NotificationType)
return NotificationRoute{
NotificationID: record.NotificationID,
RouteID: string(intentstream.ChannelEmail) + ":" + recipientRef,
Channel: intentstream.ChannelEmail,
RecipientRef: recipientRef,
Status: RouteStatusSkipped,
AttemptCount: 0,
MaxAttempts: service.emailMaxAttempts,
CreatedAt: now,
UpdatedAt: now,
SkippedAt: now,
}
}
func (service *Service) adminEmailsFor(notificationType intentstream.NotificationType) []string {
switch notificationType {
case intentstream.NotificationTypeGeoReviewRecommended:
return append([]string(nil), service.adminRouting.GeoReviewRecommended...)
case intentstream.NotificationTypeGameGenerationFailed:
return append([]string(nil), service.adminRouting.GameGenerationFailed...)
case intentstream.NotificationTypeLobbyRuntimePausedAfterStart:
return append([]string(nil), service.adminRouting.LobbyRuntimePausedAfterStart...)
case intentstream.NotificationTypeLobbyApplicationSubmitted:
return append([]string(nil), service.adminRouting.LobbyApplicationSubmitted...)
default:
return nil
}
}
func (service *Service) maxAttempts(channel intentstream.Channel) int {
switch channel {
case intentstream.ChannelPush:
return service.pushMaxAttempts
case intentstream.ChannelEmail:
return service.emailMaxAttempts
default:
return 0
}
}
func resolveLocale(preferredLanguage string) string {
if preferredLanguage == intentstream.DefaultResolvedLocale() {
return intentstream.DefaultResolvedLocale()
}
return intentstream.DefaultResolvedLocale()
}
func (service *Service) resolveReplay(ctx context.Context, input AcceptInput, fingerprint string) (Result, bool, error) {
record, found, err := service.store.GetIdempotency(ctx, input.Intent.Producer, input.Intent.IdempotencyKey)
if err != nil {
return Result{}, true, fmt.Errorf("%w: load idempotency: %v", ErrServiceUnavailable, err)
}
if !found {
return Result{}, false, nil
}
if record.RequestFingerprint != fingerprint {
return Result{}, true, fmt.Errorf("%w: request conflicts with current state", ErrConflict)
}
notificationRecord, found, err := service.store.GetNotification(ctx, record.NotificationID)
if err != nil {
return Result{}, true, fmt.Errorf("%w: load notification: %v", ErrServiceUnavailable, err)
}
if !found {
return Result{}, true, fmt.Errorf("%w: notification %q is missing for idempotency scope", ErrServiceUnavailable, record.NotificationID)
}
if notificationRecord.NotificationID != record.NotificationID {
return Result{}, true, fmt.Errorf("%w: replay notification id mismatch", ErrServiceUnavailable)
}
result := Result{Outcome: OutcomeDuplicate}
if err := result.Validate(); err != nil {
return Result{}, true, fmt.Errorf("%w: %v", ErrServiceUnavailable, err)
}
service.recordIntentOutcome(ctx, notificationRecord, string(result.Outcome))
logArgs := logging.NotificationAttrs(
notificationRecord.NotificationID,
notificationRecord.NotificationType,
notificationRecord.Producer,
notificationRecord.AudienceKind,
notificationRecord.IdempotencyKey,
notificationRecord.RequestID,
notificationRecord.TraceID,
)
logArgs = append(logArgs,
"outcome", string(result.Outcome),
)
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
service.logger.Info("notification intent replay resolved", logArgs...)
return result, true, nil
}
func requestFingerprint(intent intentstream.Intent) (string, error) {
if err := intent.Validate(); err != nil {
return "", err
}
normalized := struct {
NotificationType intentstream.NotificationType `json:"notification_type"`
AudienceKind intentstream.AudienceKind `json:"audience_kind"`
RecipientUserIDs []string `json:"recipient_user_ids,omitempty"`
PayloadJSON json.RawMessage `json:"payload_json"`
}{
NotificationType: intent.NotificationType,
AudienceKind: intent.AudienceKind,
RecipientUserIDs: append([]string(nil), intent.RecipientUserIDs...),
PayloadJSON: json.RawMessage(intent.PayloadJSON),
}
payload, err := json.Marshal(normalized)
if err != nil {
return "", fmt.Errorf("marshal request fingerprint: %w", err)
}
sum := sha256.Sum256(payload)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
func (service *Service) recordIntentOutcome(ctx context.Context, record NotificationRecord, outcome string) {
if service == nil || service.telemetry == nil || strings.TrimSpace(outcome) == "" {
return
}
service.telemetry.RecordIntentOutcome(
ctx,
string(record.NotificationType),
string(record.Producer),
string(record.AudienceKind),
outcome,
)
}
func (service *Service) recordUserEnrichmentAttempt(ctx context.Context, notificationType intentstream.NotificationType, result string) {
if service == nil || service.telemetry == nil || strings.TrimSpace(result) == "" {
return
}
service.telemetry.RecordUserEnrichmentAttempt(ctx, string(notificationType), result)
}
func validateTimestamp(name string, value time.Time) error {
if value.IsZero() {
return fmt.Errorf("%s must not be zero", name)
}
if !value.Equal(value.UTC()) {
return fmt.Errorf("%s must be UTC", name)
}
if !value.Equal(value.Truncate(time.Millisecond)) {
return fmt.Errorf("%s must use millisecond precision", name)
}
return nil
}
@@ -0,0 +1,613 @@
package acceptintent
import (
"context"
"errors"
"testing"
"time"
"galaxy/notification/internal/api/intentstream"
"galaxy/notification/internal/config"
"github.com/stretchr/testify/require"
)
func TestServiceAcceptsIntentAndMaterializesUserRoutes(t *testing.T) {
t.Parallel()
store := newRecordingStore()
directory := newStaticUserDirectory(map[string]UserRecord{
"user-1": {Email: "one@example.com", PreferredLanguage: "en"},
"user-2": {Email: "two@example.com", PreferredLanguage: "en-US"},
})
service, err := New(Config{
Store: store,
UserDirectory: directory,
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
PushMaxAttempts: 3,
EmailMaxAttempts: 7,
IdempotencyTTL: 7 * 24 * time.Hour,
})
require.NoError(t, err)
result, err := service.Execute(context.Background(), AcceptInput{
NotificationID: "1775121700000-0",
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-2", "user-1"}, "request-123", "trace-123", time.UnixMilli(1775121700001).UTC()),
})
require.NoError(t, err)
require.Equal(t, OutcomeAccepted, result.Outcome)
require.Len(t, store.createInputs, 1)
createInput := store.createInputs[0]
require.Equal(t, "1775121700000-0", createInput.Notification.NotificationID)
require.Equal(t, []string{"user-1", "user-2"}, createInput.Notification.RecipientUserIDs)
require.Equal(t, `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, createInput.Notification.PayloadJSON)
require.Len(t, createInput.Routes, 4)
pushUser1 := routeByID(t, createInput.Routes, "push:user:user-1")
emailUser1 := routeByID(t, createInput.Routes, "email:user:user-1")
pushUser2 := routeByID(t, createInput.Routes, "push:user:user-2")
emailUser2 := routeByID(t, createInput.Routes, "email:user:user-2")
require.Equal(t, RouteStatusPending, pushUser1.Status)
require.Equal(t, 3, pushUser1.MaxAttempts)
require.Equal(t, "one@example.com", pushUser1.ResolvedEmail)
require.Equal(t, "en", pushUser1.ResolvedLocale)
require.Equal(t, RouteStatusPending, emailUser1.Status)
require.Equal(t, 7, emailUser1.MaxAttempts)
require.Equal(t, "one@example.com", emailUser1.ResolvedEmail)
require.Equal(t, "en", emailUser1.ResolvedLocale)
require.Equal(t, "two@example.com", pushUser2.ResolvedEmail)
require.Equal(t, "en", pushUser2.ResolvedLocale)
require.Equal(t, "two@example.com", emailUser2.ResolvedEmail)
require.Equal(t, "en", emailUser2.ResolvedLocale)
require.Equal(t, []string{"user-1", "user-2"}, directory.lookups)
}
func TestServiceTreatsEquivalentReplayAsDuplicate(t *testing.T) {
t.Parallel()
store := newRecordingStore()
directory := newStaticUserDirectory(map[string]UserRecord{
"user-1": {Email: "one@example.com", PreferredLanguage: "en"},
})
service, err := New(Config{
Store: store,
UserDirectory: directory,
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
PushMaxAttempts: 3,
EmailMaxAttempts: 7,
IdempotencyTTL: 7 * 24 * time.Hour,
})
require.NoError(t, err)
firstInput := AcceptInput{
NotificationID: "1775121700000-0",
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "request-1", "trace-1", time.UnixMilli(1775121700001).UTC()),
}
secondInput := AcceptInput{
NotificationID: "1775121700001-0",
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "request-2", "trace-2", time.UnixMilli(1775121799999).UTC()),
}
firstResult, err := service.Execute(context.Background(), firstInput)
require.NoError(t, err)
require.Equal(t, OutcomeAccepted, firstResult.Outcome)
secondResult, err := service.Execute(context.Background(), secondInput)
require.NoError(t, err)
require.Equal(t, OutcomeDuplicate, secondResult.Outcome)
require.Len(t, store.createInputs, 1)
require.Equal(t, []string{"user-1"}, directory.lookups)
}
func TestServiceRejectsConflictOnSameIdempotencyScope(t *testing.T) {
t.Parallel()
store := newRecordingStore()
directory := newStaticUserDirectory(map[string]UserRecord{
"user-1": {Email: "one@example.com", PreferredLanguage: "en"},
})
service, err := New(Config{
Store: store,
UserDirectory: directory,
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
PushMaxAttempts: 3,
EmailMaxAttempts: 7,
IdempotencyTTL: 7 * 24 * time.Hour,
})
require.NoError(t, err)
_, err = service.Execute(context.Background(), AcceptInput{
NotificationID: "1775121700000-0",
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()),
})
require.NoError(t, err)
_, err = service.Execute(context.Background(), AcceptInput{
NotificationID: "1775121700002-0",
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":55}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700002).UTC()),
})
require.ErrorIs(t, err, ErrConflict)
}
func TestServiceMaterializesPublicLobbyApplicationAdminRoutes(t *testing.T) {
t.Parallel()
store := newRecordingStore()
directory := newStaticUserDirectory(nil)
service, err := New(Config{
Store: store,
UserDirectory: directory,
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
PushMaxAttempts: 3,
EmailMaxAttempts: 7,
IdempotencyTTL: 7 * 24 * time.Hour,
AdminRouting: config.AdminRoutingConfig{
LobbyApplicationSubmitted: []string{"owner@example.com"},
},
})
require.NoError(t, err)
result, err := service.Execute(context.Background(), AcceptInput{
NotificationID: "1775121700000-0",
Intent: validPublicApplicationIntent(),
})
require.NoError(t, err)
require.Equal(t, OutcomeAccepted, result.Outcome)
require.Len(t, store.createInputs, 1)
require.Len(t, store.createInputs[0].Routes, 2)
pushRoute := routeByID(t, store.createInputs[0].Routes, "push:email:owner@example.com")
emailRoute := routeByID(t, store.createInputs[0].Routes, "email:email:owner@example.com")
require.Equal(t, RouteStatusSkipped, pushRoute.Status)
require.Equal(t, "owner@example.com", pushRoute.ResolvedEmail)
require.Equal(t, "en", pushRoute.ResolvedLocale)
require.Equal(t, RouteStatusPending, emailRoute.Status)
require.Equal(t, "owner@example.com", emailRoute.ResolvedEmail)
require.Equal(t, "en", emailRoute.ResolvedLocale)
require.Empty(t, directory.lookups)
}
func TestServiceMaterializesSyntheticAdminConfigRouteWhenListIsEmpty(t *testing.T) {
t.Parallel()
store := newRecordingStore()
directory := newStaticUserDirectory(nil)
service, err := New(Config{
Store: store,
UserDirectory: directory,
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
PushMaxAttempts: 3,
EmailMaxAttempts: 7,
IdempotencyTTL: 7 * 24 * time.Hour,
})
require.NoError(t, err)
result, err := service.Execute(context.Background(), AcceptInput{
NotificationID: "1775121700000-0",
Intent: validPublicApplicationIntent(),
})
require.NoError(t, err)
require.Equal(t, OutcomeAccepted, result.Outcome)
require.Len(t, store.createInputs, 1)
require.Len(t, store.createInputs[0].Routes, 1)
route := store.createInputs[0].Routes[0]
require.Equal(t, "email:config:lobby.application.submitted", route.RouteID)
require.Equal(t, RouteStatusSkipped, route.Status)
require.Equal(t, 7, route.MaxAttempts)
require.True(t, route.NextAttemptAt.IsZero())
require.Empty(t, directory.lookups)
}
func TestServiceMaterializesChannelMatrixAndRetryBudgets(t *testing.T) {
t.Parallel()
now := time.UnixMilli(1775121700000).UTC()
tests := []struct {
name string
intent intentstream.Intent
adminRouting config.AdminRoutingConfig
wantRoutes map[string]struct {
status RouteStatus
maxAttempts int
}
}{
{
name: "user push and email",
intent: validTurnReadyIntent(
`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
[]string{"user-1"},
"",
"",
now,
),
wantRoutes: map[string]struct {
status RouteStatus
maxAttempts int
}{
"push:user:user-1": {status: RouteStatusPending, maxAttempts: 3},
"email:user:user-1": {status: RouteStatusPending, maxAttempts: 7},
},
},
{
name: "user email only",
intent: intentstream.Intent{
NotificationType: intentstream.NotificationTypeLobbyInviteExpired,
Producer: intentstream.ProducerGameLobby,
AudienceKind: intentstream.AudienceKindUser,
RecipientUserIDs: []string{"user-1"},
IdempotencyKey: "game-123:invite-expired",
OccurredAt: now,
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","invitee_name":"Nova Pilot","invitee_user_id":"user-2"}`,
},
wantRoutes: map[string]struct {
status RouteStatus
maxAttempts int
}{
"push:user:user-1": {status: RouteStatusSkipped, maxAttempts: 3},
"email:user:user-1": {status: RouteStatusPending, maxAttempts: 7},
},
},
{
name: "admin email only",
intent: intentstream.Intent{
NotificationType: intentstream.NotificationTypeGeoReviewRecommended,
Producer: intentstream.ProducerGeoProfile,
AudienceKind: intentstream.AudienceKindAdminEmail,
IdempotencyKey: "geo:user-1",
OccurredAt: now,
PayloadJSON: `{"observed_country":"DE","review_reason":"country_mismatch","usual_connection_country":"PL","user_email":"pilot@example.com","user_id":"user-1"}`,
},
adminRouting: config.AdminRoutingConfig{
GeoReviewRecommended: []string{"admin@example.com"},
},
wantRoutes: map[string]struct {
status RouteStatus
maxAttempts int
}{
"push:email:admin@example.com": {status: RouteStatusSkipped, maxAttempts: 3},
"email:email:admin@example.com": {status: RouteStatusPending, maxAttempts: 7},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
store := newRecordingStore()
directory := newStaticUserDirectory(map[string]UserRecord{
"user-1": {Email: "pilot@example.com", PreferredLanguage: "fr-FR"},
})
service, err := New(Config{
Store: store,
UserDirectory: directory,
Clock: fixedClock{now: now},
PushMaxAttempts: 3,
EmailMaxAttempts: 7,
IdempotencyTTL: 7 * 24 * time.Hour,
AdminRouting: tt.adminRouting,
})
require.NoError(t, err)
result, err := service.Execute(context.Background(), AcceptInput{
NotificationID: "1775121700000-0",
Intent: tt.intent,
})
require.NoError(t, err)
require.Equal(t, OutcomeAccepted, result.Outcome)
require.Len(t, store.createInputs, 1)
require.Len(t, store.createInputs[0].Routes, len(tt.wantRoutes))
for routeID, want := range tt.wantRoutes {
route := routeByID(t, store.createInputs[0].Routes, routeID)
require.Equal(t, want.status, route.Status)
require.Equal(t, want.maxAttempts, route.MaxAttempts)
}
})
}
}
func TestServiceReturnsRecipientNotFoundForMissingUser(t *testing.T) {
t.Parallel()
store := newRecordingStore()
directory := newStaticUserDirectory(nil)
service, err := New(Config{
Store: store,
UserDirectory: directory,
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
PushMaxAttempts: 3,
EmailMaxAttempts: 7,
IdempotencyTTL: 7 * 24 * time.Hour,
})
require.NoError(t, err)
_, err = service.Execute(context.Background(), AcceptInput{
NotificationID: "1775121700000-0",
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-missing"}, "", "", time.UnixMilli(1775121700001).UTC()),
})
require.ErrorIs(t, err, ErrRecipientNotFound)
require.Empty(t, store.createInputs)
require.Equal(t, []string{"user-missing"}, directory.lookups)
}
func TestServiceReturnsServiceUnavailableWhenDirectoryFails(t *testing.T) {
t.Parallel()
store := newRecordingStore()
directory := newStaticUserDirectory(nil)
directory.errByUserID["user-1"] = errors.New("user service unavailable")
service, err := New(Config{
Store: store,
UserDirectory: directory,
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
PushMaxAttempts: 3,
EmailMaxAttempts: 7,
IdempotencyTTL: 7 * 24 * time.Hour,
})
require.NoError(t, err)
_, err = service.Execute(context.Background(), AcceptInput{
NotificationID: "1775121700000-0",
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()),
})
require.ErrorIs(t, err, ErrServiceUnavailable)
require.Empty(t, store.createInputs)
}
func TestServiceRecordsIntentAndUserEnrichmentTelemetry(t *testing.T) {
t.Parallel()
store := newRecordingStore()
directory := newStaticUserDirectory(map[string]UserRecord{
"user-1": {Email: "one@example.com", PreferredLanguage: "en"},
})
telemetry := &recordingTelemetry{}
service, err := New(Config{
Store: store,
UserDirectory: directory,
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
Telemetry: telemetry,
PushMaxAttempts: 3,
EmailMaxAttempts: 7,
IdempotencyTTL: 7 * 24 * time.Hour,
})
require.NoError(t, err)
input := AcceptInput{
NotificationID: "1775121700000-0",
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()),
}
result, err := service.Execute(context.Background(), input)
require.NoError(t, err)
require.Equal(t, OutcomeAccepted, result.Outcome)
duplicateInput := input
duplicateInput.NotificationID = "1775121700001-0"
result, err = service.Execute(context.Background(), duplicateInput)
require.NoError(t, err)
require.Equal(t, OutcomeDuplicate, result.Outcome)
require.Equal(t, []intentOutcomeRecord{
{
notificationType: "game.turn.ready",
producer: "game_master",
audienceKind: "user",
outcome: "accepted",
},
{
notificationType: "game.turn.ready",
producer: "game_master",
audienceKind: "user",
outcome: "duplicate",
},
}, telemetry.intentOutcomes)
require.Equal(t, []userEnrichmentRecord{
{notificationType: "game.turn.ready", result: "success"},
}, telemetry.userEnrichment)
}
func TestServiceRecordsUserEnrichmentFailureTelemetry(t *testing.T) {
t.Parallel()
tests := []struct {
name string
directory *staticUserDirectory
want string
}{
{
name: "recipient not found",
directory: newStaticUserDirectory(nil),
want: "recipient_not_found",
},
{
name: "service unavailable",
directory: func() *staticUserDirectory {
directory := newStaticUserDirectory(nil)
directory.errByUserID["user-1"] = errors.New("user service unavailable")
return directory
}(),
want: "service_unavailable",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
telemetry := &recordingTelemetry{}
service, err := New(Config{
Store: newRecordingStore(),
UserDirectory: tt.directory,
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
Telemetry: telemetry,
PushMaxAttempts: 3,
EmailMaxAttempts: 7,
IdempotencyTTL: 7 * 24 * time.Hour,
})
require.NoError(t, err)
_, err = service.Execute(context.Background(), AcceptInput{
NotificationID: "1775121700000-0",
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()),
})
require.Error(t, err)
require.Equal(t, []userEnrichmentRecord{
{notificationType: "game.turn.ready", result: tt.want},
}, telemetry.userEnrichment)
})
}
}
type recordingStore struct {
createInputs []CreateAcceptanceInput
idempotency map[string]IdempotencyRecord
notifications map[string]NotificationRecord
}
func newRecordingStore() *recordingStore {
return &recordingStore{
idempotency: make(map[string]IdempotencyRecord),
notifications: make(map[string]NotificationRecord),
}
}
func (store *recordingStore) CreateAcceptance(_ context.Context, input CreateAcceptanceInput) error {
if err := input.Validate(); err != nil {
return err
}
key := string(input.Idempotency.Producer) + ":" + input.Idempotency.IdempotencyKey
if _, ok := store.idempotency[key]; ok {
return ErrConflict
}
store.createInputs = append(store.createInputs, input)
store.idempotency[key] = input.Idempotency
store.notifications[input.Notification.NotificationID] = input.Notification
return nil
}
func (store *recordingStore) GetIdempotency(_ context.Context, producer intentstream.Producer, idempotencyKey string) (IdempotencyRecord, bool, error) {
record, ok := store.idempotency[string(producer)+":"+idempotencyKey]
return record, ok, nil
}
func (store *recordingStore) GetNotification(_ context.Context, notificationID string) (NotificationRecord, bool, error) {
record, ok := store.notifications[notificationID]
return record, ok, nil
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
func validTurnReadyIntent(payload string, recipients []string, requestID string, traceID string, occurredAt time.Time) intentstream.Intent {
sorted := append([]string(nil), recipients...)
if len(sorted) == 2 && sorted[0] == "user-2" {
sorted[0], sorted[1] = sorted[1], sorted[0]
}
return intentstream.Intent{
NotificationType: intentstream.NotificationTypeGameTurnReady,
Producer: intentstream.ProducerGameMaster,
AudienceKind: intentstream.AudienceKindUser,
RecipientUserIDs: sorted,
IdempotencyKey: "game-123:turn-54",
OccurredAt: occurredAt.UTC().Truncate(time.Millisecond),
RequestID: requestID,
TraceID: traceID,
PayloadJSON: payload,
}
}
func validPublicApplicationIntent() intentstream.Intent {
return intentstream.Intent{
NotificationType: intentstream.NotificationTypeLobbyApplicationSubmitted,
Producer: intentstream.ProducerGameLobby,
AudienceKind: intentstream.AudienceKindAdminEmail,
IdempotencyKey: "game-456:application-submitted:user-42",
OccurredAt: time.UnixMilli(1775121700002).UTC(),
PayloadJSON: `{"applicant_name":"Nova Pilot","applicant_user_id":"user-42","game_id":"game-456","game_name":"Orion Front"}`,
}
}
func routeByID(t *testing.T, routes []NotificationRoute, routeID string) NotificationRoute {
t.Helper()
for _, route := range routes {
if route.RouteID == routeID {
return route
}
}
t.Fatalf("route %q not found", routeID)
return NotificationRoute{}
}
type staticUserDirectory struct {
records map[string]UserRecord
errByUserID map[string]error
lookups []string
}
func newStaticUserDirectory(records map[string]UserRecord) *staticUserDirectory {
return &staticUserDirectory{
records: records,
errByUserID: make(map[string]error),
}
}
func (directory *staticUserDirectory) GetUserByID(_ context.Context, userID string) (UserRecord, error) {
directory.lookups = append(directory.lookups, userID)
if err, ok := directory.errByUserID[userID]; ok {
return UserRecord{}, err
}
record, ok := directory.records[userID]
if !ok {
return UserRecord{}, ErrRecipientNotFound
}
return record, nil
}
type recordingTelemetry struct {
intentOutcomes []intentOutcomeRecord
userEnrichment []userEnrichmentRecord
}
func (telemetry *recordingTelemetry) RecordIntentOutcome(_ context.Context, notificationType string, producer string, audienceKind string, outcome string) {
telemetry.intentOutcomes = append(telemetry.intentOutcomes, intentOutcomeRecord{
notificationType: notificationType,
producer: producer,
audienceKind: audienceKind,
outcome: outcome,
})
}
func (telemetry *recordingTelemetry) RecordUserEnrichmentAttempt(_ context.Context, notificationType string, result string) {
telemetry.userEnrichment = append(telemetry.userEnrichment, userEnrichmentRecord{
notificationType: notificationType,
result: result,
})
}
type intentOutcomeRecord struct {
notificationType string
producer string
audienceKind string
outcome string
}
type userEnrichmentRecord struct {
notificationType string
result string
}
+3
View File
@@ -0,0 +1,3 @@
// Package service reserves the application-service namespace of Notification
// Service.
package service
@@ -0,0 +1,135 @@
// Package malformedintent defines the operator-visible record used for
// malformed notification intents.
package malformedintent
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// FailureCode identifies one stable malformed-intent rejection reason.
type FailureCode string
const (
// FailureCodeInvalidIntent reports malformed top-level intent fields or an
// invalid normalized envelope.
FailureCodeInvalidIntent FailureCode = "invalid_intent"
// FailureCodeInvalidPayload reports malformed or schema-invalid
// `payload_json`.
FailureCodeInvalidPayload FailureCode = "invalid_payload"
// FailureCodeIdempotencyConflict reports a duplicate idempotency scope that
// conflicts with already accepted normalized content.
FailureCodeIdempotencyConflict FailureCode = "idempotency_conflict"
// FailureCodeRecipientNotFound reports that a user-targeted recipient user
// id could not be resolved through User Service.
FailureCodeRecipientNotFound FailureCode = "recipient_not_found"
)
// Entry stores one operator-visible malformed notification-intent record.
type Entry struct {
// StreamEntryID stores the Redis Stream entry identifier of the rejected
// intent.
StreamEntryID string
// NotificationType stores the optional raw notification type extracted from
// the rejected entry.
NotificationType string
// Producer stores the optional raw producer value extracted from the
// rejected entry.
Producer string
// IdempotencyKey stores the optional raw idempotency key extracted from the
// rejected entry.
IdempotencyKey string
// FailureCode stores the stable rejection classification.
FailureCode FailureCode
// FailureMessage stores the detailed validation or decode failure.
FailureMessage string
// RawFields stores the raw top-level stream fields captured for operator
// inspection.
RawFields map[string]any
// RecordedAt stores when the malformed intent was durably recorded.
RecordedAt time.Time
}
// IsKnown reports whether code belongs to the frozen malformed-intent
// rejection surface.
func (code FailureCode) IsKnown() bool {
switch code {
case FailureCodeInvalidIntent, FailureCodeInvalidPayload, FailureCodeIdempotencyConflict, FailureCodeRecipientNotFound:
return true
default:
return false
}
}
// Validate reports whether entry contains a complete malformed-intent record.
func (entry Entry) Validate() error {
if strings.TrimSpace(entry.StreamEntryID) == "" {
return fmt.Errorf("malformed intent stream entry id must not be empty")
}
if !entry.FailureCode.IsKnown() {
return fmt.Errorf("malformed intent failure code %q is unsupported", entry.FailureCode)
}
if strings.TrimSpace(entry.FailureMessage) == "" {
return fmt.Errorf("malformed intent failure message must not be empty")
}
if strings.TrimSpace(entry.FailureMessage) != entry.FailureMessage {
return fmt.Errorf("malformed intent failure message must not contain surrounding whitespace")
}
if entry.RawFields == nil {
return fmt.Errorf("malformed intent raw fields must not be nil")
}
if err := validateJSONObject("malformed intent raw fields", entry.RawFields); err != nil {
return err
}
if err := validateTimestamp("malformed intent recorded at", entry.RecordedAt); err != nil {
return err
}
return nil
}
func validateJSONObject(name string, value map[string]any) error {
payload, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
if string(payload) == "null" {
return fmt.Errorf("%s must encode as a JSON object", name)
}
var decoded map[string]any
if err := json.Unmarshal(payload, &decoded); err != nil {
return fmt.Errorf("%s: %w", name, err)
}
if decoded == nil {
return fmt.Errorf("%s must encode as a JSON object", name)
}
return nil
}
func validateTimestamp(name string, value time.Time) error {
if value.IsZero() {
return fmt.Errorf("%s must not be zero", name)
}
if !value.Equal(value.UTC()) {
return fmt.Errorf("%s must be UTC", name)
}
if !value.Equal(value.Truncate(time.Millisecond)) {
return fmt.Errorf("%s must use millisecond precision", name)
}
return nil
}
@@ -0,0 +1,178 @@
// Package publishmail encodes accepted email routes into Mail Service generic
// asynchronous template commands.
package publishmail
import (
"encoding/json"
"fmt"
netmail "net/mail"
"strconv"
"strings"
"time"
"galaxy/notification/internal/api/intentstream"
"galaxy/notification/internal/service/acceptintent"
)
const (
commandSourceNotification = "notification"
commandPayloadModeTemplate = "template"
)
// Command stores one Mail Service-compatible template delivery command
// produced from a durable notification email route.
type Command struct {
// DeliveryID stores the stable route-level delivery identifier.
DeliveryID string
// IdempotencyKey stores the stable Mail Service deduplication key.
IdempotencyKey string
// RequestedAt stores when Notification Service durably accepted the
// notification intent.
RequestedAt time.Time
// PayloadJSON stores the fully encoded template-mode command payload.
PayloadJSON string
// RequestID stores the optional correlation identifier.
RequestID string
// TraceID stores the optional tracing correlation identifier.
TraceID string
}
// Values returns the Redis Stream fields appended to the Mail Service command
// stream for Command.
func (command Command) Values() map[string]any {
values := map[string]any{
"delivery_id": command.DeliveryID,
"source": commandSourceNotification,
"payload_mode": commandPayloadModeTemplate,
"idempotency_key": command.IdempotencyKey,
"requested_at_ms": strconv.FormatInt(command.RequestedAt.UTC().UnixMilli(), 10),
"payload_json": command.PayloadJSON,
}
if command.RequestID != "" {
values["request_id"] = command.RequestID
}
if command.TraceID != "" {
values["trace_id"] = command.TraceID
}
return values
}
// Encoder converts one accepted notification record plus its email route into
// one Mail Service-compatible generic template command.
type Encoder struct{}
// Encode converts notification plus route into one template delivery command.
func (Encoder) Encode(notification acceptintent.NotificationRecord, route acceptintent.NotificationRoute) (Command, error) {
if err := notification.Validate(); err != nil {
return Command{}, fmt.Errorf("encode mail command: %w", err)
}
if err := route.Validate(); err != nil {
return Command{}, fmt.Errorf("encode mail command: %w", err)
}
if notification.NotificationID != route.NotificationID {
return Command{}, fmt.Errorf("encode mail command: notification id %q does not match route notification id %q", notification.NotificationID, route.NotificationID)
}
if route.Channel != intentstream.ChannelEmail {
return Command{}, fmt.Errorf("encode mail command: route channel %q is unsupported", route.Channel)
}
if !notification.NotificationType.SupportsChannel(notification.AudienceKind, intentstream.ChannelEmail) {
return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: notification type %q does not support email", notification.NotificationType)
}
recipientEmail, err := normalizedRecipientEmail(route.ResolvedEmail)
if err != nil {
return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: %w", err)
}
locale, err := normalizedLocale(route.ResolvedLocale)
if err != nil {
return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: %w", err)
}
variables, err := payloadVariables(notification.PayloadJSON)
if err != nil {
return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: %w", err)
}
payloadJSON, err := json.Marshal(templatePayloadJSON{
To: []string{recipientEmail},
Cc: []string{},
Bcc: []string{},
ReplyTo: []string{},
TemplateID: string(notification.NotificationType),
Locale: locale,
Variables: variables,
Attachments: []templateAttachmentJSON{},
})
if err != nil {
return Command{}, fmt.Errorf("encode mail command: payload_encoding_failed: marshal payload_json: %w", err)
}
return Command{
DeliveryID: notification.NotificationID + "/" + route.RouteID,
IdempotencyKey: "notification:" + notification.NotificationID + "/" + route.RouteID,
RequestedAt: notification.AcceptedAt,
PayloadJSON: string(payloadJSON),
RequestID: notification.RequestID,
TraceID: notification.TraceID,
}, nil
}
type templatePayloadJSON struct {
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
ReplyTo []string `json:"reply_to"`
TemplateID string `json:"template_id"`
Locale string `json:"locale"`
Variables json.RawMessage `json:"variables"`
Attachments []templateAttachmentJSON `json:"attachments"`
}
type templateAttachmentJSON struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
ContentBase64 string `json:"content_base64"`
}
func normalizedRecipientEmail(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", fmt.Errorf("resolved email must not be empty")
}
parsed, err := netmail.ParseAddress(value)
if err != nil {
return "", fmt.Errorf("resolved email %q must be valid: %w", value, err)
}
if parsed.Name != "" || parsed.Address != value {
return "", fmt.Errorf("resolved email %q must not include a display name", value)
}
return value, nil
}
func normalizedLocale(value string) (string, error) {
switch {
case strings.TrimSpace(value) == "":
return "", fmt.Errorf("resolved locale must not be empty")
case strings.TrimSpace(value) != value:
return "", fmt.Errorf("resolved locale %q must not contain surrounding whitespace", value)
default:
return value, nil
}
}
func payloadVariables(payloadJSON string) (json.RawMessage, error) {
var payloadObject map[string]json.RawMessage
if err := json.Unmarshal([]byte(payloadJSON), &payloadObject); err != nil {
return nil, fmt.Errorf("decode payload_json: %w", err)
}
if payloadObject == nil {
return nil, fmt.Errorf("payload_json must be a JSON object")
}
return json.RawMessage(payloadJSON), nil
}
@@ -0,0 +1,275 @@
package publishmail
import (
"encoding/json"
"testing"
"time"
"galaxy/notification/internal/api/intentstream"
"galaxy/notification/internal/service/acceptintent"
"github.com/stretchr/testify/require"
)
func TestEncoderEncodesUserAndAdminEmailCommands(t *testing.T) {
t.Parallel()
now := time.UnixMilli(1775121700000).UTC()
tests := []struct {
name string
notification acceptintent.NotificationRecord
route acceptintent.NotificationRoute
wantDeliveryID string
wantIdempotency string
wantPayloadJSON string
}{
{
name: "user route",
notification: acceptintent.NotificationRecord{
NotificationID: "1775121700000-0",
NotificationType: intentstream.NotificationTypeGameTurnReady,
Producer: intentstream.ProducerGameMaster,
AudienceKind: intentstream.AudienceKindUser,
RecipientUserIDs: []string{"user-1"},
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
IdempotencyKey: "game-123:turn-54",
RequestFingerprint: "sha256:deadbeef",
AcceptedAt: now,
OccurredAt: now,
UpdatedAt: now,
},
route: acceptintent.NotificationRoute{
NotificationID: "1775121700000-0",
RouteID: "email:user:user-1",
Channel: intentstream.ChannelEmail,
RecipientRef: "user:user-1",
Status: acceptintent.RouteStatusPending,
MaxAttempts: 7,
NextAttemptAt: now,
ResolvedEmail: "pilot@example.com",
ResolvedLocale: "en",
CreatedAt: now,
UpdatedAt: now,
},
wantDeliveryID: "1775121700000-0/email:user:user-1",
wantIdempotency: "notification:1775121700000-0/email:user:user-1",
wantPayloadJSON: `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn.ready","locale":"en","variables":{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54},"attachments":[]}`,
},
{
name: "admin route",
notification: acceptintent.NotificationRecord{
NotificationID: "1775121700001-0",
NotificationType: intentstream.NotificationTypeLobbyApplicationSubmitted,
Producer: intentstream.ProducerGameLobby,
AudienceKind: intentstream.AudienceKindAdminEmail,
PayloadJSON: `{"applicant_name":"Nova Pilot","applicant_user_id":"user-42","game_id":"game-456","game_name":"Orion Front"}`,
IdempotencyKey: "game-456:application-submitted:user-42",
RequestFingerprint: "sha256:cafebabe",
AcceptedAt: now,
OccurredAt: now,
UpdatedAt: now,
},
route: acceptintent.NotificationRoute{
NotificationID: "1775121700001-0",
RouteID: "email:email:owner@example.com",
Channel: intentstream.ChannelEmail,
RecipientRef: "email:owner@example.com",
Status: acceptintent.RouteStatusPending,
MaxAttempts: 7,
NextAttemptAt: now,
ResolvedEmail: "owner@example.com",
ResolvedLocale: "en",
CreatedAt: now,
UpdatedAt: now,
},
wantDeliveryID: "1775121700001-0/email:email:owner@example.com",
wantIdempotency: "notification:1775121700001-0/email:email:owner@example.com",
wantPayloadJSON: `{"to":["owner@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"lobby.application.submitted","locale":"en","variables":{"applicant_name":"Nova Pilot","applicant_user_id":"user-42","game_id":"game-456","game_name":"Orion Front"},"attachments":[]}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
command, err := Encoder{}.Encode(tt.notification, tt.route)
require.NoError(t, err)
require.Equal(t, tt.wantDeliveryID, command.DeliveryID)
require.Equal(t, tt.wantIdempotency, command.IdempotencyKey)
require.Equal(t, now, command.RequestedAt)
require.JSONEq(t, tt.wantPayloadJSON, command.PayloadJSON)
values := command.Values()
require.Equal(t, tt.wantDeliveryID, values["delivery_id"])
require.Equal(t, "notification", values["source"])
require.Equal(t, "template", values["payload_mode"])
require.Equal(t, tt.wantIdempotency, values["idempotency_key"])
require.Equal(t, "1775121700000", values["requested_at_ms"])
})
}
}
func TestEncoderPropagatesTracingMetadata(t *testing.T) {
t.Parallel()
now := time.UnixMilli(1775121700000).UTC()
command, err := Encoder{}.Encode(
acceptintent.NotificationRecord{
NotificationID: "1775121700000-0",
NotificationType: intentstream.NotificationTypeGameTurnReady,
Producer: intentstream.ProducerGameMaster,
AudienceKind: intentstream.AudienceKindUser,
RecipientUserIDs: []string{"user-1"},
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
IdempotencyKey: "game-123:turn-54",
RequestFingerprint: "sha256:deadbeef",
RequestID: "request-1",
TraceID: "trace-1",
AcceptedAt: now,
OccurredAt: now,
UpdatedAt: now,
},
acceptintent.NotificationRoute{
NotificationID: "1775121700000-0",
RouteID: "email:user:user-1",
Channel: intentstream.ChannelEmail,
RecipientRef: "user:user-1",
Status: acceptintent.RouteStatusPending,
MaxAttempts: 7,
NextAttemptAt: now,
ResolvedEmail: "pilot@example.com",
ResolvedLocale: "en",
CreatedAt: now,
UpdatedAt: now,
},
)
require.NoError(t, err)
values := command.Values()
require.Equal(t, "request-1", values["request_id"])
require.Equal(t, "trace-1", values["trace_id"])
}
func TestEncoderPreservesNormalizedPayloadAsTemplateVariables(t *testing.T) {
t.Parallel()
now := time.UnixMilli(1775121700000).UTC()
command, err := Encoder{}.Encode(
acceptintent.NotificationRecord{
NotificationID: "1775121700000-0",
NotificationType: intentstream.NotificationTypeGameFinished,
Producer: intentstream.ProducerGameMaster,
AudienceKind: intentstream.AudienceKindUser,
RecipientUserIDs: []string{"user-1"},
PayloadJSON: `{"final_turn_number":81,"game_id":"game-123","game_name":"Nebula Clash"}`,
IdempotencyKey: "game-123:final",
RequestFingerprint: "sha256:deadbeef",
AcceptedAt: now,
OccurredAt: now,
UpdatedAt: now,
},
acceptintent.NotificationRoute{
NotificationID: "1775121700000-0",
RouteID: "email:user:user-1",
Channel: intentstream.ChannelEmail,
RecipientRef: "user:user-1",
Status: acceptintent.RouteStatusPending,
MaxAttempts: 7,
NextAttemptAt: now,
ResolvedEmail: "pilot@example.com",
ResolvedLocale: "en",
CreatedAt: now,
UpdatedAt: now,
},
)
require.NoError(t, err)
var payload struct {
Variables map[string]any `json:"variables"`
}
require.NoError(t, json.Unmarshal([]byte(command.PayloadJSON), &payload))
require.Equal(t, map[string]any{
"final_turn_number": float64(81),
"game_id": "game-123",
"game_name": "Nebula Clash",
}, payload.Variables)
}
func TestEncoderUsesEmptyAncillaryEnvelopeFields(t *testing.T) {
t.Parallel()
now := time.UnixMilli(1775121700000).UTC()
command, err := Encoder{}.Encode(
acceptintent.NotificationRecord{
NotificationID: "1775121700000-0",
NotificationType: intentstream.NotificationTypeLobbyInviteExpired,
Producer: intentstream.ProducerGameLobby,
AudienceKind: intentstream.AudienceKindUser,
RecipientUserIDs: []string{"user-1"},
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","invitee_name":"Nova Pilot","invitee_user_id":"user-2"}`,
IdempotencyKey: "game-123:invite-expired",
RequestFingerprint: "sha256:deadbeef",
AcceptedAt: now,
OccurredAt: now,
UpdatedAt: now,
},
acceptintent.NotificationRoute{
NotificationID: "1775121700000-0",
RouteID: "email:user:user-1",
Channel: intentstream.ChannelEmail,
RecipientRef: "user:user-1",
Status: acceptintent.RouteStatusPending,
MaxAttempts: 7,
NextAttemptAt: now,
ResolvedEmail: "pilot@example.com",
ResolvedLocale: "en",
CreatedAt: now,
UpdatedAt: now,
},
)
require.NoError(t, err)
require.JSONEq(
t,
`{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"lobby.invite.expired","locale":"en","variables":{"game_id":"game-123","game_name":"Nebula Clash","invitee_name":"Nova Pilot","invitee_user_id":"user-2"},"attachments":[]}`,
command.PayloadJSON,
)
}
func TestEncoderRejectsInvalidRouteForMailPublication(t *testing.T) {
t.Parallel()
now := time.UnixMilli(1775121700000).UTC()
_, err := Encoder{}.Encode(
acceptintent.NotificationRecord{
NotificationID: "1775121700000-0",
NotificationType: intentstream.NotificationTypeGameTurnReady,
Producer: intentstream.ProducerGameMaster,
AudienceKind: intentstream.AudienceKindUser,
RecipientUserIDs: []string{"user-1"},
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
IdempotencyKey: "game-123:turn-54",
RequestFingerprint: "sha256:deadbeef",
AcceptedAt: now,
OccurredAt: now,
UpdatedAt: now,
},
acceptintent.NotificationRoute{
NotificationID: "1775121700000-0",
RouteID: "push:user:user-1",
Channel: intentstream.ChannelPush,
RecipientRef: "user:user-1",
Status: acceptintent.RouteStatusPending,
MaxAttempts: 3,
NextAttemptAt: now,
ResolvedEmail: "pilot@example.com",
ResolvedLocale: "en",
CreatedAt: now,
UpdatedAt: now,
},
)
require.Error(t, err)
require.ErrorContains(t, err, `route channel "push" is unsupported`)
}
@@ -0,0 +1,221 @@
// Package publishpush encodes user-facing notification routes into Gateway
// client-event payloads.
package publishpush
import (
"encoding/json"
"errors"
"fmt"
"strings"
"galaxy/notification/internal/api/intentstream"
"galaxy/notification/internal/service/acceptintent"
"galaxy/transcoder"
)
// Event stores one Gateway-compatible client event produced from a
// user-targeted notification route.
type Event struct {
// UserID stores the authenticated user fan-out target.
UserID string
// EventType stores the stable client-facing event type.
EventType string
// EventID stores the stable route-level event identifier.
EventID string
// PayloadBytes stores the encoded FlatBuffers payload bytes.
PayloadBytes []byte
// RequestID stores the optional correlation identifier.
RequestID string
// TraceID stores the optional tracing correlation identifier.
TraceID string
}
// Encoder maps one supported notification_type to the corresponding checked-in
// FlatBuffers payload encoder.
type Encoder struct{}
// Encode converts one accepted notification record plus its push route into a
// Gateway-compatible client event.
func (Encoder) Encode(notification acceptintent.NotificationRecord, route acceptintent.NotificationRoute) (Event, error) {
if err := notification.Validate(); err != nil {
return Event{}, fmt.Errorf("encode push event: %w", err)
}
if err := route.Validate(); err != nil {
return Event{}, fmt.Errorf("encode push event: %w", err)
}
if route.Channel != intentstream.ChannelPush {
return Event{}, fmt.Errorf("encode push event: route channel %q is unsupported", route.Channel)
}
userID, err := userIDFromRecipientRef(route.RecipientRef)
if err != nil {
return Event{}, fmt.Errorf("encode push event: %w", err)
}
payloadBytes, err := encodePayload(notification.NotificationType, notification.PayloadJSON)
if err != nil {
return Event{}, fmt.Errorf("encode push event: %w", err)
}
return Event{
UserID: userID,
EventType: string(notification.NotificationType),
EventID: notification.NotificationID + "/" + route.RouteID,
PayloadBytes: payloadBytes,
RequestID: notification.RequestID,
TraceID: notification.TraceID,
}, nil
}
func encodePayload(notificationType intentstream.NotificationType, payloadJSON string) ([]byte, error) {
switch notificationType {
case intentstream.NotificationTypeGameTurnReady:
var payload struct {
GameID string `json:"game_id"`
TurnNumber int64 `json:"turn_number"`
}
if err := decodePayload(payloadJSON, &payload); err != nil {
return nil, err
}
if payload.GameID == "" {
return nil, errors.New("payload_encoding_failed: game_id is empty")
}
if payload.TurnNumber < 1 {
return nil, errors.New("payload_encoding_failed: turn_number must be at least 1")
}
return wrapPayloadEncoding(transcoder.GameTurnReadyEventToPayload(&transcoder.GameTurnReadyEvent{
GameID: payload.GameID,
TurnNumber: payload.TurnNumber,
}))
case intentstream.NotificationTypeGameFinished:
var payload struct {
GameID string `json:"game_id"`
FinalTurnNumber int64 `json:"final_turn_number"`
}
if err := decodePayload(payloadJSON, &payload); err != nil {
return nil, err
}
if payload.GameID == "" {
return nil, errors.New("payload_encoding_failed: game_id is empty")
}
if payload.FinalTurnNumber < 1 {
return nil, errors.New("payload_encoding_failed: final_turn_number must be at least 1")
}
return wrapPayloadEncoding(transcoder.GameFinishedEventToPayload(&transcoder.GameFinishedEvent{
GameID: payload.GameID,
FinalTurnNumber: payload.FinalTurnNumber,
}))
case intentstream.NotificationTypeLobbyApplicationSubmitted:
var payload struct {
GameID string `json:"game_id"`
ApplicantUserID string `json:"applicant_user_id"`
}
if err := decodePayload(payloadJSON, &payload); err != nil {
return nil, err
}
if payload.GameID == "" {
return nil, errors.New("payload_encoding_failed: game_id is empty")
}
if payload.ApplicantUserID == "" {
return nil, errors.New("payload_encoding_failed: applicant_user_id is empty")
}
return wrapPayloadEncoding(transcoder.LobbyApplicationSubmittedEventToPayload(&transcoder.LobbyApplicationSubmittedEvent{
GameID: payload.GameID,
ApplicantUserID: payload.ApplicantUserID,
}))
case intentstream.NotificationTypeLobbyMembershipApproved:
var payload struct {
GameID string `json:"game_id"`
}
if err := decodePayload(payloadJSON, &payload); err != nil {
return nil, err
}
if payload.GameID == "" {
return nil, errors.New("payload_encoding_failed: game_id is empty")
}
return wrapPayloadEncoding(transcoder.LobbyMembershipApprovedEventToPayload(&transcoder.LobbyMembershipApprovedEvent{
GameID: payload.GameID,
}))
case intentstream.NotificationTypeLobbyMembershipRejected:
var payload struct {
GameID string `json:"game_id"`
}
if err := decodePayload(payloadJSON, &payload); err != nil {
return nil, err
}
if payload.GameID == "" {
return nil, errors.New("payload_encoding_failed: game_id is empty")
}
return wrapPayloadEncoding(transcoder.LobbyMembershipRejectedEventToPayload(&transcoder.LobbyMembershipRejectedEvent{
GameID: payload.GameID,
}))
case intentstream.NotificationTypeLobbyInviteCreated:
var payload struct {
GameID string `json:"game_id"`
InviterUserID string `json:"inviter_user_id"`
}
if err := decodePayload(payloadJSON, &payload); err != nil {
return nil, err
}
if payload.GameID == "" {
return nil, errors.New("payload_encoding_failed: game_id is empty")
}
if payload.InviterUserID == "" {
return nil, errors.New("payload_encoding_failed: inviter_user_id is empty")
}
return wrapPayloadEncoding(transcoder.LobbyInviteCreatedEventToPayload(&transcoder.LobbyInviteCreatedEvent{
GameID: payload.GameID,
InviterUserID: payload.InviterUserID,
}))
case intentstream.NotificationTypeLobbyInviteRedeemed:
var payload struct {
GameID string `json:"game_id"`
InviteeUserID string `json:"invitee_user_id"`
}
if err := decodePayload(payloadJSON, &payload); err != nil {
return nil, err
}
if payload.GameID == "" {
return nil, errors.New("payload_encoding_failed: game_id is empty")
}
if payload.InviteeUserID == "" {
return nil, errors.New("payload_encoding_failed: invitee_user_id is empty")
}
return wrapPayloadEncoding(transcoder.LobbyInviteRedeemedEventToPayload(&transcoder.LobbyInviteRedeemedEvent{
GameID: payload.GameID,
InviteeUserID: payload.InviteeUserID,
}))
default:
return nil, fmt.Errorf("payload_encoding_failed: notification type %q does not support push", notificationType)
}
}
func decodePayload(payloadJSON string, target any) error {
if err := json.Unmarshal([]byte(payloadJSON), target); err != nil {
return fmt.Errorf("payload_encoding_failed: decode payload_json: %w", err)
}
return nil
}
func wrapPayloadEncoding(payload []byte, err error) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("payload_encoding_failed: %w", err)
}
return payload, nil
}
func userIDFromRecipientRef(recipientRef string) (string, error) {
userID, ok := strings.CutPrefix(recipientRef, "user:")
if !ok || userID == "" {
return "", fmt.Errorf("recipient_ref %q is not user-targeted", recipientRef)
}
return userID, nil
}
@@ -0,0 +1,186 @@
package publishpush
import (
"testing"
"time"
"galaxy/notification/internal/api/intentstream"
"galaxy/notification/internal/service/acceptintent"
"galaxy/transcoder"
"github.com/stretchr/testify/require"
)
func TestEncoderEncodesSupportedPushNotificationTypes(t *testing.T) {
t.Parallel()
now := time.UnixMilli(1775121700000).UTC()
tests := []struct {
name string
notificationType intentstream.NotificationType
payloadJSON string
assertPayload func(*testing.T, []byte)
}{
{
name: "game turn ready",
notificationType: intentstream.NotificationTypeGameTurnReady,
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","turn_number":54}`,
assertPayload: func(t *testing.T, payload []byte) {
t.Helper()
event, err := transcoder.PayloadToGameTurnReadyEvent(payload)
require.NoError(t, err)
require.Equal(t, "game-1", event.GameID)
require.Equal(t, int64(54), event.TurnNumber)
},
},
{
name: "game finished",
notificationType: intentstream.NotificationTypeGameFinished,
payloadJSON: `{"final_turn_number":81,"game_id":"game-2","game_name":"Nova"}`,
assertPayload: func(t *testing.T, payload []byte) {
t.Helper()
event, err := transcoder.PayloadToGameFinishedEvent(payload)
require.NoError(t, err)
require.Equal(t, "game-2", event.GameID)
require.Equal(t, int64(81), event.FinalTurnNumber)
},
},
{
name: "lobby application submitted",
notificationType: intentstream.NotificationTypeLobbyApplicationSubmitted,
payloadJSON: `{"applicant_name":"Nova Pilot","applicant_user_id":"user-2","game_id":"game-3","game_name":"Orion Front"}`,
assertPayload: func(t *testing.T, payload []byte) {
t.Helper()
event, err := transcoder.PayloadToLobbyApplicationSubmittedEvent(payload)
require.NoError(t, err)
require.Equal(t, "game-3", event.GameID)
require.Equal(t, "user-2", event.ApplicantUserID)
},
},
{
name: "lobby membership approved",
notificationType: intentstream.NotificationTypeLobbyMembershipApproved,
payloadJSON: `{"game_id":"game-4","game_name":"Ares"}`,
assertPayload: func(t *testing.T, payload []byte) {
t.Helper()
event, err := transcoder.PayloadToLobbyMembershipApprovedEvent(payload)
require.NoError(t, err)
require.Equal(t, "game-4", event.GameID)
},
},
{
name: "lobby membership rejected",
notificationType: intentstream.NotificationTypeLobbyMembershipRejected,
payloadJSON: `{"game_id":"game-5","game_name":"Atlas"}`,
assertPayload: func(t *testing.T, payload []byte) {
t.Helper()
event, err := transcoder.PayloadToLobbyMembershipRejectedEvent(payload)
require.NoError(t, err)
require.Equal(t, "game-5", event.GameID)
},
},
{
name: "lobby invite created",
notificationType: intentstream.NotificationTypeLobbyInviteCreated,
payloadJSON: `{"game_id":"game-6","game_name":"Vega","inviter_name":"Nova Pilot","inviter_user_id":"user-9"}`,
assertPayload: func(t *testing.T, payload []byte) {
t.Helper()
event, err := transcoder.PayloadToLobbyInviteCreatedEvent(payload)
require.NoError(t, err)
require.Equal(t, "game-6", event.GameID)
require.Equal(t, "user-9", event.InviterUserID)
},
},
{
name: "lobby invite redeemed",
notificationType: intentstream.NotificationTypeLobbyInviteRedeemed,
payloadJSON: `{"game_id":"game-7","game_name":"Lyra","invitee_name":"Skipper","invitee_user_id":"user-10"}`,
assertPayload: func(t *testing.T, payload []byte) {
t.Helper()
event, err := transcoder.PayloadToLobbyInviteRedeemedEvent(payload)
require.NoError(t, err)
require.Equal(t, "game-7", event.GameID)
require.Equal(t, "user-10", event.InviteeUserID)
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
event, err := Encoder{}.Encode(
acceptintent.NotificationRecord{
NotificationID: "1775121700000-0",
NotificationType: tt.notificationType,
Producer: tt.notificationType.ExpectedProducer(),
AudienceKind: intentstream.AudienceKindUser,
RecipientUserIDs: []string{"user-1"},
PayloadJSON: tt.payloadJSON,
IdempotencyKey: "idem-1",
RequestFingerprint: "sha256:deadbeef",
RequestID: "request-1",
TraceID: "trace-1",
OccurredAt: now,
AcceptedAt: now,
UpdatedAt: now,
},
acceptintent.NotificationRoute{
NotificationID: "1775121700000-0",
RouteID: "push:user:user-1",
Channel: intentstream.ChannelPush,
RecipientRef: "user:user-1",
Status: acceptintent.RouteStatusPending,
MaxAttempts: 3,
NextAttemptAt: now,
CreatedAt: now,
UpdatedAt: now,
},
)
require.NoError(t, err)
require.Equal(t, "user-1", event.UserID)
require.Equal(t, string(tt.notificationType), event.EventType)
require.Equal(t, "1775121700000-0/push:user:user-1", event.EventID)
require.Equal(t, "request-1", event.RequestID)
require.Equal(t, "trace-1", event.TraceID)
require.NotEmpty(t, event.PayloadBytes)
tt.assertPayload(t, event.PayloadBytes)
})
}
}
func TestEncoderRejectsInvalidStoredPayload(t *testing.T) {
t.Parallel()
now := time.UnixMilli(1775121700000).UTC()
_, err := Encoder{}.Encode(
acceptintent.NotificationRecord{
NotificationID: "1775121700000-0",
NotificationType: intentstream.NotificationTypeGameTurnReady,
Producer: intentstream.ProducerGameMaster,
AudienceKind: intentstream.AudienceKindUser,
RecipientUserIDs: []string{"user-1"},
PayloadJSON: `{"game_id":"","game_name":"Nebula Clash","turn_number":0}`,
IdempotencyKey: "idem-1",
RequestFingerprint: "sha256:deadbeef",
OccurredAt: now,
AcceptedAt: now,
UpdatedAt: now,
},
acceptintent.NotificationRoute{
NotificationID: "1775121700000-0",
RouteID: "push:user:user-1",
Channel: intentstream.ChannelPush,
RecipientRef: "user:user-1",
Status: acceptintent.RouteStatusPending,
MaxAttempts: 3,
NextAttemptAt: now,
CreatedAt: now,
UpdatedAt: now,
},
)
require.Error(t, err)
require.ErrorContains(t, err, "payload_encoding_failed")
}