package inbound import ( "context" "errors" "os" "strings" "testing" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gcfg" "github.com/xuri/excelize/v2" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult" ) func TestMain(m *testing.M) { adapter, err := gcfg.NewAdapterContent("batch:\n inbound:\n upstreamBatchSize: 1\n") if err != nil { panic(err) } g.Cfg().SetAdapter(adapter) os.Exit(m.Run()) } func TestAdminSourceTypeContract(t *testing.T) { if consts.BatchSourceTypeRange != 1 || consts.BatchSourceTypeBatchInput != 2 || consts.BatchSourceTypeFile != 3 { t.Fatalf("source_type contract mismatch: range=%d input=%d file=%d", consts.BatchSourceTypeRange, consts.BatchSourceTypeBatchInput, consts.BatchSourceTypeFile) } } func TestCascadeFailurePreservesPerCardValidation(t *testing.T) { task := &Task{Rows: []*Row{ {ICCID: "invalid", Reason: "卡号不合法"}, {ICCID: "valid"}, }} applyCascadeFailure(task) if countFailedRows(task.Rows) != 1 || !IsCascadeFailure(task.Rows[1]) { t.Fatalf("unexpected validation outcomes: %+v", task.Rows) } if task.Rows[0].Reason != "卡号不合法" || inboundRowStatus(task.Rows[0]) != "失败" || inboundRowStatus(task.Rows[1]) != "未执行" { t.Fatalf("wrong per-card status: %+v", task.Rows) } } func TestValidateCollectsLaterInboundFailuresBeforeBlockingBatch(t *testing.T) { task := &Task{Rows: []*Row{ {ICCID: "bad", ChanKey: "channel"}, {ICCID: "8986000000000000001", MSISDN: "13800008000", IMSI: "460001234567890", ChanKey: "channel"}, {ICCID: "8986000000000000002", MSISDN: "invalid", IMSI: "invalid", ChanKey: "channel"}, }} executor := &executor{repository: &inboundRepositoryStub{}} if err := executor.validate(context.Background(), task); err != nil { t.Fatal(err) } applyCascadeFailure(task) if !strings.Contains(task.Rows[0].Reason, "ICCID格式不正确") { t.Fatalf("first failure was lost: %q", task.Rows[0].Reason) } if !IsCascadeFailure(task.Rows[1]) { t.Fatalf("validated card was not blocked: %q", task.Rows[1].Reason) } for _, reason := range []string{"MSISDN格式不正确", "IMSI格式不正确"} { if !strings.Contains(task.Rows[2].Reason, reason) { t.Fatalf("later card was not fully validated: %q", task.Rows[2].Reason) } } if countFailedRows(task.Rows) != 2 || countValidRows(task.Rows) != 0 { t.Fatalf("batch must have two validation failures and no executable cards: %+v", task) } } func TestValidateRowRequiresAtLeastOneCardIdentifierAndChanKey(t *testing.T) { row := &Row{} validateRow(row, "") for _, want := range []string{"ICCID/MSISDN/IMSI至少填写一个", "chanKey不能为空"} { if !containsReason(row.Reason, want) { t.Fatalf("expected reason %q in %q", want, row.Reason) } } } func TestParseInputTextAcceptsOneIdentifierPerLine(t *testing.T) { rows := parseInputText(strings.Join([]string{ "89860401786527567308", "14100007308", "460046527567308", }, "\n")) if len(rows) != 3 { t.Fatalf("row count = %d, want 3", len(rows)) } if rows[0].ICCID != "89860401786527567308" || rows[1].MSISDN != "14100007308" || rows[2].IMSI != "460046527567308" { t.Fatalf("unexpected parsed rows: %+v", rows) } for _, row := range rows { if row.InputCode == "" { t.Fatalf("expected input code to be recorded: %+v", row) } } } func TestValidateRowRejectsTooShortIdentifiers(t *testing.T) { row := &Row{ICCID: "4", MSISDN: "4", IMSI: "4", ChanKey: "chan-a"} validateRow(row, "") for _, want := range []string{"ICCID格式不正确", "MSISDN格式不正确", "IMSI格式不正确"} { if !containsReason(row.Reason, want) { t.Fatalf("expected reason %q in %q", want, row.Reason) } } } func TestExpandRangeUsesSelectedMSISDN(t *testing.T) { rows, err := expandRange(Range{RangeType: "msisdn", StartMSISDN: "13800008000", EndMSISDN: "13800008001"}) if err != nil { t.Fatal(err) } if len(rows) != 2 || rows[0].MSISDN != "13800008000" || rows[1].MSISDN != "13800008001" { t.Fatalf("unexpected MSISDN rows: %+v", rows) } if rows[0].ICCID != "" || rows[0].IMSI != "" { t.Fatalf("unselected identifiers must remain empty: %+v", rows[0]) } } func TestExpandRangeAllowsAlphaNumericICCIDRange(t *testing.T) { rows, err := expandRange(Range{RangeType: "iccid", StartICCID: "898604F000000000001", EndICCID: "898604F000000000003"}) if err != nil { t.Fatal(err) } want := []string{ "898604F000000000001", "898604F000000000002", "898604F000000000003", } if len(rows) != len(want) { t.Fatalf("row count = %d, want %d", len(rows), len(want)) } for i, value := range want { if rows[i].ICCID != value { t.Fatalf("row %d ICCID = %q, want %q", i, rows[i].ICCID, value) } } } func TestExpandRangeRejectsAlphaNumericMSISDNRange(t *testing.T) { _, err := expandRange(Range{RangeType: "msisdn", StartMSISDN: "1380013800A", EndMSISDN: "1380013800B"}) if err == nil || !strings.Contains(err.Error(), "range values must be numeric") { t.Fatalf("expected numeric range error, got %v", err) } } func TestValidateRowSelectedMSISDNDoesNotRequireOtherIdentifiers(t *testing.T) { row := &Row{MSISDN: "13800008000", ChanKey: "chan-a"} validateRow(row, "msisdn") if row.Reason != "" { t.Fatalf("unexpected validation reason: %q", row.Reason) } } func TestValidateRowAllowsAlphaNumericICCID(t *testing.T) { row := &Row{ICCID: "898604F4152391115473", MSISDN: "13800008000", IMSI: "460001234567890", ChanKey: "chan-a"} validateRow(row, "") if row.Reason != "" { t.Fatalf("unexpected validation reason: %q", row.Reason) } } func TestMarkSameBatchDuplicatesChecksICCIDAndMSISDN(t *testing.T) { rows := []*Row{ {RowNo: 1, ICCID: "8986", MSISDN: "13800008000"}, {RowNo: 2, ICCID: "8986", MSISDN: "13800008001"}, {RowNo: 3, ICCID: "8987", MSISDN: "13800008000"}, } markSameBatchDuplicates(rows) if !containsReason(rows[1].Reason, "同批重复ICCID") { t.Fatalf("expected row 2 duplicate ICCID, got %q", rows[1].Reason) } if !containsReason(rows[2].Reason, "同批重复MSISDN") { t.Fatalf("expected row 3 duplicate MSISDN, got %q", rows[2].Reason) } } func TestMarkSameBatchDuplicatesChecksIMSI(t *testing.T) { rows := []*Row{ {RowNo: 1, IMSI: "460001234567890"}, {RowNo: 2, IMSI: "460001234567890"}, } markSameBatchDuplicates(rows) if !containsReason(rows[1].Reason, "同批重复IMSI") { t.Fatalf("expected duplicate IMSI, got %q", rows[1].Reason) } } func TestValidateUpstreamBasicInfoAcceptsMatchingIdentifiers(t *testing.T) { rows := []*Row{{RowNo: 1, ICCID: "898604F4152391115473", MSISDN: "1441543615473", IMSI: "460087436015473", ChanKey: "chan-a"}} upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{ { Code: 0, SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "898604F4152391115473"}, ResData: model.SimBasicInfoItem{ ICCID: "898604F4152391115473", MSISDN: "1441543615473", IMSI: "460087436015473", }, }, }} err := (&executor{upstream: upstream}).validateUpstreamBasicInfo(context.Background(), &Task{Rows: rows}) if err != nil { t.Fatal(err) } if rows[0].Reason != "" { t.Fatalf("unexpected reason: %q", rows[0].Reason) } if upstream.calls != 1 { t.Fatalf("upstream calls = %d, want 1", upstream.calls) } if len(upstream.inputs) != 1 || len(upstream.inputs[0].SimKeys) != 1 { t.Fatalf("unexpected upstream inputs: %+v", upstream.inputs) } requestKey := upstream.inputs[0].SimKeys[0] if requestKey.ChanKey != "chan-a" || requestKey.ICCID != "898604F4152391115473" { t.Fatalf("unexpected upstream request key: %+v", requestKey) } if requestKey.MSISDN != "" || requestKey.IMSI != "" { t.Fatalf("upstream basic-info request must only carry chanKey and ICCID: %+v", requestKey) } } func TestValidateUpstreamBasicInfoRequiresCompleteResData(t *testing.T) { rows := []*Row{{RowNo: 1, ICCID: "898604F4152391115473", MSISDN: "1441543615473", IMSI: "460087436015473", ChanKey: "chan-a"}} upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{ { Code: 0, SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "898604f4152391115473", MSISDN: "1441543615473", IMSI: "460087436015473"}, ResData: model.SimBasicInfoItem{ ActiveDate: "2026-08-12 00:00:00", OpenDate: "2026-08-12 00:00:00", }, }, }} err := (&executor{upstream: upstream}).validateUpstreamBasicInfo(context.Background(), &Task{Rows: rows}) if err != nil { t.Fatal(err) } if !containsReason(rows[0].Reason, "上游未返回三码信息") { t.Fatalf("expected missing upstream data failure, got %q", rows[0].Reason) } } func TestValidateUpstreamBasicInfoPreservesNotFoundMessage(t *testing.T) { rows := []*Row{{RowNo: 1, ICCID: "8986112223600594371", ChanKey: "chan-a"}} upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{ { Code: 0, Message: "未查询到对应SIM卡信息", SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "8986112223600594371"}, }, }} err := (&executor{upstream: upstream}).validateUpstreamBasicInfo(context.Background(), &Task{Rows: rows}) if err != nil { t.Fatal(err) } if rows[0].Reason != "上游三码校验失败: 未查询到对应SIM卡信息" { t.Fatalf("unexpected reason: %q", rows[0].Reason) } } func TestValidateUpstreamBasicInfoMatchesByMSISDNAndBackfillsCodes(t *testing.T) { rows := []*Row{{RowNo: 1, MSISDN: "14100007308", ChanKey: "chan-a"}} upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{ { Code: 0, SimKey: &model.SimKey{ChanKey: "chan-a", MSISDN: "14100007308"}, ResData: model.SimBasicInfoItem{ ICCID: "89860401786527567308", MSISDN: "14100007308", IMSI: "460046527567308", }, }, }} err := (&executor{upstream: upstream}).validateUpstreamBasicInfo(context.Background(), &Task{Rows: rows}) if err != nil { t.Fatal(err) } if rows[0].Reason != "" { t.Fatalf("unexpected reason: %q", rows[0].Reason) } if rows[0].ICCID != "89860401786527567308" || rows[0].IMSI != "460046527567308" { t.Fatalf("expected upstream codes to be backfilled, got %+v", rows[0]) } requestKey := upstream.inputs[0].SimKeys[0] if requestKey.ICCID != "" || requestKey.MSISDN != "14100007308" || requestKey.IMSI != "" { t.Fatalf("unexpected upstream request key: %+v", requestKey) } } func TestValidateBatchInputSingleMSISDNBackfillsBeforeCompletenessCheck(t *testing.T) { rows := parseInputText("14100007308") repo := &inboundRepositoryStub{} upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{ { Code: 0, SimKey: &model.SimKey{ChanKey: "chan-a", MSISDN: "14100007308"}, ResData: model.SimBasicInfoItem{ ICCID: "89860401786527567308", MSISDN: "14100007308", IMSI: "460046527567308", }, }, }} task := &Task{ Batch: Batch{SourceType: consts.BatchSourceTypeBatchInput}, Options: Options{ChanKey: "chan-a", ValidateUpstreamBasicInfo: "1"}, Rows: rows, } err := (&executor{repository: repo, upstream: upstream}).validate(context.Background(), task) if err != nil { t.Fatal(err) } if task.ValidCount != 1 { t.Fatalf("valid count = %d, want 1, reason=%q", task.ValidCount, rows[0].Reason) } if rows[0].ICCID != "89860401786527567308" || rows[0].IMSI != "460046527567308" { t.Fatalf("expected upstream codes to be backfilled, got %+v", rows[0]) } if upstream.calls != 1 { t.Fatalf("upstream calls = %d, want 1", upstream.calls) } } func TestValidateSkipsUpstreamBasicInfoWhenDisabled(t *testing.T) { rows := []*Row{{ RowNo: 1, ICCID: "89860401786527567308", MSISDN: "14100007308", IMSI: "460046527567308", ChanKey: "chan-a", }} upstream := &basicInfoUpstreamStub{err: errors.New("gateway unavailable")} task := &Task{ Batch: Batch{SourceType: consts.BatchSourceTypeFile}, Options: Options{ValidateUpstreamBasicInfo: "0"}, Rows: rows, } err := (&executor{repository: &inboundRepositoryStub{}, upstream: upstream}).validate(context.Background(), task) if err != nil { t.Fatal(err) } if upstream.calls != 0 { t.Fatalf("upstream calls = %d, want 0", upstream.calls) } if task.ValidCount != 1 { t.Fatalf("valid count = %d, want 1, reason=%q", task.ValidCount, rows[0].Reason) } if task.Options.ValidateUpstreamBasicInfo != "0" { t.Fatalf("validate option = %q, want 0", task.Options.ValidateUpstreamBasicInfo) } } func TestValidateRunsUpstreamBasicInfoWhenEnabled(t *testing.T) { rows := []*Row{{ RowNo: 1, ICCID: "89860401786527567308", MSISDN: "14100007308", IMSI: "460046527567308", ChanKey: "chan-a", }} upstream := &basicInfoUpstreamStub{err: errors.New("gateway unavailable")} task := &Task{ Batch: Batch{SourceType: consts.BatchSourceTypeFile}, Options: Options{ValidateUpstreamBasicInfo: "1"}, Rows: rows, } err := (&executor{repository: &inboundRepositoryStub{}, upstream: upstream}).validate(context.Background(), task) if err != nil { t.Fatal(err) } if upstream.calls != 1 { t.Fatalf("upstream calls = %d, want 1", upstream.calls) } if task.ValidCount != 0 { t.Fatalf("valid count = %d, want 0, reason=%q", task.ValidCount, rows[0].Reason) } if task.Options.ValidateUpstreamBasicInfo != "1" { t.Fatalf("validate option = %q, want 1", task.Options.ValidateUpstreamBasicInfo) } } func TestPopulateExcelRowsReturnsRetryableErrorWhenSourceIsUnreadable(t *testing.T) { sourceErr := errors.New("OSS local file does not exist") task := &Task{Source: Source{ImportFileOssID: 1270}} repository := &inboundRepositoryStub{readOssErr: sourceErr} err := (&executor{repository: repository}).populateExcelRows(context.Background(), task) if !errors.Is(err, sourceErr) || !batch.IsRetryable(err) { t.Fatalf("populateExcelRows() error = %v, want retryable wrapper of %v", err, sourceErr) } } func TestValidateUpstreamBasicInfoWaitsForRateLimitBeforeQuery(t *testing.T) { var order []string rows := []*Row{{RowNo: 1, ICCID: "89860401786527567308", MSISDN: "14100007308", IMSI: "460046527567308", ChanKey: "chan-a"}} limiter := &recordingRateLimiter{ onWait: func(context.Context, string, int) error { order = append(order, "limit") return nil }, } upstream := &basicInfoUpstreamStub{ onQuery: func() { order = append(order, "query") }, response: []model.SimBaseRes[model.SimBasicInfoItem]{ { Code: 0, SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "89860401786527567308"}, ResData: model.SimBasicInfoItem{ ICCID: "89860401786527567308", MSISDN: "14100007308", IMSI: "460046527567308", }, }, }, } err := (&executor{upstream: upstream, rateLimiter: limiter}).validateUpstreamBasicInfo(context.Background(), &Task{ Batch: Batch{BatchNo: "B-1"}, Rows: rows, }) if err != nil { t.Fatal(err) } if limiter.calls != 1 || limiter.chanKey != "chan-a" || limiter.itemCount != 1 { t.Fatalf("unexpected limiter call: %+v", limiter) } if upstream.calls != 1 { t.Fatalf("upstream calls = %d, want 1", upstream.calls) } if len(order) != 2 || order[0] != "limit" || order[1] != "query" { t.Fatalf("unexpected call order: %+v", order) } } func TestValidateUpstreamBasicInfoStopsWhenRateLimitFails(t *testing.T) { rows := []*Row{{RowNo: 1, ICCID: "89860401786527567308", ChanKey: "chan-a"}} upstream := &basicInfoUpstreamStub{} limiter := &recordingRateLimiter{err: errors.New("redis down")} err := (&executor{upstream: upstream, rateLimiter: limiter}).validateUpstreamBasicInfo(context.Background(), &Task{ Batch: Batch{BatchNo: "B-1"}, Rows: rows, }) if err != nil { t.Fatal(err) } if upstream.calls != 0 { t.Fatalf("upstream calls = %d, want 0", upstream.calls) } if !strings.Contains(rows[0].Reason, "redis down") { t.Fatalf("expected rate limit failure reason, got %q", rows[0].Reason) } } func TestValidateUpstreamBasicInfoRejectsMismatch(t *testing.T) { rows := []*Row{{RowNo: 1, ICCID: "89860001", MSISDN: "13800008000", IMSI: "460001", ChanKey: "chan-a"}} upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{ { Code: 0, SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "89860001"}, ResData: model.SimBasicInfoItem{ICCID: "89860001", MSISDN: "13800008099", IMSI: "460001"}, }, }} err := (&executor{upstream: upstream}).validateUpstreamBasicInfo(context.Background(), &Task{Rows: rows}) if err != nil { t.Fatal(err) } if !strings.Contains(rows[0].Reason, "MSISDN") { t.Fatalf("expected MSISDN mismatch reason, got %q", rows[0].Reason) } } func TestValidateUpstreamBasicInfoRejectsUpstreamError(t *testing.T) { rows := []*Row{{RowNo: 1, ICCID: "89860001", ChanKey: "chan-a"}} upstream := &basicInfoUpstreamStub{err: errors.New("gateway unavailable")} err := (&executor{upstream: upstream}).validateUpstreamBasicInfo(context.Background(), &Task{Rows: rows}) if err != nil { t.Fatal(err) } if !strings.Contains(rows[0].Reason, "gateway unavailable") { t.Fatalf("expected upstream error reason, got %q", rows[0].Reason) } } func TestParseTableRowsAcceptsChineseInboundHeaders(t *testing.T) { rows := parseTableRows([][]string{ {"ICCID号", "手机号", "国际移动用户识别码", "国际移动设备识别码", "通道编码", "卡类型", "上游套餐编码", "供应商编码"}, {"898604F4152391115473", "1441543615473", "460087436015473", "865111111111111", "channel_003", "通用插拔 SIM 卡", "UP001", "SUP001"}, }) if len(rows) != 1 { t.Fatalf("row count = %d, want 1", len(rows)) } row := rows[0] if row.ICCID != "898604F4152391115473" || row.MSISDN != "1441543615473" || row.IMSI != "460087436015473" || row.IMEI != "865111111111111" || row.ChanKey != "channel_003" { t.Fatalf("unexpected parsed row: %+v", row) } if row.CardType != "通用插拔 SIM 卡" || row.UpPkgCode != "UP001" || row.SuppliersCode != "SUP001" { t.Fatalf("unexpected inbound options: %+v", row) } } func TestParseTableRowsAcceptsRequiredMarkerHeaders(t *testing.T) { rows := parseTableRows([][]string{ {"ICCID*", "MSISDN*", "IMSI*", "通道编号*"}, {"89860401786527567308", "14100007308", "460046527567308", "channel_003"}, }) if len(rows) != 1 { t.Fatalf("row count = %d, want 1", len(rows)) } row := rows[0] if row.ICCID != "89860401786527567308" || row.MSISDN != "14100007308" || row.IMSI != "460046527567308" || row.ChanKey != "channel_003" { t.Fatalf("unexpected parsed row: %+v", row) } } func TestInboundExcelHeaderRequiresRecognizedIdentifier(t *testing.T) { for _, fields := range [][]string{{"随机内容", "乱码"}, {"通道编号", "卡类型"}, {"ICCID", "通道编号"}, {"MSISDN", "通道编号"}, {"IMSI", "通道编号"}} { got := hasInboundIdentifierHeader(headerIndex(fields)) want := fields[0] == "ICCID" || fields[0] == "MSISDN" || fields[0] == "IMSI" if got != want { t.Fatalf("header %v valid = %t, want %t", fields, got, want) } } } func TestPopulateExcelRowsRejectsInvalidHeaderAndAcceptsMSISDNHeader(t *testing.T) { for _, test := range []struct { name string header []string valid bool }{ {name: "invalid", header: []string{"乱码", "通道编号"}}, {name: "msisdn", header: []string{"MSISDN", "通道编号"}, valid: true}, } { t.Run(test.name, func(t *testing.T) { workbook := excelize.NewFile() defer func() { _ = workbook.Close() }() for index, value := range test.header { cell, err := excelize.CoordinatesToCellName(index+1, 1) if err != nil { t.Fatal(err) } if err := workbook.SetCellValue("Sheet1", cell, value); err != nil { t.Fatal(err) } } if err := workbook.SetCellValue("Sheet1", "A2", "14100007308"); err != nil { t.Fatal(err) } content, err := workbook.WriteToBuffer() if err != nil { t.Fatal(err) } task := &Task{Source: Source{ImportFileOssID: 1}} repository := &inboundRepositoryStub{readOssContent: content.Bytes(), readOssName: "cards.xlsx"} err = (&executor{repository: repository}).populateExcelRows(context.Background(), task) if !test.valid { if err == nil || len(task.Rows) != 0 { t.Fatalf("invalid header: err=%v, rows=%v", err, task.Rows) } return } if err != nil || len(task.Rows) != 1 || task.Rows[0].MSISDN != "14100007308" { t.Fatalf("valid MSISDN file: err=%v, rows=%v", err, task.Rows) } }) } } func TestValidateInvalidRowSkipsUpstreamForEntireBatch(t *testing.T) { rows := []*Row{ {RowNo: 2, ICCID: "invalid", ChanKey: "channel_003"}, {RowNo: 3, ICCID: "89860401786527567308", ChanKey: "channel_003"}, } upstream := &basicInfoUpstreamStub{} task := &Task{Rows: rows, Options: Options{ChanKey: "channel_003", ValidateUpstreamBasicInfo: "1"}} if err := (&executor{repository: &inboundRepositoryStub{}, upstream: upstream}).validate(context.Background(), task); err != nil { t.Fatal(err) } if upstream.calls != 0 || task.ValidCount != 0 || !hasFailedRows(rows) { t.Fatalf("invalid batch should skip upstream: calls=%d valid=%d", upstream.calls, task.ValidCount) } } func TestValidateSkipsUpstreamWhenLocalIdentifierFormatFails(t *testing.T) { rows := []*Row{{RowNo: 1, ICCID: "4", MSISDN: "4", IMSI: "4", ChanKey: "chan-a"}} upstream := &basicInfoUpstreamStub{} repo := &inboundRepositoryStub{} task := &Task{ Rows: rows, Options: Options{ChanKey: "chan-a"}, } err := (&executor{repository: repo, upstream: upstream}).validate(context.Background(), task) if err != nil { t.Fatal(err) } if upstream.calls != 0 { t.Fatalf("upstream calls = %d, want 0", upstream.calls) } if !containsReason(rows[0].Reason, "ICCID格式不正确") { t.Fatalf("expected local validation failure, got %q", rows[0].Reason) } if task.ValidCount != 0 { t.Fatalf("valid count = %d, want 0", task.ValidCount) } } func TestValidateRequiresCompleteIdentifiersAfterUpstreamStep(t *testing.T) { rows := []*Row{{RowNo: 1, MSISDN: "14100007308", ChanKey: "chan-a"}} repo := &inboundRepositoryStub{} task := &Task{ Rows: rows, Source: Source{Range: Range{RangeType: "msisdn"}}, } err := (&executor{repository: repo}).validate(context.Background(), task) if err != nil { t.Fatal(err) } if !containsReason(rows[0].Reason, "三码信息不完整") { t.Fatalf("expected incomplete identifiers failure, got %q", rows[0].Reason) } if task.ValidCount != 0 { t.Fatalf("valid count = %d, want 0", task.ValidCount) } } func TestValidateNormalizesExcelInboundOptionsAndBackfillsBatchInfo(t *testing.T) { rows := parseTableRows([][]string{ {"ICCID*", "MSISDN*", "IMSI*", "通道编号*", "卡类型", "上游套餐编码", "供应商编码", "是否贴片转插拔", "是否运营商达量断网"}, {"89860401786527567308", "14100007308", "460046527567308", "channel_003", "MP1-消费级插拔物联卡", "UP001", "SUP001", "是", "否"}, {"89860401786527567309", "14100007309", "460046527567309", "channel_003", "", "UP001", "SUP001", "", ""}, }) task := &Task{Rows: rows, Batch: Batch{SourceType: consts.BatchSourceTypeFile}} err := (&executor{repository: &inboundRepositoryStub{}}).validate(context.Background(), task) if err != nil { t.Fatal(err) } if task.ValidCount != 2 { t.Fatalf("valid count = %d, want 2", task.ValidCount) } if task.Options.ChanKey != "channel_003" || task.Options.CardType != "4" || task.Options.UpPkgCode != "UP001" || task.Options.SuppliersCode != "SUP001" { t.Fatalf("unexpected task options: %+v", task.Options) } if rows[1].CardType != "4" || rows[1].InsertToPlugEnabled != "0" || rows[1].CarrierTrafficCutoffEnabled != "1" { t.Fatalf("expected blank row options to be backfilled from batch, got %+v", rows[1]) } } func TestValidateRejectsMixedInboundOptionsInOneExcelBatch(t *testing.T) { rows := parseTableRows([][]string{ {"ICCID*", "MSISDN*", "IMSI*", "通道编号*", "卡类型"}, {"89860401786527567308", "14100007308", "460046527567308", "channel_003", "1"}, {"89860401786527567309", "14100007309", "460046527567309", "channel_003", "2"}, }) task := &Task{Rows: rows, Batch: Batch{SourceType: consts.BatchSourceTypeFile}} err := (&executor{repository: &inboundRepositoryStub{}}).validate(context.Background(), task) if err != nil { t.Fatal(err) } if task.ValidCount != 0 { t.Fatalf("valid count = %d, want 0", task.ValidCount) } if task.FailureReason != "同批入库配置必须一致" { t.Fatalf("unexpected task failure reason: %q", task.FailureReason) } if !containsReason(rows[0].Reason, "卡类型必须同批一致") || !containsReason(rows[1].Reason, "卡类型必须同批一致") { t.Fatalf("expected consistency errors, got row1=%q row2=%q", rows[0].Reason, rows[1].Reason) } } func containsReason(actual, want string) bool { return strings.Contains(actual, want) } type basicInfoUpstreamStub struct { calls int inputs []*model.SimBasicInfoInput response []model.SimBaseRes[model.SimBasicInfoItem] err error onQuery func() } func (s *basicInfoUpstreamStub) QuerySimBasicInfo(_ context.Context, input *model.SimBasicInfoInput) ([]model.SimBaseRes[model.SimBasicInfoItem], error) { if s.onQuery != nil { s.onQuery() } s.calls++ if input == nil || len(input.SimKeys) == 0 { return nil, errors.New("missing sim keys") } s.inputs = append(s.inputs, cloneBasicInfoInput(input)) if s.err != nil { return nil, s.err } return s.response, nil } func cloneBasicInfoInput(input *model.SimBasicInfoInput) *model.SimBasicInfoInput { clone := &model.SimBasicInfoInput{ ReqData: input.ReqData, SimKeys: make([]*model.SimKey, 0, len(input.SimKeys)), } for _, item := range input.SimKeys { if item == nil { clone.SimKeys = append(clone.SimKeys, nil) continue } copied := *item clone.SimKeys = append(clone.SimKeys, &copied) } return clone } type recordingRateLimiter struct { calls int chanKey string itemCount int err error onWait func(context.Context, string, int) error } func (l *recordingRateLimiter) Wait(ctx context.Context, chanKey string, itemCount int) error { l.calls++ l.chanKey = chanKey l.itemCount = itemCount if l.onWait != nil { return l.onWait(ctx, chanKey, itemCount) } return l.err } type inboundRepositoryStub struct { readOssContent []byte readOssName string readOssErr error } func (s *inboundRepositoryStub) Load(context.Context, string) (*Task, error) { return nil, errors.New("not implemented") } func (s *inboundRepositoryStub) ReadOssFile(context.Context, int64) ([]byte, string, error) { if s.readOssErr != nil { return nil, "", s.readOssErr } return s.readOssContent, s.readOssName, nil } func (s *inboundRepositoryStub) FindExistingStocks(context.Context, string, []string, []string, []string) ([]Stock, error) { return nil, nil } func (s *inboundRepositoryStub) SaveValidationFailure(context.Context, *Task) error { return errors.New("not implemented") } func (s *inboundRepositoryStub) SaveSuccess(context.Context, *Task) error { return errors.New("not implemented") } func (s *inboundRepositoryStub) SaveBusinessFailure(context.Context, *Task, string) error { return errors.New("not implemented") } func (s *inboundRepositoryStub) SaveResultFile(context.Context, *Task, batchresult.Request) error { return errors.New("not implemented") }