570 lines
15 KiB
Go
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
|
|
}
|