299 lines
12 KiB
Go
299 lines
12 KiB
Go
package monthlyusagehistory
|
|
|
|
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.SimDataUsageMonthlyOutputItem]
|
|
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) QuerySimDatausageMonthly(_ context.Context, input *model.SimDataUsageMonthlyInput) ([]model.SimBaseRes[model.SimDataUsageMonthlyOutputItem], error) {
|
|
c.gotQueryDate = input.ReqData.QueryDate
|
|
return c.output, c.err
|
|
}
|
|
|
|
type fakeRepo struct {
|
|
items []model.TrafficUsageMonthlyItem
|
|
err error
|
|
}
|
|
|
|
func (r *fakeRepo) UpdateMonthly(_ context.Context, items []model.TrafficUsageMonthlyItem) error {
|
|
r.items = append(r.items, items...)
|
|
return r.err
|
|
}
|
|
|
|
func monthlyTask() queue.Task {
|
|
return queue.Task{
|
|
TaskID: "run:op:42",
|
|
RunID: "run",
|
|
Operation: model.OperationPreviousDayUsageFullSync,
|
|
QueryMonth: "202609",
|
|
ChanKey: "217",
|
|
TempKey: "sim_yd_v5",
|
|
CardID: 42,
|
|
ICCID: "89860042",
|
|
PackageCode: "PKG-A",
|
|
}
|
|
}
|
|
|
|
func TestQueryForwardsTaskMonthUpstream(t *testing.T) {
|
|
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
|
|
Code: 0,
|
|
SimKey: &model.SimKey{ICCID: "89860042"},
|
|
ResData: model.SimDataUsageMonthlyOutputItem{
|
|
DeadLine: "202609",
|
|
DataAmount: 12.5,
|
|
},
|
|
}}}
|
|
handler, err := NewHandler(client, &fakeRepo{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result := handler.Query(context.Background(), monthlyTask())
|
|
if !result.Success {
|
|
t.Fatalf("expected success, got class=%s message=%s", result.ErrorClass, result.Message)
|
|
}
|
|
if client.gotQueryDate != "202609" {
|
|
t.Fatalf("upstream queryDate = %q, want the task period 202609", 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{"202609", "2026-09", "20260901", "2026-09-01"} {
|
|
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
|
|
Code: 0,
|
|
SimKey: &model.SimKey{ICCID: "89860042"},
|
|
ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: period, DataAmount: 1},
|
|
}}}
|
|
handler, _ := NewHandler(client, &fakeRepo{})
|
|
if result := handler.Query(context.Background(), monthlyTask()); !result.Success {
|
|
t.Fatalf("period %q was rejected: class=%s message=%s", period, result.ErrorClass, result.Message)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryRejectsMismatchedMonth(t *testing.T) {
|
|
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
|
|
Code: 0,
|
|
SimKey: &model.SimKey{ICCID: "89860042"},
|
|
ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: "202608", DataAmount: 1},
|
|
}}}
|
|
handler, _ := NewHandler(client, &fakeRepo{})
|
|
result := handler.Query(context.Background(), monthlyTask())
|
|
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 TestQueryRejectsMissingMonthEcho(t *testing.T) {
|
|
// A channel that omits DeadLine from a successful response must fail too:
|
|
// the task period would be unverifiable, so writing the row risks
|
|
// wrong-month data. This mirrors the admin reconcile requirement.
|
|
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
|
|
Code: 0,
|
|
SimKey: &model.SimKey{ICCID: "89860042"},
|
|
ResData: model.SimDataUsageMonthlyOutputItem{DataAmount: 1},
|
|
}}}
|
|
handler, _ := NewHandler(client, &fakeRepo{})
|
|
result := handler.Query(context.Background(), monthlyTask())
|
|
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.SimDataUsageMonthlyOutputItem]{{
|
|
Code: 0,
|
|
SimKey: &model.SimKey{ICCID: "89869999"},
|
|
ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: "202609"},
|
|
}}}
|
|
handler, _ := NewHandler(client, &fakeRepo{})
|
|
result := handler.Query(context.Background(), monthlyTask())
|
|
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(), monthlyTask())
|
|
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(), monthlyTask())
|
|
if result.ErrorClass != "network" || !result.Retryable {
|
|
t.Fatalf("class=%s retryable=%v, want network/retryable", result.ErrorClass, result.Retryable)
|
|
}
|
|
}
|
|
|
|
func TestQueryRejectsTaskWithoutMonth(t *testing.T) {
|
|
handler, _ := NewHandler(&fakeClient{}, &fakeRepo{})
|
|
task := monthlyTask()
|
|
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 TestApplyWritesPackageCodeAndEndOfMonthTimestamp(t *testing.T) {
|
|
repo := &fakeRepo{}
|
|
handler, _ := NewHandler(&fakeClient{}, repo)
|
|
err := handler.Apply(context.Background(), []zsetconsumer.Result{
|
|
{Task: monthlyTask(), Success: true, UsageMB: 7.25},
|
|
{Task: monthlyTask(), 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 month; repository.snapshotMonth turns it into the
|
|
// end-of-month 23:59:59.000 row (covered by repository_test.go). Assert the
|
|
// month here so a period mix-up cannot slip through.
|
|
if got := item.Month.Format("2006-01"); got != "2026-09" {
|
|
t.Fatalf("month = %s, want the run period 2026-09", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyRejectsInvalidMonth(t *testing.T) {
|
|
handler, _ := NewHandler(&fakeClient{}, &fakeRepo{})
|
|
task := monthlyTask()
|
|
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 TestClassifyMonthlyHistoryFailure(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 := classifyMonthlyHistoryFailure(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, _ := classifyMonthlyHistoryFailure("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, _ := classifyMonthlyHistoryFailure("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 monthly-history 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.SimDataUsageMonthlyOutputItem]{{
|
|
Code: 500,
|
|
ErrCode: "S00004",
|
|
Message: "channel disabled",
|
|
SimKey: &model.SimKey{ICCID: "89860042"},
|
|
}}}
|
|
handler, _ := NewHandler(client, &fakeRepo{})
|
|
task := monthlyTask()
|
|
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)
|
|
}
|
|
}
|