Files
iotlcm/worker-svr/internal/service/imei/handler_test.go
T

549 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package imei
import (
"context"
"errors"
"testing"
zsetconsumer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consumer/zset"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
imeirepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/imei"
)
// fakeClient returns a canned response or error and records the call count.
type fakeClient struct {
output []model.SimBaseRes[model.SimImeiItem]
err error
calls int
}
func (f *fakeClient) QuerySimImei(context.Context, *model.SimImeiInput) ([]model.SimBaseRes[model.SimImeiItem], error) {
f.calls++
return f.output, f.err
}
// fakeRepo captures what the handler asked to persist.
type fakeRepo struct {
results []imeirepo.Result
calls int
err error
}
func (f *fakeRepo) ApplyBatch(_ context.Context, results []imeirepo.Result) error {
f.calls++
f.results = append(f.results, results...)
return f.err
}
func task(tempKey string) queue.Task {
return queue.Task{
TaskID: "run:imei_full_sync:1",
RunID: "run",
ChanKey: "173",
TempKey: tempKey,
CardID: 1,
ICCID: "898604F4152391115474",
MSISDN: "1064000000001",
IMSI: "460000000000001",
Operator: 1,
}
}
func item(code int, errCode, imei string) model.SimBaseRes[model.SimImeiItem] {
return model.SimBaseRes[model.SimImeiItem]{
Code: code,
ErrCode: errCode,
SimKey: &model.SimKey{ChanKey: "173", ICCID: "898604F4152391115474"},
ResData: model.SimImeiItem{Imei: imei},
}
}
func newTestHandler(t *testing.T, client ImeiClient, repo ImeiRepository, retries ...int) *Handler {
t.Helper()
h, err := NewHandler(client, repo, retries...)
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
return h
}
// TestQuerySuccessWithValue 覆盖 §7 的正常命中路径。
func TestQuerySuccessWithValue(t *testing.T) {
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(0, "", "865466070817587")}}
handler := newTestHandler(t, client, &fakeRepo{})
result := handler.Query(context.Background(), task("sim_yd_v5"))
if !result.Success {
t.Fatalf("应成功: %+v", result)
}
if !result.ImeiSynced {
t.Fatal("ImeiSynced 应为 true")
}
if result.Imei != "865466070817587" {
t.Fatalf("imei = %q", result.Imei)
}
if result.Retryable {
t.Fatal("成功不应可重试")
}
}
// TestQueryEmptyValueIsTerminalAndSynced 覆盖 §7 T5/T6:上游成功但无值。
// 这是最关键的语义——必须记录覆盖(ImeiSynced)但不得视为失败重试。
func TestQueryEmptyValueIsTerminalAndSynced(t *testing.T) {
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(0, "", "")}}
handler := newTestHandler(t, client, &fakeRepo{})
result := handler.Query(context.Background(), task("sim_yd_v5"))
if result.Success {
t.Fatal("空值不应标记为 Success")
}
if result.Retryable {
t.Fatal("空值是终态,不应重试")
}
if !result.ImeiSynced {
t.Fatal("空值仍应记录为已同步(心跳)")
}
if !result.Covered {
t.Fatal("空值与 14000 同族,不应进失败池")
}
if result.ErrorClass != dataInvalidClass {
t.Fatalf("errorClass = %q, want %q", result.ErrorClass, dataInvalidClass)
}
if result.Imei != "" {
t.Fatalf("imei 应为空: %q", result.Imei)
}
}
// TestQueryNoRecordIsCoveredNotFailed 覆盖生产实测的主路径(2026-10-20 chan
// 220/250/251 的 4900 条“失败”全部是 code=14000)。运营商持有卡但没有 IMEI
// 记录,这是终态正常结果:记录覆盖、绝不重试、不进失败池。
func TestQueryNoRecordIsCoveredNotFailed(t *testing.T) {
for _, tempKey := range []string{"sim_yd_v5", "sim_dx_v1"} {
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{
item(chanNoRecordCode, "", ""),
}}
handler := newTestHandler(t, client, &fakeRepo{})
// 14000 的 message 是运营商原文,处理逻辑不得依赖它。
client.output[0].Message = "系统无记录,请检查输入参数是否正确"
result := handler.Query(context.Background(), task(tempKey))
if result.Success {
t.Fatalf("%s: 无记录不得标记为 Success", tempKey)
}
if result.Retryable {
t.Fatalf("%s: 无记录是终态,不应重试", tempKey)
}
if !result.ImeiSynced {
t.Fatalf("%s: 无记录仍应记录覆盖(心跳)", tempKey)
}
if !result.Covered {
t.Fatalf("%s: 无记录不得计入失败池", tempKey)
}
if result.ErrorClass != noRecordClass {
t.Fatalf("%s: errorClass = %q, want %q", tempKey, result.ErrorClass, noRecordClass)
}
if result.Imei != "" {
t.Fatalf("%s: 无记录不得携带值: %q", tempKey, result.Imei)
}
}
}
// TestQueryNoRecordBeatsMobileBusyRange 14000 不在 13000-13007 内,
// 但仍要求移动模板走同一条“无记录”分类——不能被 busy 分支或业务错误吞掉。
func TestQueryNoRecordDoesNotRetryOnMobileTemplate(t *testing.T) {
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(chanNoRecordCode, "", "")}}
handler := newTestHandler(t, client, &fakeRepo{})
result := handler.Query(context.Background(), task("sim_yd_v5"))
if result.Retryable || result.ErrorClass == mobileBusyClass || result.ErrorClass == businessClass {
t.Fatalf("14000 应为 no_record 终态: %+v", result)
}
}
// TestQueryUnsupportedTemplateSkipsUpstream 覆盖 §7 T3:
// 不支持的模板必须在不发请求的前提下直接终态,避免失败池爆炸。
func TestQueryUnsupportedTemplateSkipsUpstream(t *testing.T) {
for _, tempKey := range []string{"sim_mingqian_v3", "sim_daihe_v1", "sim_qianxun_v3"} {
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(0, "", "865466070817587")}}
handler := newTestHandler(t, client, &fakeRepo{})
result := handler.Query(context.Background(), task(tempKey))
if result.Retryable {
t.Fatalf("%s: 不支持应为终态,不应重试", tempKey)
}
if result.ErrorClass != unsupportedClass {
t.Fatalf("%s: errorClass = %q, want %q", tempKey, result.ErrorClass, unsupportedClass)
}
if client.calls != 0 {
t.Fatalf("%s: 不支持模板不应发起上游请求,实际调用 %d 次", tempKey, client.calls)
}
if result.ImeiSynced {
t.Fatalf("%s: 未查询不应记录覆盖", tempKey)
}
}
}
// TestQueryUnsupportedTemplatesAreCaseInsensitive 模板键大小写不应影响判定。
func TestQueryUnsupportedTemplatesAreCaseInsensitive(t *testing.T) {
client := &fakeClient{}
handler := newTestHandler(t, client, &fakeRepo{})
result := handler.Query(context.Background(), task("SIM_MingQian_V3"))
if result.ErrorClass != unsupportedClass || client.calls != 0 {
t.Fatalf("大小写变体未被识别: %+v, calls=%d", result, client.calls)
}
}
// TestQuerySharedTokenTemplatesAreSupported 回归保护:
// sim_onelink_* 走 shared_token 模型 → sSimYD(支持 IMEI),
// 不得被误列入不支持白名单(这是 2026-10-12 生产基线纠正过的判断)。
func TestQuerySharedTokenTemplatesAreSupported(t *testing.T) {
for _, tempKey := range []string{
"sim_onelink_tengyu_v1",
"sim_onelink_daihe_v1",
"sim_onelink_qianxun_v1",
"sim_onelink_kedang_v1",
"sim_onelink_daliuliang_v1",
"sim_onelink_mengwang_v1",
} {
if isImeiUnsupportedTemplate(tempKey) {
t.Fatalf("%s 走 sSimYD 支持 IMEI,不应在不支持白名单", tempKey)
}
}
}
// TestQueryMobileBusyIsRetryable 覆盖 §7 T4:移动 13000 区间跨轮重试。
func TestQueryMobileBusyIsRetryable(t *testing.T) {
for _, code := range []int{13000, 13003, 13007} {
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(code, "", "")}}
handler := newTestHandler(t, client, &fakeRepo{})
result := handler.Query(context.Background(), task("sim_yd_v5"))
if result.ErrorClass != mobileBusyClass || !result.Retryable {
t.Fatalf("code %d: errorClass=%q retryable=%v, want %q/true", code, result.ErrorClass, result.Retryable, mobileBusyClass)
}
if result.Covered {
t.Fatalf("code %d: 系统忙必须计入失败", code)
}
if result.ImeiSynced {
t.Fatalf("code %d: 系统忙不应记录覆盖", code)
}
}
}
// TestQueryMobileBusyOnlyForMobileTemplates 13000 只对移动族判定为 busy。
func TestQueryMobileBusyOnlyForMobileTemplates(t *testing.T) {
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(13000, "", "")}}
handler := newTestHandler(t, client, &fakeRepo{})
// 电信模板收到 13000 不是移动系统忙语义,应按普通业务错误处理
result := handler.Query(context.Background(), task("sim_dx_v1"))
if result.ErrorClass == mobileBusyClass {
t.Fatalf("非移动模板不应判定为 mobile_busy: %+v", result)
}
if result.Retryable {
t.Fatalf("非移动模板的 13000 应为终态: %+v", result)
}
}
// TestQueryTelecomRetryableCodeIsNotTreatedAsRetryableForImei 回归保护:
// S00004 是电信「渠道限流」业务码,属于状态/流量链路的可重试语义;
// IMEI 链路没有对应的限流处理,必须按普通业务错误终态处理,
// 否则会无限重试同一张卡。
func TestQueryTelecomRetryableCodeIsNotTreatedAsRetryableForImei(t *testing.T) {
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(1, "S00004", "")}}
handler := newTestHandler(t, client, &fakeRepo{})
result := handler.Query(context.Background(), task("sim_dx_v1"))
if result.Retryable {
t.Fatalf("IMEI 链路不应把 S00004 当作可重试: %+v", result)
}
if result.ErrorClass != businessClass {
t.Fatalf("errorClass = %q, want %q", result.ErrorClass, businessClass)
}
}
// TestQueryGatewayAndTokenErrorsAreRetryable 覆盖 §7 T8。
func TestQueryGatewayAndTokenErrorsAreRetryable(t *testing.T) {
cases := []struct {
name string
code int
errCode string
wantClass string
}{
{"网关内部错误", 9999, "", gatewayClass},
{"token 过期", tokenExpiredCode, "", tokenExpiredClass},
}
for _, tc := range cases {
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(tc.code, tc.errCode, "")}}
handler := newTestHandler(t, client, &fakeRepo{})
result := handler.Query(context.Background(), task("sim_yd_v5"))
if result.ErrorClass != tc.wantClass || !result.Retryable {
t.Fatalf("%s: errorClass=%q retryable=%v, want %q/true", tc.name, result.ErrorClass, result.Retryable, tc.wantClass)
}
if result.Covered {
t.Fatalf("%s: 可重试错误必须计入失败", tc.name)
}
}
}
// TestQueryBusinessErrorIsTerminal 其他业务码为终态,避免无意义重试。
func TestQueryBusinessErrorIsTerminal(t *testing.T) {
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(11011, "", "")}}
handler := newTestHandler(t, client, &fakeRepo{})
result := handler.Query(context.Background(), task("sim_yd_v5"))
if result.Retryable {
t.Fatal("11011 码号信息错误是确定性终态,不应重试")
}
if result.ErrorClass != businessClass {
t.Fatalf("errorClass = %q, want %q", result.ErrorClass, businessClass)
}
if result.Covered {
t.Fatal("11011 是真失败,必须进失败池")
}
if result.ImeiSynced {
t.Fatal("11011 未拿到运营商结果,不应记录覆盖")
}
}
// TestQueryNetworkErrorIsRetryable 网络错误跨轮重试,且受 networkRetries 控制。
func TestQueryNetworkErrorIsRetryable(t *testing.T) {
client := &fakeClient{err: errors.New("connection refused")}
handler := newTestHandler(t, client, &fakeRepo{}, 2)
result := handler.Query(context.Background(), task("sim_yd_v5"))
if result.ErrorClass != networkClass || !result.Retryable {
t.Fatalf("errorClass=%q retryable=%v, want network/true", result.ErrorClass, result.Retryable)
}
// 1 次初始 + 2 次重试
if client.calls != 3 {
t.Fatalf("calls = %d, want 3(1 次初始 + 2 次重试)", client.calls)
}
}
// TestQueryEmptyResultSliceIsProtocolError 空切片是协议异常,应重试。
func TestQueryEmptyResultSliceIsProtocolError(t *testing.T) {
client := &fakeClient{output: nil}
handler := newTestHandler(t, client, &fakeRepo{})
result := handler.Query(context.Background(), task("sim_yd_v5"))
if result.ErrorClass != protocolClass || !result.Retryable {
t.Fatalf("errorClass=%q retryable=%v, want protocol/true", result.ErrorClass, result.Retryable)
}
}
// TestQueryMismatchedIdentifierIsProtocolError 防止上游串卡。
func TestQueryMismatchedIdentifierIsProtocolError(t *testing.T) {
mismatched := item(0, "", "865466070817587")
mismatched.SimKey = &model.SimKey{ChanKey: "173", ICCID: "OTHER-CARD"}
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{mismatched}}
handler := newTestHandler(t, client, &fakeRepo{})
result := handler.Query(context.Background(), task("sim_yd_v5"))
if result.ErrorClass != protocolClass || !result.Retryable {
t.Fatalf("串卡应判为协议错误: %+v", result)
}
if result.ImeiSynced {
t.Fatal("串卡不应记录覆盖")
}
}
// TestApplyWritesValueOnlyWhenChanged 覆盖 §7 T9/T10 与 §5.2 守卫:
// - 有值 → ValueChanged=true,带值写入
// - 空值 → ValueChanged=false,且 Imei 必须被清空(不得覆盖已有值)
// - 失败 → 完全不入库
func TestApplyWritesValueOnlyWhenChanged(t *testing.T) {
repo := &fakeRepo{}
handler := newTestHandler(t, &fakeClient{}, repo)
successTask := task("sim_yd_v5")
emptyTask := task("sim_yd_v5")
emptyTask.CardID = 2
failedTask := task("sim_yd_v5")
failedTask.CardID = 3
err := handler.Apply(context.Background(), []zsetconsumer.Result{
{Task: successTask, Success: true, ImeiSynced: true, Imei: "865466070817587"},
{Task: emptyTask, Success: false, ImeiSynced: true, ErrorClass: dataInvalidClass},
{Task: failedTask, Success: false, Retryable: true, ErrorClass: networkClass, ImeiSynced: false},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if len(repo.results) != 2 {
t.Fatalf("应只写入 2 条(成功的 + 心跳的),实际 %d 条: %+v", len(repo.results), repo.results)
}
// 第 1 条:值变更
first := repo.results[0]
if first.CardID != 1 || !first.ValueChanged || first.Imei != "865466070817587" {
t.Fatalf("第一条应为值变更: %+v", first)
}
// 第 2 条:心跳,值必须为空(防止覆盖)
second := repo.results[1]
if second.CardID != 2 || second.ValueChanged {
t.Fatalf("第二条应为心跳: %+v", second)
}
if second.Imei != "" {
t.Fatalf("心跳结果的 Imei 必须为空,否则会覆盖已有值: %q", second.Imei)
}
// 第 3 条:失败完全不入库
for _, r := range repo.results {
if r.CardID == 3 {
t.Fatal("失败结果不应入库")
}
}
}
// TestApplyNeverWritesEmptyOverValue 是 §5.2 的直接回归测试:
// 即使上游大面积返回空值,写路径也不得产生任何带值的更新。
func TestApplyNeverWritesEmptyOverValue(t *testing.T) {
repo := &fakeRepo{}
handler := newTestHandler(t, &fakeClient{}, repo)
results := make([]zsetconsumer.Result, 0, 50)
for i := int64(1); i <= 50; i++ {
tk := task("sim_yd_v5")
tk.CardID = i
results = append(results, zsetconsumer.Result{
Task: tk, Success: false, ImeiSynced: true, ErrorClass: dataInvalidClass,
})
}
if err := handler.Apply(context.Background(), results); err != nil {
t.Fatalf("Apply: %v", err)
}
if len(repo.results) != 50 {
t.Fatalf("50 张卡都应记录心跳,实际 %d", len(repo.results))
}
for _, r := range repo.results {
if r.ValueChanged || r.Imei != "" {
t.Fatalf("空值结果不得携带值: %+v", r)
}
}
}
// TestApplyNoRecordWritesHeartbeatOnly 14000 只能写心跳,绝不能清掉已有 IMEI。
func TestApplyNoRecordWritesHeartbeatOnly(t *testing.T) {
repo := &fakeRepo{}
handler := newTestHandler(t, &fakeClient{}, repo)
noRecord := task("sim_yd_v5")
if err := handler.Apply(context.Background(), []zsetconsumer.Result{
{Task: noRecord, Success: false, ImeiSynced: true, Covered: true, ErrorClass: noRecordClass, Code: chanNoRecordCode},
}); err != nil {
t.Fatalf("Apply: %v", err)
}
if len(repo.results) != 1 {
t.Fatalf("无记录仍应写入 1 条心跳,实际 %d: %+v", len(repo.results), repo.results)
}
got := repo.results[0]
if !got.Synced || got.ValueChanged || got.Imei != "" {
t.Fatalf("无记录应只写心跳: %+v", got)
}
}
// TestShouldRetryNextRound 覆盖重试策略。
func TestShouldRetryNextRound(t *testing.T) {
cases := []struct {
name string
result zsetconsumer.Result
want bool
}{
{"成功不重试", zsetconsumer.Result{Success: true}, false},
{"可重试失败", zsetconsumer.Result{Success: false, Retryable: true}, true},
{"终态失败不重试", zsetconsumer.Result{Success: false, Retryable: false}, false},
{"无记录不重试", zsetconsumer.Result{Success: false, Covered: true, ErrorClass: noRecordClass}, false},
}
for _, tc := range cases {
if got := ShouldRetryNextRound(tc.result); got != tc.want {
t.Fatalf("%s: got %v, want %v", tc.name, got, tc.want)
}
}
}
// TestQueryNoDeviceIsCoveredNotFailed 覆盖 2026-10-22 生产 run
// imei-20261022000000 的 12,865 条终态失败:全部是「卡上没有绑定设备,
// 所以没有 IMEI」,不是真失败。
//
// 证据(生产日志实采):
// - 电信 5G CMP 11,240 条:result="-1",message=查询失败,查询不到指定卡的设备信息
// (通道 142/111/187,template sim_dx_v1)
// - Tibiot 1,612 条:HTTP 500 + body status=500,message=查询不到信息(1,494)
// 或 系统无记录(118)
//
// 三者都是终态:记录覆盖(写心跳)、不重试、不进失败池。
func TestQueryNoDeviceIsCoveredNotFailed(t *testing.T) {
cases := []struct {
name string
tempKey string
code int
message string
}{
{"电信无绑定设备", "sim_dx_v1", chanNoDeviceInfoCode, "查询失败,查询不到指定卡的设备信息"},
{"Tibiot 查询不到信息", "sim_tibiot_v1", chanTibiotNoInfoCode, "查询不到信息"},
{"Tibiot 系统无记录", "sim_tibiot_v1", chanTibiotNoInfoCode, "系统无记录,请检查输入参数是否正确"},
}
for _, tc := range cases {
it := item(tc.code, "", "")
it.Message = tc.message
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{it}}
handler := newTestHandler(t, client, &fakeRepo{})
result := handler.Query(context.Background(), task(tc.tempKey))
if result.Success {
t.Fatalf("%s: 无设备不得标记为 Success", tc.name)
}
if result.Retryable {
t.Fatalf("%s: 无设备是终态,不应重试", tc.name)
}
if !result.ImeiSynced {
t.Fatalf("%s: 无设备仍应记录覆盖(心跳)", tc.name)
}
if !result.Covered {
t.Fatalf("%s: 无设备不得计入失败池", tc.name)
}
if result.ErrorClass != noRecordClass {
t.Fatalf("%s: errorClass = %q, want %q", tc.name, result.ErrorClass, noRecordClass)
}
}
}
// TestQueryTibiotNonNoInfo500StaysBusiness 锁住 500 的边界:Tibiot 也用 500
// 表达真实故障,只有「查询不到信息/系统无记录」的文案才是终态无记录。
// 不加这道闸门会把真故障当成功覆盖掉,那是比多算失败更严重的错误。
func TestQueryTibiotNonNoInfo500StaysBusiness(t *testing.T) {
for _, message := range []string{"系统繁忙,请稍后重试", "服务异常", "internal error", ""} {
it := item(chanTibiotNoInfoCode, "", "")
it.Message = message
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{it}}
handler := newTestHandler(t, client, &fakeRepo{})
result := handler.Query(context.Background(), task("sim_tibiot_v1"))
if result.Covered {
t.Fatalf("message=%q: 非无记录文案的 500 不得计入覆盖", message)
}
if result.ImeiSynced {
t.Fatalf("message=%q: 非无记录文案的 500 不应记录覆盖", message)
}
if result.ErrorClass != businessClass {
t.Fatalf("message=%q: errorClass = %q, want %q", message, result.ErrorClass, businessClass)
}
}
}
// TestQueryNoDeviceCodeIgnoresMessage 14000 与 -1 语义明确,不得依赖运营商文案
// (文案随时可能改写),即使 message 为空也要判为无记录。
func TestQueryNoDeviceCodeIgnoresMessage(t *testing.T) {
for _, code := range []int{chanNoRecordCode, chanNoDeviceInfoCode} {
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(code, "", "")}}
handler := newTestHandler(t, client, &fakeRepo{})
result := handler.Query(context.Background(), task("sim_dx_v1"))
if !result.Covered || result.ErrorClass != noRecordClass {
t.Fatalf("code=%d 空 message 仍应判 no_record: %+v", code, result)
}
}
}