package upstreamimpl import ( "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service" ) func newDXV3TestClient(t *testing.T, handler http.HandlerFunc) *sSimDXV3 { t.Helper() server := httptest.NewServer(handler) t.Cleanup(server.Close) return &sSimDXV3{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test-key", AppSecret: "test-secret", UppercaseSign: true}), custNbr: "test-customer"} } func TestDXV3APNWriteUncertainAndNoReplay(t *testing.T) { for _, tc := range []struct { name, body string drop, uncertain bool redirect int }{ {name: "断响应", drop: true, uncertain: true}, {name: "坏JSON", body: "{", uncertain: true}, {name: "缺result", body: `{"code":0}`, uncertain: true}, {name: "卡号不符", body: `{"code":0,"result":{"msisdn":"other","transactionId":"job"}}`, uncertain: true}, {name: "缺transactionId", body: `{"code":0,"result":{}}`, uncertain: true}, {name: "明确拒绝", body: `{"code":9999,"message":"拒绝"}`}, {name: "明确成功", body: `{"code":0,"result":{"transactionId":"job"}}`}, {name: "跳转307", redirect: 307, uncertain: true}, {name: "跳转308", redirect: 308, uncertain: true}, } { t.Run(tc.name, func(t *testing.T) { var writes, targetWrites atomic.Int32 client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("办理方法被改变:%s", r.Method) } writes.Add(1) if r.URL.Path == "/redirect-target" { targetWrites.Add(1) fmt.Fprint(w, `{"code":0,"result":{"transactionId":"unexpected"}}`) return } if r.URL.Path != "/api/v3/singleCutNet" { t.Errorf("错误办理路径:%s", r.URL.Path) } var body map[string]string if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["msisdn"] != "fake-msisdn" || body["operType"] != "1" { t.Errorf("办理请求不符:%+v %v", body, err) } if tc.drop { conn, _, err := w.(http.Hijacker).Hijack() if err != nil { t.Error(err) return } _ = conn.Close() return } if tc.redirect != 0 { w.Header().Set("Location", "/redirect-target") w.WriteHeader(tc.redirect) return } fmt.Fprint(w, tc.body) }) client.enableOperations = true out, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{{MSISDN: "fake-msisdn"}}, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0"}}) if err != nil || len(out) != 1 || writes.Load() != 1 || targetWrites.Load() != 0 || out[0].ResData.Uncertain != tc.uncertain || out[0].ResData.Pending { t.Fatalf("写后分类/重放错误:out=%+v writes=%d target=%d err=%v", out, writes.Load(), targetWrites.Load(), err) } if (tc.name == "明确成功") != (out[0].Code == 0) { t.Fatalf("成功失败分类错误:%+v", out[0]) } t.Logf("writes=%d targetWrites=%d uncertain=%v code=%d", writes.Load(), targetWrites.Load(), out[0].ResData.Uncertain, out[0].Code) }) } } func TestDXV3SingleCutNetAcceptanceModes(t *testing.T) { for _, mode := range []string{"同步兼容", "仅提交", "持久化回调"} { for _, accepted := range []bool{true, false} { t.Run(fmt.Sprintf("%s/受理=%t", mode, accepted), func(t *testing.T) { writes, checkpoints := 0, 0 client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { writes++ if !accepted { fmt.Fprint(w, `{"code":9999,"message":"明确拒绝"}`) return } fmt.Fprintf(w, `{"code":0,"result":{"transactionId":"order-%d"}}`, writes) }) client.enableOperations = true wantUncertain := accepted && mode != "同步兼容" check := func(items model.SimCardOperateChangeApnOutput) { t.Helper() for i, item := range items { if (item.Code == 0) != accepted || item.ResData.Uncertain != wantUncertain || item.ResData.Pending { t.Fatalf("受理与完成分类错误:%+v", item) } if accepted && item.ResData.OrderNum != fmt.Sprintf("order-%d", i+1) { t.Fatalf("未保留逐卡受理流水:%+v", item) } if wantUncertain && !strings.Contains(item.Message, "最终办理结果未确认") { t.Fatalf("受理消息未明确最终结果未知:%s", item.Message) } if !accepted && (item.ErrCode != "9999" || item.ResData.OrderNum != "") { t.Fatalf("明确拒绝不应携带受理流水:%+v", item) } } } ctx := context.Background() if mode == "持久化回调" { ctx = context.WithValue(ctx, model.ApnOperationCheckpointKey{}, func(items model.SimCardOperateChangeApnOutput) error { checkpoints++ check(items) return nil }) } items, err := client.ChangeSimApnFunction(ctx, &model.SimCardOperateChangeApnInput{ SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "card-1", MSISDN: "test-msisdn-1"}, {ChanKey: "test", ICCID: "card-2", MSISDN: "test-msisdn-2"}}, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", SubmitOnly: mode == "仅提交"}, }) if err != nil || len(items) != 2 || writes != 2 || (mode == "持久化回调" && checkpoints != 2) { t.Fatalf("逐卡处理异常:items=%+v writes=%d checkpoints=%d err=%v", items, writes, checkpoints, err) } check(items) }) } } } func TestDXV3APNPrewriteValidationSendsNothing(t *testing.T) { for _, tc := range []struct { name string enabled bool key *model.SimKey oper string }{ {"禁用办理", false, &model.SimKey{MSISDN: "fake"}, "0"}, {"缺卡号", true, nil, "0"}, {"缺msisdn", true, &model.SimKey{ICCID: "fake"}, "0"}, {"错误操作", true, &model.SimKey{MSISDN: "fake"}, "invalid"}, } { t.Run(tc.name, func(t *testing.T) { var writes atomic.Int32 client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { writes.Add(1) }) client.enableOperations = tc.enabled out, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{tc.key}, ReqData: model.SimCardOperateChangeApnInputItem{OperType: tc.oper}}) if err != nil || len(out) != 1 || writes.Load() != 0 || out[0].Code == 0 || out[0].ResData.Uncertain { t.Fatalf("预写拒绝错误:out=%+v writes=%d err=%v", out, writes.Load(), err) } }) } } func TestDXV3ReadRedirectUnchanged(t *testing.T) { var calls atomic.Int32 client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { calls.Add(1) if r.Method != http.MethodGet { t.Errorf("查询方法改变:%s", r.Method) } if r.URL.Path != "/read-target" { w.Header().Set("Location", "/read-target") w.WriteHeader(http.StatusTemporaryRedirect) return } fmt.Fprint(w, `{"code":0,"result":{"dataAmount":"2"}}`) }) out, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{MSISDN: "fake"}}}) if err != nil || len(out) != 1 || out[0].Code != 0 || calls.Load() != 2 { t.Fatalf("查询跳转被改变:%+v %v calls=%d", out, err, calls.Load()) } } func TestDXV3TemplateCoexistsWithV1(t *testing.T) { for _, key := range []string{"sim_dx_v1", "sim_dx_v3"} { if _, ok := service.UpstreamAPI().GetTemp(key); !ok { t.Fatalf("missing template %s", key) } } factory := &sSimDXV3{} config := &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{AppKey: "key", AppSecret: "secret", ChanConfig: `{"custNumber":"customer"}`}, Template: &entity.TSimChannelTemplate{TempConfig: `{"api":{"baseUrl":"https://cmp-api.ctwing.cn:20164"}}`}} instance, ok := factory.NewTemp(context.Background(), config).(*sSimDXV3) if !ok || instance.enableOperations || instance.custNbr != "customer" { t.Fatalf("unexpected v3 configuration: %+v", instance) } config.Channel.ChanConfig = "" if factory.NewTemp(context.Background(), config) == nil { t.Fatal("single-card configuration should not require custNbr") } config.Template.TempConfig = `{"api":{"baseUrl":"https://cmp-api.ctwing.cn:20164/api/v3"}}` if factory.NewTemp(context.Background(), config) != nil { t.Fatal("accepted base URL containing protocol path") } } func TestDXV3SessionAndRealTimeUsageContracts(t *testing.T) { client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("msisdn") != "106123" || len(r.URL.Query()) != 1 { t.Errorf("unexpected query: %s", r.URL.RawQuery) } want, _ := dx.BuildSignV3(r.URL.RawQuery, "", "test-secret", r.Header.Get("Timestamp")) if r.Header.Get("Sign") != want || r.Header.Get("AppKey") != "test-key" { t.Error("unexpected signed request") } switch r.URL.Path { case "/api/v3/getOnlineStatus": fmt.Fprint(w, `{"code":"0","result":{"onlineStatus":"1","apnName":"iot.apn","ipv4Address":"127.0.0.1"}}`) case "/api/v3/getDataUsage": fmt.Fprint(w, `{"code":0,"result":{"dataAmount":"12.5"}}`) default: t.Errorf("unexpected path: %s", r.URL.Path) } }) keys := []*model.SimKey{{MSISDN: "106123", ICCID: "8986", IMSI: "46011"}} session, err := client.QuerySimSession(context.Background(), &model.SimSessionInput{SimKeys: keys}) if err != nil || len(session) != 1 || session[0].Code != 0 || session[0].ResData.Status != consts.CardSessionStatusOnline || session[0].ResData.ApnId != "iot.apn" { t.Fatalf("session: %+v, %v", session, err) } usage, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: keys}) if err != nil || usage[0].Code != 0 || usage[0].ResData.DataUsage != 12.5 { t.Fatalf("usage: %+v, %v", usage, err) } } func TestDXV3ErrorsNeverBecomeZeroUsage(t *testing.T) { for _, test := range []struct { name, body, code string httpStatus int }{ {"missing code", `{"result":{"dataAmount":"0"}}`, "", 200}, {"null code", `{"code":null,"result":{"dataAmount":"0"}}`, "", 200}, {"mismatched card", `{"code":"0","result":{"msisdn":"2","dataAmount":"5"}}`, "", 200}, {"invalid amount", `{"code":"0","result":{"dataAmount":"garbage"}}`, "", 200}, {"missing result", `{"code":"0","result":null}`, "", 200}, {"business failure", `{"code":"S00004","message":"limited"}`, "S00004", 200}, {"gateway failure", `{}`, "S00004", 799}, } { t.Run(test.name, func(t *testing.T) { client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { if test.httpStatus == 799 { w.Header().Set("gw-errorCode", "S00004") } w.WriteHeader(test.httpStatus) fmt.Fprint(w, test.body) }) items, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}}) if err != nil || items[0].Code == 0 || items[0].ErrCode != test.code { t.Fatalf("unexpected error result: %+v, %v", items[0], err) } }) } } func TestDXV3HistoryDayAndDiagnosticMappings(t *testing.T) { day := time.Now().In(dxBatchLocation).AddDate(0, 0, -1).Format("20060102") calls := 0 client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ switch r.URL.Path { case "/api/v3/getDataUsageBetweenDate": if r.URL.Query().Get("beginDate") != day || r.URL.Query().Get("endDate") != day { t.Error("history day must use the same beginning and ending date") } fmt.Fprint(w, `{"code":"0","result":{"totalUsageAmount":"4.5"}}`) case "/api/v3/getApnInfo": fmt.Fprint(w, `{"code":"0","result":{"apnInfoList":[{"apnName":"iot","status":"1"}]}}`) case "/api/v3/getSimBindingInfo": fmt.Fprint(w, `{"code":"0","result":{"isOrdered":"0","bindType":"2","simSeparationStatus":"0","bindImei":"123"}}`) case "/api/v3/getSimRegionLimitStatus": fmt.Fprint(w, `{"code":"0","result":{"orderStatus":0,"limitType":3,"regionLimitStatus":"0","restrictedArea":"110000"}}`) case "/api/v3/getDevicePowerStatus": // 网关实测只接受 GET(文档写 POST 与网关不符),参数走 query。 if r.Method != "GET" || r.URL.Query().Get("msisdn") != "1" { t.Errorf("invalid power-status request: %s %s", r.Method, r.URL.RawQuery) } fmt.Fprint(w, `{"code":"0","result":{"status":"0"}}`) default: t.Errorf("unexpected diagnostic path: %s", r.URL.Path) } }) ctx := context.Background() keys := []*model.SimKey{{MSISDN: "1"}} usage, err := client.QuerySimDataUsageDaily(ctx, &model.SimDataUsageDailyInput{SimKeys: keys, ReqData: model.SimDataUsageDailyInputItem{QueryDate: day}}) if err != nil || usage[0].Code != 0 || usage[0].ResData.QueryDate != day || usage[0].ResData.DataAmount != 4.5 { t.Fatalf("history day: %+v, %v", usage, err) } today := time.Now().In(dxBatchLocation).Format("20060102") usage, _ = client.QuerySimDataUsageDaily(ctx, &model.SimDataUsageDailyInput{SimKeys: keys, ReqData: model.SimDataUsageDailyInputItem{QueryDate: today}}) if usage[0].Code == 0 || calls != 1 { t.Fatal("unfinished day reached the historical endpoint") } apns, _ := client.QuerySimApnInfo(ctx, &model.SimApnInfoInput{SimKeys: keys}) if apns[0].Code != 0 || len(apns[0].ResData.ApnList) != 1 || apns[0].ResData.ApnList[0].Status != "1" { t.Fatalf("APN mapping: %+v", apns) } bind, _ := client.QuerySimCardBindStatus(ctx, &model.SimCardBindStatusInput{SimKeys: keys}) if bind[0].Code != 0 || bind[0].ResData.BindStatus != "0" || bind[0].ResData.OrderStatus != "0" || bind[0].ResData.CardBindingType != "2" || bind[0].ResData.BindImei != "123" { t.Fatalf("binding mapping: %+v", bind) } region, _ := client.QuerySimRegionLimitStatus(ctx, &model.SimRegionLimitStatusInput{SimKeys: keys}) if region[0].Code != 0 || region[0].ResData.RegionLimitStatus != "1" || region[0].ResData.LimitType != "3" || region[0].ResData.LimitArea != "110000" { t.Fatalf("region mapping: %+v", region) } power, _ := client.QuerySimDeviceStatus(ctx, &model.SimDeviceStatusInput{SimKeys: keys}) if power[0].Code != 0 || power[0].ResData.OperatingStatus != "1" || power[0].ResData.DeviceStatus != consts.CardSessionStatusUnknown { t.Fatalf("power mapping: %+v", power) } } // TestDXV3DevicePowerStatusSupportsEmptyRecordShape 锁定该接口的真实返回形态: // 仅 3G 接口对 4G/5G 卡返回 {"Result":"-1","resultName":"无终端开关机信息"}(值是 Result, // 大写 R),应映射为“未知”而不是报错;同时确认请求走 GET(文档写 POST 与网关不符)。 func TestDXV3DevicePowerStatusSupportsEmptyRecordShape(t *testing.T) { client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { t.Errorf("device power status must be sent with GET, got %s", r.Method) } fmt.Fprint(w, `{"code":"0","result":{"Result":"-1","resultName":"无终端开关机信息"}}`) }) items, err := client.QuerySimDeviceStatus(context.Background(), &model.SimDeviceStatusInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}}) if err != nil || items[0].Code != 0 || items[0].ResData.OperatingStatus != consts.CardSessionStatusUnknown { t.Fatalf("empty power record must map to unknown: %+v, %v", items, err) } } func TestDXV3BasicInfoAllowsDocumentedPartialIdentifiers(t *testing.T) { for _, body := range []string{ `{"code":"0","result":{"msisdn":"1","createTime":"2023-03-16 11:07:50","activeTime":"2023-03-16 11:07:50","prodName":"test-product"}}`, `{"code":"0","result":{"msisdn":"1","iccid":"8986","imsi":"46011","prodName":"test-product"}}`, } { client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) }) items, err := client.QuerySimBasicInfo(context.Background(), &model.SimBasicInfoInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}}) if err != nil || items[0].Code != 0 || items[0].ResData.MSISDN != "1" || items[0].ResData.MainOfferName != "test-product" { t.Fatalf("basic info: %+v, %v", items, err) } } if _, err := dxV3BasicInfo(map[string]any{}); err == nil { t.Fatal("accepted basic info with no identifiers") } } func TestDXV3StatusBatchChunksAndMatchesCards(t *testing.T) { calls := 0 client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ if r.Method != "POST" || r.URL.Path != "/api/v3/getSimStatusBatch" { t.Errorf("unexpected batch request") } body, _ := io.ReadAll(r.Body) want, _ := dx.BuildSignV3("", string(body), "test-secret", r.Header.Get("Timestamp")) if r.Header.Get("Sign") != want { t.Error("body differs from signed body") } var request struct { Numbers []string `json:"msisdnList"` Customer string `json:"custNbr"` } if err := json.Unmarshal(body, &request); err != nil { t.Fatal(err) } if len(request.Numbers) > 100 || request.Customer != "test-customer" { t.Fatalf("invalid batch: %+v", request) } list := make([]map[string]string, 0, len(request.Numbers)) for i := len(request.Numbers) - 1; i >= 0; i-- { number := request.Numbers[i] if number == "7" { continue } item := map[string]string{"msisdn": number, "result": "0", "simStatus": "4"} if number == "8" { item["result"] = "-1" } list = append(list, item) } json.NewEncoder(w).Encode(map[string]any{"code": "0", "result": map[string]any{"simStatusList": list}}) }) keys := make([]*model.SimKey, 101) for i := range keys { keys[i] = &model.SimKey{MSISDN: fmt.Sprint(i)} } items, err := client.QuerySimCardStatusBatch(context.Background(), &model.SimCardStatusInput{SimKeys: keys}) if err != nil || len(items) != 101 || calls != 2 { t.Fatalf("batch size/calls: %d/%d err=%v", len(items), calls, err) } for i, item := range items { if item.SimKey != keys[i] { t.Fatal("card identity/order changed") } if i == 7 || i == 8 { if item.Code == 0 { t.Fatalf("partial failure %d was successful", i) } } else if item.Code != 0 || item.ResData.CardStatus != 2 { t.Fatalf("card %d: %+v", i, item) } } } func TestDXV3BatchUsageChecksPeriodAndItemCode(t *testing.T) { month := time.Now().In(dxBatchLocation).Format("200601") client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","month":%q,"unit":"MB","dataAmount":"2.5"},{"msisdn":"2","result":"1","month":%q,"dataAmount":"9"},{"msisdn":"3","result":"0","month":"200001","dataAmount":"9"}]}}`, month, month) }) items, err := client.QuerySimDataUsageMonthlyBatch(context.Background(), &model.SimDataUsageMonthlyInput{SimKeys: []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}, {MSISDN: "3"}}, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}}) if err != nil || items[0].Code != 0 || items[0].ResData.DataAmount != 2.5 || items[1].Code == 0 || items[2].Code == 0 { t.Fatalf("unexpected usage batch: %+v, %v", items, err) } } func TestDXV3OperationsDefaultOffAndUseNewSemantics(t *testing.T) { calls := 0 client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ switch r.URL.Path { case "/api/v3/singleCutNet": var body map[string]string json.NewDecoder(r.Body).Decode(&body) if r.Method != "POST" || body["operType"] != "2" || body["msisdn"] != "1" { t.Errorf("invalid cut-net request: %+v", body) } case "/api/v3/updateSimStatus": if r.URL.Query().Get("operType") != "19" { t.Error("wrong status operation") } default: t.Error("unexpected operation path") } fmt.Fprint(w, `{"code":"0","result":{"transactionId":"order-1"}}`) }) status := &model.SimCardStatusChangeInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}, ReqData: model.SimCardStatusChangeInputItem{OperType: "0"}} items, _ := client.ChangeSimStatus(context.Background(), status) if items[0].Code == 0 || calls != 0 { t.Fatal("disabled operation sent a request") } client.enableOperations = true items, _ = client.ChangeSimStatus(context.Background(), status) if items[0].Code != 0 || items[0].ResData.OrderNum != "order-1" { t.Fatalf("status: %+v", items[0]) } data, _ := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: status.SimKeys, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"}}) if data[0].Code != 0 || calls != 2 { t.Fatal("new cut-net operation did not succeed") } if _, err := dxV3Params(nil); err == nil { t.Fatal("nil key accepted") } if !strings.Contains(items[0].ResData.OrderNum, "order") { t.Fatal("missing operation reference") } } func TestDXV3CutNetIgnoresApnNameLikeV1(t *testing.T) { calls := 0 client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ if r.URL.Path != "/api/v3/singleCutNet" { t.Errorf("unexpected path: %s", r.URL.Path) } var body map[string]string json.NewDecoder(r.Body).Decode(&body) if body["operType"] != "2" || body["msisdn"] != "1" { t.Errorf("invalid cut-net request: %+v", body) } if _, ok := body["apnName"]; ok { t.Errorf("ApnName must not be forwarded: %+v", body) } fmt.Fprint(w, `{"code":"0","result":{"transactionId":"order-9"}}`) }) client.enableOperations = true items, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{ SimKeys: []*model.SimKey{{MSISDN: "1"}}, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", ApnName: "CMIOT"}, }) if err != nil || calls != 1 || len(items) != 1 || items[0].Code != 0 || items[0].ResData.OrderNum != "order-9" { t.Fatalf("named APN must be ignored, not rejected: %+v, %v", items, err) } } func TestDXV3UnsupportedQueriesDegradeLikeV1(t *testing.T) { client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Errorf("unsupported queries must not reach the upstream: %s", r.URL.Path) }) keys := []*model.SimKey{{MSISDN: "1"}} frozen, err := client.QuerySimFrozenState(context.Background(), &model.SimFrozenStateInput{SimKeys: keys}) if err != nil || len(frozen) != 1 || frozen[0].Code != consts.ApiStatusSuccess || frozen[0].ResData.FrozenStatus != "不支持查询" { t.Fatalf("frozen state should degrade gracefully: %+v, %v", frozen, err) } diagnosis, err := client.QuerySimDataDiagnosis(context.Background(), &model.SimDataDiagnosisInput{SimKeys: keys}) if err != nil || len(diagnosis) != 1 || diagnosis[0].Code != consts.ApiInvokeException || diagnosis[0].Message == "" { t.Fatalf("data diagnosis should report as unsupported: %+v, %v", diagnosis, err) } } func TestDXV3MultiCardUsageUpgradesToBatch(t *testing.T) { month := time.Now().In(dxBatchLocation).Format("200601") day := time.Now().In(dxBatchLocation).AddDate(0, 0, -1).Format("20060102") paths := make([]string, 0, 4) client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.URL.Path) switch r.URL.Path { case "/api/v3/getDataUsageByMonthBatch": fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","month":%q,"unit":"MB","dataAmount":"2.5"},{"msisdn":"2","result":"0","month":%q,"unit":"MB","dataAmount":"3.5"}]}}`, month, month) case "/api/v3/getDataUsageByDayBatch": fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","date":%q,"unit":"MB","dataAmount":"1.5"},{"msisdn":"2","result":"0","date":%q,"unit":"MB","dataAmount":"2.5"}]}}`, day, day) default: fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"1","unit":"MB"}}`) } }) keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}} monthly, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}}) if err != nil || len(monthly) != 2 || monthly[0].ResData.DataAmount != 2.5 || monthly[1].ResData.DataAmount != 3.5 { t.Fatalf("monthly upgrade: %+v, %v", monthly, err) } if len(paths) != 1 || paths[0] != "/api/v3/getDataUsageByMonthBatch" { t.Fatalf("monthly query should collapse into one batch call, got %v", paths) } paths = paths[:0] daily, err := client.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{SimKeys: keys, ReqData: model.SimDataUsageDailyInputItem{QueryDate: day}}) if err != nil || len(daily) != 2 || daily[0].ResData.DataAmount != 1.5 { t.Fatalf("daily upgrade: %+v, %v", daily, err) } if len(paths) != 1 || paths[0] != "/api/v3/getDataUsageByDayBatch" { t.Fatalf("daily query should collapse into one batch call, got %v", paths) } } func TestDXV3MultiCardUsageFallsBackToConcurrentPerCard(t *testing.T) { month := time.Now().In(dxBatchLocation).Format("200601") calls := 0 client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ if r.URL.Path != "/api/v3/getDataUsageByMonth" { t.Errorf("batch must not be used without custNbr: %s", r.URL.Path) } fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"1","unit":"MB"}}`) }) client.custNbr = "" keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}, {MSISDN: "3"}} items, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}}) if err != nil || len(items) != 3 || calls != 3 { t.Fatalf("fallback should query every card once: %d, %+v, %v", calls, items, err) } for index, item := range items { if item.SimKey != keys[index] || item.Code != 0 { t.Fatalf("fallback lost order or failed: %+v", item) } } } // 批量接口整批不可用(网关/鉴权/报文错误)时,必须回退单卡查询,而不是整批报废。 func TestDXV3BatchOutageFallsBackToSingleCard(t *testing.T) { month := time.Now().In(dxBatchLocation).Format("200601") batchCalls, singleCalls := 0, 0 client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v3/getDataUsageByMonthBatch": batchCalls++ w.Header().Set("gw-errorCode", "S00004") w.WriteHeader(http.StatusBadGateway) fmt.Fprint(w, `{}`) case "/api/v3/getDataUsageByMonth": singleCalls++ fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"4","unit":"MB"}}`) default: t.Errorf("unexpected path: %s", r.URL.Path) } }) keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}} items, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{ SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}}) if err != nil || len(items) != 2 { t.Fatalf("batch outage must not fail the whole query: %+v, %v", items, err) } for index, item := range items { if item.SimKey != keys[index] || item.Code != consts.ApiStatusSuccess || item.ResData.DataAmount != 4 { t.Fatalf("card %d was not recovered by the single-card fallback: %+v", index, item) } } if batchCalls != 1 || singleCalls != 2 { t.Fatalf("unexpected call split: batch=%d single=%d", batchCalls, singleCalls) } } // 批量只漏/错某张卡时,只对该卡用单卡补齐,批量成功项保留(不重复请求)。 func TestDXV3BatchOnlyRefillsFailedCards(t *testing.T) { month := time.Now().In(dxBatchLocation).Format("200601") singleCalls := 0 client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v3/getDataUsageByMonthBatch": fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","month":%q,"unit":"MB","dataAmount":"2.5"}]}}`, month) case "/api/v3/getDataUsageByMonth": singleCalls++ if r.URL.Query().Get("msisdn") != "2" { t.Errorf("only the failed card may be refilled, got %s", r.URL.Query().Get("msisdn")) } fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"7","unit":"MB"}}`) default: t.Errorf("unexpected path: %s", r.URL.Path) } }) keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}} items, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{ SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}}) if err != nil || len(items) != 2 || singleCalls != 1 { t.Fatalf("only failed cards should be refilled: single=%d, %+v, %v", singleCalls, items, err) } if items[0].Code != consts.ApiStatusSuccess || items[0].ResData.DataAmount != 2.5 { t.Fatalf("batch success must be kept: %+v", items[0]) } if items[1].Code != consts.ApiStatusSuccess || items[1].ResData.DataAmount != 7 { t.Fatalf("omitted card must be refilled by single query: %+v", items[1]) } }