package member import ( "database/sql" "errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "perms-system-server/internal/consts" productModel "perms-system-server/internal/model/product" memberModel "perms-system-server/internal/model/productmember" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "perms-system-server/internal/testutil/ctxhelper" "perms-system-server/internal/types" "testing" "time" ) func TestUpdateMember_Normal(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) logic := NewUpdateMemberLogic(ctx, svcCtx) mt := "ADMIN" st := int64(2) err = logic.UpdateMember(&types.UpdateMemberReq{ Id: mId, MemberType: &mt, Status: &st, }) require.NoError(t, err) updated, err := svcCtx.SysProductMemberModel.FindOne(ctx, mId) require.NoError(t, err) assert.Equal(t, "ADMIN", updated.MemberType) assert.Equal(t, int64(2), updated.Status) } // TC-0221: 无效MemberType func TestUpdateMember_InvalidMemberType(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) logic := NewUpdateMemberLogic(ctx, svcCtx) mt := "INVALID" err = logic.UpdateMember(&types.UpdateMemberReq{ Id: mId, MemberType: &mt, }) require.Error(t, err) ce, ok := err.(*response.CodeError) require.True(t, ok) assert.Equal(t, 400, ce.Code()) assert.Equal(t, "无效的成员类型", ce.Error()) } // TC-0220: 不存在 func TestUpdateMember_NotFound(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUpdateMemberLogic(ctx, svcCtx) mt := "ADMIN" err := logic.UpdateMember(&types.UpdateMemberReq{ Id: 999999999, MemberType: &mt, }) require.Error(t, err) ce, ok := err.(*response.CodeError) require.True(t, ok) assert.Equal(t, 404, ce.Code()) assert.Equal(t, "成员不存在", ce.Error()) } func int64Ptr(v int64) *int64 { return &v } // TC-1056: 两字段同时 nil,必须 400,且不得做任何 DB 读写 func TestUpdateMember_BothFieldsNil_RejectedWith400(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember) t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) }) err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{ Id: sp.mId, }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce), "两字段全 nil 必须命中 response.ErrBadRequest,且以 *CodeError 传递") assert.Equal(t, 400, ce.Code(), "两字段全 nil 必须 400,不得退化成 200 no-op 或 500") assert.Contains(t, ce.Error(), "至少提供一个", "错误消息应明确提示至少要传 memberType 或 status 之一,便于接入方排查空请求体") got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId) require.NoError(t, err) assert.Equal(t, consts.MemberTypeMember, got.MemberType, "Logic 提前 400,任何 DB 落库都是回归;MemberType 必须保留原值") assert.Equal(t, int64(consts.StatusEnabled), got.Status, "Logic 提前 400 时 Status 也必须保留原值") } // TC-1057: 仅改 Status,MemberType 字段必须按原值保留;绝不回归成 "" func TestUpdateMember_OnlyStatus_PreservesMemberType(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember) t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) }) require.NoError(t, NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{ Id: sp.mId, Status: int64Ptr(consts.StatusDisabled), }), "只改 Status 必须成功") got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId) require.NoError(t, err) assert.Equal(t, int64(consts.StatusDisabled), got.Status, "Status 应按入参更新到禁用") assert.Equal(t, consts.MemberTypeMember, got.MemberType, "防线:旧实现若以空串当缺省值会把 memberType 改写成 '',"+ "权限侧会当这行为非法成员吊销其全部权限,必须杜绝") } // TC-1058: 仅改 MemberType,Status 原值保留 func TestUpdateMember_OnlyMemberType_PreservesStatus(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember) t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) }) require.NoError(t, NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{ Id: sp.mId, MemberType: strPtr(consts.MemberTypeAdmin), }), "只改 MemberType 必须成功") got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId) require.NoError(t, err) assert.Equal(t, consts.MemberTypeAdmin, got.MemberType) assert.Equal(t, int64(consts.StatusEnabled), got.Status, "Status 未提供时必须保留原值;若回归成 0 会让该成员瞬间冻结") } // TC-1059: DEVELOPER 成员仅改 Status 冻结,不得误触 CheckMemberTypeAssignment // 语义上 CheckMemberTypeAssignment 拦的是"用普通 admin 指派/变更 DEVELOPER"的动作;仅冻结 // 一个已存在的 DEVELOPER 不属于"指派 DEVELOPER",必须放行。否则普通 admin 将永远无法管理 // DEVELOPER 的启停,只能靠超管——属于管理面瘫痪。 func TestUpdateMember_DeveloperStatusOnly_BypassesAssignmentCheck(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeDeveloper) t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) }) require.NoError(t, NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{ Id: sp.mId, Status: int64Ptr(consts.StatusDisabled), }), "DEVELOPER 只冻结 (Status=2) 时必须跳过 CheckMemberTypeAssignment;"+ "修复前的 `if memberType == DEVELOPER` 早期校验会把这条合法请求拒成 403") got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId) require.NoError(t, err) assert.Equal(t, consts.MemberTypeDeveloper, got.MemberType, "MemberType 在未传时必须保留 DEVELOPER") assert.Equal(t, int64(consts.StatusDisabled), got.Status) } // TC-1060: 无效 Status(非 1/2)必须 400,不得被"只传 Status"分支绕过校验 func TestUpdateMember_InvalidStatusValue_Rejected(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember) t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) }) for _, bad := range []int64{0, 3, -1, 999} { err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{ Id: sp.mId, Status: int64Ptr(bad), }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce), "无效 Status 必须 *CodeError") assert.Equal(t, 400, ce.Code(), "Status=%d 必须 400 被拒,严禁靠 DB CHECK 或下游枚举触发 500", bad) } got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId) require.NoError(t, err) assert.Equal(t, int64(consts.StatusEnabled), got.Status, "非法 Status 全部被拒,原值必须不变") } // TC-1061: 值与现状完全一致(no-op)不报错,且**不触发** DB 事务/缓存失效 // 这里只能通过"不返回 error"以及"DB 值稳定 + updateTime 不前进"的软信号来近似验证; // 契约上:nextType == member.MemberType && nextStatus == member.Status 时 Logic 早退。 func TestUpdateMember_NoOpUpdate_ReturnsNil(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember) t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) }) before, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId) require.NoError(t, err) require.NoError(t, NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{ Id: sp.mId, MemberType: strPtr(before.MemberType), Status: int64Ptr(before.Status), }), "no-op update 必须 nil,不得被 ErrUpdateConflict 之类误报") after, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId) require.NoError(t, err) assert.Equal(t, before.MemberType, after.MemberType) assert.Equal(t, before.Status, after.Status) assert.Equal(t, before.UpdateTime, after.UpdateTime, "Logic 在 no-op 时应直接 return nil,不进事务,"+ "updateTime 不应被推进;推进即说明 Logic 仍然走了一次冗余 UPDATE") } // TC-0725: 修复:不能将最后一个 ADMIN 降级为 MEMBER func TestUpdateMember_DemoteLastAdminRejected(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) sp := seedEnabledProductWithMember(t, svcCtx, "ADMIN") t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) }) err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{ Id: sp.mId, MemberType: strPtr("MEMBER"), }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) assert.Contains(t, ce.Error(), "最后一个管理员") m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId) require.NoError(t, err) assert.Equal(t, "ADMIN", m.MemberType, "MemberType 不应被改动") } // TC-0726: 修复:有多个 ADMIN 时可以降级其中一个 func TestUpdateMember_DemoteAdmin_WhenMultiple_Allowed(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() code := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() var uIds, mIds []int64 for i := 0; i < 2; i++ { uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: testutil.UniqueId(), Password: testutil.HashPassword("pw"), Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() uIds = append(uIds, uId) mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: code, UserId: uId, MemberType: "ADMIN", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() mIds = append(mIds, mId) } t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTable(ctx, conn, "`sys_user`", uIds...) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{ Id: mIds[0], MemberType: strPtr("MEMBER"), }) require.NoError(t, err) m, err := svcCtx.SysProductMemberModel.FindOne(ctx, mIds[0]) require.NoError(t, err) assert.Equal(t, "MEMBER", m.MemberType) } // TC-0727: 修复:禁用状态的 ADMIN 不计入 active admin 计数,导致剩余 0 个启用 ADMIN 时仍拒绝降级 // 说明:CountActiveAdmins 只统计 status=1 的 ADMIN;即便 DB 里有 2 个 ADMIN,但仅 1 个启用, // 降级这个唯一启用的 ADMIN 仍应被拒绝。 func TestUpdateMember_DemoteLastActiveAdmin_Rejected(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() code := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() var uIds, mIds []int64 statuses := []int64{1, 2} // 一个启用,一个禁用 for _, st := range statuses { uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: testutil.UniqueId(), Password: testutil.HashPassword("pw"), Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() uIds = append(uIds, uId) mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: code, UserId: uId, MemberType: "ADMIN", Status: st, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() mIds = append(mIds, mId) } t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTable(ctx, conn, "`sys_user`", uIds...) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) // 启用中的那个 ADMIN (mIds[0]) 降级应被拒绝 err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{ Id: mIds[0], MemberType: strPtr("DEVELOPER"), }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) assert.Contains(t, ce.Error(), "最后一个管理员") }