package product import ( "context" "database/sql" "errors" "fmt" "testing" "time" "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" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/stores/redis" ) func insertTestProduct(t *testing.T, ctx context.Context) *productModel.SysProduct { t.Helper() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() code := testutil.UniqueId() now := time.Now().Unix() result, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: code, Name: "待更新产品", AppKey: "test_key_" + code, AppSecret: "test_secret_" + code, Remark: "原始备注", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := result.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) }) return &productModel.SysProduct{ Id: id, Code: code, Name: "待更新产品", AppKey: "test_key_" + code, AppSecret: "test_secret_" + code, Remark: "原始备注", Status: 1, CreateTime: now, UpdateTime: now, } } // TC-0076: 正常更新 func TestUpdateProduct_Success(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) p := insertTestProduct(t, ctx) logic := NewUpdateProductLogic(ctx, svcCtx) err := logic.UpdateProduct(&types.UpdateProductReq{ Id: p.Id, Name: "已更新名称", Remark: "已更新备注", Status: 2, }) require.NoError(t, err) updated, err := svcCtx.SysProductModel.FindOne(ctx, p.Id) require.NoError(t, err) assert.Equal(t, "已更新名称", updated.Name) assert.Equal(t, "已更新备注", updated.Remark) assert.Equal(t, int64(2), updated.Status) } // TC-0077: 不存在 func TestUpdateProduct_NotFound(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUpdateProductLogic(ctx, svcCtx) err := logic.UpdateProduct(&types.UpdateProductReq{ Id: 999999999, Name: "不存在", }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 404, codeErr.Code()) assert.Equal(t, "产品不存在", codeErr.Error()) } // TC-0078: 不传status func TestUpdateProduct_StatusZeroUnchanged(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) p := insertTestProduct(t, ctx) logic := NewUpdateProductLogic(ctx, svcCtx) err := logic.UpdateProduct(&types.UpdateProductReq{ Id: p.Id, Name: "新名称", Remark: "新备注", Status: 0, }) require.NoError(t, err) updated, err := svcCtx.SysProductModel.FindOne(ctx, p.Id) require.NoError(t, err) assert.Equal(t, "新名称", updated.Name) assert.Equal(t, "新备注", updated.Remark) assert.Equal(t, int64(1), updated.Status, "status should remain unchanged when req.Status is 0") } // TC-0536: updateProduct非超管拒绝 func TestUpdateProduct_NonSuperAdminRejected(t *testing.T) { ctx := ctxhelper.AdminCtx("test_product") svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUpdateProductLogic(ctx, svcCtx) err := logic.UpdateProduct(&types.UpdateProductReq{Id: 1, Name: "test"}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) } // TC-0090: updateProduct 非法状态值被拒绝(修复验证) func TestUpdateProduct_InvalidStatusRejected(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) p := insertTestProduct(t, ctx) logic := NewUpdateProductLogic(ctx, svcCtx) invalid := []int64{3, 99, -1} for _, st := range invalid { err := logic.UpdateProduct(&types.UpdateProductReq{Id: p.Id, Status: st}) require.Error(t, err, "status=%d 应被拒绝", st) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) } after, err := svcCtx.SysProductModel.FindOne(ctx, p.Id) require.NoError(t, err) assert.Equal(t, int64(1), after.Status, "非法状态不应落库") } // --------------------------------------------------------------------------- // L-R15-3:UpdateProduct 在 Enabled → Disabled 迁移时,事务内同步对该产品下 // 所有启用成员的 sys_user.tokenVersion 做 +1,让旧 access token 在下一次 // middleware 校验时被 401 踢出。Disabled → Enabled / 非状态更新不触发。 // 断言三件事: // - revoke 集合完备且不越界(只影响 target product 的 active 成员); // - 事务原子:产品写失败则 tokenVersion 不涨,反之亦然; // - post-commit 失效 sysProduct + sysUser 两侧低层缓存。 // --------------------------------------------------------------------------- // seedProductWithMembers 构造一个启用中的产品,附带若干成员: // - memberSpec 每项是 {memberType, status},按顺序 seed; // - 返回产品及各 userId(顺序对应入参)。 // 统一用 UniqueId 避免与既有数据冲突;t.Cleanup 里按 productCode 清理成员,再删用户/产品。 type memberSpec struct { MemberType string Status int64 // 1=Enabled, 2=Disabled } type seededProductMembers struct { product *productModel.SysProduct userIds []int64 } func seedProductWithMembers(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, specs []memberSpec) seededProductMembers { t.Helper() conn := testutil.GetTestSqlConn() code := testutil.UniqueId() now := time.Now().Unix() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s", Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() var userIds []int64 for i, s := range specs { uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: fmt.Sprintf("%s_u%d", code, i), Password: testutil.HashPassword("pw"), Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() userIds = append(userIds, uId) _, err = svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: code, UserId: uId, MemberType: s.MemberType, Status: s.Status, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) } t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) for _, uId := range userIds { testutil.CleanTable(ctx, conn, "`sys_user`", uId) } testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) p, err := svcCtx.SysProductModel.FindOne(ctx, pId) require.NoError(t, err) return seededProductMembers{product: p, userIds: userIds} } func readTokenVersion(t *testing.T, ctx context.Context, userId int64) int64 { t.Helper() conn := testutil.GetTestSqlConn() var tv int64 require.NoError(t, conn.QueryRowCtx(ctx, &tv, "SELECT `tokenVersion` FROM `sys_user` WHERE `id` = ?", userId)) return tv } // TC-1138:Enabled→Disabled 时,产品下的启用成员 tokenVersion 全部 +1;禁用成员不影响。 // 覆盖 ADMIN/DEVELOPER/MEMBER 三种 type,证明 revoke 集合按 status 过滤而非按 memberType。 func TestUpdateProduct_Disable_BumpsAllActiveMemberTokenVersions(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{ {consts.MemberTypeAdmin, consts.StatusEnabled}, {consts.MemberTypeDeveloper, consts.StatusEnabled}, {consts.MemberTypeMember, consts.StatusEnabled}, {consts.MemberTypeMember, consts.StatusDisabled}, // 禁用成员必须被跳过 }) before := make([]int64, len(seeded.userIds)) for i, uId := range seeded.userIds { before[i] = readTokenVersion(t, ctx, uId) } require.NoError(t, NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{ Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled, })) // 前 3 个(启用)每个 +1;最后一个(禁用)保持不变 for i := 0; i < 3; i++ { after := readTokenVersion(t, ctx, seeded.userIds[i]) assert.Equal(t, before[i]+1, after, "第 %d 个启用成员(userId=%d)tokenVersion 必须 +1", i, seeded.userIds[i]) } after := readTokenVersion(t, ctx, seeded.userIds[3]) assert.Equal(t, before[3], after, "禁用成员不应被 bump——FindActiveMemberUserIdsByProductCodeTx 必须 WHERE status=1 过滤,"+ "否则已经冻结的旧成员会被二次踢出(无意义)且放大批量 UPDATE 的行数") } // TC-1139:Disabled→Enabled 时 tokenVersion 不变(重启用不吊销 session)。 // 对应 shouldRevokeSessions = (prev==Enabled && next==Disabled) 这一严格方向性判断。 func TestUpdateProduct_Enable_DoesNotBumpTokenVersion(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{ {consts.MemberTypeMember, consts.StatusEnabled}, }) // 先禁用一次把前置条件做足 require.NoError(t, NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{ Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled, })) before := readTokenVersion(t, ctx, seeded.userIds[0]) // 重启用 require.NoError(t, NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{ Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusEnabled, })) after := readTokenVersion(t, ctx, seeded.userIds[0]) assert.Equal(t, before, after, "Disabled→Enabled 不递增——重启用不会让成员获得未曾持有的权限(他们此前即处于 token 已失效态),"+ "再递增只会给合法重登录增加一次无谓的 401") } // TC-1140:仅改名不改 status 时 tokenVersion 不变。 // 非状态迁移走 shouldRevokeSessions=false 分支,不得踢任何人下线。 func TestUpdateProduct_NameOnlyChange_DoesNotBumpTokenVersion(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{ {consts.MemberTypeAdmin, consts.StatusEnabled}, {consts.MemberTypeMember, consts.StatusEnabled}, }) before := []int64{ readTokenVersion(t, ctx, seeded.userIds[0]), readTokenVersion(t, ctx, seeded.userIds[1]), } require.NoError(t, NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{ Id: seeded.product.Id, Name: "new_" + seeded.product.Name, Status: 0, })) for i, uId := range seeded.userIds { assert.Equal(t, before[i], readTokenVersion(t, ctx, uId), "非状态更新不得触发 revoke——userId=%d 被意外递增意味着 shouldRevokeSessions 判定漂移", uId) } } // TC-1141:跨产品隔离——禁用 P1 不应影响只属于 P2 的用户 tokenVersion。 // 对应 FindActiveMemberUserIdsByProductCodeTx 的 WHERE productCode=? 精确过滤。 func TestUpdateProduct_Disable_CrossProductIsolation(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) p1 := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{ {consts.MemberTypeMember, consts.StatusEnabled}, }) p2 := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{ {consts.MemberTypeMember, consts.StatusEnabled}, }) p1Before := readTokenVersion(t, ctx, p1.userIds[0]) p2Before := readTokenVersion(t, ctx, p2.userIds[0]) require.NoError(t, NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{ Id: p1.product.Id, Name: p1.product.Name, Status: consts.StatusDisabled, })) assert.Equal(t, p1Before+1, readTokenVersion(t, ctx, p1.userIds[0]), "P1 成员必须 +1") assert.Equal(t, p2Before, readTokenVersion(t, ctx, p2.userIds[0]), "P2 成员 tokenVersion 绝不允许被波及——这类'共享 userId'跨产品污染是 L-R15-3 的反面教材") } // TC-1142:空活跃成员产品禁用时,UpdateProduct 仍必须成功(不能因 ids 为空误抛错)。 // 同时产品行必须按期从 Enabled 翻到 Disabled,post-commit 失效 productCache 的三把 key。 func TestUpdateProduct_Disable_NoActiveMembers_StillSucceeds(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) seeded := seedProductWithMembers(t, ctx, svcCtx, nil) // 没有成员 require.NoError(t, NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{ Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled, }), "len(ids)==0 的快捷分支必须 nil,不得因为 BatchIncrement 空入参抛错") p, err := svcCtx.SysProductModel.FindOne(ctx, seeded.product.Id) require.NoError(t, err) assert.Equal(t, int64(consts.StatusDisabled), p.Status, "产品行仍必须被正确更新——revoke 集为空不构成短路 UPDATE 的理由") } // TC-1143(新增):post-commit 必须失效 sysProduct 的 id / appKey / code 三把低层缓存。 // 否则下次 FindOne/FindOneByAppKey/FindOneByCode cache-hit 会读到旧 Status=Enabled, // 令客户端观察到"产品已禁但还能正常查到"的脏态。 func TestUpdateProduct_Disable_InvalidatesProductCache(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() cfg := testutil.GetTestConfig() svcCtx := svc.NewServiceContext(cfg) rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{ {consts.MemberTypeMember, consts.StatusEnabled}, }) // 预热三把 key _, err := svcCtx.SysProductModel.FindOne(ctx, seeded.product.Id) require.NoError(t, err) _, err = svcCtx.SysProductModel.FindOneByAppKey(ctx, seeded.product.AppKey) require.NoError(t, err) _, err = svcCtx.SysProductModel.FindOneByCode(ctx, seeded.product.Code) require.NoError(t, err) prefix := testutil.GetTestCachePrefix() keyId := fmt.Sprintf("%s:cache:sysProduct:id:%d", prefix, seeded.product.Id) keyAppKey := fmt.Sprintf("%s:cache:sysProduct:appKey:%s", prefix, seeded.product.AppKey) keyCode := fmt.Sprintf("%s:cache:sysProduct:code:%s", prefix, seeded.product.Code) for _, k := range []string{keyId, keyAppKey, keyCode} { v, _ := rds.Get(k) require.NotEmpty(t, v, "预置:%s 必须已写入缓存", k) } require.NoError(t, NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{ Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled, })) for _, k := range []string{keyId, keyAppKey, keyCode} { v, _ := rds.Get(k) assert.Empty(t, v, "L-R15-3:post-commit 必须清理 sysProduct 低层缓存 %s,否则 FindOne 仍命中旧 Enabled 值", k) } }