package userservice import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "testing" "time" "galaxy/gateway/internal/downstream" usermodel "galaxy/model/user" "galaxy/transcoder" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewHTTPClient(t *testing.T) { t.Parallel() tests := []struct { name string baseURL string wantURL string wantErr string }{ { name: "absolute URL is normalized", baseURL: " http://127.0.0.1:8081/ ", wantURL: "http://127.0.0.1:8081", }, { name: "empty base URL is rejected", baseURL: " ", wantErr: "base URL must not be empty", }, { name: "relative base URL is rejected", baseURL: "/relative", wantErr: "base URL must be absolute", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() client, err := NewHTTPClient(tt.baseURL) if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) return } require.NoError(t, err) assert.Equal(t, tt.wantURL, client.baseURL) }) } } func TestHTTPClientExecuteGetMyAccountSuccess(t *testing.T) { t.Parallel() wantResponse := sampleAccountResponse() server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { require.Equal(t, http.MethodGet, request.Method) require.Equal(t, "/api/v1/internal/users/user-123/account", request.URL.Path) require.NoError(t, json.NewEncoder(writer).Encode(wantResponse)) })) defer server.Close() client := newTestHTTPClient(t, server) payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{}) require.NoError(t, err) result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{ UserID: "user-123", MessageType: usermodel.MessageTypeGetMyAccount, PayloadBytes: payload, }) require.NoError(t, err) assert.Equal(t, getMyAccountResultCodeOK, result.ResultCode) decoded, err := transcoder.PayloadToAccountResponse(result.PayloadBytes) require.NoError(t, err) assert.Equal(t, wantResponse, decoded) } func TestHTTPClientExecuteUpdateMyProfileProjectsConflict(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { require.Equal(t, http.MethodPost, request.Method) require.Equal(t, "/api/v1/internal/users/user-123/profile", request.URL.Path) body, err := io.ReadAll(request.Body) require.NoError(t, err) require.JSONEq(t, `{"display_name":"NovaPrime"}`, string(body)) writer.WriteHeader(http.StatusConflict) require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{ Error: usermodel.ErrorBody{ Code: "conflict", Message: "request conflicts with current state", }, })) })) defer server.Close() client := newTestHTTPClient(t, server) payload, err := transcoder.UpdateMyProfileRequestToPayload(&usermodel.UpdateMyProfileRequest{DisplayName: "NovaPrime"}) require.NoError(t, err) result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{ UserID: "user-123", MessageType: usermodel.MessageTypeUpdateMyProfile, PayloadBytes: payload, }) require.NoError(t, err) assert.Equal(t, "conflict", result.ResultCode) decoded, err := transcoder.PayloadToErrorResponse(result.PayloadBytes) require.NoError(t, err) assert.Equal(t, &usermodel.ErrorResponse{ Error: usermodel.ErrorBody{ Code: "conflict", Message: "request conflicts with current state", }, }, decoded) } func TestHTTPClientExecuteUpdateMySettingsProjectsInvalidRequest(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { require.Equal(t, http.MethodPost, request.Method) require.Equal(t, "/api/v1/internal/users/user-123/settings", request.URL.Path) body, err := io.ReadAll(request.Body) require.NoError(t, err) require.JSONEq(t, `{"preferred_language":"bad","time_zone":"Mars/Base"}`, string(body)) writer.WriteHeader(http.StatusBadRequest) require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{ Error: usermodel.ErrorBody{ Code: "invalid_request", Message: "request is invalid", }, })) })) defer server.Close() client := newTestHTTPClient(t, server) payload, err := transcoder.UpdateMySettingsRequestToPayload(&usermodel.UpdateMySettingsRequest{ PreferredLanguage: "bad", TimeZone: "Mars/Base", }) require.NoError(t, err) result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{ UserID: "user-123", MessageType: usermodel.MessageTypeUpdateMySettings, PayloadBytes: payload, }) require.NoError(t, err) assert.Equal(t, "invalid_request", result.ResultCode) decoded, err := transcoder.PayloadToErrorResponse(result.PayloadBytes) require.NoError(t, err) assert.Equal(t, "invalid_request", decoded.Error.Code) } func TestHTTPClientExecuteCommandProjectsSubjectNotFound(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusNotFound) require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{ Error: usermodel.ErrorBody{ Code: "subject_not_found", Message: "subject not found", }, })) })) defer server.Close() client := newTestHTTPClient(t, server) payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{}) require.NoError(t, err) result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{ UserID: "user-missing", MessageType: usermodel.MessageTypeGetMyAccount, PayloadBytes: payload, }) require.NoError(t, err) assert.Equal(t, "subject_not_found", result.ResultCode) } func TestHTTPClientExecuteCommandMaps503ToUnavailable(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusServiceUnavailable) require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{ Error: usermodel.ErrorBody{ Code: "service_unavailable", Message: "service is unavailable", }, })) })) defer server.Close() client := newTestHTTPClient(t, server) payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{}) require.NoError(t, err) _, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{ UserID: "user-123", MessageType: usermodel.MessageTypeGetMyAccount, PayloadBytes: payload, }) require.Error(t, err) assert.ErrorIs(t, err, downstream.ErrDownstreamUnavailable) } func TestHTTPClientExecuteCommandUsesCallerContext(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { <-request.Context().Done() })) defer server.Close() client := newTestHTTPClient(t, server) payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{}) require.NoError(t, err) ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) defer cancel() _, err = client.ExecuteCommand(ctx, downstream.AuthenticatedCommand{ UserID: "user-123", MessageType: usermodel.MessageTypeGetMyAccount, PayloadBytes: payload, }) require.Error(t, err) assert.ErrorIs(t, err, context.DeadlineExceeded) } func TestHTTPClientExecuteCommandRejectsMalformedSuccessPayload(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { _, _ = writer.Write([]byte(`{"account":{"user_id":"user-123","unexpected":true}}`)) })) defer server.Close() client := newTestHTTPClient(t, server) payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{}) require.NoError(t, err) _, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{ UserID: "user-123", MessageType: usermodel.MessageTypeGetMyAccount, PayloadBytes: payload, }) require.Error(t, err) assert.Contains(t, err.Error(), "decode success response") } func TestHTTPClientExecuteCommandRejectsUnsupportedMessageType(t *testing.T) { t.Parallel() server := httptest.NewServer(http.NotFoundHandler()) defer server.Close() client := newTestHTTPClient(t, server) _, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{ UserID: "user-123", MessageType: "user.unsupported", PayloadBytes: []byte("payload"), }) require.Error(t, err) assert.Contains(t, err.Error(), "unsupported message type") } func TestNewRoutesReserveUserMessageTypesWhenUnconfigured(t *testing.T) { t.Parallel() routes, closeFn, err := NewRoutes("") require.NoError(t, err) require.NoError(t, closeFn()) router := downstream.NewStaticRouter(routes) for _, messageType := range []string{ usermodel.MessageTypeGetMyAccount, usermodel.MessageTypeUpdateMyProfile, usermodel.MessageTypeUpdateMySettings, } { client, routeErr := router.Route(messageType) require.NoError(t, routeErr) _, execErr := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{ UserID: "user-123", MessageType: messageType, }) require.Error(t, execErr) assert.ErrorIs(t, execErr, downstream.ErrDownstreamUnavailable) } } func TestUnavailableClientReturnsDownstreamUnavailable(t *testing.T) { t.Parallel() _, err := unavailableClient{}.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{}) require.Error(t, err) assert.ErrorIs(t, err, downstream.ErrDownstreamUnavailable) } func newTestHTTPClient(t *testing.T, server *httptest.Server) *HTTPClient { t.Helper() client, err := newHTTPClient(server.URL, server.Client()) require.NoError(t, err) return client } func sampleAccountResponse() *usermodel.AccountResponse { now := time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC) expiresAt := now.Add(30 * 24 * time.Hour) return &usermodel.AccountResponse{ Account: usermodel.Account{ UserID: "user-123", Email: "pilot@example.com", UserName: "player-abcdefgh", DisplayName: "PilotNova", PreferredLanguage: "en", TimeZone: "Europe/Kaliningrad", DeclaredCountry: "DE", Entitlement: usermodel.EntitlementSnapshot{ PlanCode: "free", IsPaid: false, Source: "auth_registration", Actor: usermodel.ActorRef{Type: "service", ID: "user-service"}, ReasonCode: "initial_free_entitlement", StartsAt: now, UpdatedAt: now, }, ActiveSanctions: []usermodel.ActiveSanction{ { SanctionCode: "profile_update_block", Scope: "lobby", ReasonCode: "manual_block", Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"}, AppliedAt: now, ExpiresAt: &expiresAt, }, }, ActiveLimits: []usermodel.ActiveLimit{ { LimitCode: "max_owned_private_games", Value: 3, ReasonCode: "manual_override", Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"}, AppliedAt: now, }, }, CreatedAt: now, UpdatedAt: now, }, } } func TestDecodeUserServiceErrorNormalizesBlankFields(t *testing.T) { t.Parallel() response, err := decodeUserServiceError(http.StatusBadRequest, []byte(`{"error":{"code":" ","message":" "}}`)) require.NoError(t, err) assert.Equal(t, "invalid_request", response.Error.Code) assert.Equal(t, "request is invalid", response.Error.Message) } func TestHTTPClientExecuteCommandRejectsNilContext(t *testing.T) { t.Parallel() server := httptest.NewServer(http.NotFoundHandler()) defer server.Close() client := newTestHTTPClient(t, server) _, err := client.ExecuteCommand(nil, downstream.AuthenticatedCommand{}) require.Error(t, err) assert.Contains(t, err.Error(), "nil context") }