Files
iotlcm/internal/service/mingqian/mingqian_test.go
T
2026-10-29 14:59:32 +08:00

296 lines
11 KiB
Go

package mingqian
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
)
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 != 0 || result[0].Code == 0 {
t.Fatal("operations must be disabled by default")
}
adapter.operations = true
input.ReqData.OperType = "1"
result, _ = adapter.ChangeSimStatus(context.Background(), input)
if calls != 0 || 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.operations {
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)
})
adapter.operations = true
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")
}
}