package mingqian import ( "context" "encoding/json" "fmt" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" "net/http" "net/http/httptest" "regexp" "strconv" "strings" "testing" "time" ) func testAdapter(t *testing.T, handler http.HandlerFunc) *Adapter { t.Helper() server := httptest.NewServer(handler) t.Cleanup(server.Close) client, err := NewClient(server.URL, "test-app", "test-secret", time.Second) if err != nil { t.Fatal(err) } return &Adapter{client: client} } func TestSign(t *testing.T) { params := map[string]string{"appId": "test-app", "reqtime": "2026-09-17", "timestamp": "1789603260000", "transid": "20260917080000000abcdef", "version": "3.0", "sign": "ignored"} if got := Sign(params, "test-secret"); got != "78e5d4f24b86159ad2f664ddbcc66282" { t.Fatalf("wrong sign: %s", got) } } func TestWireAndUsageMapping(t *testing.T) { adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodPost || (request.URL.Path != usagePath && request.URL.Path != packageUsagePath) || request.Header.Get("Content-Type") != "application/json" { t.Error("incorrect HTTP contract") } var payload struct { Body map[string]string `json:"body"` Data string `json:"data"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Error(err) writer.WriteHeader(400) return } if payload.Body["sign"] != Sign(payload.Body, "test-secret") { t.Error("invalid signature") } stamp, err := strconv.ParseInt(payload.Body["timestamp"], 10, 64) if err != nil || len(payload.Body["timestamp"]) != 13 || time.Until(time.UnixMilli(stamp)) < 55*time.Second || time.Until(time.UnixMilli(stamp)) > 65*time.Second { t.Error("wrong millisecond timestamp offset") } if !regexp.MustCompile(`^\d{17}[0-9a-f]{6}$`).MatchString(payload.Body["transid"]) { t.Error("invalid transaction ID") } var data struct { Numbers []string `json:"numberArray"` } if err := json.Unmarshal([]byte(payload.Data), &data); err != nil || len(data.Numbers) != 3 { t.Error("data must be escaped JSON containing requested cards") } if request.URL.Path == packageUsagePath { fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"B","totalpackage":10240,"targetpackageusage":2048},{"iccid":"A","totalpackage":"10240","targetpackageusage":"8192"}]}`) return } fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"B","use":2048},{"iccid":"A","use":"1024"}]}`) }) result, err := adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{ICCID: "A"}, {ICCID: "B"}, {ICCID: "missing"}}}) if err != nil || len(result) != 3 { t.Fatalf("result: %v %v", result, err) } if result[0].Code != 0 || result[0].ResData.DataUsage != 1 || result[1].ResData.DataUsage != 2 || result[2].Code == 0 { t.Fatalf("incorrect per-card mapping: %+v", result) } if result[0].ResData.TotalFlow != nil { t.Fatal("monthly usage must not invent package total") } packageFlow := result[0].ResData.PackageFlow if packageFlow == nil || packageFlow.TotalFlow != 10240 || packageFlow.DataUsage != 8192 || packageFlow.RemainingFlow != 2048 { t.Fatalf("package cumulative flow must remain in MB and separate from monthly usage: %+v", packageFlow) } if result[1].ResData.PackageFlow == nil || result[1].ResData.PackageFlow.RemainingFlow != 8192 { t.Fatal("package response must match the requested card, not array position") } } func TestInvalidUsage(t *testing.T) { for _, data := range []string{`{"iccid":"A"}`, `{"iccid":"A","use":null}`, `{"iccid":"A","use":-1}`, `{"iccid":"A","use":"NaN"}`, `{"iccid":"A","use":""}`} { t.Run(data, func(t *testing.T) { adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { fmt.Fprintf(writer, `{"code":200,"data":[%s]}`, data) }) result, _ := adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{ICCID: "A"}}}) if result[0].Code == 0 { t.Fatal("unknown usage reported as zero success") } }) } } func TestCardStates(t *testing.T) { for state, want := range map[int]int{1: 1, 2: 2, 3: 4, 4: 6, 5: 7, 6: 9} { got, err := cardStatus(&state) if err != nil || got != want { t.Fatalf("state %d mapped to %d: %v", state, got, err) } } unknown := 7 if _, err := cardStatus(&unknown); err == nil { t.Fatal("expired state cannot be assumed to mean cancelled") } if _, err := cardStatus(nil); err == nil { t.Fatal("missing state must fail") } } func TestBatchAndBasicInfo(t *testing.T) { calls := 0 adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { calls++ var payload struct { Data string `json:"data"` } json.NewDecoder(request.Body).Decode(&payload) var data struct { Numbers []string `json:"numberArray"` } json.Unmarshal([]byte(payload.Data), &data) if len(data.Numbers) > 50 { t.Error("query batch exceeds 50") } rows := []map[string]any{} for _, value := range data.Numbers { rows = append(rows, map[string]any{"iccid": value, "msisdn": "144000", "state": 2, "statename": "active", "activedate": "2026-01-01"}) } json.NewEncoder(writer).Encode(map[string]any{"code": 200, "data": rows}) }) keys := []*model.SimKey{} for index := 0; index < 51; index++ { keys = append(keys, &model.SimKey{ICCID: strconv.Itoa(index)}) } result, _ := adapter.QuerySimBasicInfo(context.Background(), &model.SimBasicInfoInput{SimKeys: keys}) if calls != 2 || len(result) != 51 { t.Fatalf("calls=%d rows=%d", calls, len(result)) } for _, item := range result { if item.Code != 0 || item.ResData.ActiveDate != "" || item.ResData.IMSI != "" { t.Fatal("incorrect or fabricated basic fields") } } } func TestOperationsAreSafe(t *testing.T) { calls := 0 adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { calls++ if request.URL.Path != "/simlist/test-resume" && request.URL.Path != "/simlist/get-sim-switchoff-by-number" { t.Error("wrong operation route") } fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"A","issuccess":1},{"iccid":"B","issuccess":0}]}`) }) input := &model.SimCardStatusChangeInput{SimKeys: []*model.SimKey{{ICCID: "A"}, {ICCID: "B"}}, ReqData: model.SimCardStatusChangeInputItem{OperType: "0"}} result, _ := adapter.ChangeSimStatus(context.Background(), input) if calls != 1 || result[0].Code != 0 || result[1].Code == 0 { t.Fatal("stop operation must reach the confirmed endpoint and inspect per-card success flags") } before := calls input.ReqData.OperType = "1" result, _ = adapter.ChangeSimStatus(context.Background(), input) if calls != before || result[0].Code == 0 { t.Fatal("unconfirmed resume path must not send any request") } adapter.resumePath = "/simlist/test-resume" for _, operation := range []string{"0", "1"} { input.ReqData.OperType = operation result, _ = adapter.ChangeSimStatus(context.Background(), input) if result[0].Code != 0 || result[1].Code == 0 { t.Fatal("must inspect per-card success flags") } } before = calls input.ReqData.OperType = "invalid" adapter.ChangeSimStatus(context.Background(), input) if calls != before { t.Fatal("invalid operation sent") } } func TestResponseFailuresAndNoRetry(t *testing.T) { for _, body := range []string{`{}`, `{"code":401,"msg":"test-secret"}`, `{"code":200}`, `{"code":200,"data":null}`, `{"code":200,"data":[{"iccid":"A","state":2},{"iccid":"A","state":3}]}`} { t.Run(body, func(t *testing.T) { calls := 0 adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { calls++; fmt.Fprint(writer, body) }) result, _ := adapter.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{ICCID: "A"}}}) if calls != 1 || result[0].Code == 0 || strings.Contains(result[0].Message, "test-secret") { t.Fatal("invalid response accepted, retried or leaked secret") } }) } } func TestConfiguration(t *testing.T) { cfg := &model.SimChannelConfigItemCache{Template: &entity.TSimChannelTemplate{TempConfig: `{"api":{"timeout":"1s"}}`}, Channel: &entity.TSimChannels{AppKey: "app", AppSecret: "secret"}} adapter, err := New(cfg) if err != nil || adapter.resumePath != "" { t.Fatalf("invalid default configuration: %v", err) } cfg.Channel.AppSecret = "" if _, err := New(cfg); err == nil { t.Fatal("missing secret accepted") } cfg.Channel.AppSecret = "secret" for _, raw := range []string{`{"api":`, `{"api":{"timeout":"0s"}}`, `{"api":{"resumePath":"/simlist/get-sim-switchoff-by-number"}}`} { cfg.Template.TempConfig = raw if _, err := New(cfg); err == nil { t.Fatal("invalid config accepted") } } } func TestOperationBatchLimitAndHTTPFailure(t *testing.T) { calls := 0 adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { calls++ var payload struct { Data string `json:"data"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Error(err) } var data struct { Numbers []string `json:"numberArray"` } if err := json.Unmarshal([]byte(payload.Data), &data); err != nil { t.Error(err) } if len(data.Numbers) > 20 { t.Error("operation batch exceeds 20") } writer.WriteHeader(http.StatusTooManyRequests) }) keys := make([]*model.SimKey, 21) for index := range keys { keys[index] = &model.SimKey{ICCID: strconv.Itoa(index)} } output, err := adapter.ChangeSimStatus(context.Background(), &model.SimCardStatusChangeInput{SimKeys: keys, ReqData: model.SimCardStatusChangeInputItem{OperType: "0"}}) if err != nil || calls != 2 || len(output) != 21 { t.Fatalf("calls=%d len=%d err=%v", calls, len(output), err) } for _, item := range output { if item.Code == 0 || !strings.Contains(item.Message, "429") { t.Fatal("HTTP failure must not appear as successful operation") } } } func TestAccountGateSharing(t *testing.T) { first, err := NewClient("http://127.0.0.1", "same-account", "secret", time.Second) if err != nil { t.Fatal(err) } second, err := NewClient("http://127.0.0.1", "same-account", "secret", time.Second) if err != nil { t.Fatal(err) } if first.gate != second.gate { t.Fatal("same account must share rate limiter") } first.gate.mu.Lock() first.gate.next = time.Now().Add(time.Second) first.gate.mu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) defer cancel() if err := second.gate.wait(ctx); err == nil { t.Fatal("rate limit wait must honor context timeout") } } func TestCancelledAndUnsupportedDoNotCall(t *testing.T) { calls := 0 adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { calls++ }) ctx, cancel := context.WithCancel(context.Background()) cancel() adapter.QuerySimCardStatus(ctx, &model.SimCardStatusInput{SimKeys: []*model.SimKey{{ICCID: "A"}}}) adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{nil, {IMSI: "only-imsi"}}}) adapter.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "A"}}}) if calls != 0 { t.Fatal("unexpected upstream request") } }