2026-04-04 01:28:57 +08:00

556 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"
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
}