Files

528 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package upstream
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcfg"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
func startAPNHTTPChainHelper(t *testing.T, module string, extraEnv ...string) string {
t.Helper()
root, err := filepath.Abs(filepath.Join("..", "..", "..", "..", module))
if err != nil {
t.Fatal(err)
}
buildCtx, buildCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer buildCancel()
binary := filepath.Join(t.TempDir(), module+"-http-test.exe")
build := exec.CommandContext(buildCtx, "go", "-C", root, "test", "-work", "-c", "-o", binary, "./internal/cmd")
build.Env = append(os.Environ(), "GOTMPDIR="+t.TempDir())
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("%s模拟进程编译失败:%v\n%s", module, err, output)
}
ctx, cancel := context.WithTimeout(context.Background(), 170*time.Second)
cmd := exec.CommandContext(ctx, binary, "-test.run=^TestAPNHTTPHarness$", "-test.v", "-test.count=1")
cmd.Dir = filepath.Join(root, "internal", "cmd")
cmd.Env = append(append(os.Environ(), "APN_HTTP_CHAIN_HELPER=1"), extraEnv...)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
t.Fatal(err)
}
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
cancel()
t.Fatal(err)
}
ready := make(chan string, 1)
var mu sync.Mutex
var log strings.Builder
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
mu.Lock()
log.WriteString(line + "\n")
mu.Unlock()
if strings.HasPrefix(line, "APN_CHAIN_READY=") {
ready <- strings.TrimPrefix(line, "APN_CHAIN_READY=")
}
}
}()
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
var address string
select {
case address = <-ready:
case err := <-done:
cancel()
mu.Lock()
text := log.String()
mu.Unlock()
t.Fatalf("%s模拟进程启动即退出:%v\n%s", module, err, text)
case <-ctx.Done():
cancel()
<-done
mu.Lock()
text := log.String()
mu.Unlock()
t.Fatalf("%s模拟进程启动失败:%s", module, text)
}
t.Cleanup(func() {
requestCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)
defer stop()
req, _ := http.NewRequestWithContext(requestCtx, http.MethodPost, address+"/__test/stop", nil)
if res, err := http.DefaultClient.Do(req); err == nil {
_ = res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("%s停止请求失败:%d", module, res.StatusCode)
}
} else {
t.Errorf("%s停止请求失败:%v", module, err)
}
if err := <-done; err != nil {
mu.Lock()
text := log.String()
mu.Unlock()
t.Errorf("%s模拟进程异常退出:%v\n%s", module, err, text)
} else {
t.Logf("%s helper正常shutdown并退出0", module)
}
cancel()
})
return address
}
func TestAPNHTTPChainWorkerClientProxyChanCarrier(t *testing.T) {
if os.Getenv("APN_HTTP_CHAIN_INTEGRATION") != "1" {
t.Skip("未启用本机跨模块HTTP及专用MySQL模拟链")
}
chanURL := startAPNHTTPChainHelper(t, "iotlcm-upstream-chan")
proxyURL := startAPNHTTPChainHelper(t, "iotlcm-upstream-proxy", "APN_HTTP_CHAIN_CHAN="+chanURL)
oldAdapter := g.Cfg().GetAdapter()
defer g.Cfg().SetAdapter(oldAdapter)
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"2s","apnAsyncProtocolVersion":1}}}`, strings.TrimPrefix(proxyURL, "http://")))
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
client := New()
expectedWrites := map[string]int{}
expectedQueries := map[string]int{}
expectedSubmits := 0
for _, scenario := range []string{"drop-response", "top-error", "lost-response", "failed-task"} {
t.Run(scenario, func(t *testing.T) {
input := &model.SimCardApnChangeInput{ReqData: model.SimCardApnChangeInputItem{OperationID: fmt.Sprintf("http-chain-%s-%d", scenario, time.Now().UnixNano()), OperType: "1", ApnName: "CMIOT"}}
prefix := scenario + "-"
if scenario == "lost-response" {
prefix = "lost-"
}
if scenario == "failed-task" {
prefix = "failed-"
}
for i := 0; i < 10; i++ {
input.SimKeys = append(input.SimKeys, &model.SimKey{ChanKey: "fake-channel", ICCID: fmt.Sprintf("fake-card-%s-%d", scenario, i), MSISDN: fmt.Sprintf("%s%d", prefix, i)})
}
out, err := client.SubmitSimApnFunction(context.Background(), input)
if err != nil || len(out) != 10 {
t.Fatalf("首发结果无效:%+v %v", out, err)
}
for _, item := range out {
if scenario != "failed-task" && !item.ResData.Uncertain {
t.Fatalf("丢响应被确认:%+v", item)
}
}
res, err := http.Post(chanURL+"/__test/restart", "application/json", nil)
if err != nil {
t.Fatal(err)
}
_ = res.Body.Close()
recovered, err := client.QuerySimApnFunctionResult(context.Background(), input)
if err != nil || len(recovered) != 10 {
t.Fatalf("恢复失败:%v", err)
}
duplicate, err := client.SubmitSimApnFunction(context.Background(), input)
if err != nil || len(duplicate) != 10 {
t.Fatalf("重复恢复失败:%v", err)
}
if scenario == "lost-response" {
for i, item := range recovered {
if !item.ResData.Uncertain || item.ResData.OrderNum != "" || !duplicate[i].ResData.Uncertain {
t.Fatalf("无jobId必须未知:%+v", item)
}
}
} else {
job := recovered[0].ResData.OrderNum
for i, item := range recovered {
if job == "" || item.ResData.OrderNum != job || !item.ResData.Pending || duplicate[i].ResData.OrderNum != job {
t.Fatalf("恢复丢失已持久化jobId:%+v", item)
}
}
expectedQueries[job] = 3
input.ReqData.QueryJobID = job
for attempt := 0; attempt < 3; attempt++ {
result, e := client.QuerySimApnFunctionResult(context.Background(), input)
if e != nil || len(result) != 10 {
t.Fatalf("单次查询失败:%v", e)
}
for _, item := range result {
if attempt < 2 && (!item.ResData.Pending || item.Message == "") {
t.Fatalf("14008/处理中不得终态:%+v", item)
}
if attempt == 2 && (item.ResData.Pending || item.ResData.Uncertain || (scenario == "failed-task" && item.Code != 9999) || (scenario != "failed-task" && item.Code != 0)) {
t.Fatalf("终态语义错误:%+v", item)
}
}
}
}
statsResponse, e := http.Get(chanURL + "/__test/stats")
if e != nil {
t.Fatal(e)
}
var stats struct {
Writes map[string]int `json:"writes"`
Queries map[string]int `json:"queries"`
Submits int `json:"submits"`
Unexpected int `json:"unexpected"`
}
e = json.NewDecoder(statsResponse.Body).Decode(&stats)
_ = statsResponse.Body.Close()
expectedSubmits++
for _, key := range input.SimKeys {
expectedWrites[key.MSISDN] = 1
}
if e != nil || stats.Submits != expectedSubmits || stats.Unexpected != 0 || len(stats.Writes) != len(expectedWrites) || len(stats.Queries) != len(expectedQueries) {
t.Fatalf("额外或缺失上游请求:stats=%+v err=%v", stats, e)
}
for key, count := range expectedWrites {
if stats.Writes[key] != count {
t.Fatalf("逐卡办理次数错误:%s=%d,预期%d", key, stats.Writes[key], count)
}
}
for job, count := range expectedQueries {
if stats.Queries[job] != count {
t.Fatalf("纯查询次数错误:%s=%d,预期%d", job, stats.Queries[job], count)
}
}
counts := make([]int, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
counts = append(counts, stats.Writes[key.MSISDN])
}
t.Logf("十卡实际办理计数=%v 累计提交=%d 无额外请求;丢失jobId=%v 明确失败终态=%v", counts, stats.Submits, scenario == "lost-response", scenario == "failed-task")
})
}
}
func TestAsyncApnCapabilityGateBlocksOldChanBeforeSubmit(t *testing.T) {
oldAdapter := g.Cfg().GetAdapter()
defer g.Cfg().SetAdapter(oldAdapter)
for _, tc := range []struct {
name string
enabled int
version int
wantError bool
}{{"显式关闭", 0, 1, true}, {"旧版无能力", 1, 0, true}, {"能力就绪", 1, 1, false}} {
t.Run(tc.name, func(t *testing.T) {
probes, submits := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/sim/query/sim-apn-capability" {
probes++
_, _ = fmt.Fprintf(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":%d}}]}`, tc.version)
return
}
submits++
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"resData":{"pending":true,"orderNum":"fake-job"}}]}`))
}))
defer server.Close()
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":%d}}}`, strings.TrimPrefix(server.URL, "http://"), tc.enabled))
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
_, err = New().SubmitSimApnFunction(context.Background(), &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}})
var notSent interface{ APNRequestNotSent() bool }
if (err != nil) != tc.wantError || (tc.wantError && (submits != 0 || !errors.As(err, &notSent) || !notSent.APNRequestNotSent())) || (!tc.wantError && (submits != 1 || probes != 1)) {
t.Fatalf("能力门禁失效:probes=%d submits=%d err=%v", probes, submits, err)
}
})
}
}
// TestAsyncApnSubmitDefaultsEnabledWithoutGateConfig 固化默认开启:
// 配置里不写 apnAsyncProtocolVersion 时,提交不应再被门禁拦下,兼容性交给协议能力探测。
func TestAsyncApnSubmitDefaultsEnabledWithoutGateConfig(t *testing.T) {
oldAdapter := g.Cfg().GetAdapter()
defer g.Cfg().SetAdapter(oldAdapter)
probes, submits := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/sim/query/sim-apn-capability" {
probes++
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":1}}]}`))
return
}
submits++
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"resData":{"pending":true,"orderNum":"fake-job"}}]}`))
}))
defer server.Close()
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s"}}}`, strings.TrimPrefix(server.URL, "http://")))
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
if _, err = New().SubmitSimApnFunction(context.Background(), &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}}); err != nil {
t.Fatalf("未配置开关时提交应默认放行:%v", err)
}
if probes != 1 || submits != 1 {
t.Fatalf("默认放行后仍应做协议探测:probes=%d submits=%d", probes, submits)
}
}
func TestAsyncApnReadOnlyQueryBypassesSubmitGateButRequiresProtocol(t *testing.T) {
oldAdapter := g.Cfg().GetAdapter()
defer g.Cfg().SetAdapter(oldAdapter)
for _, tc := range []struct {
name, jobID, operationID string
version int
missingProbe bool
wantQuery bool
}{
{name: "existing_job", jobID: "existing-job", version: 1, wantQuery: true},
{name: "existing_operation", operationID: "existing-operation", version: 1, wantQuery: true},
{name: "legacy_protocol", jobID: "existing-job", version: 0},
{name: "missing_capability", jobID: "existing-job", missingProbe: true},
} {
t.Run(tc.name, func(t *testing.T) {
probes, queries := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/sim/query/sim-apn-capability" {
probes++
if tc.missingProbe {
_ = json.NewEncoder(w).Encode(map[string]any{"code": 404, "message": "missing capability"})
return
}
_, _ = fmt.Fprintf(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":%d}}]}`, tc.version)
return
}
if r.URL.Path != "/sim/change/sim-apn" {
t.Errorf("unexpected endpoint: %s", r.URL.Path)
}
queries++
var request model.SimCardApnChangeInput
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Error(err)
}
if request.ReqData.SubmitOnly || request.ReqData.QueryJobID != tc.jobID || request.ReqData.RecoverOnly != (tc.jobID == "") {
t.Errorf("query accidentally submitted: %+v", request.ReqData)
}
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"orderNum":"existing-job","pending":true}}]}`))
}))
defer server.Close()
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":0}}}`, strings.TrimPrefix(server.URL, "http://")))
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
result, err := New().QuerySimApnFunctionResult(context.Background(), &model.SimCardApnChangeInput{
SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}},
ReqData: model.SimCardApnChangeInputItem{OperationID: tc.operationID, QueryJobID: tc.jobID},
})
if probes != 1 || (err == nil) != tc.wantQuery || queries != map[bool]int{true: 1, false: 0}[tc.wantQuery] {
t.Fatalf("read-only protocol guard failed: probes=%d queries=%d err=%v result=%+v", probes, queries, err, result)
}
})
}
}
func TestAsyncApnSecondProbeRejectionIsTypedAndTransportIsNot(t *testing.T) {
oldAdapter := g.Cfg().GetAdapter()
defer g.Cfg().SetAdapter(oldAdapter)
for _, probeFails := range []bool{true, false} {
probes, submits := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 夹具不共享连接:并发跑包时复用空闲连接会被对端关闭,
// 使失败从"提交阶段"漂移到"只读探测阶段",造成与被测语义无关的抖动。
w.Header().Set("Connection", "close")
if r.URL.Path == "/sim/query/sim-apn-capability" {
probes++
if probes == 2 && probeFails {
http.Error(w, "模拟第二次探测失败", http.StatusNotFound)
return
}
fmt.Fprint(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":1}}]}`)
return
}
submits++
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Error(err)
return
}
_ = conn.Close()
}))
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":1}}}`, strings.TrimPrefix(server.URL, "http://")))
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
input := &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}}
client := New()
if err := client.CheckSimApnAsyncCapability(context.Background(), input); err != nil {
t.Fatal(err)
}
_, err = client.SubmitSimApnFunction(context.Background(), input)
server.Close()
var notSent interface{ APNRequestNotSent() bool }
isNotSent := errors.As(err, &notSent) && notSent.APNRequestNotSent()
if err == nil || isNotSent != probeFails || (probeFails && submits != 0) || (!probeFails && submits != 1) || probes != 2 {
t.Fatalf("零写类型边界错误:probeFails=%v probes=%d submits=%d err=%v", probeFails, probes, submits, err)
}
}
}
func TestSimCardStatusChangeProtocol(t *testing.T) {
input := model.SimCardStatusChangeInput{
SimKeys: []*model.SimKey{{ChanKey: "channel-a", ICCID: "89860001"}},
ReqData: model.SimCardStatusChangeInputItem{OperType: "1"},
}
body, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
const want = `{"simKeys":[{"chanKey":"channel-a","iccid":"89860001","imsi":"","msisdn":""}],"reqData":{"operType":"1"}}`
if got := string(body); got != want {
t.Fatalf("request body = %s, want %s", got, want)
}
}
func TestCurrentUsageConsumerProtocol(t *testing.T) {
input := model.SimDataUsageInput{
SimKeys: []*model.SimKey{{ChanKey: "channel-a", ICCID: "89860001", IMSI: "460001", MSISDN: "13800000000"}},
}
body, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
const want = `{"simKeys":[{"chanKey":"channel-a","iccid":"89860001","imsi":"460001","msisdn":"13800000000"}],"reqData":{}}`
if got := string(body); got != want {
t.Fatalf("request body = %s, want %s", got, want)
}
client := NewWithCurrentUsageClient("iotlcmupstreamchan-http-svc:8110")
if client.currentUsageClientName != "iotlcmupstreamchan-http-svc:8110" {
t.Fatalf("current usage client = %q", client.currentUsageClientName)
}
if client.currentUsageTimeout != 0 {
t.Fatalf("default current usage timeout = %s, want unset", client.currentUsageTimeout)
}
configured := NewWithCurrentUsageClient("iotlcmupstreamchan-http-svc:8110", 90*time.Second)
if configured.currentUsageTimeout != 90*time.Second {
t.Fatalf("configured current usage timeout = %s, want 90s", configured.currentUsageTimeout)
}
}
func TestInboundBasicInfoClientCanUseDedicatedEndpoint(t *testing.T) {
client := NewWithInboundBasicInfoClient("iotlcm.example.com:8110")
clientName, err := client.inboundBasicInfoAddress(context.Background())
if err != nil {
t.Fatal(err)
}
if clientName != "iotlcm.example.com:8110" {
t.Fatalf("inbound basic info client = %q, want dedicated chan endpoint", clientName)
}
}
func TestInboundBasicInfoClientCanUseEnvEndpoint(t *testing.T) {
const endpoint = "iotlcm.example.com:8110"
t.Setenv(inboundBasicInfoClientNameEnv, endpoint)
client := New()
clientName, err := client.inboundBasicInfoAddress(context.Background())
if err != nil {
t.Fatal(err)
}
if clientName != endpoint {
t.Fatalf("inbound basic info client = %q, want env endpoint", clientName)
}
}
func TestConfiguredInboundBasicInfoClientUsesDedicatedEndpoint(t *testing.T) {
if value := os.Getenv(inboundBasicInfoClientNameEnv); value != "" {
t.Setenv(inboundBasicInfoClientNameEnv, "")
}
client := New()
clientName, err := client.inboundBasicInfoAddress(context.Background())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(clientName, ":8110") {
t.Fatalf("inbound basic info client = %q, want dedicated chan endpoint", clientName)
}
}
func TestDecodeAsyncApnResponseKeepsUnconfirmedState(t *testing.T) {
body := []byte(`{"code":0,"data":[{"code":0,"resData":{"orderNum":"job-1","pending":true}},{"code":1,"message":"响应未确认","resData":{"orderNum":"job-2","uncertain":true}}]}`)
items, err := decodeResponse[model.SimCardApnChangeOutputItem](body)
if err != nil || len(items) != 2 {
t.Fatalf("解析异步响应失败:%v", err)
}
if !items[0].ResData.Pending || items[0].ResData.OrderNum != "job-1" || !items[1].ResData.Uncertain {
t.Fatalf("异步待查字段丢失:%+v", items)
}
}
func TestSimCardApnChangeProtocol(t *testing.T) {
input := model.SimCardApnChangeInput{
SimKeys: []*model.SimKey{{ChanKey: "channel-a", ICCID: "89860001"}},
ReqData: model.SimCardApnChangeInputItem{OperType: "0", ApnName: "CMIOT"},
}
body, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
const want = `{"simKeys":[{"chanKey":"channel-a","iccid":"89860001","imsi":"","msisdn":""}],"reqData":{"operType":"0","apnName":"CMIOT"}}`
if got := string(body); got != want {
t.Fatalf("request body = %s, want %s", got, want)
}
}
func TestDecodeSimCardApnChangeResponseKeepsFailedItems(t *testing.T) {
body := []byte(`{"code":0,"message":"ok","data":[{"code":0,"message":"success","simKey":{"iccid":"89860001"},"resData":{"orderNum":"order-1"}},{"code":4001,"message":"denied","simKey":{"iccid":"89860002"},"resData":{"orderNum":"order-2"}}]}`)
items, err := decodeResponse[model.SimCardApnChangeOutputItem](body)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("item count = %d, want 2", len(items))
}
if items[1].Code != 4001 || items[1].ResData.OrderNum != "order-2" {
t.Fatalf("failed item was not preserved: %+v", items[1])
}
}
func TestDecodeSimCardStatusChangeResponseKeepsFailedItems(t *testing.T) {
body := []byte(`{"code":0,"message":"ok","data":[{"code":0,"message":"success","simKey":{"iccid":"89860001"},"resData":{"orderNum":"order-1"}},{"code":4001,"message":"denied","simKey":{"iccid":"89860002"},"resData":{"orderNum":"order-2"}}]}`)
items, err := decodeResponse[model.SimCardStatusChangeOutputItem](body)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("item count = %d, want 2", len(items))
}
if items[1].Code != 4001 || items[1].ResData.OrderNum != "order-2" {
t.Fatalf("failed item was not preserved: %+v", items[1])
}
}