package httpserver_test import ( "bytes" "context" "encoding/json" "errors" "io" "log/slog" "net" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "chatappgateway/internal/integration/paygrpc" "chatappgateway/internal/integration/usergrpc" "chatappgateway/internal/service/auth" payservice "chatappgateway/internal/service/pay" httpserver "chatappgateway/internal/transport/http" commonpb "gitea.haiyihy.com/hy/chatappcommon/proto" "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("chatappuser 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, "chatappuser not ready") }) } func TestRegisterValidation(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: "missing account", body: `{"password":"secret"}`, message: "account is required", }, { name: "missing password", body: `{"account":"demo@example.com"}`, message: "password 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/users/register", 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) } }) } } func TestRegisterDelegatesToUserGRPC(t *testing.T) { t.Parallel() env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) defer env.Close() env.userServer.response = &commonpb.RegisterResponse{ UserId: "u-100", AccessToken: "access-token", IsNewUser: true, Profile: &commonpb.UserProfile{ UserId: "u-100", Nickname: "Neo", AvatarUrl: "https://example.com/avatar.png", }, } body := `{ "account":" demo@example.com ", "password":" secret ", "nickname":" Neo ", "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/users/register", 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[commonpb.RegisterResponse] 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.Account != "demo@example.com" { t.Fatalf("unexpected account: %q", request.Account) } if request.Nickname != "Neo" { t.Fatalf("unexpected nickname: %q", request.Nickname) } } func TestPayValidation(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: "missing order_no", body: `{"user_id":"u-1","amount":"9.99"}`, message: "order_no is required", }, { name: "missing user_id", body: `{"order_no":"ord-1","amount":"9.99"}`, message: "user_id is required", }, { name: "missing amount", body: `{"order_no":"ord-1","user_id":"u-1"}`, message: "amount 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/pay", 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) } }) } } func TestPayDelegatesToPayGRPC(t *testing.T) { t.Parallel() env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) defer env.Close() env.payServer.response = &commonpb.PayResponse{ PaymentId: "pay-001", OrderNo: "order-001", UserId: "u-100", Status: "processing", Amount: "9.99", Currency: "USD", PayMethod: "apple_pay", Subject: "vip", CreatedAt: "2026-04-04T12:00:00Z", } body := `{"order_no":"order-001","user_id":"u-100","amount":"9.99","currency":"USD","pay_method":"apple_pay","subject":"vip"}` resp, responseBody := doRequest(t, env.server.Client(), http.MethodPost, env.server.URL+"/api/v1/pay", 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[commonpb.PayResponse] 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("register unauthorized", func(t *testing.T) { env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) defer env.Close() env.userServer.err = status.Error(codes.Unauthenticated, "register denied") resp, responseBody := doRequest(t, env.server.Client(), http.MethodPost, env.server.URL+"/api/v1/users/register", strings.NewReader(`{"account":"demo","password":"secret"}`)) defer resp.Body.Close() assertErrorResponse(t, resp.StatusCode, responseBody, http.StatusUnauthorized, "register denied") }) t.Run("pay timeout", func(t *testing.T) { env := newTestEnv(t, 200*time.Millisecond, 20*time.Millisecond) defer env.Close() env.payServer.delay = 150 * time.Millisecond resp, responseBody := doRequest(t, env.server.Client(), http.MethodPost, env.server.URL+"/api/v1/pay", strings.NewReader(`{"order_no":"ord-1","user_id":"u-1","amount":"9.99"}`)) 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.MethodPost, env.server.URL+"/api/v1/pay", strings.NewReader(`{"order_no":"ord-1","user_id":"u-1","amount":"9.99"}`)) 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: &commonpb.RegisterResponse{ UserId: "default-user", AccessToken: "default-access", IsNewUser: true, Profile: &commonpb.UserProfile{ UserId: "default-user", Nickname: "default", }, }, } payServer := &mockPayServer{ response: &commonpb.PayResponse{ PaymentId: "default-pay", OrderNo: "default-order", UserId: "default-user", Status: "processing", Amount: "9.99", Currency: "USD", }, } userConn, userClose := newBufConnClient(t, func(server *grpc.Server) { commonpb.RegisterChatAppUserServiceServer(server, userServer) }) payConn, payClose := newBufConnClient(t, func(server *grpc.Server) { commonpb.RegisterChatAppPayServiceServer(server, payServer) }) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) authService := auth.New(usergrpc.New(commonpb.NewChatAppUserServiceClient(userConn), userTimeout)) payService := payservice.New(paygrpc.New(commonpb.NewChatAppPayServiceClient(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 { commonpb.UnimplementedChatAppUserServiceServer mu sync.Mutex lastReq *commonpb.RegisterRequest response *commonpb.RegisterResponse err error delay time.Duration } func (s *mockUserServer) Register(ctx context.Context, request *commonpb.RegisterRequest) (*commonpb.RegisterResponse, 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 *mockUserServer) LastRequest() *commonpb.RegisterRequest { s.mu.Lock() defer s.mu.Unlock() if s.lastReq == nil { return nil } copied := *s.lastReq return &copied } type mockPayServer struct { commonpb.UnimplementedChatAppPayServiceServer mu sync.Mutex lastReq *commonpb.PayRequest response *commonpb.PayResponse err error delay time.Duration } func (s *mockPayServer) Pay(ctx context.Context, request *commonpb.PayRequest) (*commonpb.PayResponse, 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() *commonpb.PayRequest { 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 }