package productmember import ( "context" "testing" "time" "perms-system-server/internal/consts" "perms-system-server/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/stores/sqlx" ) // --------------------------------------------------------------------------- // 覆盖目标:审计第 6 轮 L-5 修复回归 —— CountOtherActiveAdminsTx 排除目标自己后计数。 // // 原做法:removeMember / 降级时调用 CountActiveAdminsTx,再在业务层做 <=1 判断。 // 问题:业务语义模糊、边界条件容易算错("算不算目标自己"),出事故后排查痛苦。 // 修复后的契约: // - CountOtherActiveAdminsTx(ctx, session, productCode, excludeId) // 返回 productCode 下 memberType=ADMIN 且 status=Enabled 且 id != excludeId 的行数。 // - 不再依赖调用方做减法,0 就是"最后一个 admin",1 就是"还有 1 个 backup admin"。 // - 作用于事务 session,且为 FOR UPDATE,避免并发场景重复判断、双双放过。 // --------------------------------------------------------------------------- // TC-0867: 唯一 admin 场景,排除自己后返回 0(代表"删掉/降级此人后没有启用 admin")。 func TestCountOtherActiveAdminsTx_SoleAdmin_ReturnsZero(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := NewSysProductMemberModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) pc := "t_pm_coaa_sole_" + testutil.UniqueId() adminUser := randProductMemberUserId() ts := time.Now().Unix() res, err := m.Insert(ctx, &SysProductMember{ ProductCode: pc, UserId: adminUser, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts, }) require.NoError(t, err) adminId, _ := res.LastInsertId() defer testutil.CleanTable(ctx, conn, "sys_product_member", adminId) err = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { n, e := m.CountOtherActiveAdminsTx(c, session, pc, adminId) require.NoError(t, e) assert.Equal(t, int64(0), n, "L-5:唯一 admin 排除自己后必须为 0,调用方据此才能阻止删除最后一个 admin") return nil }) require.NoError(t, err) } // TC-0868: 多 admin 场景,排除 A 后返回剩余 backup admin 数量。 func TestCountOtherActiveAdminsTx_MultipleAdmins_ExcludesSelf(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := NewSysProductMemberModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) pc := "t_pm_coaa_multi_" + testutil.UniqueId() ts := time.Now().Unix() // 插三个启用 ADMIN + 一个启用 MEMBER + 一个禁用 ADMIN,用来检验 WHERE 条件完整性。 type row struct { mt string status int64 } rows := []row{ {consts.MemberTypeAdmin, consts.StatusEnabled}, {consts.MemberTypeAdmin, consts.StatusEnabled}, {consts.MemberTypeAdmin, consts.StatusEnabled}, {consts.MemberTypeMember, consts.StatusEnabled}, // 不计入 {consts.MemberTypeAdmin, consts.StatusDisabled}, // 不计入 } ids := make([]int64, 0, len(rows)) for _, r := range rows { uid := randProductMemberUserId() res, err := m.Insert(ctx, &SysProductMember{ ProductCode: pc, UserId: uid, MemberType: r.mt, Status: r.status, CreateTime: ts, UpdateTime: ts, }) require.NoError(t, err) id, _ := res.LastInsertId() ids = append(ids, id) } t.Cleanup(func() { for _, id := range ids { testutil.CleanTable(ctx, conn, "sys_product_member", id) } }) // 排除 ids[0]:剩下应还有两个启用 ADMIN。 err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { n, e := m.CountOtherActiveAdminsTx(c, session, pc, ids[0]) require.NoError(t, e) assert.Equal(t, int64(2), n, "L-5:MEMBER 与 Disabled 行不得被计入;排除自己后剩余 admin 数必须等于 2") return nil }) require.NoError(t, err) } // TC-0869: 排除一个根本不存在的 id,CountOtherActiveAdminsTx 应直接返回总数 2。 // // 本轮审计 L-2 已经把冗余的 CountActiveAdminsTx(不带 Other)从接口删掉,自洽性校验从 // // CountOther(-1) == CountActive(pc) // // 收紧为 // // CountOther(-1) == 已知播种总数 // // —— 语义等价,但不再依赖已删除的镜像方法(审计 L-2 收敛 surface area)。 func TestCountOtherActiveAdminsTx_NonExistentExclude_EqualsTotal(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := NewSysProductMemberModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) pc := "t_pm_coaa_none_" + testutil.UniqueId() ts := time.Now().Unix() var ids []int64 for i := 0; i < 2; i++ { res, err := m.Insert(ctx, &SysProductMember{ ProductCode: pc, UserId: randProductMemberUserId(), MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts, }) require.NoError(t, err) id, _ := res.LastInsertId() ids = append(ids, id) } t.Cleanup(func() { for _, id := range ids { testutil.CleanTable(ctx, conn, "sys_product_member", id) } }) err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { other, e := m.CountOtherActiveAdminsTx(c, session, pc, -1) // -1 不存在 require.NoError(t, e) assert.Equal(t, int64(2), other, "L-2/L-5:excludeId 不存在时 CountOtherActiveAdminsTx 应等于产品内 active admin 总数") return nil }) require.NoError(t, err) } // TC-0870: 空 productCode 不会串库 —— 不同产品线互不影响。 func TestCountOtherActiveAdminsTx_ScopedByProductCode(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := NewSysProductMemberModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) ts := time.Now().Unix() pcA := "t_pm_coaa_A_" + testutil.UniqueId() pcB := "t_pm_coaa_B_" + testutil.UniqueId() // 产品 A 有 1 个 admin(自己),排除后应为 0;产品 B 有 2 个 admin,这条查询不应拉到产品 B。 resA, err := m.Insert(ctx, &SysProductMember{ ProductCode: pcA, UserId: randProductMemberUserId(), MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts, }) require.NoError(t, err) aId, _ := resA.LastInsertId() defer testutil.CleanTable(ctx, conn, "sys_product_member", aId) var bIds []int64 for i := 0; i < 2; i++ { r, err := m.Insert(ctx, &SysProductMember{ ProductCode: pcB, UserId: randProductMemberUserId(), MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts, }) require.NoError(t, err) id, _ := r.LastInsertId() bIds = append(bIds, id) } t.Cleanup(func() { for _, id := range bIds { testutil.CleanTable(ctx, conn, "sys_product_member", id) } }) err = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { n, e := m.CountOtherActiveAdminsTx(c, session, pcA, aId) require.NoError(t, e) assert.Equal(t, int64(0), n, "L-5:pcA 的排除计数必须只看 pcA,绝不能把 pcB 的 2 个 admin 误计入") return nil }) require.NoError(t, err) }