2026-04-07 12:30:08 +08:00

570 lines
15 KiB
Go

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, nil).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
}