package monthlyusage import ( "context" "errors" "fmt" "strings" "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" monthlyusagerepository "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/monthlyusage" ) type usageClientStub struct { output []model.SimBaseRes[model.SimDataUsageItem] err error } func (s *usageClientStub) QuerySimDataUsage(context.Context, *model.SimDataUsageInput) ([]model.SimBaseRes[model.SimDataUsageItem], error) { return s.output, s.err } type usageRepositoryStub struct { calls int updates []monthlyusagerepository.UsageResult err error } func (s *usageRepositoryStub) ApplyBatch(_ context.Context, updates []monthlyusagerepository.UsageResult) error { s.calls++ s.updates = append(s.updates, updates...) return s.err } func TestQueryClassifiesCurrentUsageResults(t *testing.T) { task := queue.Task{TaskID: "1", ChanKey: "telecom-a", TempKey: telecomUsageTemplate, CardID: 1, ICCID: "89860001"} tests := []struct { name string client *usageClientStub success bool errorClass string retryable bool blockChannel bool }{ { name: "success", client: &usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{ SimKey: &model.SimKey{ICCID: task.ICCID}, ResData: model.SimDataUsageItem{DataUsage: 12.5}, }}}, success: true, }, { name: "mobile busy is terminal business failure", client: &usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{SimKey: &model.SimKey{ICCID: task.ICCID}, Code: 13000, Message: "busy"}}}, errorClass: "business", }, { name: "gateway internal failure is retryable", client: &usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{SimKey: &model.SimKey{ICCID: task.ICCID}, Code: 9999, Message: "upstream timeout"}}}, errorClass: "network", retryable: true, }, { name: "gateway business error code remains terminal", client: &usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{SimKey: &model.SimKey{ICCID: task.ICCID}, Code: 9999, ErrCode: "9999", Message: "invalid request"}}}, errorClass: "business", }, { name: "telecom threshold blocks channel via errCode", client: &usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{SimKey: &model.SimKey{ICCID: task.ICCID}, Code: 9999, Message: "流量查询失败", ErrCode: "S00004"}}}, errorClass: "channel_limited", blockChannel: true, }, { name: "message text alone does not block channel", client: &usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{SimKey: &model.SimKey{ICCID: task.ICCID}, Code: 13000, Message: "carrier S00004 mentioned in text"}}}, errorClass: "business", }, { name: "network failure is terminal for run", client: &usageClientStub{err: errors.New("connection reset")}, errorClass: "network", retryable: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { handler, err := NewHandler(test.client, &usageRepositoryStub{}) if err != nil { t.Fatal(err) } result := handler.Query(context.Background(), task) if result.Success != test.success || result.ErrorClass != test.errorClass || result.Retryable != test.retryable || result.BlockChannel != test.blockChannel { t.Fatalf("result = %+v", result) } }) } } func TestMobileTemplatesClassifyBusyForRoundRetry(t *testing.T) { for template := range mobileUsageTemplates { for code := mobileBusyFirstCode; code <= mobileBusyLastCode; code++ { t.Run(fmt.Sprintf("%s/code-%d", template, code), func(t *testing.T) { handler, err := NewHandler(&usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{ SimKey: &model.SimKey{ICCID: "89860001"}, Code: code, Message: "system busy", }}}, &usageRepositoryStub{}) if err != nil { t.Fatal(err) } result := handler.Query(context.Background(), queue.Task{TaskID: "busy", TempKey: template, ICCID: "89860001"}) if result.ErrorClass != mobileBusyClass || !result.Retryable || result.BlockChannel { t.Fatalf("template %q code %d result = %+v", template, code, result) } }) } } } func TestLanshangSharedTokenTemplatesRetryAcrossRounds(t *testing.T) { for _, template := range []string{"lanshang_8000", "lanshang_8003"} { for _, code := range []int{13000, 13001} { t.Run(fmt.Sprintf("%s/code-%d", template, code), func(t *testing.T) { errorClass, retryable, blockChannel := classifyUsageFailure(template, code, "") if errorClass != mobileBusyClass || !retryable || blockChannel { t.Fatalf("template %q code %d = (%q, %t, %t)", template, code, errorClass, retryable, blockChannel) } }) } } } func TestMobileBusyRangeBoundariesAndPermanentCodes(t *testing.T) { template := "sim_onelink_kedang_v1" tests := []struct { name string code int errCode string mobileBusy bool retryable bool }{ {name: "13007 is last busy code", code: 13007, mobileBusy: true, retryable: true}, {name: "13008 is outside busy range", code: 13008}, {name: "12021 token expiry is retryable but not busy", code: 12021, retryable: true}, {name: "11011 permanent failure is not busy", code: 11011}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { errorClass, retryable, _ := classifyUsageFailure(template, test.code, test.errCode) if test.mobileBusy { if errorClass != mobileBusyClass || !retryable { t.Fatalf("code %d = (%q, %t)", test.code, errorClass, retryable) } return } if errorClass == mobileBusyClass { t.Fatalf("code %d = (%q, %t)", test.code, errorClass, retryable) } if retryable != test.retryable { t.Fatalf("code %d retryable = %t, want %t (errorClass=%q)", test.code, retryable, test.retryable, errorClass) } }) } } func TestTelecomTemplateIsNeverMobileBusy(t *testing.T) { for code := mobileBusyFirstCode; code <= mobileBusyLastCode; code++ { errorClass, retryable, blockChannel := classifyUsageFailure(telecomUsageTemplate, code, "") if errorClass == mobileBusyClass || retryable || blockChannel { t.Fatalf("telecom code %d = (%q, %t, %t)", code, errorClass, retryable, blockChannel) } } } func TestUnknownTemplateDefaultsToGenericNotMobile(t *testing.T) { if carrier := usageCarrierForTemplate("sim_dx_v2_future"); carrier != "generic" { t.Fatalf("unknown template carrier = %q", carrier) } errorClass, retryable, _ := classifyUsageFailure("sim_dx_v2_future", 13000, "") if errorClass == mobileBusyClass || retryable { t.Fatalf("unknown template 13000 = (%q, %t)", errorClass, retryable) } } func TestTelecomV3ChannelLimitClassification(t *testing.T) { errorClass, retryable, blockChannel := classifyUsageFailure(" SIM_DX_V3 ", 9999, "S00004") if errorClass != "channel_limited" || retryable || !blockChannel { t.Fatalf("v3 channel limit = (%q, %t, %t)", errorClass, retryable, blockChannel) } } func TestNonTelecomS00004DoesNotBlockChannel(t *testing.T) { handler, err := NewHandler(&usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{ SimKey: &model.SimKey{ICCID: "89860001"}, Code: 9999, ErrCode: errCodeTelecomChannelLimited, }}}, &usageRepositoryStub{}) if err != nil { t.Fatal(err) } result := handler.Query(context.Background(), queue.Task{TaskID: "mobile", TempKey: "sim_yd_v5", ICCID: "89860001"}) if result.ErrorClass != "business" || result.BlockChannel { t.Fatalf("result = %+v", result) } } func TestApplyWritesSuccessfulResultsInOneBatch(t *testing.T) { repository := &usageRepositoryStub{} handler, err := NewHandler(&usageClientStub{}, repository) if err != nil { t.Fatal(err) } results := []zsetconsumer.Result{ {Task: queue.Task{CardID: 1}, Success: true, UsageMB: 1.5}, {Task: queue.Task{CardID: 2}, ErrorClass: "business", Code: 13000}, {Task: queue.Task{CardID: 3}, Success: true, UsageMB: 3.5}, } if err = handler.Apply(context.Background(), results); err != nil { t.Fatal(err) } if repository.calls != 1 || len(repository.updates) != 2 { t.Fatalf("repository calls = %d, updates = %+v", repository.calls, repository.updates) } if repository.updates[0].CardID != 1 || repository.updates[1].CardID != 3 { t.Fatalf("updates = %+v", repository.updates) } } func TestQueryPreservesNetworkFailureMessage(t *testing.T) { handler, err := NewHandler(&usageClientStub{err: errors.New("connection refused")}, &usageRepositoryStub{}) if err != nil { t.Fatal(err) } result := handler.Query(context.Background(), queue.Task{ICCID: "89860001"}) if result.ErrorClass != "network" || !strings.Contains(result.Message, "connection refused") { t.Fatalf("result = %+v", result) } } func TestQuerySkipsIccidCheckWhenTaskHasNoIccid(t *testing.T) { handler, err := NewHandler(&usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{ SimKey: &model.SimKey{ICCID: ""}, ResData: model.SimDataUsageItem{DataUsage: 7.5}, }}}, &usageRepositoryStub{}) if err != nil { t.Fatal(err) } result := handler.Query(context.Background(), queue.Task{TaskID: "1", ChanKey: "telecom-a", CardID: 1, MSISDN: "14900000001"}) if !result.Success || result.UsageMB != 7.5 { t.Fatalf("result = %+v", result) } }