feat: gamemaster
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
// Package notificationpublisher provides the Redis-Streams-backed
|
||||
// notification-intent publisher Game Master uses for the three GM-owned
|
||||
// types listed in `gamemaster/README.md §Notification Contracts`:
|
||||
// `game.turn.ready`, `game.finished`, `game.generation_failed`.
|
||||
//
|
||||
// The adapter is a thin shim over `galaxy/notificationintent.Publisher`
|
||||
// that drops the entry id at the wrapper boundary; it mirrors
|
||||
// `rtmanager/internal/adapters/notificationpublisher` byte-for-byte
|
||||
// (`rtmanager/docs/domain-and-ports.md §7` justifies that decision and
|
||||
// applies here for the same reason).
|
||||
package notificationpublisher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"galaxy/notificationintent"
|
||||
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
)
|
||||
|
||||
// Config groups the dependencies and stream name required to construct
|
||||
// a Publisher.
|
||||
type Config struct {
|
||||
// Client appends entries to Redis Streams. Must be non-nil.
|
||||
Client *redis.Client
|
||||
|
||||
// Stream stores the Redis Stream key intents are published to.
|
||||
// When empty, `notificationintent.DefaultIntentsStream` is used.
|
||||
Stream string
|
||||
}
|
||||
|
||||
// Publisher implements `ports.NotificationIntentPublisher` on top of
|
||||
// the shared `notificationintent.Publisher`.
|
||||
type Publisher struct {
|
||||
inner *notificationintent.Publisher
|
||||
}
|
||||
|
||||
// NewPublisher constructs a Publisher from cfg. Validation errors and
|
||||
// transport errors propagate verbatim.
|
||||
func NewPublisher(cfg Config) (*Publisher, error) {
|
||||
if cfg.Client == nil {
|
||||
return nil, errors.New("new gamemaster notification publisher: nil redis client")
|
||||
}
|
||||
inner, err := notificationintent.NewPublisher(notificationintent.PublisherConfig{
|
||||
Client: cfg.Client,
|
||||
Stream: cfg.Stream,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new gamemaster notification publisher: %w", err)
|
||||
}
|
||||
return &Publisher{inner: inner}, nil
|
||||
}
|
||||
|
||||
// Publish forwards intent to the underlying notificationintent
|
||||
// publisher and discards the resulting Redis Stream entry id. A failed
|
||||
// publish surfaces as the underlying error.
|
||||
func (publisher *Publisher) Publish(ctx context.Context, intent notificationintent.Intent) error {
|
||||
if publisher == nil || publisher.inner == nil {
|
||||
return errors.New("publish notification intent: nil publisher")
|
||||
}
|
||||
if _, err := publisher.inner.Publish(ctx, intent); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time assertion: Publisher implements
|
||||
// ports.NotificationIntentPublisher.
|
||||
var _ ports.NotificationIntentPublisher = (*Publisher)(nil)
|
||||
@@ -0,0 +1,167 @@
|
||||
package notificationpublisher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"galaxy/notificationintent"
|
||||
)
|
||||
|
||||
func newRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
return client, server
|
||||
}
|
||||
|
||||
func readStream(t *testing.T, client *redis.Client, stream string) []redis.XMessage {
|
||||
t.Helper()
|
||||
messages, err := client.XRange(context.Background(), stream, "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
return messages
|
||||
}
|
||||
|
||||
func TestNewPublisherValidation(t *testing.T) {
|
||||
t.Run("nil client", func(t *testing.T) {
|
||||
_, err := NewPublisher(Config{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "nil redis client")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPublishGameTurnReady(t *testing.T) {
|
||||
client, _ := newRedis(t)
|
||||
|
||||
publisher, err := NewPublisher(Config{Client: client, Stream: "notification:intents"})
|
||||
require.NoError(t, err)
|
||||
|
||||
intent, err := notificationintent.NewGameTurnReadyIntent(
|
||||
notificationintent.Metadata{
|
||||
IdempotencyKey: "gamemaster:turn:game-1:42",
|
||||
OccurredAt: time.UnixMilli(1714200000000).UTC(),
|
||||
},
|
||||
[]string{"u-2", "u-1"},
|
||||
notificationintent.GameTurnReadyPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Galaxy",
|
||||
TurnNumber: 42,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, publisher.Publish(context.Background(), intent))
|
||||
|
||||
messages := readStream(t, client, "notification:intents")
|
||||
require.Len(t, messages, 1)
|
||||
values := messages[0].Values
|
||||
assert.Equal(t, "game.turn.ready", values["notification_type"])
|
||||
assert.Equal(t, "game_master", values["producer"])
|
||||
assert.Equal(t, "user", values["audience_kind"])
|
||||
assert.Equal(t, "gamemaster:turn:game-1:42", values["idempotency_key"])
|
||||
|
||||
recipients, ok := values["recipient_user_ids_json"].(string)
|
||||
require.True(t, ok)
|
||||
var ids []string
|
||||
require.NoError(t, json.Unmarshal([]byte(recipients), &ids))
|
||||
assert.ElementsMatch(t, []string{"u-1", "u-2"}, ids)
|
||||
|
||||
payloadRaw, ok := values["payload_json"].(string)
|
||||
require.True(t, ok)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(payloadRaw), &payload))
|
||||
assert.Equal(t, "game-1", payload["game_id"])
|
||||
assert.Equal(t, float64(42), payload["turn_number"])
|
||||
}
|
||||
|
||||
func TestPublishGameFinished(t *testing.T) {
|
||||
client, _ := newRedis(t)
|
||||
publisher, err := NewPublisher(Config{Client: client, Stream: "notification:intents"})
|
||||
require.NoError(t, err)
|
||||
|
||||
intent, err := notificationintent.NewGameFinishedIntent(
|
||||
notificationintent.Metadata{
|
||||
IdempotencyKey: "gamemaster:finished:g-1",
|
||||
OccurredAt: time.UnixMilli(1714200000000).UTC(),
|
||||
},
|
||||
[]string{"u-1"},
|
||||
notificationintent.GameFinishedPayload{
|
||||
GameID: "g-1",
|
||||
GameName: "Galaxy",
|
||||
FinalTurnNumber: 100,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, publisher.Publish(context.Background(), intent))
|
||||
|
||||
messages := readStream(t, client, "notification:intents")
|
||||
require.Len(t, messages, 1)
|
||||
assert.Equal(t, "game.finished", messages[0].Values["notification_type"])
|
||||
assert.Equal(t, "user", messages[0].Values["audience_kind"])
|
||||
}
|
||||
|
||||
func TestPublishGameGenerationFailed(t *testing.T) {
|
||||
client, _ := newRedis(t)
|
||||
publisher, err := NewPublisher(Config{Client: client, Stream: "notification:intents"})
|
||||
require.NoError(t, err)
|
||||
|
||||
intent, err := notificationintent.NewGameGenerationFailedIntent(
|
||||
notificationintent.Metadata{
|
||||
IdempotencyKey: "gamemaster:gen-failed:g-1:42",
|
||||
OccurredAt: time.UnixMilli(1714200000000).UTC(),
|
||||
},
|
||||
notificationintent.GameGenerationFailedPayload{
|
||||
GameID: "g-1",
|
||||
GameName: "Galaxy",
|
||||
FailureReason: "engine timeout",
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, publisher.Publish(context.Background(), intent))
|
||||
|
||||
messages := readStream(t, client, "notification:intents")
|
||||
require.Len(t, messages, 1)
|
||||
values := messages[0].Values
|
||||
assert.Equal(t, "game.generation_failed", values["notification_type"])
|
||||
assert.Equal(t, "admin_email", values["audience_kind"])
|
||||
_, hasRecipients := values["recipient_user_ids_json"]
|
||||
assert.False(t, hasRecipients, "admin_email audience must not carry recipient ids")
|
||||
}
|
||||
|
||||
func TestPublishForwardsValidationError(t *testing.T) {
|
||||
client, _ := newRedis(t)
|
||||
publisher, err := NewPublisher(Config{Client: client})
|
||||
require.NoError(t, err)
|
||||
|
||||
bad := notificationintent.Intent{
|
||||
NotificationType: notificationintent.NotificationTypeGameTurnReady,
|
||||
Producer: notificationintent.ProducerGameMaster,
|
||||
AudienceKind: notificationintent.AudienceKindUser,
|
||||
IdempotencyKey: "k",
|
||||
PayloadJSON: `{"game_id":"g","game_name":"x","turn_number":1}`,
|
||||
}
|
||||
require.Error(t, publisher.Publish(context.Background(), bad))
|
||||
}
|
||||
|
||||
func TestPublishDefaultStream(t *testing.T) {
|
||||
client, _ := newRedis(t)
|
||||
publisher, err := NewPublisher(Config{Client: client, Stream: ""})
|
||||
require.NoError(t, err)
|
||||
|
||||
intent, err := notificationintent.NewGameTurnReadyIntent(
|
||||
notificationintent.Metadata{IdempotencyKey: "k", OccurredAt: time.UnixMilli(1).UTC()},
|
||||
[]string{"u-1"},
|
||||
notificationintent.GameTurnReadyPayload{GameID: "g", GameName: "n", TurnNumber: 1},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, publisher.Publish(context.Background(), intent))
|
||||
|
||||
messages := readStream(t, client, notificationintent.DefaultIntentsStream)
|
||||
require.Len(t, messages, 1)
|
||||
}
|
||||
Reference in New Issue
Block a user