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, ¬Sent) || !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, ¬Sent) && 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]) } }