diff --git a/controllers/corp_signing.go b/controllers/corp_signing.go index 60f4fac..5f9d538 100644 --- a/controllers/corp_signing.go +++ b/controllers/corp_signing.go @@ -3,6 +3,8 @@ package controllers import ( "strings" + "github.com/beego/beego/v2/core/logs" + "github.com/opensourceways/app-cla-server/models" "github.com/opensourceways/app-cla-server/signing/domain/dp" "github.com/opensourceways/app-cla-server/worker" @@ -365,10 +367,20 @@ func (ctl *CorporationSigningController) UpdateRepresentative() { return } - if merr := models.UpdateCorpRepresentative(pl.UserId, linkID, signingID, &opt); merr != nil { + if created, merr := models.UpdateCorpRepresentative(pl.UserId, linkID, signingID, &opt); merr != nil { ctl.sendModelErrorAsResp(merr, action) } else { ctl.sendSuccessResp(action, "representative updated successfully") + + // 本次为新管理员补建了账号:发送账号通知邮件(best-effort) + if created != nil { + orgInfo, omerr := models.GetLink(linkID) + if omerr != nil { + logs.Error("get link info failed after updating representative, link_id: %s, err: %s", linkID, omerr.Error()) + } else { + notifyCorpAdmin(linkID, &orgInfo, created) + } + } } } diff --git a/models/corp_signing.go b/models/corp_signing.go index e3364b6..62397ae 100644 --- a/models/corp_signing.go +++ b/models/corp_signing.go @@ -63,7 +63,7 @@ type RepresentativeUpdateOption struct { RepEmail string `json:"rep_email" valid:"Required;Email"` } -func UpdateCorpRepresentative(userId, linkID, signingID string, opt *RepresentativeUpdateOption) IModelError { +func UpdateCorpRepresentative(userId, linkID, signingID string, opt *RepresentativeUpdateOption) (*CorporationManagerCreateOption, IModelError) { return corpSigningAdapterInstance.UpdateRepresentative(userId, linkID, signingID, opt) } diff --git a/models/init.go b/models/init.go index d14366a..b545491 100644 --- a/models/init.go +++ b/models/init.go @@ -28,7 +28,7 @@ type corpSigningAdapter interface { FindCorpSummary(linkId string, email string) (interface{}, IModelError) FindDiffCLAFile(signingId string) (string, IModelError) Agree(signingId string) IModelError - UpdateRepresentative(userId, linkID, signingID string, opt *RepresentativeUpdateOption) IModelError + UpdateRepresentative(userId, linkID, signingID string, opt *RepresentativeUpdateOption) (*CorporationManagerCreateOption, IModelError) FindPendingAgreements(userId, linkId string) ([]CorporationSigningPendingItem, IModelError) } diff --git a/signing/adapter/corp_signing.go b/signing/adapter/corp_signing.go index 40e218b..35e446d 100644 --- a/signing/adapter/corp_signing.go +++ b/signing/adapter/corp_signing.go @@ -272,20 +272,25 @@ func getAllSigningInfo( return r, nil } -func (adapter *corpSigningAdatper) UpdateRepresentative(userId, linkID, signingID string, opt *models.RepresentativeUpdateOption) models.IModelError { +func (adapter *corpSigningAdatper) UpdateRepresentative(userId, linkID, signingID string, opt *models.RepresentativeUpdateOption) (*models.CorporationManagerCreateOption, models.IModelError) { if opt.RepName == "" || opt.RepEmail == "" { - return toModelError(errors.New("representative name and email are required")) + return nil, toModelError(errors.New("representative name and email are required")) } if _, err := dp.NewEmailAddr(opt.RepEmail); err != nil { - return toModelError(err) + return nil, toModelError(err) } - err := adapter.s.UpdateRepresentative(userId, linkID, signingID, opt.RepName, opt.RepEmail) + created, err := adapter.s.UpdateRepresentative(userId, linkID, signingID, opt.RepName, opt.RepEmail) if err != nil { - return toModelError(err) + return nil, toModelError(err) } - return nil + if created == nil { + return nil, nil + } + + r := toCorporationManagerCreateOption(created) + return &r, nil } func (adapter *corpSigningAdatper) FindPendingAgreements(userId, linkId string) ([]models.CorporationSigningPendingItem, models.IModelError) { diff --git a/signing/adapter/corp_signing_test.go b/signing/adapter/corp_signing_test.go new file mode 100644 index 0000000..ca51b6d --- /dev/null +++ b/signing/adapter/corp_signing_test.go @@ -0,0 +1,133 @@ +package adapter + +import ( + "errors" + "testing" + + "github.com/opensourceways/app-cla-server/models" + "github.com/opensourceways/app-cla-server/signing/app" + "github.com/opensourceways/app-cla-server/signing/domain" +) + +// ---- mock ---- + +type mockCorpSigningService struct { + app.CorpSigningService // embed nil interface + + created *app.ManagerDTO + err error + + calledRep bool + repName string + repEmail string +} + +func (m *mockCorpSigningService) UpdateRepresentative(userId, linkID, signingID, repName, repEmail string) (*app.ManagerDTO, error) { + m.calledRep = true + m.repName = repName + m.repEmail = repEmail + return m.created, m.err +} + +// ---- tests ---- + +func TestUpdateRepresentative_Validation_EmptyName(t *testing.T) { + a := &corpSigningAdatper{s: &mockCorpSigningService{}} + opt := &models.RepresentativeUpdateOption{RepName: "", RepEmail: "b@corp.com"} + + r, merr := a.UpdateRepresentative("u", "l", "s", opt) + if merr == nil { + t.Fatal("expected model error for empty name") + } + if r != nil { + t.Errorf("result should be nil, got %+v", r) + } +} + +func TestUpdateRepresentative_Validation_EmptyEmail(t *testing.T) { + a := &corpSigningAdatper{s: &mockCorpSigningService{}} + opt := &models.RepresentativeUpdateOption{RepName: "Bob", RepEmail: ""} + + r, merr := a.UpdateRepresentative("u", "l", "s", opt) + if merr == nil { + t.Fatal("expected model error for empty email") + } + if r != nil { + t.Errorf("result should be nil, got %+v", r) + } +} + +func TestUpdateRepresentative_Validation_InvalidEmail(t *testing.T) { + a := &corpSigningAdatper{s: &mockCorpSigningService{}} + opt := &models.RepresentativeUpdateOption{RepName: "Bob", RepEmail: "not-an-email"} + + r, merr := a.UpdateRepresentative("u", "l", "s", opt) + if merr == nil { + t.Fatal("expected model error for invalid email") + } + if r != nil { + t.Errorf("result should be nil, got %+v", r) + } +} + +func TestUpdateRepresentative_NoNewAccount(t *testing.T) { + svc := &mockCorpSigningService{created: nil} + a := &corpSigningAdatper{s: svc} + opt := &models.RepresentativeUpdateOption{RepName: "Bob", RepEmail: "b@corp.com"} + + r, merr := a.UpdateRepresentative("u", "l", "s", opt) + if merr != nil { + t.Fatalf("unexpected error: %v", merr) + } + if r != nil { + t.Errorf("result should be nil when no account created, got %+v", r) + } + if !svc.calledRep { + t.Error("service UpdateRepresentative should be called") + } + if svc.repName != "Bob" || svc.repEmail != "b@corp.com" { + t.Errorf("args: name=%s email=%s", svc.repName, svc.repEmail) + } +} + +func TestUpdateRepresentative_NewAccountCreated(t *testing.T) { + created := &app.ManagerDTO{ + Role: domain.RoleAdmin, + Name: "Bob", + Account: "admin_corp.com", + Password: []byte("init-pw"), + EmailAddr: "b@corp.com", + } + svc := &mockCorpSigningService{created: created} + a := &corpSigningAdatper{s: svc} + opt := &models.RepresentativeUpdateOption{RepName: "Bob", RepEmail: "b@corp.com"} + + r, merr := a.UpdateRepresentative("u", "l", "s", opt) + if merr != nil { + t.Fatalf("unexpected error: %v", merr) + } + if r == nil { + t.Fatal("result should not be nil when account created") + } + if r.ID != "admin_corp.com" || r.Email != "b@corp.com" || r.Role != domain.RoleAdmin { + t.Errorf("mapped result: %+v", r) + } + if string(r.Password) != "init-pw" { + t.Errorf("password: got %s", string(r.Password)) + } +} + +func TestUpdateRepresentative_ServiceError(t *testing.T) { + wantErr := errors.New("boom") + svc := &mockCorpSigningService{err: wantErr} + a := &corpSigningAdatper{s: svc} + opt := &models.RepresentativeUpdateOption{RepName: "Bob", RepEmail: "b@corp.com"} + + r, merr := a.UpdateRepresentative("u", "l", "s", opt) + if merr == nil { + t.Fatal("expected model error") + } + if r != nil { + t.Errorf("result should be nil on error, got %+v", r) + } +} diff --git a/signing/adapter/main_test.go b/signing/adapter/main_test.go new file mode 100644 index 0000000..d4eb0d2 --- /dev/null +++ b/signing/adapter/main_test.go @@ -0,0 +1,20 @@ +package adapter + +import ( + "os" + "testing" + + "github.com/opensourceways/app-cla-server/signing/domain/dp" +) + +func TestMain(m *testing.M) { + dp.Init(&dp.Config{ + MaxLengthOfName: 100, + MaxLengthOfTitle: 200, + MaxLengthOfEmail: 200, + MaxLengthOfAccount: 100, + MaxLengthOfCorpName: 200, + }) + + os.Exit(m.Run()) +} diff --git a/signing/app/corp_signing.go b/signing/app/corp_signing.go index f5e3c89..ea42f89 100644 --- a/signing/app/corp_signing.go +++ b/signing/app/corp_signing.go @@ -4,6 +4,8 @@ import ( "errors" "time" + "github.com/beego/beego/v2/core/logs" + commonRepo "github.com/opensourceways/app-cla-server/common/domain/repository" "github.com/opensourceways/app-cla-server/signing/domain" "github.com/opensourceways/app-cla-server/signing/domain/claservice" @@ -41,7 +43,7 @@ type CorpSigningService interface { FindCorpSummary(cmd *CmdToFindCorpSummary) ([]CorpSummaryDTO, error) FindDiffCLAFile(signingId string) (string, error) AgreeWithLatestCLA(signingId string) error - UpdateRepresentative(userId, linkID, signingID, repName, repEmail string) error + UpdateRepresentative(userId, linkID, signingID, repName, repEmail string) (*ManagerDTO, error) FindPendingAgreements(userId, linkId string) ([]CorpSigningPendingDTO, error) } @@ -248,27 +250,27 @@ func (s *corpSigningService) AgreeWithLatestCLA(signingId string) error { return s.repo.UpdateClaId(&signed) } -func (s *corpSigningService) UpdateRepresentative(userId, linkID, signingID, repName, repEmail string) error { +func (s *corpSigningService) UpdateRepresentative(userId, linkID, signingID, repName, repEmail string) (*ManagerDTO, error) { // 权限验证 - 只有社区管理员可以操作 if _, err := checkIfCommunityManager(userId, linkID, s.linkRepo); err != nil { - return err + return nil, err } // 查找企业签名 cs, err := s.repo.Find(signingID) if err != nil { - return err + return nil, err } // 验证link_id匹配 if cs.Link.Id != linkID { - return commonRepo.NewErrorResourceNotFound(errors.New("signing not found")) + return nil, commonRepo.NewErrorResourceNotFound(errors.New("signing not found")) } // 创建新的代表信息 newRep, err := domain.NewRepresentative(repName, repEmail) if err != nil { - return err + return nil, err } oldEmail := cs.Rep.EmailAddr @@ -283,17 +285,69 @@ func (s *corpSigningService) UpdateRepresentative(userId, linkID, signingID, rep // 保存到数据库 if err := s.repo.Update(&cs); err != nil { - return err + return nil, err } - // 同步更新 User 表中的邮箱 - if cs.Admin.Id != "" { + // 无管理员账号或邮箱未变:无需同步 user 表 + if cs.Admin.Id == "" || oldEmail.EmailAddr() == newRep.EmailAddr.EmailAddr() { + return nil, nil + } + + // 旧邮箱对应账号已存在:复用并改写邮箱 + exists, err := s.userService.IsAValidUser(cs.Link.Id, oldEmail) + if err != nil { + return nil, err + } + if exists { if err := s.userService.UpdateEmail(cs.Link.Id, oldEmail, newRep.EmailAddr); err != nil { - return err + return nil, err + } + return nil, nil + } + + // 旧账号缺失(未预置/迁移遗留):为当前管理员新建账号 + logs.Info( + "create user account for the new representative, since the old email(%s) is not found, link_id: %s, signing_id: %s", + oldEmail.EmailAddr(), cs.Link.Id, signingID, + ) + + pws, _, err := s.userService.Add(cs.Link.Id, signingID, []domain.Manager{cs.Admin}) + if err == nil { + account, aerr := cs.Admin.Account() + if aerr != nil { + return nil, aerr + } + + admin := &cs.Admin + return &ManagerDTO{ + Role: domain.RoleAdmin, + Name: admin.Name.Name(), + Account: account.Account(), + Password: pws[admin.Id].Password(), + EmailAddr: admin.EmailAddr.EmailAddr(), + }, nil + } + + // 新建失败且为账号重复(存在邮箱漂移的孤儿管理员账号): + // 按新账号定位孤儿并改写其邮箱,复用已有账号 + if domain.IsErrorOf(err, domain.ErrorCodeUserExists) { + account, aerr := cs.Admin.Account() + if aerr != nil { + return nil, aerr + } + + logs.Info( + "fallback to update email by account(%s) for orphan admin, link_id: %s", + account.Account(), cs.Link.Id, + ) + + if uerr := s.userService.UpdateEmailByAccount(cs.Link.Id, account, newRep.EmailAddr); uerr != nil { + return nil, uerr } + return nil, nil } - return nil + return nil, err } func (s *corpSigningService) FindPendingAgreements(userId, linkId string) ([]CorpSigningPendingDTO, error) { diff --git a/signing/app/corp_signing_test.go b/signing/app/corp_signing_test.go new file mode 100644 index 0000000..9af8719 --- /dev/null +++ b/signing/app/corp_signing_test.go @@ -0,0 +1,489 @@ +package app + +import ( + "errors" + "testing" + + commonRepo "github.com/opensourceways/app-cla-server/common/domain/repository" + "github.com/opensourceways/app-cla-server/signing/domain" + "github.com/opensourceways/app-cla-server/signing/domain/dp" + "github.com/opensourceways/app-cla-server/signing/domain/repository" + "github.com/opensourceways/app-cla-server/signing/domain/userservice" +) + +// ---- mocks ---- + +type mockUserService struct { + userservice.UserService // embed nil interface; only overridden methods are used + + validUser bool + validErr error + updateEmailErr error + updateByAcctErr error + addPws map[string]dp.Password + addIDs []string + addErr error + + calledIsAValid bool + calledUpdateEmail bool + calledUpdateByAcct bool + calledAdd bool + + isValidEmailArg dp.EmailAddr + updateEmailOld dp.EmailAddr + updateEmailNew dp.EmailAddr + updateByAcctAcct dp.Account + updateByAcctEmail dp.EmailAddr + addManagers []domain.Manager +} + +func (m *mockUserService) IsAValidUser(linkId string, email dp.EmailAddr) (bool, error) { + m.calledIsAValid = true + m.isValidEmailArg = email + return m.validUser, m.validErr +} + +func (m *mockUserService) UpdateEmail(linkId string, oldEmail, newEmail dp.EmailAddr) error { + m.calledUpdateEmail = true + m.updateEmailOld = oldEmail + m.updateEmailNew = newEmail + return m.updateEmailErr +} + +func (m *mockUserService) UpdateEmailByAccount(linkId string, account dp.Account, newEmail dp.EmailAddr) error { + m.calledUpdateByAcct = true + m.updateByAcctAcct = account + m.updateByAcctEmail = newEmail + return m.updateByAcctErr +} + +func (m *mockUserService) Add(linkId, csId string, managers []domain.Manager) (map[string]dp.Password, []string, error) { + m.calledAdd = true + m.addManagers = managers + return m.addPws, m.addIDs, m.addErr +} + +type mockLinkRepo struct { + repository.Link + link domain.Link + err error +} + +func (m *mockLinkRepo) Find(string) (domain.Link, error) { + return m.link, m.err +} + +type mockCorpSigningRepo struct { + repository.CorpSigning + findResult domain.CorpSigning + findErr error + updateErr error + updated *domain.CorpSigning +} + +func (m *mockCorpSigningRepo) Find(string) (domain.CorpSigning, error) { + return m.findResult, m.findErr +} + +func (m *mockCorpSigningRepo) Update(cs *domain.CorpSigning) error { + m.updated = cs + return m.updateErr +} + +// ---- helpers ---- + +func newService(repo repository.CorpSigning, linkRepo repository.Link, us userservice.UserService) *corpSigningService { + return &corpSigningService{repo: repo, linkRepo: linkRepo, userService: us} +} + +func mustName(s string) dp.Name { + v, err := dp.NewName(s) + if err != nil { + panic(err) + } + return v +} + +func mustEmail(s string) dp.EmailAddr { + v, err := dp.NewEmailAddr(s) + if err != nil { + panic(err) + } + return v +} + +func mustPW(s string) dp.Password { + v, err := dp.NewPassword([]byte(s)) + if err != nil { + panic(err) + } + return v +} + +// cs with admin set, rep email == admin email == a@corp.com +func baseCorpSigning() domain.CorpSigning { + rep := domain.Representative{Name: mustName("Alice"), EmailAddr: mustEmail("a@corp.com")} + return domain.CorpSigning{ + Id: "cs1", + Link: domain.LinkInfo{Id: "link1"}, + Rep: rep, + Admin: domain.Manager{ + Id: "admin", + Representative: rep, + }, + } +} + +// ---- tests ---- + +// 用例1: 账号已存在(同域换人) -> UpdateEmail, 不新建, dto=nil +func TestUpdateRepresentative_AccountExists_SameDomain(t *testing.T) { + cs := baseCorpSigning() + repo := &mockCorpSigningRepo{findResult: cs} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "user1"}} + us := &mockUserService{validUser: true} + + s := newService(repo, linkRepo, us) + dto, err := s.UpdateRepresentative("user1", "link1", "cs1", "Bob", "b@corp.com") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dto != nil { + t.Errorf("dto should be nil when reusing existing account, got %+v", dto) + } + if !us.calledIsAValid { + t.Error("IsAValidUser should be called") + } + if !us.calledUpdateEmail { + t.Error("UpdateEmail should be called") + } + if us.calledAdd { + t.Error("Add should NOT be called when account exists") + } + if us.updateEmailOld.EmailAddr() != "a@corp.com" { + t.Errorf("old email: got %s", us.updateEmailOld.EmailAddr()) + } + if us.updateEmailNew.EmailAddr() != "b@corp.com" { + t.Errorf("new email: got %s", us.updateEmailNew.EmailAddr()) + } +} + +// 用例2: 账号已存在(跨域换人) -> UpdateEmail, account 由 userservice 内部改写 +func TestUpdateRepresentative_AccountExists_CrossDomain(t *testing.T) { + cs := baseCorpSigning() + repo := &mockCorpSigningRepo{findResult: cs} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "user1"}} + us := &mockUserService{validUser: true} + + s := newService(repo, linkRepo, us) + dto, err := s.UpdateRepresentative("user1", "link1", "cs1", "Bob", "b@newcorp.com") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dto != nil { + t.Errorf("dto should be nil, got %+v", dto) + } + if !us.calledUpdateEmail { + t.Error("UpdateEmail should be called") + } + if us.updateEmailNew.EmailAddr() != "b@newcorp.com" { + t.Errorf("new email: got %s", us.updateEmailNew.EmailAddr()) + } +} + +// 用例3(核心): 账号未预置 -> Add 新建, dto!=nil 含初始口令 +func TestUpdateRepresentative_AccountMissing_CreateNew(t *testing.T) { + cs := baseCorpSigning() + repo := &mockCorpSigningRepo{findResult: cs} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "user1"}} + us := &mockUserService{ + validUser: false, + addPws: map[string]dp.Password{"admin": mustPW("init-pw")}, + addIDs: []string{"u1"}, + } + + s := newService(repo, linkRepo, us) + dto, err := s.UpdateRepresentative("user1", "link1", "cs1", "Bob", "b@corp.com") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dto == nil { + t.Fatal("dto should not be nil when a new account is created") + } + if !us.calledAdd { + t.Error("Add should be called when account is missing") + } + if us.calledUpdateEmail || us.calledUpdateByAcct { + t.Error("UpdateEmail/UpdateEmailByAccount should NOT be called") + } + if dto.Role != domain.RoleAdmin { + t.Errorf("role: got %s, want %s", dto.Role, domain.RoleAdmin) + } + if dto.Name != "Bob" { + t.Errorf("name: got %s, want Bob", dto.Name) + } + if dto.EmailAddr != "b@corp.com" { + t.Errorf("email: got %s, want b@corp.com", dto.EmailAddr) + } + if dto.Account != "admin_corp.com" { + t.Errorf("account: got %s, want admin_corp.com", dto.Account) + } + if string(dto.Password) != "init-pw" { + t.Errorf("password: got %s", string(dto.Password)) + } + if len(us.addManagers) != 1 || us.addManagers[0].Id != "admin" { + t.Errorf("add managers: %+v", us.addManagers) + } +} + +// 用例4: 邮箱未变 -> 跳过账号同步 +func TestUpdateRepresentative_EmailUnchanged(t *testing.T) { + cs := baseCorpSigning() + repo := &mockCorpSigningRepo{findResult: cs} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "user1"}} + us := &mockUserService{validUser: true} + + s := newService(repo, linkRepo, us) + dto, err := s.UpdateRepresentative("user1", "link1", "cs1", "NewName", "a@corp.com") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dto != nil { + t.Errorf("dto should be nil, got %+v", dto) + } + if us.calledIsAValid || us.calledUpdateEmail || us.calledAdd { + t.Error("no user service call expected when email is unchanged") + } +} + +// 用例5: 管理员尚未创建(cs.Admin.Id=="") -> 仅更新 rep, 不碰 user 表 +func TestUpdateRepresentative_AdminNotCreated(t *testing.T) { + cs := baseCorpSigning() + cs.Admin.Id = "" + repo := &mockCorpSigningRepo{findResult: cs} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "user1"}} + us := &mockUserService{} + + s := newService(repo, linkRepo, us) + dto, err := s.UpdateRepresentative("user1", "link1", "cs1", "Bob", "b@corp.com") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dto != nil { + t.Errorf("dto should be nil, got %+v", dto) + } + if us.calledIsAValid || us.calledUpdateEmail || us.calledAdd || us.calledUpdateByAcct { + t.Error("no user service call expected when admin id is empty") + } +} + +// 用例6(异常): 孤儿账号 -> Add 返回 user_exists -> 按账号回退 UpdateEmailByAccount +func TestUpdateRepresentative_OrphanAccount_FallbackByAccount(t *testing.T) { + cs := baseCorpSigning() + repo := &mockCorpSigningRepo{findResult: cs} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "user1"}} + us := &mockUserService{ + validUser: false, + addErr: domain.NewDomainError(domain.ErrorCodeUserExists), + } + + s := newService(repo, linkRepo, us) + dto, err := s.UpdateRepresentative("user1", "link1", "cs1", "Bob", "b@corp.com") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dto != nil { + t.Errorf("dto should be nil for orphan fallback, got %+v", dto) + } + if !us.calledAdd { + t.Error("Add should be attempted first") + } + if !us.calledUpdateByAcct { + t.Error("UpdateEmailByAccount should be called as fallback") + } + if us.updateByAcctAcct.Account() != "admin_corp.com" { + t.Errorf("account: got %s, want admin_corp.com", us.updateByAcctAcct.Account()) + } + if us.updateByAcctEmail.EmailAddr() != "b@corp.com" { + t.Errorf("email: got %s", us.updateByAcctEmail.EmailAddr()) + } +} + +// 用例7(异常): Add 返回非 user_exists 错误 -> 直接返回错误, 不回退 +func TestUpdateRepresentative_AddOtherError(t *testing.T) { + cs := baseCorpSigning() + repo := &mockCorpSigningRepo{findResult: cs} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "user1"}} + wantErr := errors.New("db down") + us := &mockUserService{ + validUser: false, + addErr: wantErr, + } + + s := newService(repo, linkRepo, us) + dto, err := s.UpdateRepresentative("user1", "link1", "cs1", "Bob", "b@corp.com") + + if err != wantErr { + t.Fatalf("err: got %v, want %v", err, wantErr) + } + if dto != nil { + t.Errorf("dto should be nil, got %+v", dto) + } + if us.calledUpdateByAcct { + t.Error("UpdateEmailByAccount should NOT be called for non-user_exists error") + } +} + +// 用例8(非法): 权限不足 -> 返回错误, 不读 corp_signing/不动 user 表 +func TestUpdateRepresentative_NoPermission(t *testing.T) { + cs := baseCorpSigning() + repo := &mockCorpSigningRepo{findResult: cs} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "owner"}} + us := &mockUserService{} + + s := newService(repo, linkRepo, us) + _, err := s.UpdateRepresentative("stranger", "link1", "cs1", "Bob", "b@corp.com") + + if err == nil { + t.Fatal("expected permission error") + } + if us.calledIsAValid || us.calledAdd || us.calledUpdateEmail { + t.Error("no user service call expected when permission denied") + } + if repo.updated != nil { + t.Error("repo.Update should NOT be called when permission denied") + } +} + +// 用例9(非法): link_id 不匹配 -> 返回 not found +func TestUpdateRepresentative_LinkIdMismatch(t *testing.T) { + cs := baseCorpSigning() + repo := &mockCorpSigningRepo{findResult: cs} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "user1"}} + us := &mockUserService{} + + s := newService(repo, linkRepo, us) + _, err := s.UpdateRepresentative("user1", "other-link", "cs1", "Bob", "b@corp.com") + + if err == nil { + t.Fatal("expected error for link_id mismatch") + } + if !commonRepo.IsErrorResourceNotFound(err) { + t.Errorf("err should be ErrorResourceNotFound, got %T", err) + } + if us.calledIsAValid || us.calledAdd { + t.Error("no user service call expected on link mismatch") + } +} + +// 用例10(异常): repo.Find 失败 +func TestUpdateRepresentative_FindError(t *testing.T) { + findErr := errors.New("find failed") + repo := &mockCorpSigningRepo{findErr: findErr} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "user1"}} + us := &mockUserService{} + + s := newService(repo, linkRepo, us) + _, err := s.UpdateRepresentative("user1", "link1", "cs1", "Bob", "b@corp.com") + + if err != findErr { + t.Fatalf("err: got %v, want %v", err, findErr) + } +} + +// 用例11(异常): IsAValidUser 失败 +func TestUpdateRepresentative_IsAValidUserError(t *testing.T) { + cs := baseCorpSigning() + repo := &mockCorpSigningRepo{findResult: cs} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "user1"}} + validErr := errors.New("db error") + us := &mockUserService{validErr: validErr} + + s := newService(repo, linkRepo, us) + _, err := s.UpdateRepresentative("user1", "link1", "cs1", "Bob", "b@corp.com") + + if err != validErr { + t.Fatalf("err: got %v, want %v", err, validErr) + } + if us.calledUpdateEmail || us.calledAdd { + t.Error("no further user service call expected after IsAValidUser error") + } +} + +// 用例12(异常): UpdateEmail 失败 +func TestUpdateRepresentative_UpdateEmailError(t *testing.T) { + cs := baseCorpSigning() + repo := &mockCorpSigningRepo{findResult: cs} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "user1"}} + updateErr := errors.New("update failed") + us := &mockUserService{validUser: true, updateEmailErr: updateErr} + + s := newService(repo, linkRepo, us) + _, err := s.UpdateRepresentative("user1", "link1", "cs1", "Bob", "b@corp.com") + + if err != updateErr { + t.Fatalf("err: got %v, want %v", err, updateErr) + } +} + +// 用例13(异常): 孤儿回退 UpdateEmailByAccount 失败 +func TestUpdateRepresentative_FallbackByAccountError(t *testing.T) { + cs := baseCorpSigning() + repo := &mockCorpSigningRepo{findResult: cs} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "user1"}} + acctErr := errors.New("find by account failed") + us := &mockUserService{ + validUser: false, + addErr: domain.NewDomainError(domain.ErrorCodeUserExists), + updateByAcctErr: acctErr, + } + + s := newService(repo, linkRepo, us) + _, err := s.UpdateRepresentative("user1", "link1", "cs1", "Bob", "b@corp.com") + + if err != acctErr { + t.Fatalf("err: got %v, want %v", err, acctErr) + } +} + +// 用例14(异常): rep 参数非法(空邮箱) -> 返回错误 +func TestUpdateRepresentative_InvalidEmail(t *testing.T) { + repo := &mockCorpSigningRepo{findResult: baseCorpSigning()} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "user1"}} + us := &mockUserService{} + + s := newService(repo, linkRepo, us) + _, err := s.UpdateRepresentative("user1", "link1", "cs1", "Bob", "not-an-email") + + if err == nil { + t.Fatal("expected error for invalid email") + } + if repo.updated != nil { + t.Error("repo.Update should NOT be called for invalid email") + } +} + +// 用例15(异常): repo.Update 失败 +func TestUpdateRepresentative_UpdateError(t *testing.T) { + cs := baseCorpSigning() + updateErr := errors.New("update failed") + repo := &mockCorpSigningRepo{findResult: cs, updateErr: updateErr} + linkRepo := &mockLinkRepo{link: domain.Link{Id: "link1", Submitter: "user1"}} + us := &mockUserService{} + + s := newService(repo, linkRepo, us) + _, err := s.UpdateRepresentative("user1", "link1", "cs1", "Bob", "b@corp.com") + + if err != updateErr { + t.Fatalf("err: got %v, want %v", err, updateErr) + } + if us.calledIsAValid || us.calledAdd { + t.Error("no user service call expected when repo.Update fails") + } +} diff --git a/signing/app/main_test.go b/signing/app/main_test.go new file mode 100644 index 0000000..3607306 --- /dev/null +++ b/signing/app/main_test.go @@ -0,0 +1,34 @@ +package app + +import ( + "os" + "testing" + + "github.com/opensourceways/app-cla-server/signing/domain" + "github.com/opensourceways/app-cla-server/signing/domain/dp" +) + +func TestMain(m *testing.M) { + dp.Init(&dp.Config{ + MaxLengthOfName: 100, + MaxLengthOfTitle: 200, + MaxLengthOfEmail: 200, + MaxLengthOfAccount: 100, + MaxLengthOfCorpName: 200, + }) + + domain.Init(&domain.Config{ + MaxNumOfEmployeeManager: 5, + DefaultGracePeriodDays: 30, + VerificationCodeExpiry: 300, + AccessTokenExpiry: 3600, + MaxNumOfFailedLogin: 5, + IntervalOfCreatingVC: 60, + CommunityManagerLinkId: "fake_link", + SourceOfCLAPDF: []string{"https://gitee.com"}, + MaxSizeOfCLAContent: 2 << 20, + FileTypeOfCLAContent: "pdf", + }) + + os.Exit(m.Run()) +} diff --git a/signing/domain/userservice/service.go b/signing/domain/userservice/service.go index d216de1..e7c2e6e 100644 --- a/signing/domain/userservice/service.go +++ b/signing/domain/userservice/service.go @@ -34,6 +34,7 @@ type UserService interface { ChangePassword(index string, old, newOne dp.Password) error ResetPassword(linkId string, email dp.EmailAddr, newOne dp.Password) error UpdateEmail(linkId string, oldEmail, newEmail dp.EmailAddr) error + UpdateEmailByAccount(linkId string, account dp.Account, newEmail dp.EmailAddr) error } type userService struct { @@ -172,6 +173,17 @@ func (s *userService) UpdateEmail(linkId string, oldEmail, newEmail dp.EmailAddr return s.repo.Save(&u) } +func (s *userService) UpdateEmailByAccount(linkId string, account dp.Account, newEmail dp.EmailAddr) error { + u, err := s.repo.FindByAccount(linkId, account) + if err != nil { + return err + } + + u.EmailAddr = newEmail + + return s.repo.Save(&u) +} + func (s *userService) IsAValidUser(linkId string, email dp.EmailAddr) (bool, error) { if _, err := s.repo.FindByEmail(linkId, email); err != nil { if commonRepo.IsErrorResourceNotFound(err) { diff --git a/signing/domain/userservice/service_test.go b/signing/domain/userservice/service_test.go new file mode 100644 index 0000000..374de52 --- /dev/null +++ b/signing/domain/userservice/service_test.go @@ -0,0 +1,97 @@ +package userservice + +import ( + "errors" + "testing" + + "github.com/opensourceways/app-cla-server/signing/domain" + "github.com/opensourceways/app-cla-server/signing/domain/dp" + "github.com/opensourceways/app-cla-server/signing/domain/repository" +) + +// ---- mock ---- + +type mockUserRepo struct { + repository.User + + findByAccountResult domain.User + findByAccountErr error + saveErr error + + saved *domain.User +} + +func (m *mockUserRepo) FindByAccount(linkId string, a dp.Account) (domain.User, error) { + return m.findByAccountResult, m.findByAccountErr +} + +func (m *mockUserRepo) Save(u *domain.User) error { + m.saved = u + return m.saveErr +} + +// ---- tests ---- + +func TestUpdateEmailByAccount_Success(t *testing.T) { + origEmail := dp.CreateEmailAddr("old@corp.com") + repo := &mockUserRepo{ + findByAccountResult: domain.User{ + LinkId: "link1", + UserBasicInfo: domain.UserBasicInfo{ + Id: "u1", + Account: dp.CreateAccount("admin_corp.com"), + EmailAddr: origEmail, + }, + }, + } + s := NewUserService(repo, nil, nil) + + newEmail := dp.CreateEmailAddr("new@corp.com") + if err := s.UpdateEmailByAccount("link1", dp.CreateAccount("admin_corp.com"), newEmail); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if repo.saved == nil { + t.Fatal("Save should be called") + } + if repo.saved.EmailAddr.EmailAddr() != "new@corp.com" { + t.Errorf("saved email: got %s, want new@corp.com", repo.saved.EmailAddr.EmailAddr()) + } + // account 不应变 + if repo.saved.Account.Account() != "admin_corp.com" { + t.Errorf("saved account: got %s, want admin_corp.com", repo.saved.Account.Account()) + } +} + +func TestUpdateEmailByAccount_AccountNotFound(t *testing.T) { + wantErr := errors.New("not found") + repo := &mockUserRepo{findByAccountErr: wantErr} + s := NewUserService(repo, nil, nil) + + err := s.UpdateEmailByAccount("link1", dp.CreateAccount("admin_corp.com"), dp.CreateEmailAddr("new@corp.com")) + if err != wantErr { + t.Fatalf("err: got %v, want %v", err, wantErr) + } + if repo.saved != nil { + t.Error("Save should NOT be called when FindByAccount fails") + } +} + +func TestUpdateEmailByAccount_SaveError(t *testing.T) { + wantErr := errors.New("save failed") + repo := &mockUserRepo{ + findByAccountResult: domain.User{ + UserBasicInfo: domain.UserBasicInfo{ + Account: dp.CreateAccount("admin_corp.com"), + EmailAddr: dp.CreateEmailAddr("old@corp.com"), + }, + }, + saveErr: wantErr, + } + s := NewUserService(repo, nil, nil) + + err := s.UpdateEmailByAccount("link1", dp.CreateAccount("admin_corp.com"), dp.CreateEmailAddr("new@corp.com")) + if err != wantErr { + t.Fatalf("err: got %v, want %v", err, wantErr) + } +}