package lobbyeventspublisher import ( "context" "encoding/json" "strconv" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "galaxy/gamemaster/internal/domain/runtime" "galaxy/gamemaster/internal/ports" ) const testStream = "gm:lobby_events" func newTestPublisher(t *testing.T) (*Publisher, *redis.Client) { t.Helper() server := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: server.Addr()}) t.Cleanup(func() { _ = client.Close() }) publisher, err := NewPublisher(Config{Client: client, Stream: testStream}) require.NoError(t, err) return publisher, client } func TestNewPublisherValidation(t *testing.T) { t.Run("nil client", func(t *testing.T) { _, err := NewPublisher(Config{Stream: testStream}) require.Error(t, err) }) t.Run("empty stream", func(t *testing.T) { client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}) t.Cleanup(func() { _ = client.Close() }) _, err := NewPublisher(Config{Client: client}) require.Error(t, err) }) } func TestPublishSnapshotUpdateHappyPath(t *testing.T) { publisher, client := newTestPublisher(t) occurredAt := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC) msg := ports.RuntimeSnapshotUpdate{ GameID: "game-1", CurrentTurn: 17, RuntimeStatus: runtime.StatusRunning, EngineHealthSummary: "healthy", PlayerTurnStats: []ports.PlayerTurnStats{ {UserID: "user-1", Planets: 4, Population: 12000}, {UserID: "user-2", Planets: 3, Population: 9000}, }, OccurredAt: occurredAt, } require.NoError(t, publisher.PublishSnapshotUpdate(context.Background(), msg)) entries, err := client.XRange(context.Background(), testStream, "-", "+").Result() require.NoError(t, err) require.Len(t, entries, 1) values := entries[0].Values assert.Equal(t, "runtime_snapshot_update", values[fieldEventType]) assert.Equal(t, "game-1", values[fieldGameID]) assert.Equal(t, "17", values[fieldCurrentTurn]) assert.Equal(t, "running", values[fieldRuntimeStatus]) assert.Equal(t, "healthy", values[fieldEngineHealthSummary]) assert.Equal(t, strconv.FormatInt(occurredAt.UnixMilli(), 10), values[fieldOccurredAtMS]) statsRaw, ok := values[fieldPlayerTurnStats].(string) require.True(t, ok) var stats []playerTurnStatEnvelope require.NoError(t, json.Unmarshal([]byte(statsRaw), &stats)) assert.Equal(t, []playerTurnStatEnvelope{ {UserID: "user-1", Planets: 4, Population: 12000}, {UserID: "user-2", Planets: 3, Population: 9000}, }, stats) } func TestPublishSnapshotUpdateEmptyStatsBecomesArray(t *testing.T) { publisher, client := newTestPublisher(t) msg := ports.RuntimeSnapshotUpdate{ GameID: "g", CurrentTurn: 0, RuntimeStatus: runtime.StatusStarting, EngineHealthSummary: "", OccurredAt: time.Now().UTC(), } require.NoError(t, publisher.PublishSnapshotUpdate(context.Background(), msg)) entries, err := client.XRange(context.Background(), testStream, "-", "+").Result() require.NoError(t, err) require.Len(t, entries, 1) assert.Equal(t, "[]", entries[0].Values[fieldPlayerTurnStats]) } func TestPublishSnapshotUpdateRejectsInvalid(t *testing.T) { publisher, client := newTestPublisher(t) require.Error(t, publisher.PublishSnapshotUpdate(context.Background(), ports.RuntimeSnapshotUpdate{})) entries, err := client.XRange(context.Background(), testStream, "-", "+").Result() require.NoError(t, err) assert.Empty(t, entries, "invalid messages must not reach the stream") } func TestPublishGameFinishedHappyPath(t *testing.T) { publisher, client := newTestPublisher(t) finishedAt := time.Date(2026, 4, 28, 8, 30, 0, 0, time.UTC) msg := ports.GameFinished{ GameID: "game-1", FinalTurnNumber: 42, RuntimeStatus: runtime.StatusFinished, PlayerTurnStats: []ports.PlayerTurnStats{ {UserID: "user-1", Planets: 6, Population: 25000}, {UserID: "user-2", Planets: 0, Population: 0}, }, FinishedAt: finishedAt, } require.NoError(t, publisher.PublishGameFinished(context.Background(), msg)) entries, err := client.XRange(context.Background(), testStream, "-", "+").Result() require.NoError(t, err) require.Len(t, entries, 1) values := entries[0].Values assert.Equal(t, "game_finished", values[fieldEventType]) assert.Equal(t, "game-1", values[fieldGameID]) assert.Equal(t, "42", values[fieldFinalTurnNumber]) assert.Equal(t, "finished", values[fieldRuntimeStatus]) assert.Equal(t, strconv.FormatInt(finishedAt.UnixMilli(), 10), values[fieldFinishedAtMS]) _, hasOccurred := values[fieldOccurredAtMS] assert.False(t, hasOccurred, "game_finished must not carry occurred_at_ms") _, hasCurrentTurn := values[fieldCurrentTurn] assert.False(t, hasCurrentTurn, "game_finished must not carry current_turn") _, hasHealth := values[fieldEngineHealthSummary] assert.False(t, hasHealth, "game_finished must not carry engine_health_summary") } func TestPublishGameFinishedRejectsBadStatus(t *testing.T) { publisher, client := newTestPublisher(t) require.Error(t, publisher.PublishGameFinished(context.Background(), ports.GameFinished{ GameID: "g", FinalTurnNumber: 1, RuntimeStatus: runtime.StatusRunning, // wrong status FinishedAt: time.Now().UTC(), })) entries, err := client.XRange(context.Background(), testStream, "-", "+").Result() require.NoError(t, err) assert.Empty(t, entries) } func TestTimestampsNormalisedToUTC(t *testing.T) { publisher, client := newTestPublisher(t) loc, err := time.LoadLocation("Asia/Tokyo") require.NoError(t, err) msg := ports.RuntimeSnapshotUpdate{ GameID: "g", CurrentTurn: 1, RuntimeStatus: runtime.StatusRunning, OccurredAt: time.Date(2026, 4, 27, 21, 0, 0, 0, loc), } require.NoError(t, publisher.PublishSnapshotUpdate(context.Background(), msg)) entries, err := client.XRange(context.Background(), testStream, "-", "+").Result() require.NoError(t, err) require.Len(t, entries, 1) wantMs := msg.OccurredAt.UTC().UnixMilli() assert.Equal(t, strconv.FormatInt(wantMs, 10), entries[0].Values[fieldOccurredAtMS]) } func TestRejectsNilContext(t *testing.T) { publisher, _ := newTestPublisher(t) //nolint:staticcheck // explicitly testing nil-context rejection. err := publisher.PublishSnapshotUpdate(nil, ports.RuntimeSnapshotUpdate{ GameID: "g", CurrentTurn: 0, RuntimeStatus: runtime.StatusStarting, OccurredAt: time.Now().UTC(), }) require.Error(t, err) }