You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
895 lines
35 KiB
895 lines
35 KiB
// user 模块的全部单元测试集中在本文件,别再按接口拆一堆 xxx_test.go。
|
|
// 分区顺序:① 公共桩 ② getappconfig_v3 ③ getagents_v3 ④ getchannelapp ⑤ 老客户端兼容 ⑥ 杂项 ⑦ 线上联调探针。
|
|
// 用内部包 package user,测试可以直接调未导出的函数(resolveAgentVoices / mergeAppEnv 等)。
|
|
//
|
|
// 约定:每个测试函数自带它要用的数据/地址/密钥(写在函数体内的局部 const/变量),
|
|
// 不放包级全局——改一个接口的联调参数不会波及别的测试。①区只放无状态的桩类型与构造函数。
|
|
package user
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net/http"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"yunyan/comm"
|
|
"yunyan/lego/utils/crypto/aes"
|
|
"yunyan/pb"
|
|
|
|
"github.com/golang-jwt/jwt/v4"
|
|
"gorm.io/gorm/schema"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ① 公共桩
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// fakeEchomeet 假的会议记录模块,只为把一份能力快照喂给 v3 的 meet 字段。
|
|
type fakeEchomeet struct{ caps comm.MeetCapabilities }
|
|
|
|
func (f *fakeEchomeet) MeetCapabilities() comm.MeetCapabilities { return f.caps }
|
|
|
|
// ChatLLM 本测试用不到(user 模块只关心能力下发),给个空实现满足接口。
|
|
func (f *fakeEchomeet) ChatLLM(ctx context.Context, svcId, systemPrompt, userContent string) (string, string, error) {
|
|
return "", "", nil
|
|
}
|
|
|
|
// newV3Comp 造一个只够跑纯逻辑的 apiV3Comp:不 Init(Init 要真 service),
|
|
// 只挂一个带 echomeet 的 User。echomeet 传 nil = 本服务没装 echomeet 模块。
|
|
func newV3Comp(echomeet comm.IEchomeet) *apiV3Comp {
|
|
return &apiV3Comp{module: &User{echomeet: echomeet}}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ② user_getappconfig_v3
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// env 是客户端启动就要用的服务地址/密钥。凭据 2026-09-12 起统一收在 svc_config,
|
|
// 由字段上的 env_key 回填一份 env 给存量客户端——这层改名映射错一点,
|
|
// 老客户端就拿不到凭据,且只会表现为「某个能力鉴权失败」,不报配置错误。
|
|
func TestSvcEnvOverlay(t *testing.T) {
|
|
svc := &comm.ThirdSvcConfig{
|
|
Id: "stt_azure", Name: "Azure STT", Categories: "1", Enable: true,
|
|
Fields: []comm.SvcField{
|
|
{Key: "subscription_key", DefValue: "k-default", EnvKey: "AZURE_SPEECH_KEY"},
|
|
{Key: "region", DefValue: "eastasia", EnvKey: "AZURE_SPEECH_REGION"},
|
|
{Key: "note", DefValue: "内部备注"}, // 没有 env_key,不该进 env
|
|
},
|
|
}
|
|
|
|
t.Run("按 env_key 改名进 env,没配 env_key 的字段不进", func(t *testing.T) {
|
|
fields, err := comm.ResolveSvcPlainFields(svc, nil, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
env := comm.ResolveSvcEnvKeys(svc, nil, fields)
|
|
if env["AZURE_SPEECH_KEY"] != "k-default" {
|
|
t.Fatalf("字段值应按 env_key 下发,实际 %q", env["AZURE_SPEECH_KEY"])
|
|
}
|
|
if _, ok := env["note"]; ok {
|
|
t.Fatal("没配 env_key 的字段不能出现在 env 里")
|
|
}
|
|
if len(env) != 2 {
|
|
t.Fatalf("只有两个字段配了 env_key,实际 %+v", env)
|
|
}
|
|
})
|
|
|
|
t.Run("区域覆盖的值进 env,区域停用的字段不进", func(t *testing.T) {
|
|
ovr := &comm.SvcRegionOverride{
|
|
SvcId: svc.Id,
|
|
Region: int32(pb.Region_RegionChina),
|
|
Overrides: map[string]string{"region": "chinaeast2"},
|
|
DisabledKeys: []string{"subscription_key"},
|
|
}
|
|
fields, err := comm.ResolveSvcPlainFields(svc, ovr, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
env := comm.ResolveSvcEnvKeys(svc, ovr, fields)
|
|
if env["AZURE_SPEECH_REGION"] != "chinaeast2" {
|
|
t.Fatalf("应取本区域覆盖值,实际 %q", env["AZURE_SPEECH_REGION"])
|
|
}
|
|
// 区域停用的字段在 ResolveSvcPlainFields 那一步就被剔掉了,env 里也不该凭空出现。
|
|
if _, ok := env["AZURE_SPEECH_KEY"]; ok {
|
|
t.Fatal("该区域停用的字段不能下发")
|
|
}
|
|
})
|
|
|
|
t.Run("区域专属字段也能带自己的 env_key", func(t *testing.T) {
|
|
ovr := &comm.SvcRegionOverride{
|
|
SvcId: svc.Id,
|
|
Region: int32(pb.Region_RegionChina),
|
|
ExtraFields: []comm.SvcField{{Key: "endpoint", DefValue: "https://cn", EnvKey: "AZURE_SPEECH_ENDPOINT"}},
|
|
}
|
|
fields, err := comm.ResolveSvcPlainFields(svc, ovr, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
env := comm.ResolveSvcEnvKeys(svc, ovr, fields)
|
|
if env["AZURE_SPEECH_ENDPOINT"] != "https://cn" {
|
|
t.Fatalf("区域专属字段应按自己的 env_key 下发,实际 %q", env["AZURE_SPEECH_ENDPOINT"])
|
|
}
|
|
})
|
|
|
|
// 这条是闸门:VIP 过期时 filterSvcsByVip 会把 AST 服务从 items 里拿掉,
|
|
// env 必须跟着消失——否则老客户端照样能从 env 读出通话翻译凭据直连服务商。
|
|
t.Run("被过滤掉的服务,其 env 也不下发", func(t *testing.T) {
|
|
envById := map[string]map[string]string{
|
|
"stt_azure": {"AZURE_SPEECH_KEY": "k"},
|
|
"ast_alibaba": {"ALIBABA_OPENSPEECH_APP_KEY": "secret"},
|
|
}
|
|
kept := []*pb.ThirdSvcItem{{Id: "stt_azure", Categories: "1"}}
|
|
env := svcEnvOverlay(kept, envById)
|
|
if env["AZURE_SPEECH_KEY"] != "k" {
|
|
t.Fatal("保留下来的服务,其 env 应下发")
|
|
}
|
|
if _, ok := env["ALIBABA_OPENSPEECH_APP_KEY"]; ok {
|
|
t.Fatal("已被 VIP 过滤掉的服务,其凭据不能从 env 漏出去")
|
|
}
|
|
})
|
|
|
|
// 客户端大量写的是 `AppConfig.env('X') ?? 默认值`,Dart 的 `??` 只兜 null——
|
|
// 下发一个空串会把默认值顶掉。老的两张扁平表对「没配的键」是**根本不下发**,
|
|
// 这里必须保持同样的行为。真机实测 AZURE_TRANSLATION_ENDPOINT 栽过这个坑。
|
|
t.Run("空值不产出 env 键", func(t *testing.T) {
|
|
svc := &comm.ThirdSvcConfig{
|
|
Id: "mt_azure", Enable: true,
|
|
Fields: []comm.SvcField{
|
|
{Key: "subscription_key", DefValue: "k", EnvKey: "AZURE_TRANSLATION_KEY"},
|
|
{Key: "endpoint", DefValue: "", EnvKey: "AZURE_TRANSLATION_ENDPOINT"},
|
|
{Key: "region", DefValue: " ", EnvKey: "AZURE_TRANSLATION_REGION"},
|
|
},
|
|
}
|
|
fields, err := comm.ResolveSvcPlainFields(svc, nil, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
env := comm.ResolveSvcEnvKeys(svc, nil, fields)
|
|
if env["AZURE_TRANSLATION_KEY"] != "k" {
|
|
t.Fatal("有值的字段照常下发")
|
|
}
|
|
if _, ok := env["AZURE_TRANSLATION_ENDPOINT"]; ok {
|
|
t.Fatal("空值字段不能下发——客户端的 ?? 默认值会被空串顶掉")
|
|
}
|
|
if _, ok := env["AZURE_TRANSLATION_REGION"]; ok {
|
|
t.Fatal("只有空白字符的字段同样按没配处理")
|
|
}
|
|
})
|
|
|
|
t.Run("空输入返回空 map 而不是 nil", func(t *testing.T) {
|
|
env := svcEnvOverlay(nil, nil)
|
|
if env == nil {
|
|
t.Fatal("env 必须非 nil,否则下发 JSON 是 null,客户端取值直接崩")
|
|
}
|
|
if len(env) != 0 {
|
|
t.Fatalf("空输入应得空 map,实际 %+v", env)
|
|
}
|
|
})
|
|
}
|
|
|
|
// echomeet 没随本服务装载时(比如只跑 home 不跑会议记录),meet 得是个字段齐全的空壳,
|
|
// 不能是 null——客户端不该为此写一堆判空。
|
|
func TestV3MeetCapabilitiesWithoutEchomeet(t *testing.T) {
|
|
item := newV3Comp(nil).meetCapabilities()
|
|
if item == nil {
|
|
t.Fatal("meet 不能为 nil")
|
|
}
|
|
if item.TranscribeLanguages == nil || item.TranslateLanguages == nil || item.SummaryServices == nil {
|
|
t.Fatalf("列表必须是空数组而不是 nil:%+v", item)
|
|
}
|
|
buf, err := json.Marshal(item)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(buf), "null") {
|
|
t.Fatalf("下发 JSON 不该出现 null:%s", buf)
|
|
}
|
|
}
|
|
|
|
// 能力快照 → 下发结构的逐字段映射。漏一个字段客户端就会少一种语言/少一个可选模型。
|
|
func TestV3MeetCapabilitiesMapping(t *testing.T) {
|
|
caps := comm.MeetCapabilities{
|
|
TranscribeLanguages: []string{"zh-CN", "en-US"},
|
|
TranscribeAnyLanguage: true,
|
|
TranslateLanguages: []string{"ja-JP"},
|
|
TranslateAnyLanguage: false,
|
|
SummaryClientChoice: true,
|
|
SummaryServices: []comm.MeetSummaryService{
|
|
{SvcId: "llm_qwen", Name: "通义千问", IsDefault: true},
|
|
{SvcId: "llm_gpt", Name: "GPT", IsDefault: false},
|
|
},
|
|
}
|
|
item := newV3Comp(&fakeEchomeet{caps: caps}).meetCapabilities()
|
|
|
|
if !reflect.DeepEqual(item.TranscribeLanguages, caps.TranscribeLanguages) {
|
|
t.Fatalf("识别语言映射错:%+v", item.TranscribeLanguages)
|
|
}
|
|
if !reflect.DeepEqual(item.TranslateLanguages, caps.TranslateLanguages) {
|
|
t.Fatalf("翻译语言映射错:%+v", item.TranslateLanguages)
|
|
}
|
|
if !item.TranscribeAnyLanguage || item.TranslateAnyLanguage {
|
|
t.Fatalf("不限语言标记映射错:transcribe=%v translate=%v", item.TranscribeAnyLanguage, item.TranslateAnyLanguage)
|
|
}
|
|
if !item.SummaryClientChoice {
|
|
t.Fatal("summary_client_choice 映射错,客户端会以为不能选模型")
|
|
}
|
|
if len(item.SummaryServices) != 2 {
|
|
t.Fatalf("总结模型应有 2 项,实际 %d", len(item.SummaryServices))
|
|
}
|
|
got := item.SummaryServices[0]
|
|
if got.SvcId != "llm_qwen" || got.Name != "通义千问" || !got.IsDefault {
|
|
t.Fatalf("总结模型项映射错:%+v", got)
|
|
}
|
|
if item.SummaryServices[1].IsDefault {
|
|
t.Fatal("非默认项不该被标成默认,客户端会选错模型")
|
|
}
|
|
}
|
|
|
|
// echomeet 在,但后台还没编排出任何服务:语言列表仍要是空数组;
|
|
// 「不限语言」这类标记即使列表为空也得照传(此时列表为空不代表不支持)。
|
|
func TestV3MeetCapabilitiesEmptySnapshot(t *testing.T) {
|
|
item := newV3Comp(&fakeEchomeet{caps: comm.MeetCapabilities{TranscribeAnyLanguage: true}}).meetCapabilities()
|
|
if item.TranscribeLanguages == nil || item.TranslateLanguages == nil || item.SummaryServices == nil {
|
|
t.Fatalf("空快照下列表仍须为空数组:%+v", item)
|
|
}
|
|
if !item.TranscribeAnyLanguage {
|
|
t.Fatal("列表为空时 any_language 更要照传,否则客户端会以为一种语言都不支持")
|
|
}
|
|
}
|
|
|
|
// v3 的响应契约:不再有 mcps(并入 thirdsvcs),必须有 meet 与 call_translate。
|
|
// pb 重新生成或有人手改 proto 把字段动了,这里先炸,而不是等客户端解析失败。
|
|
func TestV3AppConfigRespContract(t *testing.T) {
|
|
resp := &pb.UserGetAppConfigV3Resp{
|
|
Env: map[string]string{},
|
|
Agents: []*pb.AgentItemV3{}, // v3 下发的是带 type/服务编排的新结构,不是旧 agent 表
|
|
Products: []*pb.DBProduct{},
|
|
Thirdsvcs: []*pb.ThirdSvcItem{},
|
|
Meet: newV3Comp(nil).meetCapabilities(),
|
|
CallTranslate: &pb.CallTranslateItem{
|
|
Rules: []*pb.CallTranslateRuleItem{},
|
|
Agents: []*pb.AgentItemV3{},
|
|
},
|
|
}
|
|
buf, err := json.Marshal(resp)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var got map[string]any
|
|
if err = json.Unmarshal(buf, &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, k := range []string{"env", "agents", "products", "thirdsvcs", "meet", "call_translate"} {
|
|
if _, ok := got[k]; !ok {
|
|
t.Fatalf("v3 响应缺字段 %s:%s", k, buf)
|
|
}
|
|
}
|
|
if _, ok := got["mcps"]; ok {
|
|
t.Fatalf("v3 不该再下发 mcps(已并入 thirdsvcs):%s", buf)
|
|
}
|
|
}
|
|
|
|
// 客户端靠 categories 含 "10" 从 thirdsvcs 里挑出 MCP 服务——v3 去掉 mcps 后这是唯一识别方式。
|
|
func TestV3McpIdentifiedByCategories(t *testing.T) {
|
|
svcs := []*pb.ThirdSvcItem{
|
|
{Id: "svc_llm", Categories: "1,2"},
|
|
{Id: "svc_mcp", Categories: fmt.Sprintf("3,%d", comm.SvcCatMCP)},
|
|
{Id: "svc_mcp_only", Categories: fmt.Sprintf("%d", comm.SvcCatMCP)},
|
|
}
|
|
mcps := make([]string, 0, len(svcs))
|
|
for _, s := range svcs {
|
|
if comm.CategoriesHasMCP(s.Categories) {
|
|
mcps = append(mcps, s.Id)
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(mcps, []string{"svc_mcp", "svc_mcp_only"}) {
|
|
t.Fatalf("MCP 识别错,实际 %+v", mcps)
|
|
}
|
|
}
|
|
|
|
// 路由靠反射按方法名注册:方法改名 / 签名不合规 → 路由静默消失,客户端 404。
|
|
// Version 在 Init 里置为 "v3",这里按注册规则算出路由名对一遍。
|
|
func TestV3GetAppConfigRoute(t *testing.T) {
|
|
m, ok := reflect.TypeOf(&apiV3Comp{}).MethodByName("GetAppConfig")
|
|
if !ok {
|
|
t.Fatal("apiV3Comp 上找不到 GetAppConfig,路由 user_getappconfig_v3 会消失")
|
|
}
|
|
mt := m.Type
|
|
if mt.NumIn() != 3 || mt.NumOut() != 2 {
|
|
t.Fatalf("签名不合反射注册约定:入参 %d 出参 %d", mt.NumIn(), mt.NumOut())
|
|
}
|
|
if !mt.In(1).Implements(reflect.TypeOf((*comm.IUserSession)(nil)).Elem()) {
|
|
t.Fatalf("第一个入参须是 comm.IUserSession,实际 %s", mt.In(1))
|
|
}
|
|
if mt.In(2) != reflect.TypeOf(&pb.UserGetAppConfigV3Req{}) {
|
|
t.Fatalf("请求类型应为 *pb.UserGetAppConfigV3Req,实际 %s", mt.In(2))
|
|
}
|
|
if mt.Out(0) != reflect.TypeOf(&pb.UserGetAppConfigV3Resp{}) {
|
|
t.Fatalf("响应类型应为 *pb.UserGetAppConfigV3Resp,实际 %s", mt.Out(0))
|
|
}
|
|
if mt.Out(1) != reflect.TypeOf(&pb.ErrorData{}) {
|
|
t.Fatalf("第二个出参应为 *pb.ErrorData,实际 %s", mt.Out(1))
|
|
}
|
|
route := fmt.Sprintf("%s_%s_%s", comm.ModuleUser, strings.ToLower(m.Name), "v3")
|
|
if route != "user_getappconfig_v3" {
|
|
t.Fatalf("反射算出的路由是 %s,与客户端约定的 user_getappconfig_v3 不一致", route)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ③ user_getagents_v3
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// v3 下发的 voice_map 是「补全后」的:客户端不带音色表,切语言时得能直接查到。
|
|
func TestResolveAgentVoices(t *testing.T) {
|
|
a := &comm.AgentConfig{
|
|
Type: comm.AgentTypeChat,
|
|
Gender: comm.GenderFemale,
|
|
SupportLangs: "zh-CN, en-US ,ja-JP",
|
|
DefaultLang: "zh-CN", // 与 support_langs 重复,不该出两条
|
|
VoiceMap: []comm.AgentVoice{
|
|
{Language: "zh-CN", Gender: comm.GenderFemale, Voice: "zh-CN-XiaoyiNeural"}, // 运营选过,优先
|
|
},
|
|
}
|
|
got := resolveAgentVoices(a, comm.TTSProviderAzure)
|
|
if len(got) != 3 {
|
|
t.Fatalf("三种支持语言应各一条,实际 %d 条: %+v", len(got), got)
|
|
}
|
|
byLang := map[string]string{}
|
|
for _, v := range got {
|
|
if _, dup := byLang[v.Language]; dup {
|
|
t.Fatalf("语言 %s 出现重复", v.Language)
|
|
}
|
|
byLang[v.Language] = v.Voice
|
|
}
|
|
if byLang["zh-CN"] != "zh-CN-XiaoyiNeural" {
|
|
t.Fatalf("运营在后台选过的音色应优先,实际 %s", byLang["zh-CN"])
|
|
}
|
|
if byLang["en-US"] != "en-US-JennyNeural" || byLang["ja-JP"] != "ja-JP-NanamiNeural" {
|
|
t.Fatalf("未配的语言应按音色表取第一个女声,实际 %+v", byLang)
|
|
}
|
|
}
|
|
|
|
// 音色表覆盖不到的:不能瞎发一个别的语言的音色,宁可不发让客户端走服务商默认。
|
|
func TestResolveAgentVoicesSkipsUnknown(t *testing.T) {
|
|
a := &comm.AgentConfig{Type: comm.AgentTypeChat, Gender: comm.GenderMale, SupportLangs: "zh-CN,xx-YY"}
|
|
got := resolveAgentVoices(a, comm.TTSProviderAzure)
|
|
if len(got) != 1 || got[0].Language != "zh-CN" {
|
|
t.Fatalf("未知语言不该下发,实际 %+v", got)
|
|
}
|
|
// 服务商不在音色表里(火山等)且运营没手配 → 一条都不发
|
|
if n := len(resolveAgentVoices(a, "volcengine")); n != 0 {
|
|
t.Fatalf("音色表未覆盖的服务商应不下发音色,实际 %d 条", n)
|
|
}
|
|
// 但运营手填过的照发
|
|
a2 := &comm.AgentConfig{
|
|
Type: comm.AgentTypeChat, SupportLangs: "zh-CN",
|
|
VoiceMap: []comm.AgentVoice{{Language: "zh-CN", Voice: "BV700_streaming"}},
|
|
}
|
|
got2 := resolveAgentVoices(a2, "volcengine")
|
|
if len(got2) != 1 || got2[0].Voice != "BV700_streaming" {
|
|
t.Fatalf("手填音色应照常下发,实际 %+v", got2)
|
|
}
|
|
}
|
|
|
|
// 出声的是哪个服务:对话类看 TTS,端到端对话看 STS(音色候选按服务商区分,取错就全错)。
|
|
// 三段式(chat / translate)出声靠 TTS,端到端由 STS / AST 服务自己合成。
|
|
// 取错服务就会拿别家服务商的音色表去补全 voice_map,下发一串对端根本不认的音色名。
|
|
func TestVoiceSvcIdOf(t *testing.T) {
|
|
a := &comm.AgentConfig{TtsSvcId: "tts_azure", StsSvcId: "sts_x", AstSvcId: "ast_y"}
|
|
for _, c := range []struct{ typ, want string }{
|
|
{comm.AgentTypeChat, "tts_azure"},
|
|
{comm.AgentTypeTranslate, "tts_azure"},
|
|
{comm.AgentTypeStsChat, "sts_x"},
|
|
{comm.AgentTypeAstTranslate, "ast_y"},
|
|
} {
|
|
a.Type = c.typ
|
|
if got := voiceSvcIdOf(a); got != c.want {
|
|
t.Fatalf("%s 应取 %s,实际 %s", c.typ, c.want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ④ user_getchannelapp(应用发版版本判定)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// 客户端拿到的就是这三个布尔值,各端据此决定「提示更新 / 阻断使用 / 隐藏未过审功能」,
|
|
// 判定错了直接表现为线上事故,故逐个组合固化。
|
|
func TestEvalChannelUpgrade(t *testing.T) {
|
|
cfg := &comm.AppRelease{Version: "1.10.0", MinVersion: "1.5.0", ForceUpdate: true, ReviewVersion: "1.11.0"}
|
|
|
|
cases := []struct {
|
|
name string
|
|
cfg *comm.AppRelease
|
|
client string
|
|
need, force, reviewing bool
|
|
}{
|
|
{"低于最低支持版本:强更", cfg, "1.4.9", true, true, false},
|
|
{"高于最低支持、低于线上:只提示更新", cfg, "1.9.0", true, false, false},
|
|
{"已是线上最新:什么都不提示", cfg, "1.10.0", false, false, false},
|
|
{"高于线上版本(内测包):不提示", cfg, "1.11.0", false, false, true},
|
|
{"送审版本:标记审核期", cfg, "1.11.0", false, false, true},
|
|
{"客户端未上报版本:一律 false,不能把老客户端全挡住", cfg, "", false, false, false},
|
|
{"该应用未配置发版信息:一律 false", nil, "1.0.0", false, false, false},
|
|
{"字符串比较陷阱:1.9 < 1.10 应判定为需更新", cfg, "1.9.9", true, false, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
need, force, reviewing := evalChannelUpgrade(c.cfg, c.client)
|
|
if need != c.need || force != c.force || reviewing != c.reviewing {
|
|
t.Fatalf("client=%q 得到 need=%v force=%v reviewing=%v,期望 need=%v force=%v reviewing=%v",
|
|
c.client, need, force, reviewing, c.need, c.force, c.reviewing)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 运营把最低支持版本填得比线上版本还高时,不能出现「必须更新但没有更新可用」这种客户端无解的组合。
|
|
func TestEvalChannelUpgradeForceImpliesNeed(t *testing.T) {
|
|
cfg := &comm.AppRelease{Version: "1.0.0", MinVersion: "2.0.0", ForceUpdate: true}
|
|
need, force, _ := evalChannelUpgrade(cfg, "1.5.0")
|
|
if !force {
|
|
t.Fatal("低于最低支持版本应强更")
|
|
}
|
|
if !need {
|
|
t.Fatal("强更时 needupdate 必须同时为 true,否则客户端会收到「必须更新但无更新」")
|
|
}
|
|
}
|
|
|
|
// 后台还没配发版信息时 model 层给的是 nil,这里确认 nil 不会 panic 也不会误判。
|
|
func TestEvalChannelUpgradeNilSafe(t *testing.T) {
|
|
if need, force, reviewing := evalChannelUpgrade(nil, "1.0.0"); need || force || reviewing {
|
|
t.Fatal("nil 配置必须全 false")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ⑤ 老客户端兼容字段
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// 老包按「非空 int」解析 factoryid,缺字段整个响应就解析不了。这里固化两件事:
|
|
// 值等于 brandid,且 brandid=0 时也要输出 0(不能被 omitempty 吃掉变成 null)。
|
|
func TestCompatProductFillsFactoryid(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
brandid uint32
|
|
}{
|
|
{"正常品牌商", 40981},
|
|
{"无品牌归属:仍要输出 0,不能缺字段", 0},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
p := compatProduct(&pb.DBProduct{Id: 45107, Brandid: c.brandid})
|
|
if p.Factoryid != c.brandid {
|
|
t.Fatalf("factoryid=%d,期望等于 brandid=%d", p.Factoryid, c.brandid)
|
|
}
|
|
buf, err := json.Marshal(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var got map[string]any
|
|
if err = json.Unmarshal(buf, &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
v, ok := got["factoryid"]
|
|
if !ok || v == nil {
|
|
t.Fatalf("下发 JSON 缺 factoryid(老客户端会 TypeError):%s", buf)
|
|
}
|
|
if v.(float64) != float64(c.brandid) {
|
|
t.Fatalf("下发 factoryid=%v,期望 %d", v, c.brandid)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCompatProductsNilSafe(t *testing.T) {
|
|
if compatProduct(nil) != nil {
|
|
t.Fatal("nil 入参不应造出对象")
|
|
}
|
|
compatProducts([]*pb.DBProduct{nil, {Brandid: 7}}) // 不能 panic
|
|
}
|
|
|
|
// factoryid 是纯下发字段:gorm 必须完全忽略它,否则 AutoMigrate 会给产品表建一个恒为 0 的多余列。
|
|
func TestFactoryidNotPersisted(t *testing.T) {
|
|
s, err := schema.Parse(&pb.DBProduct{}, &sync.Map{}, schema.NamingStrategy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f := s.LookUpField("factoryid"); f != nil {
|
|
t.Fatalf("factoryid 被 gorm 当成了库字段(列 %s),应由 gorm:\"-\" 忽略", f.DBName)
|
|
}
|
|
if s.LookUpField("brandid") == nil {
|
|
t.Fatal("brandid 应该是库字段,测试基准不成立")
|
|
}
|
|
}
|
|
|
|
// devicetype 同样是纯下发字段(2026-09-08 废弃):产品上的「设备类型」已删除,
|
|
// pb 里只留一个恒为 0 的兼容字段给老 App 包解析用。gorm 必须完全忽略它,
|
|
// 否则 AutoMigrate 会把刚 DROP 掉的列又建回来。
|
|
func TestDevicetypeNotPersisted(t *testing.T) {
|
|
s, err := schema.Parse(&pb.DBProduct{}, &sync.Map{}, schema.NamingStrategy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f := s.LookUpField("devicetype"); f != nil {
|
|
t.Fatalf("devicetype 被 gorm 当成了库字段(列 %s),应由 gorm:\"-\" 忽略", f.DBName)
|
|
}
|
|
}
|
|
|
|
// 老客户端按「非空 int」解析 devicetype,字段不下发即 TypeError、整个响应解析失败。
|
|
// 所以哪怕它已废弃,JSON 里也必须始终有这个键(值恒为 0)。
|
|
func TestDevicetypeStillEmittedInJSON(t *testing.T) {
|
|
b, err := json.Marshal(&pb.DBProduct{Id: 1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
v, ok := m["devicetype"]
|
|
if !ok {
|
|
t.Fatal("下发的产品 JSON 里缺少 devicetype 键——已发布的 App 包会整个解析失败")
|
|
}
|
|
if v != float64(0) {
|
|
t.Fatalf("devicetype 应恒为 0,实际 %v", v)
|
|
}
|
|
}
|
|
|
|
// scanuuid 与 devicetype 同一处理(2026-09-08 废弃):设备校验与绑定奖励都改成按 MAC 判定,
|
|
// 这个 UUID 不再有任何用途,库列已删,pb 里只留一个 gorm:"-" 的下发兼容字段。
|
|
func TestScanuuidNotPersisted(t *testing.T) {
|
|
s, err := schema.Parse(&pb.DBProduct{}, &sync.Map{}, schema.NamingStrategy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f := s.LookUpField("scanuuid"); f != nil {
|
|
t.Fatalf("scanuuid 被 gorm 当成了库字段(列 %s),应由 gorm:\"-\" 忽略", f.DBName)
|
|
}
|
|
}
|
|
|
|
// 老客户端按「非空 String」解析 scanuuid(`json['scanuuid'] as String`),
|
|
// 字段不下发即 TypeError、整个 user_getappconfig 解析失败。所以键必须始终在,值恒为空串。
|
|
func TestScanuuidStillEmittedInJSON(t *testing.T) {
|
|
b, err := json.Marshal(&pb.DBProduct{Id: 1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
v, ok := m["scanuuid"]
|
|
if !ok {
|
|
t.Fatal("下发的产品 JSON 里缺少 scanuuid 键——已发布的 App 包会整个解析失败")
|
|
}
|
|
if v != "" {
|
|
t.Fatalf("scanuuid 应恒为空串,实际 %v", v)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ⑥ 杂项(token / 加解密工具)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func Test_Claims(t *testing.T) {
|
|
claims := &jwt.RegisteredClaims{
|
|
Issuer: "Earphone",
|
|
Subject: "Sgin",
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)), // 24 hours expiration
|
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
ID: "2010882799132737536",
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenString, err := token.SignedString([]byte("asd123adqeqwe"))
|
|
if err != nil {
|
|
t.Fatalf("Error signing token: %v", err)
|
|
}
|
|
fmt.Println("Generated Token:", tokenString)
|
|
|
|
// Call the parse function to verify the token
|
|
parsedClaims, err := ParseToken(tokenString, []byte("asd123adqeqwe"))
|
|
if err != nil {
|
|
t.Fatalf("Error parsing token: %v", err)
|
|
}
|
|
|
|
// Validate the claims
|
|
if parsedClaims.Issuer != claims.Issuer {
|
|
t.Errorf("Expected Issuer %v, got %v", claims.Issuer, parsedClaims.Issuer)
|
|
}
|
|
if parsedClaims.Subject != claims.Subject {
|
|
t.Errorf("Expected Subject %v, got %v", claims.Subject, parsedClaims.Subject)
|
|
}
|
|
if parsedClaims.ID != claims.ID {
|
|
t.Errorf("Expected ID %v, got %v", claims.ID, parsedClaims.ID)
|
|
}
|
|
}
|
|
|
|
func ParseToken(tokenString string, secretKey []byte) (*jwt.RegisteredClaims, error) {
|
|
parsedToken, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
|
|
// Ensure the token method conforms to "SigningMethodHMAC"
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
}
|
|
return secretKey, nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Validate the token and extract the claims
|
|
if claims, ok := parsedToken.Claims.(*jwt.RegisteredClaims); ok && parsedToken.Valid {
|
|
return claims, nil
|
|
} else {
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
}
|
|
|
|
// 生成随机大写字母授权码(无重复字符)
|
|
func generateRandomCode(length int) string {
|
|
// 定义大写字母集合
|
|
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
// 初始化随机种子
|
|
rand.Seed(time.Now().UnixNano())
|
|
|
|
// 打乱字母顺序
|
|
shuffled := []rune(letters)
|
|
rand.Shuffle(len(shuffled), func(i, j int) {
|
|
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
|
|
})
|
|
|
|
// 取前 length 个字符
|
|
return string(shuffled[:length])
|
|
}
|
|
|
|
func Test_CBC(t *testing.T) {
|
|
token := aes.AesEncryptCBC("+8613536758045", "gDSiGlKJIGuu84ZNe+j4FQ==")
|
|
phone := aes.AesDecryptCBC(token, "gDSiGlKJIGuu84ZNe+j4FQ==")
|
|
fmt.Println(token, phone)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ⑦ 线上联调探针(会真发网络请求,`go test -short` 可跳过)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// 联调用:打一次线上接口,解密后反序列化成对象,方便打断点看数据。
|
|
// 每个探针的 url / token / key 都写在**它自己的函数里**,测哪个接口就只改那个函数,互不影响。
|
|
// key 留空 = 不解密(v1 之类不加密的接口)。⚠️ 数据含明文凭据,别外传。
|
|
//
|
|
// probeCall 打一次接口并返回(必要时已解密的)响应体明文。
|
|
// probeEnv 给联调探针取环境参数:默认打阿龙测试环境,可用环境变量临时覆盖。
|
|
//
|
|
// PROBE_HOST=https://xxx PROBE_TOKEN=<新token> PROBE_KEY=<该环境的key> \
|
|
// go test ./modules/user/ -run TestApiProbeAppConfigV3 -v
|
|
//
|
|
// ⚠️ token 必须由调用方给:JWT 会过期,写死在代码里的那个迟早失效,
|
|
// 而这两个探针属于 `go test ./...` 的一部分,一旦失效就变成常态红灯
|
|
// (2026-09-03 之前就是这样:写死的 token 早已过期,每次跑都 code=18 NoLogin)。
|
|
// 没给 token 就 Skip —— 探针是手工联调工具,不该拖累整包测试。
|
|
func probeEnv(t *testing.T) (host, token, key string) {
|
|
t.Helper()
|
|
host = os.Getenv("PROBE_HOST")
|
|
if host == "" {
|
|
host = "https://ym-dev.ymaikj.com" // 阿龙测试环境,同 apps/client/.env 的 SERVER_URL
|
|
}
|
|
key = os.Getenv("PROBE_KEY")
|
|
if key == "" {
|
|
// 阿龙测试环境 .env 的 GATEWAY_ENCRYPT_KEY。
|
|
// ⚠️ 换环境必须同时换 key,否则响应解密不出来(各环境的 key 不同)。
|
|
key = "83KNTeWWQ4GiQHhINJH1H8RyN88HNUOf"
|
|
}
|
|
token = os.Getenv("PROBE_TOKEN")
|
|
if token == "" {
|
|
t.Skip("联调探针需要一个有效的登录 token:PROBE_TOKEN=<token> go test ...")
|
|
}
|
|
return
|
|
}
|
|
|
|
func probeCall(t *testing.T, url, token, key string) string {
|
|
t.Helper()
|
|
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBufferString("{}"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", token)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("请求失败: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
|
|
body := string(raw)
|
|
if resp.Header.Get("X-Encrypted") == "1" {
|
|
if key == "" {
|
|
t.Fatal("响应是加密的,请在调用方的 key 里填该环境的 GATEWAY_ENCRYPT_KEY")
|
|
}
|
|
body = aes.AesDecryptCBC(strings.TrimSpace(body), key)
|
|
if !strings.HasPrefix(strings.TrimSpace(body), "{") {
|
|
t.Fatalf("解密失败:key 与该环境的 GATEWAY_ENCRYPT_KEY 不一致(长度须 16/24/32,当前 %d)", len(key))
|
|
}
|
|
}
|
|
return body
|
|
}
|
|
|
|
// user_getappconfig_v2 的线上联调:
|
|
//
|
|
// go test ./modules/user/ -run TestApiProbeAppConfigV2 -v
|
|
func TestApiProbeAppConfigV2(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("联调探针会打线上接口,-short 下跳过")
|
|
}
|
|
host, token, key := probeEnv(t)
|
|
body := probeCall(t, host+"/api/home/user_getappconfig_v2", token, key)
|
|
|
|
// ① 强类型对象——断点看它,字段能直接展开
|
|
var result struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data pb.UserGetAppConfigResp `json:"data"`
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
|
t.Fatalf("反序列化失败: %v", err)
|
|
}
|
|
env := result.Data.Env
|
|
agents := result.Data.Agents
|
|
mcps := result.Data.Mcps
|
|
products := result.Data.Products
|
|
|
|
// ② 通用对象——换了接口懒得改 Data 类型时,看这个
|
|
var generic map[string]interface{}
|
|
_ = json.Unmarshal([]byte(body), &generic)
|
|
|
|
// 断点打在这一行,watch: result / env / mcps / generic
|
|
t.Logf("code=%d msg=%s | env=%d agents=%d mcps=%d products=%d",
|
|
result.Code, result.Msg, len(env), len(agents), len(mcps), len(products))
|
|
|
|
for k, v := range env {
|
|
t.Logf(" env[%s] = %s", k, v)
|
|
}
|
|
for id, m := range mcps {
|
|
t.Logf(" mcp[%s] name=%s url=%s type=%v tools=%s", id, m.Servername, m.Url, m.Type, m.Tools)
|
|
}
|
|
for _, a := range agents {
|
|
t.Logf(" agent id=%s name=%s", a.Id, a.Name)
|
|
}
|
|
}
|
|
|
|
// user_getappconfig_v3 的线上联调:核对 v3 相对 v2 的两处变化真的落到了响应里
|
|
// —— 不再有 mcps(MCP 混在 thirdsvcs 里按 categories 含 10 识别)、多了 meet。
|
|
//
|
|
// go test ./modules/user/ -run TestApiProbeAppConfigV3 -v
|
|
func TestApiProbeAppConfigV3(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("联调探针会打线上接口,-short 下跳过")
|
|
}
|
|
host, token, key := probeEnv(t)
|
|
body := probeCall(t, host+"/api/home/user_getappconfig_v3", token, key)
|
|
|
|
var result struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data pb.UserGetAppConfigV3Resp `json:"data"`
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
|
t.Fatalf("反序列化失败: %v", err)
|
|
}
|
|
if result.Code != 0 {
|
|
t.Fatalf("接口返回失败 code=%d msg=%s", result.Code, result.Msg)
|
|
}
|
|
d := &result.Data // 取指针:pb 结构体含锁,直接复制会被 go vet 拦下
|
|
|
|
// 断点打在下一行,watch: result / d.Env / d.Thirdsvcs / d.Meet
|
|
t.Logf("env=%d agents=%d products=%d thirdsvcs=%d", len(d.Env), len(d.Agents), len(d.Products), len(d.Thirdsvcs))
|
|
for k, v := range d.Env {
|
|
t.Logf(" env[%s] = %s", k, v)
|
|
}
|
|
for _, s := range d.Thirdsvcs {
|
|
t.Logf(" svc[%s] name=%s provider=%s categories=%s fields=%d mcp=%v",
|
|
s.Id, s.Name, s.Provider, s.Categories, len(s.Fields), comm.CategoriesHasMCP(s.Categories))
|
|
}
|
|
for _, a := range d.Agents {
|
|
t.Logf(" agent id=%s name=%s", a.Id, a.Name)
|
|
}
|
|
|
|
// meet 是 v3 新增的:为空可以(后台没编排会议记录),为 null 不行——客户端会崩。
|
|
if d.Meet == nil {
|
|
t.Fatal("v3 必须下发 meet 字段(没编排也要给空壳)")
|
|
}
|
|
t.Logf(" meet transcribe=%v(any=%v) translate=%v(any=%v) summary=%d(choice=%v)",
|
|
d.Meet.TranscribeLanguages, d.Meet.TranscribeAnyLanguage,
|
|
d.Meet.TranslateLanguages, d.Meet.TranslateAnyLanguage,
|
|
len(d.Meet.SummaryServices), d.Meet.SummaryClientChoice)
|
|
for _, s := range d.Meet.SummaryServices {
|
|
t.Logf(" summary svc=%s name=%s default=%v", s.SvcId, s.Name, s.IsDefault)
|
|
}
|
|
|
|
// v3 不再单独下发 mcps:真出现了说明有人往回加字段,客户端会重复处理 MCP。
|
|
var generic map[string]any
|
|
if err := json.Unmarshal([]byte(body), &generic); err == nil {
|
|
if data, ok := generic["data"].(map[string]any); ok {
|
|
if _, ok := data["mcps"]; ok {
|
|
t.Fatal("v3 响应里出现了 mcps,应只在 thirdsvcs 里下发 MCP")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ⑥ 下发结构由两张表拼装(app_release + channel_app)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ⚠️ 这一条钉的是真实事故:运营在「游客显隐控制」里配好了游客登录,App 却一直不显示入口。
|
|
// 原因是 toPbChannelApp 在渠道行缺失时整个返回 nil —— 应用级的开关被「渠道分发里有没有
|
|
// 这一行」卡死。测试机是荣耀,品牌推导出 CHINA_HONOR,而后台只建过官方渠道的行。
|
|
func TestToPbChannelAppSurvivesMissingChannelRow(t *testing.T) {
|
|
rel := &comm.AppRelease{
|
|
Version: "1.0.1", Tourists: true, TouristsVersion: "1.0.1",
|
|
Address: "https://default/app.apk",
|
|
}
|
|
|
|
got := toPbChannelApp(comm.ChannelChinaHonor, nil, rel, "1.0.1")
|
|
if got == nil {
|
|
t.Fatal("渠道行缺失但发版配置存在时必须照常下发,否则应用级开关会被渠道行卡死")
|
|
}
|
|
if !got.Tourists {
|
|
t.Fatal("游客登录是应用级开关,与有没有建这个渠道的行无关")
|
|
}
|
|
if got.Channel != comm.ChannelChinaHonor {
|
|
t.Fatalf("渠道号应回显请求的渠道,实际 %d", got.Channel)
|
|
}
|
|
if got.Version != "1.0.1" {
|
|
t.Fatalf("版本应取自 app_release,实际 %q", got.Version)
|
|
}
|
|
if got.Address != "https://default/app.apk" {
|
|
t.Fatalf("没有渠道地址时应回退应用默认地址,实际 %q", got.Address)
|
|
}
|
|
}
|
|
|
|
// 反过来:渠道行在、发版配置没配,也要照常下发渠道那部分(支付渠道等),
|
|
// 只是版本相关字段为空 —— 客户端据此走「无更新」分支。
|
|
func TestToPbChannelAppSurvivesMissingRelease(t *testing.T) {
|
|
ch := &comm.ChannelApp{
|
|
Channel: comm.ChannelChinaXiaomi, Address: "https://xiaomi/store",
|
|
PayChannels: "wxpay", AllowSkipDeviceBinding: true, Status: comm.ChannelStatusOnline,
|
|
}
|
|
got := toPbChannelApp(comm.ChannelChinaXiaomi, ch, nil, "1.0.1")
|
|
if got == nil {
|
|
t.Fatal("发版配置未配时仍应下发渠道信息")
|
|
}
|
|
if got.Paychannels != "wxpay" || !got.Allowskipdevicebinding {
|
|
t.Fatalf("渠道字段丢失:%+v", got)
|
|
}
|
|
if got.Version != "" || got.Tourists {
|
|
t.Fatalf("发版配置缺失时版本/游客字段必须为零值,实际 %+v", got)
|
|
}
|
|
if got.Address != "https://xiaomi/store" {
|
|
t.Fatalf("渠道地址应优先,实际 %q", got.Address)
|
|
}
|
|
}
|
|
|
|
// 渠道地址优先于应用默认地址:各商店链接不同,一套地址会把用户引到别家商店。
|
|
func TestToPbChannelAppAddressPrecedence(t *testing.T) {
|
|
rel := &comm.AppRelease{Address: "https://default/app.apk"}
|
|
ch := &comm.ChannelApp{Channel: comm.ChannelChinaHuawei, Address: "https://huawei/store"}
|
|
if got := toPbChannelApp(comm.ChannelChinaHuawei, ch, rel, "1.0.1"); got.Address != "https://huawei/store" {
|
|
t.Fatalf("渠道地址应覆盖应用默认地址,实际 %q", got.Address)
|
|
}
|
|
}
|
|
|
|
// 两张表都没配才返回 nil。
|
|
func TestToPbChannelAppBothNil(t *testing.T) {
|
|
if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, nil, "1.0.1"); got != nil {
|
|
t.Fatalf("两边都没配时应返回 nil,实际 %+v", got)
|
|
}
|
|
}
|
|
|