276 lines
9.1 KiB
Go
276 lines
9.1 KiB
Go
package control
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"cmpp-platform/gateway/internal/inbound"
|
|
"cmpp-platform/gateway/internal/queue"
|
|
)
|
|
|
|
type controlRecordingLimiter struct {
|
|
channelID string
|
|
rate int
|
|
configuredChannelID string
|
|
configuredRate int
|
|
}
|
|
|
|
func (l *controlRecordingLimiter) Wait(_ context.Context, channelID string, rate int) (time.Duration, error) {
|
|
l.channelID = channelID
|
|
l.rate = rate
|
|
return 0, nil
|
|
}
|
|
|
|
func (l *controlRecordingLimiter) Configure(_ context.Context, channelID string, rate int) error {
|
|
l.configuredChannelID = channelID
|
|
l.configuredRate = rate
|
|
return nil
|
|
}
|
|
|
|
func TestUpstreamSubmitUsesGatewayChannelLimiter(t *testing.T) {
|
|
limiter := &controlRecordingLimiter{}
|
|
handler := handlerWithServer(Server{
|
|
Limiter: limiter,
|
|
Submit: func(_ context.Context, command queue.SubmitCommand) (queue.SubmitResult, error) {
|
|
return queue.SubmitResult{Envelope: command.Envelope, SubmitStatus: "accepted"}, nil
|
|
},
|
|
})
|
|
resp := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/upstream/submit", strings.NewReader(`{
|
|
"schemaVersion":"v1","messageType":"SubmitCommand","messageId":"msg-1","channelId":"channel-1",
|
|
"submitId":"submit-1","tenantId":"tenant-1","applicationId":"app-1","phoneNumber":"13800138000","content":"hello",
|
|
"route":{"channelCode":"CMPP-A","cmppAccountCode":"sp","rateLimitPerSecond":100},
|
|
"cmpp":{"serviceId":"SMS","srcId":"10690000","registeredDelivery":1,"msgFmt":8},
|
|
"upstream":{"gatewayHost":"127.0.0.1","gatewayPort":17890,"account":"sp","passwordCipher":"secret","cmppVersion":"3.0"},
|
|
"retry":{"attempt":0,"maxAttempts":1}
|
|
}`))
|
|
handler.ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
|
|
}
|
|
if limiter.channelID != "channel-1" || limiter.rate != 100 {
|
|
t.Fatalf("unexpected limiter call: %+v", limiter)
|
|
}
|
|
}
|
|
|
|
func TestConnectChannelCallbacksConnectedState(t *testing.T) {
|
|
limiter := &controlRecordingLimiter{}
|
|
handler := handlerWithServer(Server{Limiter: limiter, Connect: func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error) {
|
|
return ConnectionStateCallback{
|
|
ChannelID: "channel-1",
|
|
ConnectionID: "channel-1:primary",
|
|
Status: "connected",
|
|
DesiredConnections: 2,
|
|
CurrentConnections: 2,
|
|
LastConnectedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
LastHeartbeatAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
}, nil
|
|
}})
|
|
|
|
resp := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/connections/connect", strings.NewReader(validConnectCommand()))
|
|
handler.ServeHTTP(resp, req)
|
|
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
|
|
}
|
|
var callback ConnectionStateCallback
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &callback); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if callback.Status != "connected" || callback.CurrentConnections != 2 || callback.DesiredConnections != 2 {
|
|
t.Fatalf("unexpected callback state: %+v", callback)
|
|
}
|
|
if callback.ChannelID != "channel-1" || callback.ConnectionID != "channel-1:primary" {
|
|
t.Fatalf("unexpected callback identity: %+v", callback)
|
|
}
|
|
if callback.LastConnectedAt == "" || callback.LastHeartbeatAt == "" {
|
|
t.Fatalf("expected connection timestamps: %+v", callback)
|
|
}
|
|
if limiter.configuredChannelID != "channel-1" || limiter.configuredRate != 100 {
|
|
t.Fatalf("unexpected configured limiter: %+v", limiter)
|
|
}
|
|
}
|
|
|
|
func TestConnectChannelCallbacksFailedState(t *testing.T) {
|
|
handler := handlerWithConnect(func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error) {
|
|
return ConnectionStateCallback{
|
|
ChannelID: "channel-1",
|
|
ConnectionID: "channel-1:primary",
|
|
Status: "failed",
|
|
DesiredConnections: 2,
|
|
CurrentConnections: 0,
|
|
LastDisconnectedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
LastError: errTestConnect.Error(),
|
|
}, nil
|
|
})
|
|
|
|
resp := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/connections/connect", strings.NewReader(validConnectCommand()))
|
|
handler.ServeHTTP(resp, req)
|
|
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
|
|
}
|
|
var callback ConnectionStateCallback
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &callback); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if callback.Status != "failed" || callback.CurrentConnections != 0 || callback.LastError == "" {
|
|
t.Fatalf("unexpected callback state: %+v", callback)
|
|
}
|
|
}
|
|
|
|
func TestConnectChannelRejectsInvalidCommand(t *testing.T) {
|
|
handler := handlerWithConnect(func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error) {
|
|
return ConnectionStateCallback{}, nil
|
|
})
|
|
|
|
resp := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/connections/connect", strings.NewReader(`{"messageType":"SubmitCommand"}`))
|
|
handler.ServeHTTP(resp, req)
|
|
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("unexpected response status: %d", resp.Code)
|
|
}
|
|
}
|
|
|
|
func TestRecoveryCandidatesEndpointReturnsView(t *testing.T) {
|
|
handler := handlerWithServer(Server{
|
|
RecoveryCandidates: func(context.Context) ([]inbound.DownstreamPresence, error) {
|
|
return []inbound.DownstreamPresence{{
|
|
Account: "100001",
|
|
GatewayInstanceID: "gateway-a",
|
|
State: "connected",
|
|
UpdatedAt: time.Now().UTC(),
|
|
}}, nil
|
|
},
|
|
})
|
|
|
|
resp := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/downstream/recovery-candidates", nil)
|
|
handler.ServeHTTP(resp, req)
|
|
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
|
|
}
|
|
var payload []inbound.DownstreamPresence
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if len(payload) != 1 || payload[0].Account != "100001" {
|
|
t.Fatalf("unexpected payload: %+v", payload)
|
|
}
|
|
}
|
|
|
|
func TestRecoveryStatusesEndpointReturnsView(t *testing.T) {
|
|
handler := handlerWithServer(Server{
|
|
RecoveryStatuses: func(context.Context) ([]inbound.DownstreamRecoveryStatus, error) {
|
|
return []inbound.DownstreamRecoveryStatus{{
|
|
Account: "100001",
|
|
GatewayInstanceID: "gateway-a",
|
|
State: "waiting_connection",
|
|
AttemptCount: 2,
|
|
}}, nil
|
|
},
|
|
})
|
|
|
|
resp := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/downstream/recovery-statuses", nil)
|
|
handler.ServeHTTP(resp, req)
|
|
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
|
|
}
|
|
var payload []inbound.DownstreamRecoveryStatus
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if len(payload) != 1 || payload[0].State != "waiting_connection" || payload[0].AttemptCount != 2 {
|
|
t.Fatalf("unexpected payload: %+v", payload)
|
|
}
|
|
}
|
|
|
|
func TestRecoveryOverviewEndpointReturnsCombinedView(t *testing.T) {
|
|
handler := handlerWithServer(Server{
|
|
RecoveryCandidates: func(context.Context) ([]inbound.DownstreamPresence, error) {
|
|
return []inbound.DownstreamPresence{{
|
|
Account: "100001",
|
|
GatewayInstanceID: "gateway-a",
|
|
State: "connected",
|
|
}}, nil
|
|
},
|
|
RecoveryStatuses: func(context.Context) ([]inbound.DownstreamRecoveryStatus, error) {
|
|
return []inbound.DownstreamRecoveryStatus{{
|
|
Account: "100001",
|
|
GatewayInstanceID: "gateway-a",
|
|
State: "success",
|
|
}}, nil
|
|
},
|
|
})
|
|
|
|
resp := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/downstream/recovery-overview", nil)
|
|
handler.ServeHTTP(resp, req)
|
|
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
|
|
}
|
|
var payload DownstreamRecoveryOverview
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if len(payload.Candidates) != 1 || payload.Candidates[0].Account != "100001" {
|
|
t.Fatalf("unexpected candidate payload: %+v", payload.Candidates)
|
|
}
|
|
if len(payload.Statuses) != 1 || payload.Statuses[0].State != "success" {
|
|
t.Fatalf("unexpected status payload: %+v", payload.Statuses)
|
|
}
|
|
}
|
|
|
|
type testConnectError struct{}
|
|
|
|
func (testConnectError) Error() string {
|
|
return "connect failed"
|
|
}
|
|
|
|
var errTestConnect testConnectError
|
|
|
|
func handlerWithConnect(connect ConnectFunc) http.Handler {
|
|
return handlerWithServer(Server{Connect: connect})
|
|
}
|
|
|
|
func handlerWithServer(server Server) http.Handler {
|
|
mux := http.NewServeMux()
|
|
Register(mux, server)
|
|
return mux
|
|
}
|
|
|
|
func validConnectCommand() string {
|
|
return `{
|
|
"schemaVersion": "v1",
|
|
"messageType": "ConnectChannel",
|
|
"traceId": "trace-1",
|
|
"channelId": "channel-1",
|
|
"connectionId": "channel-1:primary",
|
|
"reason": "channel_created",
|
|
"desiredConnections": 2,
|
|
"channel": {
|
|
"code": "CMPP-A",
|
|
"name": "主通道",
|
|
"gatewayHost": "127.0.0.1",
|
|
"gatewayPort": 17890,
|
|
"account": "sp",
|
|
"passwordCipher": "secret",
|
|
"srcId": "10690000",
|
|
"cmppVersion": "3.0",
|
|
"rateLimitPerSecond": 100
|
|
}
|
|
}`
|
|
}
|