package dailyusage 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" ) type fakeClient struct { output []model.SimBaseRes[model.SimDataUsageDailyOutputItem] err error // gotQueryDate records the period the handler sent upstream so a test can // prove the task period is forwarded verbatim. gotQueryDate string } func (c *fakeClient) QuerySimDatausageDaily(_ context.Context, input *model.SimDataUsageDailyInput) ([]model.SimBaseRes[model.SimDataUsageDailyOutputItem], error) { c.gotQueryDate = input.ReqData.QueryDate return c.output, c.err } type fakeRepo struct { items []model.TrafficUsageDailyItem err error } func (r *fakeRepo) UpdateDaily(_ context.Context, items []model.TrafficUsageDailyItem) error { r.items = append(r.items, items...) return r.err } func dailyTask() queue.Task { return queue.Task{ TaskID: "run:op:42", RunID: "run", Operation: model.OperationPreviousDayUsageFullSync, QueryMonth: "20261009", ChanKey: "217", TempKey: "sim_yd_v5", CardID: 42, ICCID: "89860042", PackageCode: "PKG-A", } } func TestQueryForwardsTaskPeriodUpstream(t *testing.T) { client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{{ Code: 0, SimKey: &model.SimKey{ICCID: "89860042"}, ResData: model.SimDataUsageDailyOutputItem{ QueryDate: "20261009", DataAmount: 12.5, }, }}} handler, err := NewHandler(client, &fakeRepo{}) if err != nil { t.Fatal(err) } result := handler.Query(context.Background(), dailyTask()) if !result.Success { t.Fatalf("expected success, got class=%s message=%s", result.ErrorClass, result.Message) } if client.gotQueryDate != "20261009" { t.Fatalf("upstream queryDate = %q, want the task period 20261009", client.gotQueryDate) } if result.UsageMB != 12.5 { t.Fatalf("usage = %v, want 12.5", result.UsageMB) } } func TestQueryAcceptsEquivalentPeriodFormats(t *testing.T) { for _, period := range []string{"20261009", "2026-10-09"} { client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{{ Code: 0, SimKey: &model.SimKey{ICCID: "89860042"}, ResData: model.SimDataUsageDailyOutputItem{QueryDate: period, DataAmount: 1}, }}} handler, _ := NewHandler(client, &fakeRepo{}) if result := handler.Query(context.Background(), dailyTask()); !result.Success { t.Fatalf("period %q was rejected: class=%s message=%s", period, result.ErrorClass, result.Message) } } } func TestQueryRejectsMismatchedPeriod(t *testing.T) { client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{{ Code: 0, SimKey: &model.SimKey{ICCID: "89860042"}, ResData: model.SimDataUsageDailyOutputItem{QueryDate: "20261008", DataAmount: 1}, }}} handler, _ := NewHandler(client, &fakeRepo{}) result := handler.Query(context.Background(), dailyTask()) if result.Success { t.Fatal("a mismatched period must not be accepted") } if result.ErrorClass != "protocol" || !result.Retryable { t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable) } } func TestQueryRejectsMissingPeriodEcho(t *testing.T) { // A channel that omits QueryDate from a successful response must fail too: // the task period would be unverifiable, so writing the row risks wrong-day // data. This mirrors the admin reconcile requirement. client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{{ Code: 0, SimKey: &model.SimKey{ICCID: "89860042"}, ResData: model.SimDataUsageDailyOutputItem{DataAmount: 1}, }}} handler, _ := NewHandler(client, &fakeRepo{}) result := handler.Query(context.Background(), dailyTask()) if result.Success { t.Fatal("a missing period echo must not be accepted") } if result.ErrorClass != "protocol" || !result.Retryable { t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable) } } func TestQueryRejectsMismatchedICCID(t *testing.T) { client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{{ Code: 0, SimKey: &model.SimKey{ICCID: "89869999"}, ResData: model.SimDataUsageDailyOutputItem{QueryDate: "20261009"}, }}} handler, _ := NewHandler(client, &fakeRepo{}) result := handler.Query(context.Background(), dailyTask()) if result.Success || result.ErrorClass != "protocol" || !result.Retryable { t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable) } } func TestQueryRejectsEmptyResult(t *testing.T) { handler, _ := NewHandler(&fakeClient{}, &fakeRepo{}) result := handler.Query(context.Background(), dailyTask()) if result.Success || result.ErrorClass != "protocol" || !result.Retryable { t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable) } } func TestQueryTreatsTransportErrorAsRetryableNetwork(t *testing.T) { handler, _ := NewHandler(&fakeClient{err: errors.New("connection refused")}, &fakeRepo{}) result := handler.Query(context.Background(), dailyTask()) if result.ErrorClass != "network" || !result.Retryable { t.Fatalf("class=%s retryable=%v, want network/retryable", result.ErrorClass, result.Retryable) } } func TestQueryRejectsTaskWithoutPeriod(t *testing.T) { handler, _ := NewHandler(&fakeClient{}, &fakeRepo{}) task := dailyTask() task.QueryMonth = "" result := handler.Query(context.Background(), task) if result.Success || result.Retryable { t.Fatalf("a task without a period must fail without retrying: %+v", result) } } func TestApplyWritesPackageCodeAndEndOfDayTimestamp(t *testing.T) { repo := &fakeRepo{} handler, _ := NewHandler(&fakeClient{}, repo) err := handler.Apply(context.Background(), []zsetconsumer.Result{ {Task: dailyTask(), Success: true, UsageMB: 7.25}, {Task: dailyTask(), Success: false}, }) if err != nil { t.Fatal(err) } if len(repo.items) != 1 { t.Fatalf("wrote %d items, want only the successful one", len(repo.items)) } item := repo.items[0] if item.Iccid != "89860042" || item.UsageMb != 7.25 || item.PackageCode != "PKG-A" { t.Fatalf("item = %+v, want iccid/usage/packageCode carried from the task", item) } // The handler supplies the day; repository.snapshotDay turns it into the // 23:59:59.000 row (covered by repository_test.go). Assert the day here so a // period mix-up cannot slip through. if got := item.Date.Format("2006-01-02"); got != "2026-10-09" { t.Fatalf("date = %s, want the run period 2026-10-09", got) } } func TestApplyRejectsInvalidPeriod(t *testing.T) { handler, _ := NewHandler(&fakeClient{}, &fakeRepo{}) task := dailyTask() task.QueryMonth = "2026-10-09" if err := handler.Apply(context.Background(), []zsetconsumer.Result{{Task: task, Success: true}}); err == nil { t.Fatal("expected an unparseable period to fail the write") } } func TestClassifyDailyUsageFailure(t *testing.T) { tests := []struct { name string tempKey string code int errCode string wantClass string wantRetry bool wantBlocked bool }{ {name: "telecom channel limited", tempKey: "sim_dx_v1", code: 500, errCode: "S00004", wantClass: "channel_limited", wantBlocked: true}, {name: "telecom channel limited lowercase", tempKey: "sim_dx_v1", code: 500, errCode: "s00004", wantClass: "channel_limited", wantBlocked: true}, // sim_dx_v3 is a telecom template too. It is resolved through // model.IsTelecomTemplate so a v3 channel keeps S00004 blocking instead // of falling through to generic and retrying every card to exhaustion. {name: "telecom v3 channel limited", tempKey: "sim_dx_v3", code: 500, errCode: "S00004", wantClass: "channel_limited", wantBlocked: true}, {name: "telecom v3 template is not treated as mobile", tempKey: "sim_dx_v3", code: 13000, wantClass: "business"}, {name: "mobile busy first code", tempKey: "sim_yd_v5", code: 13000, wantClass: "mobile_busy", wantRetry: true}, {name: "mobile busy last code", tempKey: "sim_yd_v5", code: 13007, wantClass: "mobile_busy", wantRetry: true}, {name: "mobile one past the busy range", tempKey: "sim_yd_v5", code: 13008, wantClass: "business"}, {name: "mobile one below the busy range", tempKey: "sim_yd_v5", code: 12999, wantClass: "business"}, {name: "busy code on a non-mobile template", tempKey: "sim_qianxun_v3_bogus", code: 13000, wantClass: "business"}, {name: "token expired", tempKey: "sim_yd_v5", code: 12021, wantClass: "token_expired", wantRetry: true}, {name: "gateway internal without errCode", tempKey: "sim_yd_v5", code: 9999, errCode: "", wantClass: "network", wantRetry: true}, {name: "gateway code with a carrier errCode stays business", tempKey: "sim_yd_v5", code: 9999, errCode: "E42", wantClass: "business"}, {name: "telecom S00004 without the telecom template is not channel limited", tempKey: "sim_yd_v5", code: 500, errCode: "S00004", wantClass: "business"}, {name: "unknown code is not retried", tempKey: "sim_yd_v5", code: 4242, wantClass: "business"}, {name: "unknown code on an unknown template is not retried", tempKey: "some_new_channel_v9", code: 13000, wantClass: "business"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { class, retry, blocked := classifyDailyUsageFailure(test.tempKey, test.code, test.errCode) if class != test.wantClass || retry != test.wantRetry || blocked != test.wantBlocked { t.Fatalf("classify(%q, %d, %q) = (%s, %v, %v), want (%s, %v, %v)", test.tempKey, test.code, test.errCode, class, retry, blocked, test.wantClass, test.wantRetry, test.wantBlocked) } }) } } // TestClassifyIgnoresMessageText pins the 2026-09-14 discipline: classification // must never depend on carrier text, because text matching caused the channel // retry loop that the rule was introduced to prevent. func TestClassifyIgnoresMessageText(t *testing.T) { // A "busy"-looking message on an unknown code must stay non-retryable. if _, retry, _ := classifyDailyUsageFailure("sim_yd_v5", 4242, ""); retry { t.Fatal("an unknown code must not become retryable because its message looked transient") } // And a busy code must stay retryable regardless of what the text says. if _, retry, _ := classifyDailyUsageFailure("sim_yd_v5", 13000, ""); !retry { t.Fatal("a mobile busy code must stay retryable") } } func TestShouldRetryNextRoundFollowsClassifier(t *testing.T) { if ShouldRetryNextRound(zsetconsumer.Result{Success: true}) { t.Fatal("a successful result must not be retried") } if !ShouldRetryNextRound(zsetconsumer.Result{Retryable: true}) { t.Fatal("a retryable failure must be deferred to the next round") } if ShouldRetryNextRound(zsetconsumer.Result{Retryable: false}) { t.Fatal("a terminal failure must not be retried") } } func TestUsageCarrierForTemplateMatchesSharedDefinition(t *testing.T) { // The daily classifier must agree with model.IsTelecomTemplate for every // telecom template the rest of the worker recognises. A drift here means a // channel silently loses its S00004 blocking behaviour. for _, tempKey := range []string{"sim_dx_v1", "sim_dx_v3", "SIM_DX_V1", " sim_dx_v3 "} { if got := usageCarrierForTemplate(tempKey); got != "telecom" { t.Errorf("usageCarrierForTemplate(%q) = %q, want telecom", tempKey, got) } } for _, tempKey := range []string{"sim_yd_v5", "sim_onelink_daihe_v1"} { if got := usageCarrierForTemplate(tempKey); got != "mobile" { t.Errorf("usageCarrierForTemplate(%q) = %q, want mobile", tempKey, got) } } for _, tempKey := range []string{"", "some_new_channel_v9", "sim_dx_v2"} { if got := usageCarrierForTemplate(tempKey); got != "generic" { t.Errorf("usageCarrierForTemplate(%q) = %q, want generic", tempKey, got) } } } func TestQueryBlockChannelPropagatesForTelecomS00004(t *testing.T) { client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{{ Code: 500, ErrCode: "S00004", Message: "channel disabled", SimKey: &model.SimKey{ICCID: "89860042"}, }}} handler, _ := NewHandler(client, &fakeRepo{}) task := dailyTask() task.TempKey = "sim_dx_v1" result := handler.Query(context.Background(), task) if result.ErrorClass != "channel_limited" || !result.BlockChannel || result.Retryable { t.Fatalf("telecom S00004 must block the channel without retrying: %+v", result) } }