package lifecycle import ( "context" "sync" "testing" "time" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" ) type fakeExecutorRepository struct { mu sync.Mutex claims []bool claimCalls int loadedBatchNos []string task *Task cards map[string]*entity.TSimCardInfo validationFailureCalls int outcomes []Outcome finalizeCalls int markFailedCalls int } func (r *fakeExecutorRepository) ClaimBatch(context.Context, string) (bool, error) { r.mu.Lock() defer r.mu.Unlock() index := r.claimCalls r.claimCalls++ if index >= len(r.claims) { return false, nil } return r.claims[index], nil } func (r *fakeExecutorRepository) LoadTask(_ context.Context, batchNo string) (*Task, error) { r.loadedBatchNos = append(r.loadedBatchNos, batchNo) return r.task, nil } func (r *fakeExecutorRepository) LoadCards(context.Context, *Task) (map[string]*entity.TSimCardInfo, error) { return r.cards, nil } func (r *fakeExecutorRepository) SaveValidationFailure(context.Context, *Task) error { r.validationFailureCalls++ return nil } func (r *fakeExecutorRepository) SaveOutcome(_ context.Context, _ *Task, _ *Row, _ *entity.TSimCardInfo, outcome Outcome) error { r.outcomes = append(r.outcomes, outcome) return nil } func (r *fakeExecutorRepository) SaveDetailFailure(context.Context, *Task, *Row) error { return nil } func (r *fakeExecutorRepository) FinalizeBatch(context.Context, *Task) error { r.finalizeCalls++ return nil } func (r *fakeExecutorRepository) MarkBatchFailed(context.Context, string, string) error { r.markFailedCalls++ return nil } type fakeStatusChanger struct { calls int requests []*model.SimCardStatusChangeInput response []model.SimBaseRes[model.SimCardStatusChangeItem] err error } func (c *fakeStatusChanger) ChangeSimStatus(_ context.Context, input *model.SimCardStatusChangeInput) ([]model.SimBaseRes[model.SimCardStatusChangeItem], error) { c.calls++ c.requests = append(c.requests, input) return c.response, c.err } func TestActionOperTypeMapping(t *testing.T) { stop, err := OperationSuspend.Config() if err != nil { t.Fatal(err) } start, err := OperationResume.Config() if err != nil { t.Fatal(err) } if stop.OperType != "0" || stop.BatchType != consts.BatchTypeStop { t.Fatalf("unexpected stop mapping: %+v", stop) } if start.OperType != "1" || start.BatchType != consts.BatchTypeStart { t.Fatalf("unexpected start mapping: %+v", start) } } func TestExecuteValidationFailureSkipsUpstream(t *testing.T) { task := newTestTask(t, OperationSuspend, []*Row{ {RowNo: 1, Iccid: "missing", Authorized: true}, {RowNo: 2, Iccid: "valid", Authorized: true}, }) repo := &fakeExecutorRepository{ claims: []bool{true}, task: task, cards: map[string]*entity.TSimCardInfo{ "valid": {Id: 2, Iccid: "valid", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated}, }, } upstream := &fakeStatusChanger{} if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-test"); err != nil { t.Fatal(err) } if upstream.calls != 0 { t.Fatalf("upstream called %d times for invalid batch", upstream.calls) } if repo.validationFailureCalls != 1 || repo.finalizeCalls != 1 { t.Fatalf("validation/finalize calls = %d/%d", repo.validationFailureCalls, repo.finalizeCalls) } if task.Rows[0].Reason == "" || task.Rows[1].Reason == "" { t.Fatalf("expected all rows to retain failure reasons: %+v", task.Rows) } } func TestValidateStopStatus(t *testing.T) { t.Skip("TODO: 恢复卡状态限制后启用此测试") tests := []struct { name string status int valid bool }{ {name: "activated", status: consts.V3ApiSimBossCardStatusActivated, valid: true}, {name: "pending", status: consts.V3ApiSimBossCardStatusPendingActivate}, {name: "testable", status: consts.V3ApiSimBossCardStatusTestable}, {name: "stock", status: consts.V3ApiSimBossCardStatusStock}, {name: "stopped", status: consts.V3ApiSimBossCardStatusStopped}, {name: "pre-cancel", status: consts.V3ApiSimBossCardStatusPreCancel}, {name: "canceled", status: consts.V3ApiSimBossCardStatusCanceled}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { row := &Row{Iccid: "card-1"} validateCardStatus(OperationSuspend, row, &entity.TSimCardInfo{BossCardStatus: tt.status}) if got := row.Reason == ""; got != tt.valid { t.Fatalf("status %d valid=%v, reason=%q", tt.status, got, row.Reason) } }) } } func TestExecuteMixedStopStatusSkipsUpstream(t *testing.T) { t.Skip("TODO: 恢复卡状态限制后启用此测试") task := newTestTask(t, OperationSuspend, []*Row{ {RowNo: 1, Iccid: "activated", Authorized: true}, {RowNo: 2, Iccid: "stock", Authorized: true}, }) repo := &fakeExecutorRepository{ claims: []bool{true}, task: task, cards: map[string]*entity.TSimCardInfo{ "activated": {Id: 1, Iccid: "activated", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated}, "stock": {Id: 2, Iccid: "stock", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusStock}, }, } upstream := &fakeStatusChanger{} if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-mixed"); err != nil { t.Fatal(err) } if upstream.calls != 0 { t.Fatalf("upstream called %d times for mixed status batch", upstream.calls) } if repo.validationFailureCalls != 1 || repo.finalizeCalls != 1 { t.Fatalf("validation/finalize calls = %d/%d", repo.validationFailureCalls, repo.finalizeCalls) } if task.Rows[0].Reason == "" || task.Rows[1].Reason == "" { t.Fatalf("expected both rows to be failed without upstream call: %+v", task.Rows) } } func TestExecuteRestoresByBatchNoAndMapsPerCardResults(t *testing.T) { task := newTestTask(t, OperationSuspend, []*Row{ {RowNo: 1, Iccid: "card-1", Authorized: true}, {RowNo: 2, Iccid: "card-2", Authorized: true}, }) repo := &fakeExecutorRepository{ claims: []bool{true}, task: task, cards: map[string]*entity.TSimCardInfo{ "card-1": {Id: 1, Iccid: "card-1", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated}, "card-2": {Id: 2, Iccid: "card-2", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated}, }, } upstream := &fakeStatusChanger{response: []model.SimBaseRes[model.SimCardStatusChangeItem]{ {Code: 0, SimKey: &model.SimKey{ICCID: "card-1"}, ResData: model.SimCardStatusChangeItem{OrderNum: "order-1"}}, {Code: 500, Message: "operator rejected", SimKey: &model.SimKey{ICCID: "card-2"}}, }} if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-restore"); err != nil { t.Fatal(err) } if len(repo.loadedBatchNos) != 1 || repo.loadedBatchNos[0] != "STP-restore" { t.Fatalf("executor did not restore by batchNo: %+v", repo.loadedBatchNos) } if upstream.calls != 1 || len(upstream.requests) != 1 { t.Fatalf("unexpected upstream calls: %d", upstream.calls) } if upstream.requests[0].ReqData.OperType != "0" { t.Fatalf("unexpected operType: %s", upstream.requests[0].ReqData.OperType) } if len(repo.outcomes) != 2 || !repo.outcomes[0].Success || repo.outcomes[1].Success { t.Fatalf("unexpected per-card outcomes: %+v", repo.outcomes) } if repo.outcomes[0].OrderNum != "order-1" || repo.outcomes[1].Message != "operator rejected" { t.Fatalf("upstream details were not preserved: %+v", repo.outcomes) } } func TestExecuteDuplicateClaimRunsOnlyOnce(t *testing.T) { task := newTestTask(t, OperationResume, []*Row{{RowNo: 1, Iccid: "card-1", Authorized: true}}) repo := &fakeExecutorRepository{ claims: []bool{true, false}, task: task, cards: map[string]*entity.TSimCardInfo{ "card-1": {Id: 1, Iccid: "card-1", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusStopped}, }, } upstream := &fakeStatusChanger{response: []model.SimBaseRes[model.SimCardStatusChangeItem]{ {Code: 0, SimKey: &model.SimKey{ICCID: "card-1"}}, }} executor := newExecutor(repo, upstream) if err := executor.Execute(context.Background(), "STR-once"); err != nil { t.Fatal(err) } if err := executor.Execute(context.Background(), "STR-once"); err != nil { t.Fatal(err) } if upstream.calls != 1 || len(repo.loadedBatchNos) != 1 || repo.finalizeCalls != 1 { t.Fatalf("duplicate claim executed twice: upstream=%d load=%d finalize=%d", upstream.calls, len(repo.loadedBatchNos), repo.finalizeCalls) } if upstream.requests[0].ReqData.OperType != "1" { t.Fatalf("unexpected start operType: %s", upstream.requests[0].ReqData.OperType) } } type blockingExecutor struct { started chan struct{} release chan struct{} } func (e *blockingExecutor) Execute(context.Context, string) error { close(e.started) <-e.release return nil } func (e *blockingExecutor) MarkFailed(context.Context, string, string) error { return nil } func TestDispatchReturnsBeforeExecutorCompletes(t *testing.T) { executor := &blockingExecutor{started: make(chan struct{}), release: make(chan struct{})} Dispatch(executor, "STP-async") select { case <-executor.started: case <-time.After(time.Second): t.Fatal("background executor did not start") } select { case <-executor.release: t.Fatal("release channel should still be blocked") default: } close(executor.release) } func newTestTask(t *testing.T, operation Operation, rows []*Row) *Task { t.Helper() config, err := operation.Config() if err != nil { t.Fatal(err) } return &Task{ Batch: &entity.BatchMain{BatchNo: "batch-test", BatchType: config.BatchType, TenantId: "tenant-1"}, Operation: operation, Config: config, Rows: rows, } }