package healtheventspublisher_test import ( "context" "encoding/json" "strconv" "sync" "testing" "time" "galaxy/rtmanager/internal/adapters/healtheventspublisher" "galaxy/rtmanager/internal/domain/health" "galaxy/rtmanager/internal/ports" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // fakeSnapshots captures Upsert invocations for assertions. type fakeSnapshots struct { mu sync.Mutex upserts []health.HealthSnapshot upsertErr error } func (s *fakeSnapshots) Upsert(_ context.Context, snapshot health.HealthSnapshot) error { s.mu.Lock() defer s.mu.Unlock() if s.upsertErr != nil { return s.upsertErr } s.upserts = append(s.upserts, snapshot) return nil } func (s *fakeSnapshots) Get(_ context.Context, _ string) (health.HealthSnapshot, error) { return health.HealthSnapshot{}, nil } func newPublisher(t *testing.T, snapshots ports.HealthSnapshotStore) (*healtheventspublisher.Publisher, *miniredis.Miniredis, *redis.Client) { t.Helper() server := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: server.Addr()}) t.Cleanup(func() { _ = client.Close() }) publisher, err := healtheventspublisher.NewPublisher(healtheventspublisher.Config{ Client: client, Snapshots: snapshots, Stream: "runtime:health_events", }) require.NoError(t, err) return publisher, server, client } func TestNewPublisherRejectsMissingCollaborators(t *testing.T) { _, err := healtheventspublisher.NewPublisher(healtheventspublisher.Config{}) require.Error(t, err) _, err = healtheventspublisher.NewPublisher(healtheventspublisher.Config{ Client: redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}), }) require.Error(t, err) _, err = healtheventspublisher.NewPublisher(healtheventspublisher.Config{ Client: redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}), Snapshots: &fakeSnapshots{}, }) require.Error(t, err) } func TestPublishContainerStartedUpsertsHealthyAndXAdds(t *testing.T) { snapshots := &fakeSnapshots{} publisher, _, client := newPublisher(t, snapshots) occurredAt := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC) envelope := ports.HealthEventEnvelope{ GameID: "game-1", ContainerID: "c-1", EventType: health.EventTypeContainerStarted, OccurredAt: occurredAt, Details: json.RawMessage(`{"image_ref":"galaxy/game:1.2.3"}`), } require.NoError(t, publisher.Publish(context.Background(), envelope)) require.Len(t, snapshots.upserts, 1) snapshot := snapshots.upserts[0] assert.Equal(t, "game-1", snapshot.GameID) assert.Equal(t, "c-1", snapshot.ContainerID) assert.Equal(t, health.SnapshotStatusHealthy, snapshot.Status) assert.Equal(t, health.SnapshotSourceDockerEvent, snapshot.Source) assert.JSONEq(t, `{"image_ref":"galaxy/game:1.2.3"}`, string(snapshot.Details)) assert.Equal(t, occurredAt, snapshot.ObservedAt) entries, err := client.XRange(context.Background(), "runtime:health_events", "-", "+").Result() require.NoError(t, err) require.Len(t, entries, 1) values := entries[0].Values assert.Equal(t, "game-1", values["game_id"]) assert.Equal(t, "c-1", values["container_id"]) assert.Equal(t, "container_started", values["event_type"]) assert.Equal(t, strconv.FormatInt(occurredAt.UnixMilli(), 10), values["occurred_at_ms"]) assert.JSONEq(t, `{"image_ref":"galaxy/game:1.2.3"}`, values["details"].(string)) } func TestPublishMapsEveryEventTypeToASnapshot(t *testing.T) { t.Parallel() cases := []struct { eventType health.EventType expectStatus health.SnapshotStatus expectSource health.SnapshotSource }{ {health.EventTypeContainerStarted, health.SnapshotStatusHealthy, health.SnapshotSourceDockerEvent}, {health.EventTypeContainerExited, health.SnapshotStatusExited, health.SnapshotSourceDockerEvent}, {health.EventTypeContainerOOM, health.SnapshotStatusOOM, health.SnapshotSourceDockerEvent}, {health.EventTypeContainerDisappeared, health.SnapshotStatusContainerDisappeared, health.SnapshotSourceDockerEvent}, {health.EventTypeInspectUnhealthy, health.SnapshotStatusInspectUnhealthy, health.SnapshotSourceInspect}, {health.EventTypeProbeFailed, health.SnapshotStatusProbeFailed, health.SnapshotSourceProbe}, {health.EventTypeProbeRecovered, health.SnapshotStatusHealthy, health.SnapshotSourceProbe}, } for _, tc := range cases { t.Run(string(tc.eventType), func(t *testing.T) { t.Parallel() snapshots := &fakeSnapshots{} publisher, _, _ := newPublisher(t, snapshots) require.NoError(t, publisher.Publish(context.Background(), ports.HealthEventEnvelope{ GameID: "g", ContainerID: "c", EventType: tc.eventType, OccurredAt: time.Now().UTC(), Details: json.RawMessage(`{}`), })) require.Len(t, snapshots.upserts, 1) assert.Equal(t, tc.expectStatus, snapshots.upserts[0].Status) assert.Equal(t, tc.expectSource, snapshots.upserts[0].Source) }) } } func TestPublishEmptyDetailsBecomesEmptyObject(t *testing.T) { snapshots := &fakeSnapshots{} publisher, _, client := newPublisher(t, snapshots) envelope := ports.HealthEventEnvelope{ GameID: "g", ContainerID: "c", EventType: health.EventTypeContainerDisappeared, OccurredAt: time.Now().UTC(), } require.NoError(t, publisher.Publish(context.Background(), envelope)) require.Len(t, snapshots.upserts, 1) assert.JSONEq(t, "{}", string(snapshots.upserts[0].Details)) entries, err := client.XRange(context.Background(), "runtime:health_events", "-", "+").Result() require.NoError(t, err) require.Len(t, entries, 1) assert.JSONEq(t, "{}", entries[0].Values["details"].(string)) } func TestPublishRejectsInvalidEnvelope(t *testing.T) { snapshots := &fakeSnapshots{} publisher, _, client := newPublisher(t, snapshots) require.Error(t, publisher.Publish(context.Background(), ports.HealthEventEnvelope{})) entries, err := client.XRange(context.Background(), "runtime:health_events", "-", "+").Result() require.NoError(t, err) assert.Empty(t, entries) assert.Empty(t, snapshots.upserts) } func TestPublishSurfacesSnapshotErrorWithoutXAdd(t *testing.T) { snapshots := &fakeSnapshots{upsertErr: assertSentinelErr} publisher, _, client := newPublisher(t, snapshots) err := publisher.Publish(context.Background(), ports.HealthEventEnvelope{ GameID: "g", ContainerID: "c", EventType: health.EventTypeContainerStarted, OccurredAt: time.Now().UTC(), Details: json.RawMessage(`{"image_ref":"x"}`), }) require.Error(t, err) entries, err := client.XRange(context.Background(), "runtime:health_events", "-", "+").Result() require.NoError(t, err) assert.Empty(t, entries, "xadd must not run when snapshot upsert fails") } // assertSentinelErr is a sentinel for snapshot-failure assertions. var assertSentinelErr = sentinelError("snapshot upsert failure") type sentinelError string func (s sentinelError) Error() string { return string(s) }