package dao import ( "errors" "regexp" "strings" "testing" "github.com/DATA-DOG/go-sqlmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" "micro-bundle/internal/model" "micro-bundle/internal/testutil" ) // ---- GetPendingTaskLayout ---- // sqlmock + gorm 配合:QueryMatcherEqual 要求 SQL 字面量精确匹配;但 // gorm 生成的字段顺序与时间字段处理可能因版本略有差异,这里用 Regexp 匹配 // 关键子串,避免对生成 SQL 的细节过度耦合。 // // DAO 测试不用 t.Parallel():SetupTaskBenchDB 会改写全局 app.ModuleClients, // 并行执行会导致 sqlmock 期望链互相污染。 var reSelectLayout = regexp.MustCompile(`SELECT \* FROM .task_pending_layout. WHERE id = \?`) var reInsertLayout = regexp.MustCompile(`INSERT INTO .task_pending_layout. .*\)\s*VALUES \(.*\)\s*ON DUPLICATE KEY UPDATE .data.`) var reInsertHidden = regexp.MustCompile(`INSERT INTO .task_assignee_hidden.`) func TestGetPendingTaskLayout_NotFound(t *testing.T) { mock := testutil.SetupTaskBenchDB(t) mock.MatchExpectationsInOrder(false) mock.ExpectQuery(reSelectLayout.String()). WithArgs(1). WillReturnError(gormNotFound()) got, err := GetPendingTaskLayout() require.Error(t, err) assert.Empty(t, got) } func TestGetPendingTaskLayout_Success(t *testing.T) { mock := testutil.SetupTaskBenchDB(t) mock.MatchExpectationsInOrder(false) rows := sqlmock.NewRows([]string{"id", "data"}).AddRow(1, `[{"fieldKey":"subNum"}]`) // gorm 的 First() 内部会带一个 LIMIT 参数,这里用 .* 兼容。 mock.ExpectQuery(reSelectLayout.String()). WillReturnRows(rows) got, err := GetPendingTaskLayout() require.NoError(t, err) assert.JSONEq(t, `[{"fieldKey":"subNum"}]`, got) } // ---- SetPendingTaskLayout ---- func TestSetPendingTaskLayout_Success(t *testing.T) { mock := testutil.SetupTaskBenchDB(t) mock.MatchExpectationsInOrder(false) // gorm 会在 Create + OnConflict 时开事务,显式声明 Begin/Commit。 mock.ExpectBegin() mock.ExpectExec(reInsertLayout.String()). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() err := SetPendingTaskLayout(`[{"fieldKey":"subNum"}]`) require.NoError(t, err) } // ---- AddHiddenTaskAssignee ---- func TestAddHiddenTaskAssignee_Success(t *testing.T) { mock := testutil.SetupTaskBenchDB(t) mock.MatchExpectationsInOrder(false) mock.ExpectBegin() mock.ExpectExec(reInsertHidden.String()). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() err := AddHiddenTaskAssignee("员工A", "E001") require.NoError(t, err) } // ---- helpers ---- // gormNotFound 返回 gorm.ErrRecordNotFound。 // gorm 内部 First()/Find() 命中零行时返回该错误。 func gormNotFound() error { return gorm.ErrRecordNotFound } // 保留 errors 包,留给后续事务测试使用 dialError 等 var _ = errors.New // ---- taskStatsQueryOptions ---- // 工具函数:把字符串压成"压缩"形式,便于断言不依赖空白。 // 例如把多行 raw-string 拼成一行,去掉首尾空白,折叠中间空白。 func compactSQL(s string) string { // 把任意空白序列替换成单个空格,再去掉首尾。 re := regexp.MustCompile(`\s+`) return strings.TrimSpace(re.ReplaceAllString(s, " ")) } // TestTaskStatsQueryOptions_PayLater 验证先用后付分支的过滤条件。 func TestTaskStatsQueryOptions_PayLater(t *testing.T) { taskType, billingType, orderFilter, latestOrderFilter := taskStatsQueryOptions(model.BundleTaskTypePayLater) assert.Equal(t, model.BundleTaskTypePayLater, taskType) assert.Equal(t, model.BillingTypeBNPL, billingType) assert.Equal(t, "", latestOrderFilter, "pay-later 分支不应启用 NOT EXISTS 去重") compact := compactSQL(orderFilter) // 必备条件 // pay-later 分支对应增值先用后付,OrderType 必须是增值服务订单 (order_type=2) assert.Contains(t, compact, "bor.order_type = 2") assert.Contains(t, compact, "bor.contract_tpl_type = 3") assert.Contains(t, compact, "bor.pay_later_status IN (1, 2)") assert.Contains(t, compact, "bor.status IN (1, 2, 4)") // 不应再依赖 order_mode assert.NotContains(t, compact, "order_mode") } // TestTaskStatsQueryOptions_Normal 验证普通任务分支的过滤条件。 // 该分支由两个互斥 case 组成: // - Case A: 套餐-普通 (order_type=1, contract_tpl_type=1, status=2) // - Case B: 套餐先用后付的另一种存法 (order_type=2, contract_tpl_type=2, // status IN (1,2,4) AND pay_later_status IN (1,2)) func TestTaskStatsQueryOptions_Normal(t *testing.T) { taskType, billingType, orderFilter, latestOrderFilter := taskStatsQueryOptions(model.BundleTaskTypeNormal) assert.Equal(t, model.BundleTaskTypeNormal, taskType) assert.Equal(t, model.BillingTypeNormal, billingType) compact := compactSQL(orderFilter) // Case A: 套餐-普通,仅已签已支付 assert.Contains(t, compact, "bor.order_type = 1") assert.Contains(t, compact, "bor.contract_tpl_type = 1") assert.Contains(t, compact, "bor.status = 2") // Case B: 套餐先用后付的另一种存法 assert.Contains(t, compact, "bor.order_type = 2") assert.Contains(t, compact, "bor.contract_tpl_type = 2") assert.Contains(t, compact, "bor.status IN (1, 2, 4)") assert.Contains(t, compact, "bor.pay_later_status IN (1, 2)") // 不应再依赖 order_mode(回归保护) assert.NotContains(t, compact, "order_mode") // NOT EXISTS 内层必须与外层对齐,使用 bor2 别名 compactLatest := compactSQL(latestOrderFilter) // Case A 在内层的镜像 assert.Contains(t, compactLatest, "bor2.order_type = 1") assert.Contains(t, compactLatest, "bor2.contract_tpl_type = 1") assert.Contains(t, compactLatest, "bor2.status = 2") // Case B 在内层的镜像 assert.Contains(t, compactLatest, "bor2.order_type = 2") assert.Contains(t, compactLatest, "bor2.contract_tpl_type = 2") assert.Contains(t, compactLatest, "bor2.status IN (1, 2, 4)") assert.Contains(t, compactLatest, "bor2.pay_later_status IN (1, 2)") // 平局规则保持不变 assert.Contains(t, compactLatest, "(bor2.created_at > bor.created_at OR (bor2.created_at = bor.created_at AND bor2.id > bor.id))") } // TestTaskStatsQueryOptions_DefaultToNormal 非 pay-later 任意值都走普通任务分支。 func TestTaskStatsQueryOptions_DefaultToNormal(t *testing.T) { for _, v := range []int{0, 1, 99, -1} { taskType, billingType, orderFilter, _ := taskStatsQueryOptions(v) assert.Equal(t, model.BundleTaskTypeNormal, taskType, "input=%d 应走 normal 分支", v) assert.Equal(t, model.BillingTypeNormal, billingType, "input=%d 计费类型应为 Normal", v) assert.Contains(t, compactSQL(orderFilter), "bor.order_type = 1") assert.NotContains(t, compactSQL(orderFilter), "order_mode") } }