package upstreamimpl import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "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" ) const ( beixinTestPwdKey = "test-pwdkey" beixinTestChannelNo = "chanel-202607100639054511" beixinTestToken = "v5feb8f288c5ba96631b627dc18a5b6a5318e7e7b1923cf7e0aa0efdefa746e4" ) func newBeixinTestProvider(t *testing.T, baseURL string) *BeixinTokenProvider { t.Helper() return NewBeixinTokenProvider(BeixinTokenConfig{ BaseURL: baseURL, Path: beixinOneLinkTokenPath, PwdKey: beixinTestPwdKey, ChannelNo: beixinTestChannelNo, Timeout: time.Second, }) } // TestBeixinTokenProviderSendRequestContract pins the exact wire shape as // observed against the live endpoint on 2026-10-19: a GET with lowercase // pwdkey/c_no query parameters, and a bare JSON string in data. func TestBeixinTokenProviderSendRequestContract(t *testing.T) { var ( gotMethod string gotPath string gotPwdKey string gotCNo string ) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method gotPath = r.URL.Path gotPwdKey = r.URL.Query().Get("pwdkey") gotCNo = r.URL.Query().Get("c_no") w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`)) })) defer server.Close() if _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()); err != nil { t.Fatalf("fetch token: %v", err) } if gotMethod != http.MethodGet { t.Fatalf("method = %q, want GET", gotMethod) } if gotPath != beixinOneLinkTokenPath { t.Fatalf("path = %q, want %q", gotPath, beixinOneLinkTokenPath) } if gotPwdKey != beixinTestPwdKey { t.Fatalf("pwdkey = %q, want %q", gotPwdKey, beixinTestPwdKey) } if gotCNo != beixinTestChannelNo { t.Fatalf("c_no = %q, want %q", gotCNo, beixinTestChannelNo) } } // TestBeixinTokenProviderReturnsValueAndLocalTTL pins that the bare string in // data is unwrapped and that the local one-hour window is applied, since the // upstream reports no expiry. func TestBeixinTokenProviderReturnsValueAndLocalTTL(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`)) })) defer server.Close() before := time.Now() token, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()) if err != nil { t.Fatalf("fetch token: %v", err) } if token.Value != beixinTestToken { t.Fatalf("token value = %q, want %q", token.Value, beixinTestToken) } // Allow a second of slack for slow CI clocks. if delta := token.ExpiresAt.Sub(before); delta < beixinTokenTTL-time.Second || delta > beixinTokenTTL+time.Second { t.Fatalf("expiresAt - before = %s, want ~%s", delta, beixinTokenTTL) } } // TestBeixinTokenProviderRejectsBusinessFailure pins that a non-200 code is an // error rather than an empty-but-successful token. func TestBeixinTokenProviderRejectsBusinessFailure(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"code":500,"msg":"获取token失败","data":null}`)) })) defer server.Close() _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()) if err == nil { t.Fatal("business failure must return an error") } if !strings.Contains(err.Error(), "获取token失败") { t.Fatalf("error = %v, want the upstream message", err) } } // TestBeixinTokenProviderRejectsEmptyToken pins that a successful envelope with // an empty data string is still an error. func TestBeixinTokenProviderRejectsEmptyToken(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":""}`)) })) defer server.Close() _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()) if err == nil { t.Fatal("empty token must return an error") } if !strings.Contains(err.Error(), "beixin shared token is empty") { t.Fatalf("error = %v", err) } } // TestBeixinTokenProviderRejectsObjectData pins that data is expected to be a // JSON string. A future upstream change to an object shape must fail loudly // instead of silently yielding an empty token. func TestBeixinTokenProviderRejectsObjectData(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":{"token":"abc"}}`)) })) defer server.Close() if _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()); err == nil { t.Fatal("object-shaped data must return an error") } } // TestBeixinTokenProviderRequiresCredentials pins the guard so a channel // missing chan_config.pwdKey or channelNo cannot build a provider that fails // on every request. func TestBeixinTokenProviderRequiresCredentials(t *testing.T) { cases := map[string]BeixinTokenConfig{ "missing baseUrl": {Path: beixinOneLinkTokenPath, PwdKey: beixinTestPwdKey, ChannelNo: beixinTestChannelNo}, "missing pwdKey": {BaseURL: "http://example.com", Path: beixinOneLinkTokenPath, ChannelNo: beixinTestChannelNo}, "missing channelNo": {BaseURL: "http://example.com", Path: beixinOneLinkTokenPath, PwdKey: beixinTestPwdKey}, } for name, cfg := range cases { t.Run(name, func(t *testing.T) { start := time.Now() _, err := NewBeixinTokenProvider(cfg).Fetch(context.Background()) if err == nil { t.Fatal("incomplete credentials must return an error") } if elapsed := time.Since(start); elapsed > time.Second { t.Fatalf("validation must not hit the network, took %s", elapsed) } if !strings.Contains(err.Error(), "beixin baseUrl, pwdKey and channelNo are required") { t.Fatalf("error = %v, want the credential guard message", err) } }) } } // TestBeixinTemplateRegistration pins that a provider=beixin template // instantiates through the shared_token model with a chan_config carrying the // two required credentials. func TestBeixinTemplateRegistration(t *testing.T) { template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkBeixinTemplateKey) if !ok || template == nil { t.Fatal("beixin template not registered") } config := &model.SimChannelConfigItemCache{ Template: &entity.TSimChannelTemplate{ TempKey: sharedOneLinkBeixinTemplateKey, TempModel: templateModelSharedToken, TempConfig: `{"token":{"provider":"beixin","baseUrl":"http://www.jsbxkj.vip",` + `"path":"/prod-api/open/api/oneLinkToken"},"api":{"baseUrl":"https://api.iot.10086.cn/v5/","timeout":"1s"}}`, }, Channel: &entity.TSimChannels{ ChanKey: "252", ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`, }, } if instance := template.NewTemp(context.Background(), config); instance == nil { t.Fatal("beixin template failed to instantiate") } } // TestBeixinTokenPathComesFromTemplate pins that the request path is whatever // the template declares, not a hardcoded default. This is the same property // TestLanShuTokenPathComesFromTemplate pins for lanshu, and it is what makes a // template reusable across upstream hosts. func TestBeixinTokenPathComesFromTemplate(t *testing.T) { const templatePath = "/some/other/open/route" if templatePath == beixinOneLinkTokenPath { t.Fatal("test precondition broken: templatePath must differ from beixinOneLinkTokenPath") } var gotPath string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path _, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`)) })) defer server.Close() channelConfig := &model.SimChannelConfigItemCache{ Template: &entity.TSimChannelTemplate{ TempKey: sharedOneLinkBeixinTemplateKey, TempConfig: `{"token":{"provider":"beixin","baseUrl":"` + server.URL + `",` + `"path":"` + templatePath + `"}}`, }, Channel: &entity.TSimChannels{ ChanKey: "252", ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`, }, } cfg, _, err := parseSharedOneLinkConfig(context.Background(), channelConfig) if err != nil { t.Fatalf("parse config: %v", err) } if cfg.TokenPath != templatePath { t.Fatalf("parsed TokenPath = %q, want the template-declared %q", cfg.TokenPath, templatePath) } provider := NewBeixinTokenProvider(BeixinTokenConfig{ BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, PwdKey: cfg.PwdKey, ChannelNo: cfg.ChannelNo, Timeout: cfg.Timeout, }) if _, err := provider.Fetch(context.Background()); err != nil { t.Fatalf("fetch token: %v", err) } if gotPath != templatePath { t.Fatalf("request path = %q, want the template-declared %q", gotPath, templatePath) } } // TestBeixinChannelConfigRejectsIncompleteCredentials pins the parse-time // guard, so the gate fires before any network call. func TestBeixinChannelConfigRejectsIncompleteCredentials(t *testing.T) { base := func(chanConfig string) *model.SimChannelConfigItemCache { return &model.SimChannelConfigItemCache{ Template: &entity.TSimChannelTemplate{ TempKey: sharedOneLinkBeixinTemplateKey, TempConfig: `{"token":{"provider":"beixin","baseUrl":"http://www.jsbxkj.vip",` + `"path":"/prod-api/open/api/oneLinkToken"}}`, }, Channel: &entity.TSimChannels{ChanKey: "252", ChanConfig: chanConfig}, } } if _, _, err := parseSharedOneLinkConfig(context.Background(), base(`{"pwdKey":"only"}`)); err == nil { t.Fatal("chan_config without channelNo must be rejected") } if _, _, err := parseSharedOneLinkConfig(context.Background(), base(`{}`)); err == nil { t.Fatal("empty chan_config must be rejected") } parsed, _, err := parseSharedOneLinkConfig(context.Background(), base( `{"pwdKey":"`+beixinTestPwdKey+`","channelNo":"`+beixinTestChannelNo+`"}`)) if err != nil { t.Fatalf("complete chan_config must parse: %v", err) } if parsed.PwdKey != beixinTestPwdKey || parsed.ChannelNo != beixinTestChannelNo { t.Fatalf("parsed credentials = %q / %q", parsed.PwdKey, parsed.ChannelNo) } } // TestBeixinChannelTokenBaseURLOverride pins the per-channel host override that // lets several Beixin accounts (different c_no values, possibly different // hosts) share one template without a code change. func TestBeixinChannelTokenBaseURLOverride(t *testing.T) { const templateHost = "http://template-host.example.com" const channelHost = "http://channel-host.example.com" config := &model.SimChannelConfigItemCache{ Template: &entity.TSimChannelTemplate{ TempKey: sharedOneLinkBeixinTemplateKey, TempConfig: `{"token":{"provider":"beixin","baseUrl":"` + templateHost + `",` + `"path":"/prod-api/open/api/oneLinkToken"}}`, }, Channel: &entity.TSimChannels{ ChanKey: "252", ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `",` + `"tokenBaseUrl":"` + channelHost + `"}`, }, } parsed, _, err := parseSharedOneLinkConfig(context.Background(), config) if err != nil { t.Fatalf("parse config: %v", err) } if parsed.TokenBaseURL != channelHost { t.Fatalf("TokenBaseURL = %q, want the channel override %q", parsed.TokenBaseURL, channelHost) } // Without the override the template host must win. config.Channel.ChanConfig = `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}` parsed, _, err = parseSharedOneLinkConfig(context.Background(), config) if err != nil { t.Fatalf("parse config: %v", err) } if parsed.TokenBaseURL != templateHost { t.Fatalf("TokenBaseURL = %q, want the template default %q", parsed.TokenBaseURL, templateHost) } } // TestBeixinResponseShapeMatchesObservedBody replays the exact body captured // from the live endpoint, so field-case drift breaks the build rather than // production. func TestBeixinResponseShapeMatchesObservedBody(t *testing.T) { const observed = `{"code":200,"msg":"请求成功","data":"v5feb8f288c5ba96631b627dc18a5b6a5318e7e7b1923cf7e0aa0efdefa746e4"}` var output beixinTokenResponse if err := json.Unmarshal([]byte(observed), &output); err != nil { t.Fatalf("decode observed body: %v", err) } if output.Code != 200 || output.Msg != "请求成功" { t.Fatalf("envelope = %+v", output) } var value string if err := json.Unmarshal(output.Data, &value); err != nil { t.Fatalf("decode data: %v", err) } if len(value) != 64 || !strings.HasPrefix(value, "v5") { t.Fatalf("token shape changed: %q", value) } } // silence unused-import drift if the file is trimmed later. var _ = io.Discard