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