package upstreamimpl import ( "context" "encoding/json" "fmt" "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" "io" "net/http" "net/http/httptest" "strings" "testing" "time" ) 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 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":"192.0.2.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]) } }