package httpserver_test import ( "bytes" "context" "encoding/json" "errors" "io" "log/slog" "net" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" gatewaypb "chatappgateway/api/proto" "chatappgateway/internal/integration/paygrpc" "chatappgateway/internal/integration/usergrpc" "chatappgateway/internal/service/auth" "chatappgateway/internal/service/pay" httpserver "chatappgateway/internal/transport/http" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" ) func TestHealth(t *testing.T) { t.Parallel() env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) defer env.Close() resp, body := doRequest(t, env.server.Client(), http.MethodGet, env.server.URL+"/health", nil) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("unexpected status: %d", resp.StatusCode) } var payload map[string]string if err := json.Unmarshal(body, &payload); err != nil { t.Fatalf("Unmarshal returned error: %v", err) } if payload["status"] != "ok" || payload["service"] != "chatappgateway" { t.Fatalf("unexpected payload: %#v", payload) } } func TestReady(t *testing.T) { t.Parallel() t.Run("ready success", func(t *testing.T) { env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) defer env.Close() resp, body := doRequest(t, env.server.Client(), http.MethodGet, env.server.URL+"/ready", nil) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("unexpected status: %d", resp.StatusCode) } var payload map[string]string if err := json.Unmarshal(body, &payload); err != nil { t.Fatalf("Unmarshal returned error: %v", err) } if payload["status"] != "ready" || payload["service"] != "chatappgateway" { t.Fatalf("unexpected payload: %#v", payload) } }) t.Run("not ready", func(t *testing.T) { env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) defer env.Close() env.readinessChecker.err = errors.New("redis not ready") resp, responseBody := doRequest(t, env.server.Client(), http.MethodGet, env.server.URL+"/ready", nil) defer resp.Body.Close() assertErrorResponse(t, resp.StatusCode, responseBody, http.StatusServiceUnavailable, "redis not ready") }) } func TestLoginValidation(t *testing.T) { t.Parallel() env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) defer env.Close() testCases := []struct { name string body string message string }{ { name: "password missing account", body: `{"login_type":"password","password":"secret"}`, message: "account is required", }, { name: "sms_code missing verify_code", body: `{"login_type":"sms_code","country_code":"+86","account":"13800138000"}`, message: "verify_code is required", }, { name: "oauth missing provider_token", body: `{"login_type":"oauth","provider":"google"}`, message: "provider_token is required", }, } for _, testCase := range testCases { testCase := testCase t.Run(testCase.name, func(t *testing.T) { resp, body := doRequest(t, env.server.Client(), http.MethodPost, env.server.URL+"/api/v1/auth/login", strings.NewReader(testCase.body)) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("unexpected status: %d", resp.StatusCode) } var payload errorResponse if err := json.Unmarshal(body, &payload); err != nil { t.Fatalf("Unmarshal returned error: %v", err) } if payload.Message != testCase.message { t.Fatalf("unexpected message: %s", payload.Message) } }) } if env.userServer.CallCount() != 0 { t.Fatalf("expected user service not to be called, got %d", env.userServer.CallCount()) } } func TestLoginDelegatesToUserGRPC(t *testing.T) { t.Parallel() env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) defer env.Close() env.userServer.response = &gatewaypb.LoginResponse{ UserId: "u-100", AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 7200, IsNewUser: true, Profile: &gatewaypb.UserProfile{ UserId: "u-100", Nickname: "Neo", AvatarUrl: "https://example.com/avatar.png", }, } body := `{ "login_type":" PASSWORD ", "account":" demo@example.com ", "password":"secret", "device_id":" dev-1 ", "platform":" ios ", "app_version":" 1.0.0 " }` resp, responseBody := doRequest(t, env.server.Client(), http.MethodPost, env.server.URL+"/api/v1/auth/login", strings.NewReader(body)) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("unexpected status: %d body=%s", resp.StatusCode, string(responseBody)) } var payload successResponse[gatewaypb.LoginResponse] if err := json.Unmarshal(responseBody, &payload); err != nil { t.Fatalf("Unmarshal returned error: %v", err) } if payload.Data.UserId != "u-100" { t.Fatalf("unexpected user id: %s", payload.Data.UserId) } request := env.userServer.LastRequest() if request == nil { t.Fatal("expected request to be captured") } if request.LoginType != "password" { t.Fatalf("unexpected login type: %s", request.LoginType) } if request.Account != "demo@example.com" { t.Fatalf("unexpected account: %q", request.Account) } if request.DeviceId != "dev-1" { t.Fatalf("unexpected device id: %q", request.DeviceId) } if request.Platform != "ios" { t.Fatalf("unexpected platform: %q", request.Platform) } } func TestQueryOrderDelegatesToPayGRPC(t *testing.T) { t.Parallel() env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) defer env.Close() env.payServer.response = &gatewaypb.QueryOrderResponse{ OrderNo: "order-001", UserId: "u-100", Status: "paid", Amount: "9.99", Currency: "USD", Subject: "VIP", PayMethod: "apple_pay", PaidAt: "2026-04-04T12:00:00Z", } resp, responseBody := doRequest(t, env.server.Client(), http.MethodGet, env.server.URL+"/api/v1/pay/orders/order-001", nil) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("unexpected status: %d body=%s", resp.StatusCode, string(responseBody)) } var payload successResponse[gatewaypb.QueryOrderResponse] if err := json.Unmarshal(responseBody, &payload); err != nil { t.Fatalf("Unmarshal returned error: %v", err) } if payload.Data.OrderNo != "order-001" { t.Fatalf("unexpected order no: %s", payload.Data.OrderNo) } request := env.payServer.LastRequest() if request == nil || request.OrderNo != "order-001" { t.Fatalf("unexpected pay request: %#v", request) } } func TestErrorMapping(t *testing.T) { t.Parallel() t.Run("login unauthorized", func(t *testing.T) { env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) defer env.Close() env.userServer.err = status.Error(codes.Unauthenticated, "invalid credentials") resp, responseBody := doRequest(t, env.server.Client(), http.MethodPost, env.server.URL+"/api/v1/auth/login", strings.NewReader(`{"login_type":"password","account":"demo","password":"wrong"}`)) defer resp.Body.Close() assertErrorResponse(t, resp.StatusCode, responseBody, http.StatusUnauthorized, "invalid credentials") }) t.Run("pay not found", func(t *testing.T) { env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) defer env.Close() env.payServer.err = status.Error(codes.NotFound, "order not found") resp, responseBody := doRequest(t, env.server.Client(), http.MethodGet, env.server.URL+"/api/v1/pay/orders/missing-order", nil) defer resp.Body.Close() assertErrorResponse(t, resp.StatusCode, responseBody, http.StatusNotFound, "order not found") }) t.Run("login timeout", func(t *testing.T) { env := newTestEnv(t, 20*time.Millisecond, 200*time.Millisecond) defer env.Close() env.userServer.delay = 150 * time.Millisecond resp, responseBody := doRequest(t, env.server.Client(), http.MethodPost, env.server.URL+"/api/v1/auth/login", strings.NewReader(`{"login_type":"password","account":"demo","password":"secret"}`)) defer resp.Body.Close() assertErrorResponse(t, resp.StatusCode, responseBody, http.StatusGatewayTimeout, "upstream request timeout") }) t.Run("pay unavailable", func(t *testing.T) { env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) defer env.Close() env.payServer.err = status.Error(codes.Unavailable, "pay service unavailable") resp, responseBody := doRequest(t, env.server.Client(), http.MethodGet, env.server.URL+"/api/v1/pay/orders/order-001", nil) defer resp.Body.Close() assertErrorResponse(t, resp.StatusCode, responseBody, http.StatusBadGateway, "pay service unavailable") }) } func TestRouteNotFoundReturnsJSON(t *testing.T) { t.Parallel() env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) defer env.Close() resp, responseBody := doRequest(t, env.server.Client(), http.MethodGet, env.server.URL+"/not-exists", nil) defer resp.Body.Close() assertErrorResponse(t, resp.StatusCode, responseBody, http.StatusNotFound, "route not found") } type testEnv struct { server *httptest.Server userServer *mockUserServer payServer *mockPayServer readinessChecker *mockReadinessChecker closeFn func() } func (e *testEnv) Close() { e.server.Close() e.closeFn() } func newTestEnv(t *testing.T, userTimeout time.Duration, payTimeout time.Duration) *testEnv { t.Helper() userServer := &mockUserServer{ response: &gatewaypb.LoginResponse{ UserId: "default-user", AccessToken: "default-access", RefreshToken: "default-refresh", ExpiresIn: 3600, Profile: &gatewaypb.UserProfile{ UserId: "default-user", Nickname: "default", }, }, } payServer := &mockPayServer{ response: &gatewaypb.QueryOrderResponse{ OrderNo: "default-order", UserId: "default-user", Status: "pending", Amount: "0", Currency: "USD", }, } userConn, userClose := newBufConnClient(t, func(server *grpc.Server) { gatewaypb.RegisterChatAppUserServer(server, userServer) }) payConn, payClose := newBufConnClient(t, func(server *grpc.Server) { gatewaypb.RegisterChatAppPayServer(server, payServer) }) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) authService := auth.New(usergrpc.New(gatewaypb.NewChatAppUserClient(userConn), userTimeout)) payService := pay.New(paygrpc.New(gatewaypb.NewChatAppPayClient(payConn), payTimeout)) readinessChecker := &mockReadinessChecker{} handler := httpserver.New("chatappgateway", ":0", 2*time.Second, logger, authService, payService, readinessChecker).Handler() return &testEnv{ server: httptest.NewServer(handler), userServer: userServer, payServer: payServer, readinessChecker: readinessChecker, closeFn: func() { userClose() payClose() }, } } func newBufConnClient(t *testing.T, register func(*grpc.Server)) (*grpc.ClientConn, func()) { t.Helper() listener := bufconn.Listen(1024 * 1024) server := grpc.NewServer() register(server) go func() { _ = server.Serve(listener) }() conn, err := grpc.DialContext( context.Background(), "bufnet", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { t.Fatalf("DialContext returned error: %v", err) } return conn, func() { _ = conn.Close() server.Stop() _ = listener.Close() } } func doRequest(t *testing.T, client *http.Client, method string, url string, body io.Reader) (*http.Response, []byte) { t.Helper() request, err := http.NewRequestWithContext(context.Background(), method, url, body) if err != nil { t.Fatalf("NewRequestWithContext returned error: %v", err) } if body != nil { request.Header.Set("Content-Type", "application/json") } response, err := client.Do(request) if err != nil { t.Fatalf("client.Do returned error: %v", err) } payload, err := io.ReadAll(response.Body) if err != nil { t.Fatalf("ReadAll returned error: %v", err) } response.Body = io.NopCloser(bytes.NewReader(payload)) return response, payload } func assertErrorResponse(t *testing.T, gotStatus int, body []byte, wantStatus int, wantMessage string) { t.Helper() if gotStatus != wantStatus { t.Fatalf("unexpected status: got=%d want=%d body=%s", gotStatus, wantStatus, string(body)) } var payload errorResponse if err := json.Unmarshal(body, &payload); err != nil { t.Fatalf("Unmarshal returned error: %v", err) } if payload.Message != wantMessage { t.Fatalf("unexpected message: got=%q want=%q", payload.Message, wantMessage) } if payload.RequestID == "" { t.Fatal("request_id should not be empty") } } type successResponse[T any] struct { Code string `json:"code"` Message string `json:"message"` RequestID string `json:"request_id"` Data T `json:"data"` } type errorResponse struct { Code string `json:"code"` Message string `json:"message"` RequestID string `json:"request_id"` } type mockUserServer struct { gatewaypb.UnimplementedChatAppUserServer mu sync.Mutex callCount int lastReq *gatewaypb.LoginRequest response *gatewaypb.LoginResponse err error delay time.Duration } func (s *mockUserServer) Login(ctx context.Context, request *gatewaypb.LoginRequest) (*gatewaypb.LoginResponse, error) { s.mu.Lock() s.callCount++ copied := *request s.lastReq = &copied response := s.response err := s.err delay := s.delay s.mu.Unlock() if delay > 0 { select { case <-time.After(delay): case <-ctx.Done(): return nil, ctx.Err() } } if err != nil { return nil, err } return response, nil } func (s *mockUserServer) CallCount() int { s.mu.Lock() defer s.mu.Unlock() return s.callCount } func (s *mockUserServer) LastRequest() *gatewaypb.LoginRequest { s.mu.Lock() defer s.mu.Unlock() if s.lastReq == nil { return nil } copied := *s.lastReq return &copied } type mockPayServer struct { gatewaypb.UnimplementedChatAppPayServer mu sync.Mutex lastReq *gatewaypb.QueryOrderRequest response *gatewaypb.QueryOrderResponse err error delay time.Duration } func (s *mockPayServer) QueryOrder(ctx context.Context, request *gatewaypb.QueryOrderRequest) (*gatewaypb.QueryOrderResponse, error) { s.mu.Lock() copied := *request s.lastReq = &copied response := s.response err := s.err delay := s.delay s.mu.Unlock() if delay > 0 { select { case <-time.After(delay): case <-ctx.Done(): return nil, ctx.Err() } } if err != nil { return nil, err } return response, nil } func (s *mockPayServer) LastRequest() *gatewaypb.QueryOrderRequest { s.mu.Lock() defer s.mu.Unlock() if s.lastReq == nil { return nil } copied := *s.lastReq return &copied } type mockReadinessChecker struct { err error } func (c *mockReadinessChecker) Ready(context.Context) error { return c.err }