docs: reorder & testing
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
"galaxy/backend/internal/user"
|
||||
"galaxy/backend/push"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
@@ -13,9 +14,17 @@ import (
|
||||
|
||||
// PushPublisher is the publisher contract notification uses to emit a
|
||||
// `client_event` push frame to gateway. The real implementation lives
|
||||
// in `backend/internal/push` ; NewNoopPushPublisher satisfies
|
||||
// in `backend/push` (`*push.Service`); NewNoopPushPublisher satisfies
|
||||
// the interface for tests that do not exercise push behaviour.
|
||||
//
|
||||
// `event` is a typed `push.Event`: the publisher invokes Marshal on
|
||||
// the event at publish time, so producers stay decoupled from the
|
||||
// wire encoding. Every catalog kind has a FlatBuffers schema in
|
||||
// `pkg/schema/fbs/notification.fbs` and is built by
|
||||
// `buildClientPushEvent`; an unknown kind falls back to
|
||||
// `push.JSONEvent` so a misconfigured producer keeps the pipeline
|
||||
// flowing.
|
||||
//
|
||||
// Implementations must be concurrency-safe. The deviceSessionID pointer
|
||||
// narrows the event to a single device session when non-nil; nil means
|
||||
// fan out to every active session of userID. eventID, requestID and
|
||||
@@ -23,7 +32,7 @@ import (
|
||||
// into the signed client envelope; empty strings are forwarded
|
||||
// unchanged.
|
||||
type PushPublisher interface {
|
||||
PublishClientEvent(ctx context.Context, userID uuid.UUID, deviceSessionID *uuid.UUID, kind string, payload map[string]any, eventID, requestID, traceID string) error
|
||||
PublishClientEvent(ctx context.Context, userID uuid.UUID, deviceSessionID *uuid.UUID, event push.Event, eventID, requestID, traceID string) error
|
||||
}
|
||||
|
||||
// Mailer is the email surface notification uses for outbound mail. The
|
||||
@@ -76,11 +85,14 @@ type noopPushPublisher struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func (p *noopPushPublisher) PublishClientEvent(_ context.Context, userID uuid.UUID, deviceSessionID *uuid.UUID, kind string, payload map[string]any, eventID, requestID, traceID string) error {
|
||||
func (p *noopPushPublisher) PublishClientEvent(_ context.Context, userID uuid.UUID, deviceSessionID *uuid.UUID, event push.Event, eventID, requestID, traceID string) error {
|
||||
kind := ""
|
||||
if event != nil {
|
||||
kind = event.Kind()
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("kind", kind),
|
||||
zap.Int("payload_keys", len(payload)),
|
||||
}
|
||||
if deviceSessionID != nil {
|
||||
fields = append(fields, zap.String("device_session_id", deviceSessionID.String()))
|
||||
|
||||
@@ -121,7 +121,11 @@ func (s *Service) performDispatch(ctx context.Context, claim ClaimedRoute) error
|
||||
eventID := claim.Route.RouteID.String()
|
||||
requestID := claim.Notification.IdempotencyKey
|
||||
traceID := traceIDFromContext(ctx)
|
||||
return s.deps.Push.PublishClientEvent(ctx, *claim.Route.UserID, claim.Route.DeviceSessionID, claim.Notification.Kind, claim.Notification.Payload, eventID, requestID, traceID)
|
||||
event, err := buildClientPushEvent(claim.Notification.Kind, claim.Notification.Payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build push event %q: %w", claim.Notification.Kind, err)
|
||||
}
|
||||
return s.deps.Push.PublishClientEvent(ctx, *claim.Route.UserID, claim.Route.DeviceSessionID, event, eventID, requestID, traceID)
|
||||
case ChannelEmail:
|
||||
entry, ok := LookupCatalog(claim.Notification.Kind)
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"galaxy/backend/push"
|
||||
"galaxy/transcoder"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// preMarshaledEvent adapts a pre-encoded FlatBuffers payload to the
|
||||
// push.Event interface. The factory below pre-encodes the payload at
|
||||
// construction time so the kind-specific build error surfaces inside
|
||||
// the dispatcher (where it can drive retry / dead-letter logic) rather
|
||||
// than inside push.Service.PublishClientEvent.
|
||||
type preMarshaledEvent struct {
|
||||
kind string
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func (e preMarshaledEvent) Kind() string { return e.kind }
|
||||
func (e preMarshaledEvent) Marshal() ([]byte, error) { return e.payload, nil }
|
||||
|
||||
// buildClientPushEvent maps a catalog kind together with the producer
|
||||
// payload map onto a typed push.Event. Every catalog kind has a
|
||||
// FlatBuffers schema in `pkg/schema/fbs/notification.fbs`; an unknown
|
||||
// kind falls back to push.JSONEvent so a misconfigured producer keeps
|
||||
// the pipeline flowing while the catalog catches up.
|
||||
func buildClientPushEvent(kind string, payload map[string]any) (push.Event, error) {
|
||||
switch kind {
|
||||
case KindLobbyInviteReceived:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inviter, err := mapUUID(payload, "inviter_user_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyInviteReceivedEventToPayload(&transcoder.LobbyInviteReceivedEvent{
|
||||
GameID: gameID,
|
||||
InviterUserID: inviter,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyInviteRevoked:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyInviteRevokedEventToPayload(&transcoder.LobbyInviteRevokedEvent{GameID: gameID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyApplicationSubmitted:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appID, err := mapUUID(payload, "application_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyApplicationSubmittedEventToPayload(&transcoder.LobbyApplicationSubmittedEvent{
|
||||
GameID: gameID,
|
||||
ApplicationID: appID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyApplicationApproved:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyApplicationApprovedEventToPayload(&transcoder.LobbyApplicationApprovedEvent{GameID: gameID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyApplicationRejected:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyApplicationRejectedEventToPayload(&transcoder.LobbyApplicationRejectedEvent{GameID: gameID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyMembershipRemoved:
|
||||
bytes, err := transcoder.LobbyMembershipRemovedEventToPayload(&transcoder.LobbyMembershipRemovedEvent{
|
||||
Reason: mapStringOpt(payload, "reason"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyMembershipBlocked:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyMembershipBlockedEventToPayload(&transcoder.LobbyMembershipBlockedEvent{
|
||||
GameID: gameID,
|
||||
Reason: mapStringOpt(payload, "reason"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyRaceNameRegistered:
|
||||
raceName, err := mapString(payload, "race_name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyRaceNameRegisteredEventToPayload(&transcoder.LobbyRaceNameRegisteredEvent{RaceName: raceName})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyRaceNamePending:
|
||||
raceName, err := mapString(payload, "race_name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyRaceNamePendingEventToPayload(&transcoder.LobbyRaceNamePendingEvent{
|
||||
RaceName: raceName,
|
||||
ExpiresAt: mapStringOpt(payload, "expires_at"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyRaceNameExpired:
|
||||
raceName, err := mapString(payload, "race_name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyRaceNameExpiredEventToPayload(&transcoder.LobbyRaceNameExpiredEvent{RaceName: raceName})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindRuntimeImagePullFailed:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.RuntimeImagePullFailedEventToPayload(&transcoder.RuntimeImagePullFailedEvent{
|
||||
GameID: gameID,
|
||||
ImageRef: mapStringOpt(payload, "image_ref"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindRuntimeContainerStartFailed:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.RuntimeContainerStartFailedEventToPayload(&transcoder.RuntimeContainerStartFailedEvent{GameID: gameID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindRuntimeStartConfigInvalid:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.RuntimeStartConfigInvalidEventToPayload(&transcoder.RuntimeStartConfigInvalidEvent{
|
||||
GameID: gameID,
|
||||
Reason: mapStringOpt(payload, "reason"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
}
|
||||
|
||||
return push.JSONEvent{EventKind: kind, Payload: payload}, nil
|
||||
}
|
||||
|
||||
// mapUUID extracts a required UUID-shaped field from the producer
|
||||
// payload. Producers stringify uuid values before assembling Intent
|
||||
// payloads, so the JSON-roundtripped form is `string`.
|
||||
func mapUUID(payload map[string]any, key string) (uuid.UUID, error) {
|
||||
raw, ok := payload[key]
|
||||
if !ok {
|
||||
return uuid.Nil, fmt.Errorf("notification payload: %s is missing", key)
|
||||
}
|
||||
str, ok := raw.(string)
|
||||
if !ok {
|
||||
return uuid.Nil, fmt.Errorf("notification payload: %s must be a string, got %T", key, raw)
|
||||
}
|
||||
parsed, err := uuid.Parse(str)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("notification payload: %s is not a uuid: %w", key, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// mapString extracts a required string field from the producer payload.
|
||||
func mapString(payload map[string]any, key string) (string, error) {
|
||||
raw, ok := payload[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("notification payload: %s is missing", key)
|
||||
}
|
||||
str, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("notification payload: %s must be a string, got %T", key, raw)
|
||||
}
|
||||
if str == "" {
|
||||
return "", fmt.Errorf("notification payload: %s is empty", key)
|
||||
}
|
||||
return str, nil
|
||||
}
|
||||
|
||||
// mapStringOpt returns the string value for key, or "" when the key is
|
||||
// missing or carries a non-string value.
|
||||
func mapStringOpt(payload map[string]any, key string) string {
|
||||
raw, ok := payload[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
str, _ := raw.(string)
|
||||
return str
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"galaxy/backend/push"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestBuildClientPushEventCoversCatalog asserts that every catalog kind
|
||||
// returns a typed FB event (preMarshaledEvent) and that an unknown kind
|
||||
// falls through to the JSON safety net.
|
||||
func TestBuildClientPushEventCoversCatalog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gameID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
applicationID := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
||||
inviterID := uuid.MustParse("33333333-3333-3333-3333-333333333333")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
payload map[string]any
|
||||
}{
|
||||
{"invite received", KindLobbyInviteReceived, map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
"inviter_user_id": inviterID.String(),
|
||||
}},
|
||||
{"invite revoked", KindLobbyInviteRevoked, map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
}},
|
||||
{"application submitted", KindLobbyApplicationSubmitted, map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
"application_id": applicationID.String(),
|
||||
}},
|
||||
{"application approved", KindLobbyApplicationApproved, map[string]any{"game_id": gameID.String()}},
|
||||
{"application rejected", KindLobbyApplicationRejected, map[string]any{"game_id": gameID.String()}},
|
||||
{"membership removed", KindLobbyMembershipRemoved, map[string]any{"reason": "deleted"}},
|
||||
{"membership blocked", KindLobbyMembershipBlocked, map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
"reason": "permanent_blocked",
|
||||
}},
|
||||
{"race name registered", KindLobbyRaceNameRegistered, map[string]any{"race_name": "Skylancer"}},
|
||||
{"race name pending", KindLobbyRaceNamePending, map[string]any{
|
||||
"race_name": "Skylancer",
|
||||
"expires_at": "2026-05-06T12:00:00Z",
|
||||
}},
|
||||
{"race name expired", KindLobbyRaceNameExpired, map[string]any{"race_name": "Skylancer"}},
|
||||
{"runtime image pull failed", KindRuntimeImagePullFailed, map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
"image_ref": "gcr.io/example:1.0.0",
|
||||
}},
|
||||
{"runtime container start failed", KindRuntimeContainerStartFailed, map[string]any{"game_id": gameID.String()}},
|
||||
{"runtime start config invalid", KindRuntimeStartConfigInvalid, map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
"reason": "missing engine version",
|
||||
}},
|
||||
}
|
||||
|
||||
seenKinds := map[string]bool{}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
event, err := buildClientPushEvent(tt.kind, tt.payload)
|
||||
if err != nil {
|
||||
t.Fatalf("build %s: %v", tt.kind, err)
|
||||
}
|
||||
if event.Kind() != tt.kind {
|
||||
t.Fatalf("Kind() = %q, want %q", event.Kind(), tt.kind)
|
||||
}
|
||||
bytes, err := event.Marshal()
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if len(bytes) == 0 {
|
||||
t.Fatalf("Marshal returned empty bytes")
|
||||
}
|
||||
if _, isJSON := event.(push.JSONEvent); isJSON {
|
||||
t.Fatalf("expected typed FB event for %s, got JSONEvent", tt.kind)
|
||||
}
|
||||
})
|
||||
seenKinds[tt.kind] = true
|
||||
}
|
||||
for _, kind := range SupportedKinds() {
|
||||
if !seenKinds[kind] {
|
||||
t.Errorf("catalog kind %q is not covered by this test", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClientPushEventUnknownKindFallsBackToJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
event, err := buildClientPushEvent("unknown.kind", map[string]any{"x": 1})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := event.(push.JSONEvent); !ok {
|
||||
t.Fatalf("expected JSONEvent fallback, got %T", event)
|
||||
}
|
||||
if event.Kind() != "unknown.kind" {
|
||||
t.Fatalf("Kind() = %q", event.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClientPushEventRejectsBrokenPayloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
payload map[string]any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing required uuid",
|
||||
kind: KindLobbyApplicationSubmitted,
|
||||
payload: map[string]any{"game_id": uuid.NewString()},
|
||||
want: "application_id is missing",
|
||||
},
|
||||
{
|
||||
name: "non-uuid string",
|
||||
kind: KindLobbyInviteRevoked,
|
||||
payload: map[string]any{"game_id": "not-a-uuid"},
|
||||
want: "is not a uuid",
|
||||
},
|
||||
{
|
||||
name: "uuid not a string",
|
||||
kind: KindLobbyInviteRevoked,
|
||||
payload: map[string]any{"game_id": 42},
|
||||
want: "must be a string",
|
||||
},
|
||||
{
|
||||
name: "missing required string",
|
||||
kind: KindLobbyRaceNameRegistered,
|
||||
payload: map[string]any{},
|
||||
want: "race_name is missing",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := buildClientPushEvent(tt.kind, tt.payload)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"galaxy/backend/internal/notification"
|
||||
backendpg "galaxy/backend/internal/postgres"
|
||||
"galaxy/backend/internal/user"
|
||||
"galaxy/backend/push"
|
||||
pgshared "galaxy/postgres"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -69,7 +70,7 @@ func startPostgres(t *testing.T) *sql.DB {
|
||||
cfg := pgshared.DefaultConfig()
|
||||
cfg.PrimaryDSN = scoped
|
||||
cfg.OperationTimeout = pgOpTO
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg)
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("open primary: %v", err)
|
||||
}
|
||||
@@ -149,9 +150,17 @@ type recordedPushEvent struct {
|
||||
TraceID string
|
||||
}
|
||||
|
||||
func (r *recordingPush) PublishClientEvent(_ context.Context, userID uuid.UUID, _ *uuid.UUID, kind string, payload map[string]any, eventID, requestID, traceID string) error {
|
||||
func (r *recordingPush) PublishClientEvent(_ context.Context, userID uuid.UUID, _ *uuid.UUID, event push.Event, eventID, requestID, traceID string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
kind := ""
|
||||
var payload map[string]any
|
||||
if event != nil {
|
||||
kind = event.Kind()
|
||||
if jsonEvent, ok := event.(push.JSONEvent); ok {
|
||||
payload = jsonEvent.Payload
|
||||
}
|
||||
}
|
||||
r.calls = append(r.calls, recordedPushEvent{
|
||||
UserID: userID,
|
||||
Kind: kind,
|
||||
|
||||
Reference in New Issue
Block a user