package userservice import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "galaxy/authsession/internal/domain/common" "galaxy/authsession/internal/domain/userresolution" "galaxy/authsession/internal/ports" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewRESTClient(t *testing.T) { t.Parallel() tests := []struct { name string cfg Config wantErr string }{ { name: "valid config", cfg: Config{ BaseURL: "http://127.0.0.1:8080", RequestTimeout: time.Second, }, }, { name: "empty base url", cfg: Config{ RequestTimeout: time.Second, }, wantErr: "base URL must not be empty", }, { name: "relative base url", cfg: Config{ BaseURL: "/relative", RequestTimeout: time.Second, }, wantErr: "base URL must be absolute", }, { name: "non positive timeout", cfg: Config{ BaseURL: "http://127.0.0.1:8080", }, wantErr: "request timeout must be positive", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() client, err := NewRESTClient(tt.cfg) if tt.wantErr != "" { require.Error(t, err) assert.ErrorContains(t, err, tt.wantErr) return } require.NoError(t, err) assert.NoError(t, client.Close()) }) } } func TestRESTClientEndpointSuccessCases(t *testing.T) { t.Parallel() tests := []struct { name string run func(*testing.T, *RESTClient) }{ { name: "resolve by email", run: func(t *testing.T, client *RESTClient) { result, err := client.ResolveByEmail(context.Background(), common.Email("Pilot+Case@example.com")) require.NoError(t, err) assert.Equal(t, userresolution.Result{ Kind: userresolution.KindExisting, UserID: common.UserID("user-123"), }, result) }, }, { name: "exists by user id", run: func(t *testing.T, client *RESTClient) { exists, err := client.ExistsByUserID(context.Background(), common.UserID("user-123")) require.NoError(t, err) assert.True(t, exists) }, }, { name: "ensure user by email", run: func(t *testing.T, client *RESTClient) { result, err := client.EnsureUserByEmail(context.Background(), common.Email("created@example.com")) require.NoError(t, err) assert.Equal(t, ports.EnsureUserResult{ Outcome: ports.EnsureUserOutcomeCreated, UserID: common.UserID("user-234"), }, result) }, }, { name: "block by user id", run: func(t *testing.T, client *RESTClient) { result, err := client.BlockByUserID(context.Background(), ports.BlockUserByIDInput{ UserID: common.UserID("user-123"), ReasonCode: userresolution.BlockReasonCode("policy_blocked"), }) require.NoError(t, err) assert.Equal(t, ports.BlockUserResult{ Outcome: ports.BlockUserOutcomeBlocked, UserID: common.UserID("user-123"), }, result) }, }, { name: "block by email", run: func(t *testing.T, client *RESTClient) { result, err := client.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{ Email: common.Email("blocked@example.com"), ReasonCode: userresolution.BlockReasonCode("policy_blocked"), }) require.NoError(t, err) assert.Equal(t, ports.BlockUserResult{ Outcome: ports.BlockUserOutcomeAlreadyBlocked, UserID: common.UserID("user-345"), }, result) }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() var requestsMu sync.Mutex var requests []capturedRequest server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestsMu.Lock() requests = append(requests, captureRequest(t, r)) requestsMu.Unlock() switch { case r.Method == http.MethodPost && r.URL.Path == resolveByEmailPath: writeJSON(t, w, http.StatusOK, map[string]any{ "kind": "existing", "user_id": "user-123", }) case r.Method == http.MethodGet && r.URL.Path == "/api/v1/internal/users/user-123/exists": writeJSON(t, w, http.StatusOK, map[string]any{"exists": true}) case r.Method == http.MethodPost && r.URL.Path == ensureByEmailPath: writeJSON(t, w, http.StatusOK, map[string]any{ "outcome": "created", "user_id": "user-234", }) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/internal/users/user-123/block": writeJSON(t, w, http.StatusOK, map[string]any{ "outcome": "blocked", "user_id": "user-123", }) case r.Method == http.MethodPost && r.URL.Path == blockByEmailPath: writeJSON(t, w, http.StatusOK, map[string]any{ "outcome": "already_blocked", "user_id": "user-345", }) default: http.NotFound(w, r) } })) defer server.Close() client := newTestRESTClient(t, server.URL, 250*time.Millisecond) tt.run(t, client) requestsMu.Lock() defer requestsMu.Unlock() require.Len(t, requests, 1) switch tt.name { case "resolve by email": assert.Equal(t, capturedRequest{ Method: http.MethodPost, Path: resolveByEmailPath, ContentType: "application/json", Body: `{"email":"Pilot+Case@example.com"}`, }, requests[0]) case "exists by user id": assert.Equal(t, capturedRequest{ Method: http.MethodGet, Path: "/api/v1/internal/users/user-123/exists", }, requests[0]) case "ensure user by email": assert.Equal(t, capturedRequest{ Method: http.MethodPost, Path: ensureByEmailPath, ContentType: "application/json", Body: `{"email":"created@example.com"}`, }, requests[0]) case "block by user id": assert.Equal(t, capturedRequest{ Method: http.MethodPost, Path: "/api/v1/internal/users/user-123/block", ContentType: "application/json", Body: `{"reason_code":"policy_blocked"}`, }, requests[0]) case "block by email": assert.Equal(t, capturedRequest{ Method: http.MethodPost, Path: blockByEmailPath, ContentType: "application/json", Body: `{"email":"blocked@example.com","reason_code":"policy_blocked"}`, }, requests[0]) } }) } } func TestRESTClientPreservesNormalizedEmailExactly(t *testing.T) { t.Parallel() var captured string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { request := captureRequest(t, r) captured = request.Body writeJSON(t, w, http.StatusOK, map[string]any{"kind": "creatable"}) })) defer server.Close() client := newTestRESTClient(t, server.URL, 250*time.Millisecond) _, err := client.ResolveByEmail(context.Background(), common.Email("Pilot+Alias@Example.com")) require.NoError(t, err) assert.Equal(t, `{"email":"Pilot+Alias@Example.com"}`, captured) } func TestRESTClientBlockByUserIDNotFound(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer server.Close() client := newTestRESTClient(t, server.URL, 250*time.Millisecond) _, err := client.BlockByUserID(context.Background(), ports.BlockUserByIDInput{ UserID: common.UserID("missing-user"), ReasonCode: userresolution.BlockReasonCode("policy_blocked"), }) require.Error(t, err) assert.ErrorIs(t, err, ports.ErrNotFound) } func TestRESTClientReadMethodsRetryOnce(t *testing.T) { t.Parallel() t.Run("resolve by email retries on 503", func(t *testing.T) { t.Parallel() var calls int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ if calls == 1 { http.Error(w, "temporary", http.StatusServiceUnavailable) return } writeJSON(t, w, http.StatusOK, map[string]any{"kind": "creatable"}) })) defer server.Close() client := newTestRESTClient(t, server.URL, 250*time.Millisecond) result, err := client.ResolveByEmail(context.Background(), common.Email("pilot@example.com")) require.NoError(t, err) assert.Equal(t, userresolution.KindCreatable, result.Kind) assert.Equal(t, 2, calls) }) t.Run("exists by user id retries on transport failure", func(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, http.StatusOK, map[string]any{"exists": true}) })) defer server.Close() baseTransport := server.Client().Transport client, err := newRESTClient(Config{ BaseURL: server.URL, RequestTimeout: 250 * time.Millisecond, }, &http.Client{ Transport: &failOnceRoundTripper{ next: baseTransport, err: errors.New("temporary transport failure"), }, }) require.NoError(t, err) exists, err := client.ExistsByUserID(context.Background(), common.UserID("user-123")) require.NoError(t, err) assert.True(t, exists) }) } func TestRESTClientMutationMethodsDoNotRetry(t *testing.T) { t.Parallel() tests := []struct { name string run func(*RESTClient) error }{ { name: "ensure user by email", run: func(client *RESTClient) error { _, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com")) return err }, }, { name: "block by user id", run: func(client *RESTClient) error { _, err := client.BlockByUserID(context.Background(), ports.BlockUserByIDInput{ UserID: common.UserID("user-123"), ReasonCode: userresolution.BlockReasonCode("policy_blocked"), }) return err }, }, { name: "block by email", run: func(client *RESTClient) error { _, err := client.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{ Email: common.Email("pilot@example.com"), ReasonCode: userresolution.BlockReasonCode("policy_blocked"), }) return err }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() var calls int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ http.Error(w, "temporary", http.StatusServiceUnavailable) })) defer server.Close() client := newTestRESTClient(t, server.URL, 250*time.Millisecond) err := tt.run(client) require.Error(t, err) assert.Equal(t, 1, calls) }) } } func TestRESTClientStrictDecodingAndUnexpectedStatuses(t *testing.T) { t.Parallel() tests := []struct { name string statusCode int body string wantErrText string run func(*RESTClient) error }{ { name: "resolve by email rejects unknown field", statusCode: http.StatusOK, body: `{"kind":"creatable","extra":true}`, wantErrText: "decode response body", run: func(client *RESTClient) error { _, err := client.ResolveByEmail(context.Background(), common.Email("pilot@example.com")) return err }, }, { name: "ensure user by email rejects malformed outcome", statusCode: http.StatusOK, body: `{"outcome":"mystery"}`, wantErrText: "unsupported", run: func(client *RESTClient) error { _, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com")) return err }, }, { name: "ensure user by email rejects missing user id for created outcome", statusCode: http.StatusOK, body: `{"outcome":"created"}`, wantErrText: "user id", run: func(client *RESTClient) error { _, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com")) return err }, }, { name: "exists by user id rejects trailing json", statusCode: http.StatusOK, body: `{"exists":true}{}`, wantErrText: "unexpected trailing JSON input", run: func(client *RESTClient) error { _, err := client.ExistsByUserID(context.Background(), common.UserID("user-123")) return err }, }, { name: "block by email rejects unexpected status", statusCode: http.StatusBadGateway, body: `{"error":"temporary"}`, wantErrText: "unexpected HTTP status 502", run: func(client *RESTClient) error { _, err := client.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{ Email: common.Email("pilot@example.com"), ReasonCode: userresolution.BlockReasonCode("policy_blocked"), }) return err }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(tt.statusCode) _, err := io.WriteString(w, tt.body) require.NoError(t, err) })) defer server.Close() client := newTestRESTClient(t, server.URL, 250*time.Millisecond) err := tt.run(client) require.Error(t, err) assert.ErrorContains(t, err, tt.wantErrText) }) } } func TestRESTClientRequestTimeout(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(40 * time.Millisecond) writeJSON(t, w, http.StatusOK, map[string]any{"kind": "creatable"}) })) defer server.Close() client := newTestRESTClient(t, server.URL, 10*time.Millisecond) _, err := client.ResolveByEmail(context.Background(), common.Email("pilot@example.com")) require.Error(t, err) assert.ErrorContains(t, err, "context deadline exceeded") } func TestRESTClientContextAndValidation(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatalf("unexpected upstream call") })) defer server.Close() client := newTestRESTClient(t, server.URL, 250*time.Millisecond) cancelledCtx, cancel := context.WithCancel(context.Background()) cancel() tests := []struct { name string run func() error }{ { name: "nil context", run: func() error { _, err := client.ResolveByEmail(nil, common.Email("pilot@example.com")) return err }, }, { name: "cancelled context", run: func() error { _, err := client.ExistsByUserID(cancelledCtx, common.UserID("user-123")) return err }, }, { name: "invalid email", run: func() error { _, err := client.EnsureUserByEmail(context.Background(), common.Email(" bad@example.com ")) return err }, }, { name: "invalid user id", run: func() error { _, err := client.BlockByUserID(context.Background(), ports.BlockUserByIDInput{ UserID: common.UserID(" bad "), ReasonCode: userresolution.BlockReasonCode("policy_blocked"), }) return err }, }, { name: "invalid reason code", run: func() error { _, err := client.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{ Email: common.Email("pilot@example.com"), ReasonCode: userresolution.BlockReasonCode(" bad "), }) return err }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { err := tt.run() require.Error(t, err) }) } } type capturedRequest struct { Method string Path string ContentType string Body string } func captureRequest(t *testing.T, request *http.Request) capturedRequest { t.Helper() body, err := io.ReadAll(request.Body) require.NoError(t, err) return capturedRequest{ Method: request.Method, Path: request.URL.Path, ContentType: request.Header.Get("Content-Type"), Body: strings.TrimSpace(string(body)), } } func writeJSON(t *testing.T, writer http.ResponseWriter, statusCode int, value any) { t.Helper() payload, err := json.Marshal(value) require.NoError(t, err) writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(statusCode) _, err = writer.Write(payload) require.NoError(t, err) } func newTestRESTClient(t *testing.T, baseURL string, timeout time.Duration) *RESTClient { t.Helper() client, err := NewRESTClient(Config{ BaseURL: baseURL, RequestTimeout: timeout, }) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, client.Close()) }) return client } type failOnceRoundTripper struct { mu sync.Mutex next http.RoundTripper err error done bool } func (rt *failOnceRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { rt.mu.Lock() if !rt.done { rt.done = true err := rt.err rt.mu.Unlock() return nil, err } next := rt.next rt.mu.Unlock() return next.RoundTrip(request) }