package rtmclient import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "galaxy/gamemaster/internal/ports" ) func newTestClient(t *testing.T, baseURL string, timeout time.Duration) *Client { t.Helper() client, err := NewClient(Config{BaseURL: baseURL, RequestTimeout: timeout}) require.NoError(t, err) t.Cleanup(func() { _ = client.Close() }) return client } func TestNewClientValidatesConfig(t *testing.T) { cases := map[string]Config{ "empty base url": {BaseURL: "", RequestTimeout: time.Second}, "non-absolute": {BaseURL: "rtm:8096", RequestTimeout: time.Second}, "zero timeout": {BaseURL: "http://rtm:8096", RequestTimeout: 0}, "negative timeout": {BaseURL: "http://rtm:8096", RequestTimeout: -time.Second}, } for name, cfg := range cases { t.Run(name, func(t *testing.T) { _, err := NewClient(cfg) require.Error(t, err) }) } } func TestStopHappyPath(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodPost, r.Method) require.Equal(t, "/api/v1/internal/runtimes/game-1/stop", r.URL.Path) require.Equal(t, "application/json", r.Header.Get("Content-Type")) body, err := io.ReadAll(r.Body) require.NoError(t, err) var got stopRequestEnvelope require.NoError(t, json.Unmarshal(body, &got)) assert.Equal(t, "admin_request", got.Reason) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"game_id":"game-1","status":"stopped"}`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) require.NoError(t, client.Stop(context.Background(), "game-1", "admin_request")) } func TestStopRejectsBadInput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Fatal("must not contact rtm on bad input") })) defer server.Close() client := newTestClient(t, server.URL, time.Second) require.Error(t, client.Stop(context.Background(), " ", "admin_request")) require.Error(t, client.Stop(context.Background(), "g", " ")) ctx, cancel := context.WithCancel(context.Background()) cancel() err := client.Stop(ctx, "g", "admin_request") require.Error(t, err) assert.True(t, errors.Is(err, context.Canceled)) } func TestStopInternalError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":{"code":"internal_error","message":"boom"}}`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) err := client.Stop(context.Background(), "g", "admin_request") require.Error(t, err) assert.True(t, errors.Is(err, ports.ErrRTMUnavailable)) assert.Contains(t, err.Error(), "internal_error") } func TestStopTimeoutMapsToUnavailable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(120 * time.Millisecond) _, _ = w.Write([]byte(`{}`)) })) defer server.Close() client := newTestClient(t, server.URL, 30*time.Millisecond) err := client.Stop(context.Background(), "g", "admin_request") require.Error(t, err) assert.True(t, errors.Is(err, ports.ErrRTMUnavailable)) } func TestPatchHappyPath(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodPost, r.Method) require.Equal(t, "/api/v1/internal/runtimes/g/patch", r.URL.Path) body, err := io.ReadAll(r.Body) require.NoError(t, err) var got patchRequestEnvelope require.NoError(t, json.Unmarshal(body, &got)) assert.Equal(t, "galaxy/game:1.2.4", got.ImageRef) _, _ = w.Write([]byte(`{"game_id":"g","status":"running"}`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) require.NoError(t, client.Patch(context.Background(), "g", "galaxy/game:1.2.4")) } func TestPatchSemverConflictMapsToUnavailable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusConflict) _, _ = w.Write([]byte(`{"error":{"code":"semver_patch_only","message":"cross-major patch not allowed"}}`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) err := client.Patch(context.Background(), "g", "galaxy/game:2.0.0") require.Error(t, err) assert.True(t, errors.Is(err, ports.ErrRTMUnavailable)) assert.Contains(t, err.Error(), "semver_patch_only") } func TestPatchRejectsBadInput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Fatal("must not contact rtm on bad input") })) defer server.Close() client := newTestClient(t, server.URL, time.Second) require.Error(t, client.Patch(context.Background(), " ", "galaxy/game:1.0.0")) require.Error(t, client.Patch(context.Background(), "g", " ")) } func TestCloseIsIdempotent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{}`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) require.NoError(t, client.Stop(context.Background(), "g", "admin_request")) require.NoError(t, client.Close()) require.NoError(t, client.Close()) }