package services import ( "context" "database/sql" "errors" "testing" "time" "quyun/v2/app/commands/testx" "quyun/v2/app/errorx" tenantdto "quyun/v2/app/http/tenant/dto" tenant_join_dto "quyun/v2/app/http/tenant_join/dto" "quyun/v2/database" "quyun/v2/database/models" "quyun/v2/pkg/consts" . "github.com/smartystreets/goconvey/convey" "github.com/stretchr/testify/suite" _ "go.ipao.vip/atom" "go.ipao.vip/atom/contracts" "go.ipao.vip/gen/types" "go.uber.org/dig" ) type TenantJoinTestSuiteInjectParams struct { dig.In DB *sql.DB Initials []contracts.Initial `group:"initials"` // nolint:structcheck } type TenantJoinTestSuite struct { suite.Suite TenantJoinTestSuiteInjectParams } func Test_TenantJoin(t *testing.T) { providers := testx.Default().With(Provide) testx.Serve(providers, t, func(p TenantJoinTestSuiteInjectParams) { suite.Run(t, &TenantJoinTestSuite{TenantJoinTestSuiteInjectParams: p}) }) } func (s *TenantJoinTestSuite) truncateAll(ctx context.Context) { So(database.Truncate(ctx, s.DB, models.TableNameTenantInvite, models.TableNameTenantJoinRequest, models.TableNameTenantUser, ), ShouldBeNil) } func (s *TenantJoinTestSuite) seedInvite(ctx context.Context, tenantID, inviterUserID int64, code string, status consts.TenantInviteStatus, maxUses, usedCount int32, expiresAt time.Time) *models.TenantInvite { inv := &models.TenantInvite{ TenantID: tenantID, UserID: inviterUserID, Code: code, Status: status, MaxUses: maxUses, UsedCount: usedCount, ExpiresAt: expiresAt, Remark: "seed", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } So(_db.WithContext(ctx).Create(inv).Error, ShouldBeNil) return inv } func (s *TenantJoinTestSuite) seedJoinRequest(ctx context.Context, tenantID, userID int64, status consts.TenantJoinRequestStatus) *models.TenantJoinRequest { req := &models.TenantJoinRequest{ TenantID: tenantID, UserID: userID, Status: status, Reason: "seed", DecidedAt: time.Time{}, DecidedReason: "", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } So(_db.WithContext(ctx).Omit("decided_at", "decided_operator_user_id").Create(req).Error, ShouldBeNil) return req } func (s *TenantJoinTestSuite) Test_AdminCreateInvite() { Convey("Tenant.AdminCreateInvite", s.T(), func() { ctx := s.T().Context() s.truncateAll(ctx) tenantID := int64(1) adminUserID := int64(10) Convey("参数非法应返回参数错误", func() { _, err := Tenant.AdminCreateInvite(ctx, 0, adminUserID, &tenantdto.AdminTenantInviteCreateForm{}) So(err, ShouldNotBeNil) var appErr *errorx.AppError So(errors.As(err, &appErr), ShouldBeTrue) So(appErr.Code, ShouldEqual, errorx.CodeInvalidParameter) _, err = Tenant.AdminCreateInvite(ctx, tenantID, 0, &tenantdto.AdminTenantInviteCreateForm{}) So(err, ShouldNotBeNil) }) Convey("code 为空应自动生成并创建成功", func() { out, err := Tenant.AdminCreateInvite(ctx, tenantID, adminUserID, &tenantdto.AdminTenantInviteCreateForm{ Code: "", MaxUses: loToPtr(0), Remark: "test", }) So(err, ShouldBeNil) So(out, ShouldNotBeNil) So(out.ID, ShouldBeGreaterThan, 0) So(out.Code, ShouldNotBeBlank) So(out.Status, ShouldEqual, consts.TenantInviteStatusActive) }) Convey("重复 code 应返回重复错误", func() { _, err := Tenant.AdminCreateInvite(ctx, tenantID, adminUserID, &tenantdto.AdminTenantInviteCreateForm{ Code: "dup_code", MaxUses: loToPtr(0), }) So(err, ShouldBeNil) _, err = Tenant.AdminCreateInvite(ctx, tenantID, adminUserID, &tenantdto.AdminTenantInviteCreateForm{ Code: "dup_code", MaxUses: loToPtr(0), }) So(err, ShouldNotBeNil) var appErr *errorx.AppError So(errors.As(err, &appErr), ShouldBeTrue) So(appErr.Code, ShouldEqual, errorx.CodeRecordDuplicated) }) }) } func (s *TenantJoinTestSuite) Test_AdminDisableInvite() { Convey("Tenant.AdminDisableInvite", s.T(), func() { ctx := s.T().Context() s.truncateAll(ctx) tenantID := int64(1) adminUserID := int64(10) Convey("邀请码不存在应返回记录不存在", func() { _, err := Tenant.AdminDisableInvite(ctx, tenantID, adminUserID, 999, "x") So(err, ShouldNotBeNil) var appErr *errorx.AppError So(errors.As(err, &appErr), ShouldBeTrue) So(appErr.Code, ShouldEqual, errorx.CodeRecordNotFound) }) Convey("禁用应成功且幂等", func() { inv := s.seedInvite(ctx, tenantID, adminUserID, "c1", consts.TenantInviteStatusActive, 0, 0, time.Time{}) out, err := Tenant.AdminDisableInvite(ctx, tenantID, adminUserID, inv.ID, "reason") So(err, ShouldBeNil) So(out.Status, ShouldEqual, consts.TenantInviteStatusDisabled) So(out.DisabledOperatorUserID, ShouldEqual, adminUserID) So(out.DisabledAt.IsZero(), ShouldBeFalse) out2, err := Tenant.AdminDisableInvite(ctx, tenantID, adminUserID, inv.ID, "reason2") So(err, ShouldBeNil) So(out2.Status, ShouldEqual, consts.TenantInviteStatusDisabled) }) }) } func (s *TenantJoinTestSuite) Test_AdminInvitePage() { Convey("Tenant.AdminInvitePage", s.T(), func() { ctx := s.T().Context() s.truncateAll(ctx) tenantID := int64(1) adminUserID := int64(10) s.seedInvite(ctx, tenantID, adminUserID, "aaa", consts.TenantInviteStatusActive, 0, 0, time.Time{}) s.seedInvite(ctx, tenantID, adminUserID, "bbb", consts.TenantInviteStatusDisabled, 0, 0, time.Time{}) s.seedInvite(ctx, tenantID, adminUserID, "ccc", consts.TenantInviteStatusActive, 0, 0, time.Time{}) Convey("按 status 过滤应只返回匹配项", func() { st := consts.TenantInviteStatusActive pager, err := Tenant.AdminInvitePage(ctx, tenantID, &tenantdto.AdminTenantInviteListFilter{ Status: &st, }) So(err, ShouldBeNil) So(pager.Total, ShouldEqual, 2) }) Convey("按 code 模糊过滤应生效", func() { code := "bb" pager, err := Tenant.AdminInvitePage(ctx, tenantID, &tenantdto.AdminTenantInviteListFilter{ Code: &code, }) So(err, ShouldBeNil) So(pager.Total, ShouldEqual, 1) }) }) } func (s *TenantJoinTestSuite) Test_JoinByInvite() { Convey("Tenant.JoinByInvite", s.T(), func() { ctx := s.T().Context() s.truncateAll(ctx) tenantID := int64(1) adminUserID := int64(10) userID := int64(20) Convey("邀请码不存在应返回记录不存在", func() { _, err := Tenant.JoinByInvite(ctx, tenantID, userID, "not_exist") So(err, ShouldNotBeNil) var appErr *errorx.AppError So(errors.As(err, &appErr), ShouldBeTrue) So(appErr.Code, ShouldEqual, errorx.CodeRecordNotFound) }) Convey("成功加入应创建成员并消耗邀请码次数", func() { inv := s.seedInvite(ctx, tenantID, adminUserID, "code1", consts.TenantInviteStatusActive, 1, 0, time.Now().UTC().Add(10*time.Minute)) tu, err := Tenant.JoinByInvite(ctx, tenantID, userID, "code1") So(err, ShouldBeNil) So(tu, ShouldNotBeNil) So(tu.TenantID, ShouldEqual, tenantID) So(tu.UserID, ShouldEqual, userID) var inv2 models.TenantInvite So(_db.WithContext(ctx).Where("id = ?", inv.ID).First(&inv2).Error, ShouldBeNil) So(inv2.UsedCount, ShouldEqual, 1) So(inv2.Status, ShouldEqual, consts.TenantInviteStatusExpired) Convey("重复加入应幂等且不再消耗次数", func() { _, err := Tenant.JoinByInvite(ctx, tenantID, userID, "code1") So(err, ShouldBeNil) var inv3 models.TenantInvite So(_db.WithContext(ctx).Where("id = ?", inv.ID).First(&inv3).Error, ShouldBeNil) So(inv3.UsedCount, ShouldEqual, 1) }) }) }) } func (s *TenantJoinTestSuite) Test_CreateJoinRequest() { Convey("Tenant.CreateJoinRequest", s.T(), func() { ctx := s.T().Context() s.truncateAll(ctx) tenantID := int64(1) userID := int64(20) Convey("已是成员应返回前置条件失败", func() { So(_db.WithContext(ctx).Create(&models.TenantUser{ TenantID: tenantID, UserID: userID, Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), Status: consts.UserStatusVerified, }).Error, ShouldBeNil) _, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenant_join_dto.JoinRequestCreateForm{Reason: "x"}) So(err, ShouldNotBeNil) var appErr *errorx.AppError So(errors.As(err, &appErr), ShouldBeTrue) So(appErr.Code, ShouldEqual, errorx.CodePreconditionFailed) }) Convey("重复提交应返回同一个 pending 申请(幂等)", func() { s.truncateAll(ctx) out1, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenant_join_dto.JoinRequestCreateForm{Reason: "a"}) So(err, ShouldBeNil) So(out1, ShouldNotBeNil) out2, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenant_join_dto.JoinRequestCreateForm{Reason: "b"}) So(err, ShouldBeNil) So(out2, ShouldNotBeNil) So(out2.ID, ShouldEqual, out1.ID) So(out2.Status, ShouldEqual, consts.TenantJoinRequestStatusPending) }) }) } func (s *TenantJoinTestSuite) Test_AdminJoinRequestPage() { Convey("Tenant.AdminJoinRequestPage", s.T(), func() { ctx := s.T().Context() s.truncateAll(ctx) tenantID := int64(1) s.seedJoinRequest(ctx, tenantID, 11, consts.TenantJoinRequestStatusPending) s.seedJoinRequest(ctx, tenantID, 22, consts.TenantJoinRequestStatusRejected) s.seedJoinRequest(ctx, tenantID, 33, consts.TenantJoinRequestStatusPending) Convey("按 status 过滤应生效", func() { st := consts.TenantJoinRequestStatusPending pager, err := Tenant.AdminJoinRequestPage(ctx, tenantID, &tenantdto.AdminTenantJoinRequestListFilter{Status: &st}) So(err, ShouldBeNil) So(pager.Total, ShouldEqual, 2) }) }) } func (s *TenantJoinTestSuite) Test_AdminApproveJoinRequest() { Convey("Tenant.AdminApproveJoinRequest", s.T(), func() { ctx := s.T().Context() s.truncateAll(ctx) tenantID := int64(1) adminUserID := int64(10) userID := int64(20) Convey("申请不存在应返回记录不存在", func() { _, err := Tenant.AdminApproveJoinRequest(ctx, tenantID, adminUserID, 999, "x") So(err, ShouldNotBeNil) var appErr *errorx.AppError So(errors.As(err, &appErr), ShouldBeTrue) So(appErr.Code, ShouldEqual, errorx.CodeRecordNotFound) }) Convey("通过 pending 申请应成功且幂等", func() { req := s.seedJoinRequest(ctx, tenantID, userID, consts.TenantJoinRequestStatusPending) out, err := Tenant.AdminApproveJoinRequest(ctx, tenantID, adminUserID, req.ID, "ok") So(err, ShouldBeNil) So(out.Status, ShouldEqual, consts.TenantJoinRequestStatusApproved) So(out.DecidedOperatorUserID, ShouldEqual, adminUserID) So(out.DecidedAt.IsZero(), ShouldBeFalse) var tu models.TenantUser So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu).Error, ShouldBeNil) out2, err := Tenant.AdminApproveJoinRequest(ctx, tenantID, adminUserID, req.ID, "ok2") So(err, ShouldBeNil) So(out2.Status, ShouldEqual, consts.TenantJoinRequestStatusApproved) }) }) } func (s *TenantJoinTestSuite) Test_AdminRejectJoinRequest() { Convey("Tenant.AdminRejectJoinRequest", s.T(), func() { ctx := s.T().Context() s.truncateAll(ctx) tenantID := int64(1) adminUserID := int64(10) userID := int64(20) Convey("拒绝 pending 申请应成功且幂等", func() { req := s.seedJoinRequest(ctx, tenantID, userID, consts.TenantJoinRequestStatusPending) out, err := Tenant.AdminRejectJoinRequest(ctx, tenantID, adminUserID, req.ID, "no") So(err, ShouldBeNil) So(out.Status, ShouldEqual, consts.TenantJoinRequestStatusRejected) So(out.DecidedOperatorUserID, ShouldEqual, adminUserID) out2, err := Tenant.AdminRejectJoinRequest(ctx, tenantID, adminUserID, req.ID, "no2") So(err, ShouldBeNil) So(out2.Status, ShouldEqual, consts.TenantJoinRequestStatusRejected) }) }) } func loToPtr[T any](v T) *T { return &v }