package data import ( "context" "errors" "fmt" "strings" "sync" "sync/atomic" "testing" "time" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" ) func TestExecuteDataStartSuccess(t *testing.T) { operation, err := OperationByBatchType(consts.BatchTypeDataStart) if err != nil { t.Fatal(err) } task := &Task{ Batch: Batch{BatchNo: "D-1"}, Operation: operation, ApnName: consts.SimApnNameDefault, Details: []*Detail{ {ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusDisabled}}, }, } repo := &fakeRepository{task: task} upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardApnChangeOutputItem]{ {Code: 0, Message: "ok", SimKey: &model.SimKey{ICCID: "iccid-1"}, ResData: model.SimCardApnChangeOutputItem{OrderNum: "order-1"}}, }} if err = NewExecutor(repo, upstream).Execute(context.Background(), "D-1"); err != nil { t.Fatal(err) } if upstream.calls != 1 { t.Fatalf("upstream calls = %d, want 1", upstream.calls) } if upstream.input.ReqData.OperType != consts.SimApnOperTypeOpen || upstream.input.ReqData.ApnName != consts.SimApnNameDefault { t.Fatalf("reqData = %+v", upstream.input.ReqData) } if len(repo.saved) != 1 || !repo.saved[0].Success || repo.saved[0].OrderNum != "order-1" { t.Fatalf("saved = %+v", repo.saved) } if task.Details[0].StatusBefore != consts.SimDataServiceStatusDisabled { t.Fatalf("StatusBefore = %d", task.Details[0].StatusBefore) } if !repo.finalized { t.Fatal("batch was not finalized") } } func TestExecuteDataStopSuccess(t *testing.T) { operation, err := OperationByBatchType(consts.BatchTypeDataStop) if err != nil { t.Fatal(err) } task := &Task{ Batch: Batch{BatchNo: "D-2"}, Operation: operation, ApnName: consts.SimApnNameDefault, Details: []*Detail{ {ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}}, }, } repo := &fakeRepository{task: task} upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardApnChangeOutputItem]{ {Code: 0, Message: "ok", SimKey: &model.SimKey{ICCID: "iccid-1"}}, }} if err = NewExecutor(repo, upstream).Execute(context.Background(), "D-2"); err != nil { t.Fatal(err) } if upstream.calls != 1 || upstream.input.ReqData.OperType != consts.SimApnOperTypeStop { t.Fatalf("upstream calls = %d, operType = %s", upstream.calls, upstream.input.ReqData.OperType) } if len(repo.saved) != 1 || !repo.saved[0].Success { t.Fatalf("saved = %+v", repo.saved) } } func TestExecuteTrafficCutoffSuccess(t *testing.T) { operation, err := OperationByBatchType(consts.BatchTypeDataCut) if err != nil { t.Fatal(err) } task := &Task{ Batch: Batch{BatchNo: "DCT-1"}, Operation: operation, ApnName: consts.SimApnNameDefault, Details: []*Detail{ {ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}}, }, } repo := &fakeRepository{task: task} upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardApnChangeOutputItem]{ {Code: 0, Message: "ok", SimKey: &model.SimKey{ICCID: "iccid-1"}}, }} if err = NewExecutor(repo, upstream).Execute(context.Background(), "DCT-1"); err != nil { t.Fatal(err) } if upstream.calls != 1 || upstream.input.ReqData.OperType != consts.SimApnOperTypeStop { t.Fatalf("upstream calls = %d, operType = %s", upstream.calls, upstream.input.ReqData.OperType) } if len(repo.saved) != 1 || !repo.saved[0].Success { t.Fatalf("saved = %+v", repo.saved) } } func TestExecuteTrafficCutoffRefreshesCurrentStatusBeforeUpstream(t *testing.T) { operation, err := OperationByBatchType(consts.BatchTypeDataCut) if err != nil { t.Fatal(err) } task := &Task{ Batch: Batch{BatchNo: "DCT-3"}, Operation: operation, ApnName: consts.SimApnNameDefault, Details: []*Detail{ {ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}}, }, } repo := &fakeRepository{ task: task, refresh: func(task *Task) { if len(task.Details) == 0 || task.Details[0] == nil || task.Details[0].Card == nil { return } task.Details[0].Card.DataServiceStatus = consts.SimDataServiceStatusDisabled task.Details[0].StatusBefore = consts.SimDataServiceStatusDisabled task.Details[0].StatusAfter = consts.SimDataServiceStatusDisabled }, } upstream := &fakeUpstream{responseError: errors.New("must not be called")} if err = NewExecutor(repo, upstream).Execute(context.Background(), "DCT-3"); err != nil { t.Fatal(err) } if upstream.calls != 0 { t.Fatalf("upstream calls = %d, want 0", upstream.calls) } if len(repo.saved) != 1 || !repo.saved[0].Success { t.Fatalf("saved = %+v", repo.saved) } } func TestExecuteTrafficCutoffSkipsAlreadyDisabledCard(t *testing.T) { operation, err := OperationByBatchType(consts.BatchTypeDataCut) if err != nil { t.Fatal(err) } task := &Task{ Batch: Batch{BatchNo: "DCT-2"}, Operation: operation, ApnName: consts.SimApnNameDefault, Details: []*Detail{ {ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusDisabled}}, }, } repo := &fakeRepository{task: task} upstream := &fakeUpstream{responseError: errors.New("must not be called")} if err = NewExecutor(repo, upstream).Execute(context.Background(), "DCT-2"); err != nil { t.Fatal(err) } if upstream.calls != 0 { t.Fatalf("upstream calls = %d, want 0", upstream.calls) } if len(repo.saved) != 1 || !repo.saved[0].Success { t.Fatalf("saved = %+v", repo.saved) } if task.Details[0].Reason != "" { t.Fatalf("already disabled cutoff should be settled as success, reason = %q", task.Details[0].Reason) } if !repo.finalized { t.Fatal("batch was not finalized") } } func TestExecuteTrafficCutoffValidationFailureAllowsValidRows(t *testing.T) { operation, err := OperationByBatchType(consts.BatchTypeDataCut) if err != nil { t.Fatal(err) } task := &Task{ Batch: Batch{BatchNo: "DCT-4"}, Operation: operation, ApnName: consts.SimApnNameDefault, Details: []*Detail{ {ID: 1, ICCID: "iccid-1", Authorized: false, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}}, {ID: 2, ICCID: "iccid-2", Authorized: true, Card: &Card{ID: 12, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}}, }, } repo := &fakeRepository{task: task} upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardApnChangeOutputItem]{ {Code: 0, Message: "ok", SimKey: &model.SimKey{ICCID: "iccid-2"}}, }} if err = NewExecutor(repo, upstream).Execute(context.Background(), "DCT-4"); err != nil { t.Fatal(err) } if upstream.calls != 1 { t.Fatalf("upstream calls = %d, want 1", upstream.calls) } if len(upstream.input.SimKeys) != 1 || upstream.input.SimKeys[0].ICCID != "iccid-2" { t.Fatalf("upstream input = %+v", upstream.input.SimKeys) } if len(repo.saved) != 2 { t.Fatalf("saved count = %d, want 2; saved = %+v", len(repo.saved), repo.saved) } if repo.saved[0].Success || repo.saved[0].Message != "ICCID is outside the current operator authorization scope" { t.Fatalf("invalid row result = %+v", repo.saved[0]) } if !repo.saved[1].Success { t.Fatalf("valid row result = %+v", repo.saved[1]) } if task.Details[0].ValidationPassed || !task.Details[1].ValidationPassed { t.Fatalf("validation flags = (%v, %v)", task.Details[0].ValidationPassed, task.Details[1].ValidationPassed) } if !repo.finalized { t.Fatal("batch was not finalized") } } func TestExecuteSameLocalStatusStillCallsUpstream(t *testing.T) { operation, err := OperationByBatchType(consts.BatchTypeDataStart) if err != nil { t.Fatal(err) } task := &Task{ Batch: Batch{BatchNo: "D-3"}, Operation: operation, ApnName: consts.SimApnNameDefault, Details: []*Detail{ {ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}}, }, } repo := &fakeRepository{task: task} upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardApnChangeOutputItem]{ {Code: 0, Message: "ok", SimKey: &model.SimKey{ICCID: "iccid-1"}}, }} if err = NewExecutor(repo, upstream).Execute(context.Background(), "D-3"); err != nil { t.Fatal(err) } if upstream.calls != 1 { t.Fatalf("upstream calls = %d, want 1 even when local status already matches", upstream.calls) } if task.Details[0].Reason != "" { t.Fatalf("same local status must not be a validation blocker, reason = %q", task.Details[0].Reason) } if len(repo.saved) != 1 || !repo.saved[0].Success { t.Fatalf("saved = %+v", repo.saved) } } func TestExecuteValidationFailureSkipsUpstream(t *testing.T) { operation, err := OperationByBatchType(consts.BatchTypeDataStop) if err != nil { t.Fatal(err) } task := &Task{ Batch: Batch{BatchNo: "D-4"}, Operation: operation, ApnName: consts.SimApnNameDefault, Details: []*Detail{ {ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}}, {ID: 2, ICCID: "iccid-2", Authorized: false, Card: &Card{ID: 12, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}}, }, } repo := &fakeRepository{task: task} upstream := &fakeUpstream{responseError: errors.New("must not be called")} if err = NewExecutor(repo, upstream).Execute(context.Background(), "D-4"); err != nil { t.Fatal(err) } if upstream.calls != 0 { t.Fatalf("upstream calls = %d, want 0", upstream.calls) } if !repo.validationSaved || !repo.finalized { t.Fatal("validation failure was not persisted and finalized") } if task.Details[0].Reason == "" || task.Details[1].Reason == "" { t.Fatal("every detail must receive a validation failure reason") } } func TestExecuteUpstreamPartialSuccess(t *testing.T) { operation, err := OperationByBatchType(consts.BatchTypeDataStart) if err != nil { t.Fatal(err) } task := &Task{ Batch: Batch{BatchNo: "D-5"}, Operation: operation, ApnName: consts.SimApnNameDefault, Details: []*Detail{ {ID: 1, ICCID: "iccid-1", MSISDN: "msisdn-1", IMSI: "imsi-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", MSISDN: "msisdn-1", IMSI: "imsi-1", DataServiceStatus: consts.SimDataServiceStatusDisabled}}, {ID: 2, ICCID: "iccid-2", MSISDN: "msisdn-2", IMSI: "imsi-2", Authorized: true, Card: &Card{ID: 12, ChanKey: "channel", MSISDN: "msisdn-2", IMSI: "imsi-2", DataServiceStatus: consts.SimDataServiceStatusDisabled}}, }, } repo := &fakeRepository{task: task} upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardApnChangeOutputItem]{ {Code: 0, Message: "ok", SimKey: nil, ResData: model.SimCardApnChangeOutputItem{OrderNum: "order-1"}}, {Code: 9001, Message: "denied", SimKey: &model.SimKey{ICCID: "iccid-2"}, ResData: model.SimCardApnChangeOutputItem{OrderNum: "order-2"}}, }} if err = NewExecutor(repo, upstream).Execute(context.Background(), "D-5"); err != nil { t.Fatal(err) } if len(repo.saved) != 2 || !repo.saved[0].Success || repo.saved[0].OrderNum != "order-1" { t.Fatalf("first result = %+v", repo.saved) } if repo.saved[1].Success || repo.saved[1].Code != 9001 || repo.saved[1].Message != "denied" || repo.saved[1].OrderNum != "order-2" { t.Fatalf("second result = %+v", repo.saved[1]) } if !repo.finalized { t.Fatal("batch was not finalized") } } // 大批量场景下,每个子批次一拿到结果就立即落库,不必等所有子批次跑完。 func TestExecuteSavesEachSubBatchAsSoonAsItSettles(t *testing.T) { operation, err := OperationByBatchType(consts.BatchTypeDataStart) if err != nil { t.Fatal(err) } // 201 张 = 3 个子批(100 / 100 / 1);并发度为 5,三个子批会同时发起。 details := make([]*Detail, 0, 201) for i := 0; i < 201; i++ { details = append(details, &Detail{ ID: int64(i + 1), ICCID: fmt.Sprintf("iccid-%03d", i), Authorized: true, Card: &Card{ ID: int64(1000 + i), ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusDisabled, }, }) } task := &Task{Batch: Batch{BatchNo: "D-6"}, Operation: operation, ApnName: consts.SimApnNameDefault, Details: details} repo := &fakeRepository{task: task} release := make(chan struct{}) upstream := &subBatchUpstream{blockICCID: "iccid-100", block: release} done := make(chan error, 1) go func() { done <- NewExecutor(repo, upstream).Execute(context.Background(), "D-6") }() // 第 1、3 个子批共 101 条应先落库,此时第 2 个子批仍阻塞在上游。 waitForSavedCount(t, repo, 101) if repo.isFinalized() { t.Fatal("batch must not be finalized while one sub batch is still running") } close(release) if err = <-done; err != nil { t.Fatal(err) } if got := repo.savedCount(); got != 201 { t.Fatalf("saved count = %d, want 201", got) } if !repo.isFinalized() { t.Fatal("batch was not finalized") } if calls := atomic.LoadInt32(&upstream.calls); calls != 3 { t.Fatalf("upstream calls = %d, want 3", calls) } } func waitForSavedCount(t *testing.T, repo *fakeRepository, want int) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if repo.savedCount() >= want { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("saved count = %d, want at least %d", repo.savedCount(), want) } func TestOperationByBatchTypeMapping(t *testing.T) { start, err := OperationByBatchType(consts.BatchTypeDataStart) if err != nil || start.Name != "dataStart" || start.ApnOperType != consts.SimApnOperTypeOpen || start.TargetStatus != consts.SimDataServiceStatusEnabled { t.Fatalf("start operation = %+v, err = %v", start, err) } stop, err := OperationByBatchType(consts.BatchTypeDataStop) if err != nil || stop.Name != "dataStop" || stop.ApnOperType != consts.SimApnOperTypeStop || stop.TargetStatus != consts.SimDataServiceStatusDisabled { t.Fatalf("stop operation = %+v, err = %v", stop, err) } cut, err := OperationByBatchType(consts.BatchTypeDataCut) if err != nil || cut.Name != "trafficCutoff" || cut.ApnOperType != consts.SimApnOperTypeStop || cut.TargetStatus != consts.SimDataServiceStatusDisabled { t.Fatalf("cut operation = %+v, err = %v", cut, err) } if _, err = OperationByBatchType(consts.BatchTypeSimCardStart); err == nil { t.Fatal("lifecycle batch type must not map to a data service operation") } } func TestDetailSnapshotIncludesOperationContext(t *testing.T) { operation, err := OperationByBatchType(consts.BatchTypeDataStart) if err != nil { t.Fatal(err) } detail := &Detail{ ID: 1, ICCID: "iccid-1", ChanKey: "channel", StatusBefore: consts.SimDataServiceStatusDisabled, StatusAfter: consts.SimDataServiceStatusEnabled, OrderNum: "order-1", UpstreamCode: 0, UpstreamReason: "ok", } snapshot := detail.Snapshot(operation, consts.SimApnNameDefault) if snapshot.Get("operation").String() != "dataStart" || snapshot.Get("operType").String() != consts.SimApnOperTypeOpen || snapshot.Get("apnName").String() != consts.SimApnNameDefault || snapshot.Get("statusBefore").Int() != consts.SimDataServiceStatusDisabled || snapshot.Get("statusAfter").Int() != consts.SimDataServiceStatusEnabled || snapshot.Get("orderNum").String() != "order-1" { t.Fatalf("snapshot = %s", snapshot.MustToJsonString()) } } // fakeRepository 会被多个子批次 goroutine 并发写入,因此内部状态统一用锁保护。 type fakeRepository struct { task *Task refresh func(*Task) mu sync.Mutex validationSaved bool finalized bool saved []Outcome } func (r *fakeRepository) Load(context.Context, string) (*Task, error) { return r.task, nil } func (r *fakeRepository) RefreshDataServiceState(_ context.Context, task *Task) error { if r.refresh != nil { r.refresh(task) } return nil } func (r *fakeRepository) SaveValidationFailure(context.Context, *Task) error { r.mu.Lock() defer r.mu.Unlock() r.validationSaved = true return nil } func (r *fakeRepository) SaveResult(_ context.Context, _ *Task, detail *Detail, outcome Outcome) (Outcome, error) { detail.ApplyOutcome(outcome) r.mu.Lock() defer r.mu.Unlock() r.saved = append(r.saved, outcome) return outcome, nil } func (r *fakeRepository) Finalize(context.Context, *Task) error { r.mu.Lock() defer r.mu.Unlock() r.finalized = true return nil } func (r *fakeRepository) savedCount() int { r.mu.Lock() defer r.mu.Unlock() return len(r.saved) } func (r *fakeRepository) isFinalized() bool { r.mu.Lock() defer r.mu.Unlock() return r.finalized } // subBatchUpstream 按子批次返回成功结果,并可在指定子批(以其首张 ICCID 标识)上阻塞, // 用于验证"某个子批还没结束时,其他子批的结果已经落库"。 type subBatchUpstream struct { calls int32 blockICCID string block chan struct{} } func (u *subBatchUpstream) ChangeSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { atomic.AddInt32(&u.calls, 1) if u.block != nil && len(input.SimKeys) > 0 && strings.TrimSpace(input.SimKeys[0].ICCID) == u.blockICCID { select { case <-u.block: case <-ctx.Done(): return nil, ctx.Err() } } response := make([]model.SimBaseRes[model.SimCardApnChangeOutputItem], 0, len(input.SimKeys)) for _, key := range input.SimKeys { response = append(response, model.SimBaseRes[model.SimCardApnChangeOutputItem]{ Code: 0, Message: "ok", SimKey: &model.SimKey{ICCID: key.ICCID}, }) } return response, nil } type fakeUpstream struct { calls int input *model.SimCardApnChangeInput response []model.SimBaseRes[model.SimCardApnChangeOutputItem] responseError error } func (u *fakeUpstream) ChangeSimApnFunction(_ context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { u.calls++ u.input = input return u.response, u.responseError }