package data import ( "context" "fmt" "path/filepath" "testing" _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" "github.com/gogf/gf/v2/database/gdb" "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" service "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data" ) func TestAccuracyEmptyValidationBatchFinalizesFailed(t *testing.T) { for _, batchType := range []int{107, 109, 110} { t.Run(fmt.Sprint(batchType), func(t *testing.T) { repo, generator, ctx := accuracyTestRepository(t, batchType) if err := service.NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(ctx, "ACCURACY"); err != nil { t.Fatal(err) } row, err := repo.database().GetOne(ctx, "SELECT batch_async_status,run_result,valid_count,success_count,fail_count,fail_reason FROM batch_main WHERE batch_no='ACCURACY'") if err != nil { t.Fatal(err) } if row["batch_async_status"].Int() != consts.BatchAsyncStatusFailed || row["run_result"].Int() != consts.BatchRunResultFailed { t.Fatalf("零明细校验失败被误报成功: %+v", row) } if row["valid_count"].Int() != 0 || row["success_count"].Int() != 0 || row["fail_count"].Int() != 0 || row["fail_reason"].String() == "" { t.Fatalf("空批次不得虚构行数或丢失失败原因: %+v", row) } if len(generator.requests) != 1 || len(generator.requests[0].Rows) != 0 { t.Fatalf("空批次结果文件行数错误: %+v", generator.requests) } download, err := repo.database().GetOne(ctx, "SELECT total_count,success_count,fail_count FROM t_resource_batch_download WHERE batch_no='ACCURACY'") if err != nil || download["total_count"].Int() != 0 || download["success_count"].Int() != 0 || download["fail_count"].Int() != 0 { t.Fatalf("空批次下载统计错误: %+v err=%v", download, err) } }) } } func TestAccuracyAuthorizedSnapshotDoesNotPassValidation(t *testing.T) { repo, _, ctx := accuracyTestRepository(t, 110) if _, err := repo.database().Exec(ctx, `INSERT INTO batch_detail (id,batch_no,batch_type,row_no,item_id,detail_status,snapshot) VALUES (1,'ACCURACY',110,1,'',1,'{"authorized":true}')`); err != nil { t.Fatal(err) } task, err := repo.Load(ctx, "ACCURACY") if err != nil { t.Fatal(err) } if len(task.Details) != 1 || !task.Details[0].Authorized || task.Details[0].ValidationPassed { t.Fatalf("授权标志不能被当成校验通过: %+v", task.Details) } } func TestAccuracyFrozenOwnershipDrivesValidCount(t *testing.T) { repo, _, ctx := accuracyTestRepository(t, 110) // 冻结归属才代表已通过提交前校验:授权标记不等于校验通过, // 未被冻结进子批的明细(校验被拒)也不得计入有效数。 if _, err := repo.database().Exec(ctx, "INSERT INTO data_operation_sub_detail (detail_id,operation_id,batch_no,iccid) VALUES (1,'op-1','ACCURACY','frozen-card')"); err != nil { t.Fatal(err) } task := &service.Task{Batch: service.Batch{BatchNo: "ACCURACY", BatchType: 110}, Details: []*service.Detail{ {ID: 1, Authorized: true, DetailStatus: consts.BatchDetailStatusUnknown, Reason: "补查窗口耗尽,结果未知,禁止重发"}, {ID: 2, Authorized: true, DetailStatus: consts.BatchDetailStatusFailed, Reason: "ICCID does not exist in SIM card records"}, }} if err := repo.markFrozenValidation(ctx, task); err != nil { t.Fatal(err) } if !task.Details[0].ValidationPassed || task.Details[1].ValidationPassed { t.Fatalf("冻结归属判定错误: %+v", task.Details) } task.ValidationOK = true if count := validDetailCount(task); count != 1 { t.Fatalf("恢复时有效数错误: got=%d want=1", count) } } func TestAccuracyFinalizeEmptyBatchPreservesValidationReason(t *testing.T) { repo, _, ctx := accuracyTestRepository(t, 109) operation, err := service.OperationByBatchType(109) if err != nil { t.Fatal(err) } task := &service.Task{Batch: service.Batch{BatchNo: "ACCURACY", BatchType: 109}, Operation: operation, FailureReason: "导入文件没有可执行行"} if err := repo.Finalize(ctx, task); err != nil { t.Fatal(err) } row, err := repo.database().GetOne(ctx, "SELECT batch_async_status,run_result,fail_reason FROM batch_main WHERE batch_no='ACCURACY'") if err != nil { t.Fatal(err) } if row["batch_async_status"].Int() != consts.BatchAsyncStatusFailed || row["run_result"].Int() != consts.BatchRunResultFailed || row["fail_reason"].String() != "导入文件没有可执行行" || task.FailureReason != "导入文件没有可执行行" { t.Fatalf("空批次原始校验原因被覆盖: %+v task=%+v", row, task) } } func TestAccuracyValidationFailureKeepsPerRowReasons(t *testing.T) { for _, batchType := range []int{107, 109, 110} { t.Run(fmt.Sprint(batchType), func(t *testing.T) { repo, generator, ctx := accuracyTestRepository(t, batchType) operation, err := service.OperationByBatchType(batchType) if err != nil { t.Fatal(err) } task := &service.Task{Batch: service.Batch{BatchNo: "ACCURACY", BatchType: batchType}, Operation: operation, Details: []*service.Detail{ {ID: 1, RowNo: 1, ICCID: "same-card", Reason: "MSISDN does not match the SIM card record"}, {ID: 2, RowNo: 2, ICCID: "same-card", Reason: "IMSI does not match the SIM card record"}, }} for _, detail := range task.Details { if _, err := repo.database().Exec(ctx, "INSERT INTO batch_detail (id,batch_no,batch_type,row_no,item_id,detail_status) VALUES (?,'ACCURACY',?,?,?,1)", detail.ID, batchType, detail.RowNo, detail.ICCID); err != nil { t.Fatal(err) } } if err := repo.SaveValidationFailure(ctx, task); err != nil { t.Fatal(err) } if err := repo.Finalize(ctx, task); err != nil { t.Fatal(err) } if len(generator.requests) != 1 || len(generator.requests[0].Rows) != len(task.Details) { t.Fatalf("逐行导出缺失: %+v", generator.requests) } for i, detail := range task.Details { row, err := repo.database().GetOne(ctx, "SELECT detail_status,error_reason FROM batch_detail WHERE id=?", detail.ID) if err != nil || row["detail_status"].Int() != consts.BatchDetailStatusFailed || row["error_reason"].String() != detail.Reason { t.Fatalf("逐行原因落库错误: row=%+v detail=%+v err=%v", row, detail, err) } exported := generator.requests[0].Rows[i] if exported.Success || exported.DetailStatus != consts.BatchDetailStatusFailed || exported.Reason != detail.Reason { t.Fatalf("逐行原因导出被改写: %+v", exported) } } }) } } func TestAccuracyFinalizeUnknownStillRequiresReview(t *testing.T) { for _, statuses := range [][]int{{consts.BatchDetailStatusUnknown}, {consts.BatchDetailStatusSuccess, consts.BatchDetailStatusUnknown}, {consts.BatchDetailStatusFailed, consts.BatchDetailStatusUnknown}} { t.Run(fmt.Sprint(statuses), func(t *testing.T) { repo, _, ctx := accuracyTestRepository(t, 110) task := &service.Task{Batch: service.Batch{BatchNo: "ACCURACY", BatchType: 110}} for i, status := range statuses { task.Details = append(task.Details, &service.Detail{ID: int64(i + 1), DetailStatus: status, Reason: "该卡结果待核查"}) } if err := repo.Finalize(ctx, task); err != nil { t.Fatal(err) } row, err := repo.database().GetOne(ctx, "SELECT batch_async_status,run_result,fail_count,batch_info FROM batch_main WHERE batch_no='ACCURACY'") if err != nil { t.Fatal(err) } wantFailed := 0 if statuses[0] == consts.BatchDetailStatusFailed { wantFailed = 1 } if row["batch_async_status"].Int() != consts.BatchAsyncStatusReview || row["run_result"].Int() != consts.BatchRunResultReview || row["fail_count"].Int() != wantFailed { t.Fatalf("未知被误计成功或明确失败: %+v", row) } }) } } func TestAccuracyBatchResultCounts(t *testing.T) { for _, tc := range []struct { success, failed int status, result int }{{0, 0, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed}, {0, 2, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed}, {2, 0, consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess}, {1, 1, consts.BatchAsyncStatusPartialSuccess, consts.BatchRunResultPartialSuccess}} { t.Run(fmt.Sprintf("success=%d/failed=%d", tc.success, tc.failed), func(t *testing.T) { status, result := batchResult(tc.success, tc.failed) if status != tc.status || result != tc.result { t.Fatalf("汇总结果=(%d,%d),期望=(%d,%d)", status, result, tc.status, tc.result) } }) } } func accuracyTestRepository(t *testing.T, batchType int) (*Repository, *accuracyArtifactGenerator, context.Context) { t.Helper() ctx := context.Background() db, err := gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(" + filepath.ToSlash(filepath.Join(t.TempDir(), "accuracy.db")) + ")"}) if err != nil { t.Fatal(err) } t.Cleanup(func() { if err := db.Close(ctx); err != nil { t.Error(err) } }) for _, statement := range []string{ "CREATE TABLE batch_main (id INTEGER PRIMARY KEY,batch_no TEXT,batch_type INTEGER,batch_async_status INTEGER,tenant_id TEXT,start_time DATETIME,created_by INTEGER,created_dept INTEGER,valid_count INTEGER,success_count INTEGER,fail_count INTEGER,run_result INTEGER,fail_reason TEXT,batch_info TEXT,end_time DATETIME,updated_by INTEGER)", "CREATE TABLE batch_detail (id INTEGER PRIMARY KEY,batch_no TEXT,batch_type INTEGER,row_no INTEGER,item_id TEXT,detail_status INTEGER,error_code TEXT,error_reason TEXT,snapshot TEXT,updated_by INTEGER)", "CREATE TABLE data_operation_parent (batch_no TEXT PRIMARY KEY,ready INTEGER DEFAULT 0,finalize_token TEXT,finalize_until DATETIME,last_reason TEXT)", "CREATE TABLE data_operation_sub_batch (id INTEGER PRIMARY KEY,batch_no TEXT,status TEXT)", "CREATE TABLE data_operation_sub_detail (detail_id INTEGER PRIMARY KEY, operation_id TEXT, batch_no TEXT, iccid TEXT, UNIQUE(batch_no,iccid))", "CREATE TABLE sys_oss (id INTEGER PRIMARY KEY AUTOINCREMENT,tenant_id TEXT,file_name TEXT,original_name TEXT,file_suffix TEXT,path TEXT,url TEXT,created_dept INTEGER,created_by INTEGER,updated_by INTEGER,service TEXT,md5 TEXT,file_size INTEGER)", "CREATE TABLE t_resource_batch_download (id INTEGER PRIMARY KEY AUTOINCREMENT,batch_no TEXT,task_name TEXT,resource_type INTEGER,export_scene INTEGER,total_count INTEGER,success_count INTEGER,fail_count INTEGER,download_status INTEGER,oss_id INTEGER,file_name TEXT,file_ext TEXT,file_url TEXT,file_path TEXT,file_size INTEGER,file_md5 TEXT,download_count INTEGER,max_download_count INTEGER,dept_id INTEGER,start_time DATETIME,finish_time DATETIME,tenant_id TEXT,created_dept INTEGER,created_by INTEGER,updated_by INTEGER)", } { if _, err := db.Exec(ctx, statement); err != nil { t.Fatal(err) } } if _, err := db.Exec(ctx, "INSERT INTO batch_main (id,batch_no,batch_type,batch_async_status,tenant_id) VALUES (1,'ACCURACY',?,2,'isolated')", batchType); err != nil { t.Fatal(err) } if _, err := db.Exec(ctx, "INSERT INTO data_operation_parent (batch_no,ready) VALUES ('ACCURACY',0)"); err != nil { t.Fatal(err) } generator := &accuracyArtifactGenerator{} return &Repository{db: db, resultFiles: generator}, generator, ctx } type accuracyArtifactGenerator struct{ requests []batchresult.Request } func (g *accuracyArtifactGenerator) Create(_ context.Context, request batchresult.Request) (batchresult.Artifact, error) { g.requests = append(g.requests, request) return batchresult.Artifact{FileName: "accuracy.xlsx", OriginalName: request.BatchNo + "-result.xlsx", FileSuffix: ".xlsx", Path: "accuracy.xlsx", URL: "/accuracy.xlsx"}, nil } type accuracyNoUpstream struct{ t *testing.T } func (u *accuracyNoUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { u.t.Fatal("隔离回归不得调用同步上游") return nil, nil } func (u *accuracyNoUpstream) SubmitSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { u.t.Fatal("隔离回归不得提交上游") return nil, nil } func (u *accuracyNoUpstream) QuerySimApnFunctionResult(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) { u.t.Fatal("隔离回归不得查询上游") return nil, nil }