docs: reorder & testing

This commit is contained in:
Ilia Denisov
2026-05-07 00:58:53 +03:00
committed by GitHub
parent f446c6a2ac
commit 604fe40bcf
148 changed files with 9150 additions and 2757 deletions
+16 -4
View File
@@ -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()))
+5 -1
View File
@@ -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 {
+247
View File
@@ -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)
}
})
}
}
+11 -2
View File
@@ -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,