From fb0ba194ba1759bc5ce1da0f23ec467bdfed36fe Mon Sep 17 00:00:00 2001 From: Xenos F Date: Tue, 9 Apr 2024 10:50:20 +0800 Subject: [PATCH] [#11878] Create reject account request endpoint (#12985) * Create account request rejection endpoint * Add validation * Add check for already rejected request when sending email * Add integration test cases * Set request method to post * Fix lint errors * Update tests list * Update validation check * Add test for validation * Fix lint errors * Fix validation comparison * Fix error message test * Add email sending * Update test cases * Refactor reason check code for clarity --- .../webapi/RejectAccountRequestActionIT.java | 208 ++++++++++++++++++ .../java/teammates/common/util/Const.java | 3 + .../ui/constants/ResourceEndpoints.java | 1 + .../AccountRequestRejectionRequest.java | 46 ++++ .../teammates/ui/webapi/ActionFactory.java | 1 + .../ui/webapi/RejectAccountRequestAction.java | 59 +++++ .../ui/webapi/UpdateAccountRequestAction.java | 4 +- .../AccountRequestRejectionRequestTest.java | 51 +++++ .../ui/webapi/GetActionClassesActionTest.java | 1 + 9 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 src/it/java/teammates/it/ui/webapi/RejectAccountRequestActionIT.java create mode 100644 src/main/java/teammates/ui/request/AccountRequestRejectionRequest.java create mode 100644 src/main/java/teammates/ui/webapi/RejectAccountRequestAction.java create mode 100644 src/test/java/teammates/ui/request/AccountRequestRejectionRequestTest.java diff --git a/src/it/java/teammates/it/ui/webapi/RejectAccountRequestActionIT.java b/src/it/java/teammates/it/ui/webapi/RejectAccountRequestActionIT.java new file mode 100644 index 00000000000..74aba31e1fb --- /dev/null +++ b/src/it/java/teammates/it/ui/webapi/RejectAccountRequestActionIT.java @@ -0,0 +1,208 @@ +package teammates.it.ui.webapi; + +import java.util.UUID; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import teammates.common.datatransfer.AccountRequestStatus; +import teammates.common.exception.EntityAlreadyExistsException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.Config; +import teammates.common.util.Const; +import teammates.common.util.EmailType; +import teammates.common.util.EmailWrapper; +import teammates.common.util.HibernateUtil; +import teammates.common.util.SanitizationHelper; +import teammates.storage.sqlentity.AccountRequest; +import teammates.storage.sqlentity.Course; +import teammates.ui.output.AccountRequestData; +import teammates.ui.request.AccountRequestRejectionRequest; +import teammates.ui.request.InvalidHttpRequestBodyException; +import teammates.ui.webapi.EntityNotFoundException; +import teammates.ui.webapi.InvalidHttpParameterException; +import teammates.ui.webapi.InvalidOperationException; +import teammates.ui.webapi.JsonResult; +import teammates.ui.webapi.RejectAccountRequestAction; + +/** + * SUT: {@link RejectAccountRequestAction}. + */ +public class RejectAccountRequestActionIT extends BaseActionIT { + + private static final String TYPICAL_TITLE = "We are Unable to Create an Account for you"; + private static final String TYPICAL_BODY = new StringBuilder() + .append("

Hi, Example

\n") + .append("

Thanks for your interest in using TEAMMATES. ") + .append("We are unable to create a TEAMMATES instructor account for you.

\n\n") + .append("

\n") + .append(" Reason: The email address you provided ") + .append("is not an 'official' email address provided by your institution.
\n") + .append(" Remedy: ") + .append("Please re-submit an account request with your 'official' institution email address.\n") + .append("

\n\n") + .append("

If you need further clarification or would like to appeal this decision, ") + .append("please feel free to contact us at teammates@comp.nus.edu.sg.

\n") + .append("

Regards,
TEAMMATES Team.

\n") + .toString(); + + @Override + @BeforeMethod + protected void setUp() throws Exception { + super.setUp(); + persistDataBundle(typicalBundle); + HibernateUtil.flushSession(); + } + + @Override + protected String getActionUri() { + return Const.ResourceURIs.ACCOUNT_REQUEST_REJECTION; + } + + @Override + protected String getRequestMethod() { + return POST; + } + + @Override + public void testExecute() throws Exception { + // See individual test methods below + } + + @Test + protected void testExecute_withReasonTitleAndBody_shouldRejectWithEmail() + throws InvalidOperationException, InvalidHttpRequestBodyException { + AccountRequest accountRequest = typicalBundle.accountRequests.get("unregisteredInstructor1"); + accountRequest.setStatus(AccountRequestStatus.PENDING); + UUID id = accountRequest.getId(); + + AccountRequestRejectionRequest requestBody = new AccountRequestRejectionRequest(TYPICAL_TITLE, TYPICAL_BODY); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + RejectAccountRequestAction action = getAction(requestBody, params); + JsonResult result = action.execute(); + + assertEquals(200, result.getStatusCode()); + + AccountRequestData data = (AccountRequestData) result.getOutput(); + assertEquals(accountRequest.getName(), data.getName()); + assertEquals(accountRequest.getEmail(), data.getEmail()); + assertEquals(accountRequest.getInstitute(), data.getInstitute()); + assertEquals(AccountRequestStatus.REJECTED, data.getStatus()); + assertEquals(accountRequest.getComments(), data.getComments()); + + verifyNumberOfEmailsSent(1); + EmailWrapper sentEmail = mockEmailSender.getEmailsSent().get(0); + assertEquals(EmailType.ACCOUNT_REQUEST_REJECTION, sentEmail.getType()); + assertEquals(Config.SUPPORT_EMAIL, sentEmail.getBcc()); + assertEquals(accountRequest.getEmail(), sentEmail.getRecipient()); + assertEquals(SanitizationHelper.sanitizeForRichText(TYPICAL_BODY), sentEmail.getContent()); + assertEquals("TEAMMATES: " + TYPICAL_TITLE, sentEmail.getSubject()); + } + + @Test + protected void testExecute_withoutReasonTitleAndBody_shouldRejectWithoutEmail() + throws InvalidOperationException, InvalidHttpRequestBodyException { + AccountRequest accountRequest = typicalBundle.accountRequests.get("unregisteredInstructor1"); + accountRequest.setStatus(AccountRequestStatus.PENDING); + UUID id = accountRequest.getId(); + + AccountRequestRejectionRequest requestBody = new AccountRequestRejectionRequest(null, null); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + RejectAccountRequestAction action = getAction(requestBody, params); + JsonResult result = action.execute(); + + assertEquals(200, result.getStatusCode()); + + AccountRequestData data = (AccountRequestData) result.getOutput(); + assertEquals(accountRequest.getName(), data.getName()); + assertEquals(accountRequest.getEmail(), data.getEmail()); + assertEquals(accountRequest.getInstitute(), data.getInstitute()); + assertEquals(AccountRequestStatus.REJECTED, data.getStatus()); + assertEquals(accountRequest.getComments(), data.getComments()); + + verifyNoEmailsSent(); + } + + @Test + protected void testExecute_withReasonBodyButNoTitle_shouldThrow() { + AccountRequest accountRequest = typicalBundle.accountRequests.get("unregisteredInstructor1"); + UUID id = accountRequest.getId(); + + AccountRequestRejectionRequest requestBody = new AccountRequestRejectionRequest(null, TYPICAL_BODY); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + InvalidHttpRequestBodyException ihrbe = verifyHttpRequestBodyFailure(requestBody, params); + + assertEquals("Both reason body and title need to be null to reject silently", ihrbe.getMessage()); + verifyNoEmailsSent(); + } + + @Test + protected void testExecute_withReasonTitleButNoBody_shouldThrow() { + AccountRequest accountRequest = typicalBundle.accountRequests.get("unregisteredInstructor1"); + UUID id = accountRequest.getId(); + + AccountRequestRejectionRequest requestBody = new AccountRequestRejectionRequest(TYPICAL_TITLE, null); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + InvalidHttpRequestBodyException ihrbe = verifyHttpRequestBodyFailure(requestBody, params); + + assertEquals("Both reason body and title need to be null to reject silently", ihrbe.getMessage()); + verifyNoEmailsSent(); + } + + @Test + protected void testExecute_alreadyRejected_shouldNotSendEmail() + throws InvalidOperationException, InvalidHttpRequestBodyException { + AccountRequest accountRequest = typicalBundle.accountRequests.get("unregisteredInstructor1"); + accountRequest.setStatus(AccountRequestStatus.REJECTED); + UUID id = accountRequest.getId(); + + AccountRequestRejectionRequest requestBody = new AccountRequestRejectionRequest(TYPICAL_TITLE, TYPICAL_BODY); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + RejectAccountRequestAction action = getAction(requestBody, params); + JsonResult result = action.execute(); + + assertEquals(result.getStatusCode(), 200); + + AccountRequestData data = (AccountRequestData) result.getOutput(); + assertEquals(accountRequest.getName(), data.getName()); + assertEquals(accountRequest.getEmail(), data.getEmail()); + assertEquals(accountRequest.getInstitute(), data.getInstitute()); + assertEquals(accountRequest.getStatus(), data.getStatus()); + assertEquals(accountRequest.getComments(), data.getComments()); + + verifyNoEmailsSent(); + } + + @Test + protected void testExecute_invalidUuid_shouldThrow() { + AccountRequestRejectionRequest requestBody = new AccountRequestRejectionRequest(null, null); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, "invalid"}; + + InvalidHttpParameterException ihpe = verifyHttpParameterFailure(requestBody, params); + assertEquals("Invalid UUID string: invalid", ihpe.getMessage()); + verifyNoEmailsSent(); + } + + @Test + protected void testExecute_accountRequestNotFound_shouldThrow() { + AccountRequestRejectionRequest requestBody = new AccountRequestRejectionRequest(null, null); + String uuid = UUID.randomUUID().toString(); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, uuid}; + + EntityNotFoundException enfe = verifyEntityNotFound(requestBody, params); + assertEquals(String.format("Account request with id = %s not found", uuid), enfe.getMessage()); + verifyNoEmailsSent(); + } + + @Override + @Test + protected void testAccessControl() throws InvalidParametersException, EntityAlreadyExistsException { + Course course = typicalBundle.courses.get("course1"); + verifyOnlyAdminCanAccess(course); + } +} diff --git a/src/main/java/teammates/common/util/Const.java b/src/main/java/teammates/common/util/Const.java index 80369af91c4..2152278dd7e 100644 --- a/src/main/java/teammates/common/util/Const.java +++ b/src/main/java/teammates/common/util/Const.java @@ -45,6 +45,8 @@ public final class Const { public static final String MISSING_RESPONSE_TEXT = "No Response"; + public static final String ACCOUNT_REQUEST_NOT_FOUND = "Account request with id = %s not found"; + // These constants are used as variable values to mean that the variable is in a 'special' state. public static final int INT_UNINITIALIZED = -9999; @@ -337,6 +339,7 @@ public static class ResourceURIs { public static final String ACCOUNT_REQUEST = URI_PREFIX + "/account/request"; public static final String ACCOUNT_REQUESTS = URI_PREFIX + "/account/requests"; public static final String ACCOUNT_REQUEST_RESET = ACCOUNT_REQUEST + "/reset"; + public static final String ACCOUNT_REQUEST_REJECTION = ACCOUNT_REQUEST + "/rejection"; public static final String ACCOUNTS = URI_PREFIX + "/accounts"; public static final String RESPONSE_COMMENT = URI_PREFIX + "/responsecomment"; public static final String COURSE = URI_PREFIX + "/course"; diff --git a/src/main/java/teammates/ui/constants/ResourceEndpoints.java b/src/main/java/teammates/ui/constants/ResourceEndpoints.java index 8e288eb6264..3a7a3abe88c 100644 --- a/src/main/java/teammates/ui/constants/ResourceEndpoints.java +++ b/src/main/java/teammates/ui/constants/ResourceEndpoints.java @@ -17,6 +17,7 @@ public enum ResourceEndpoints { ACCOUNT_REQUEST(ResourceURIs.ACCOUNT_REQUEST), ACCOUNT_REQUESTS(ResourceURIs.ACCOUNT_REQUESTS), ACCOUNT_REQUEST_RESET(ResourceURIs.ACCOUNT_REQUEST_RESET), + ACCOUNT_REQUEST_REJECT(ResourceURIs.ACCOUNT_REQUEST_REJECTION), ACCOUNTS(ResourceURIs.ACCOUNTS), RESPONSE_COMMENT(ResourceURIs.RESPONSE_COMMENT), COURSE(ResourceURIs.COURSE), diff --git a/src/main/java/teammates/ui/request/AccountRequestRejectionRequest.java b/src/main/java/teammates/ui/request/AccountRequestRejectionRequest.java new file mode 100644 index 00000000000..89884e13b7d --- /dev/null +++ b/src/main/java/teammates/ui/request/AccountRequestRejectionRequest.java @@ -0,0 +1,46 @@ +package teammates.ui.request; + +import java.util.Objects; + +import javax.annotation.Nullable; + +import teammates.common.util.SanitizationHelper; + +/** + * The request reasonBody for rejecting an account request. + */ +public class AccountRequestRejectionRequest extends BasicRequest { + @Nullable + private String reasonTitle; + + @Nullable + private String reasonBody; + + public AccountRequestRejectionRequest(String reasonTitle, String reasonBody) { + this.reasonTitle = SanitizationHelper.sanitizeTitle(reasonTitle); + this.reasonBody = SanitizationHelper.sanitizeForRichText(reasonBody); + } + + @Override + public void validate() throws InvalidHttpRequestBodyException { + if (reasonBody == null || reasonTitle == null) { + assertTrue(Objects.equals(reasonBody, reasonTitle), + "Both reason body and title need to be null to reject silently"); + } + } + + public String getReasonTitle() { + return this.reasonTitle; + } + + public String getReasonBody() { + return this.reasonBody; + } + + /** + * Returns true if both reason body and title are non-null. + */ + public boolean checkHasReason() { + return this.reasonBody != null && this.reasonTitle != null; + } +} diff --git a/src/main/java/teammates/ui/webapi/ActionFactory.java b/src/main/java/teammates/ui/webapi/ActionFactory.java index 169d4ae5b07..ae834448b7a 100644 --- a/src/main/java/teammates/ui/webapi/ActionFactory.java +++ b/src/main/java/teammates/ui/webapi/ActionFactory.java @@ -53,6 +53,7 @@ public final class ActionFactory { map(ResourceURIs.ACCOUNT_REQUEST, PUT, UpdateAccountRequestAction.class); map(ResourceURIs.ACCOUNT_REQUESTS, GET, GetAccountRequestsAction.class); map(ResourceURIs.ACCOUNT_REQUEST_RESET, PUT, ResetAccountRequestAction.class); + map(ResourceURIs.ACCOUNT_REQUEST_REJECTION, POST, RejectAccountRequestAction.class); map(ResourceURIs.ACCOUNTS, GET, GetAccountsAction.class); map(ResourceURIs.COURSE, GET, GetCourseAction.class); map(ResourceURIs.COURSE, DELETE, DeleteCourseAction.class); diff --git a/src/main/java/teammates/ui/webapi/RejectAccountRequestAction.java b/src/main/java/teammates/ui/webapi/RejectAccountRequestAction.java new file mode 100644 index 00000000000..6b0b2534b44 --- /dev/null +++ b/src/main/java/teammates/ui/webapi/RejectAccountRequestAction.java @@ -0,0 +1,59 @@ +package teammates.ui.webapi; + +import java.util.UUID; + +import teammates.common.datatransfer.AccountRequestStatus; +import teammates.common.exception.EntityDoesNotExistException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.Const; +import teammates.common.util.EmailWrapper; +import teammates.storage.sqlentity.AccountRequest; +import teammates.ui.output.AccountRequestData; +import teammates.ui.request.AccountRequestRejectionRequest; +import teammates.ui.request.InvalidHttpRequestBodyException; + +/** + * Rejects an account request. + */ +public class RejectAccountRequestAction extends AdminOnlyAction { + + @Override + public JsonResult execute() throws InvalidOperationException, InvalidHttpRequestBodyException { + String id = getNonNullRequestParamValue(Const.ParamsNames.ACCOUNT_REQUEST_ID); + UUID accountRequestId; + + try { + accountRequestId = UUID.fromString(id); + } catch (IllegalArgumentException e) { + throw new InvalidHttpParameterException(e.getMessage(), e); + } + + AccountRequest accountRequest = sqlLogic.getAccountRequest(accountRequestId); + + if (accountRequest == null) { + String errorMessage = String.format(Const.ACCOUNT_REQUEST_NOT_FOUND, accountRequestId.toString()); + throw new EntityNotFoundException(errorMessage); + } + + AccountRequestRejectionRequest accountRequestRejectionRequest = + getAndValidateRequestBody(AccountRequestRejectionRequest.class); + AccountRequestStatus initialStatus = accountRequest.getStatus(); + + try { + accountRequest.setStatus(AccountRequestStatus.REJECTED); + accountRequest = sqlLogic.updateAccountRequest(accountRequest); + if (accountRequestRejectionRequest.checkHasReason() + && initialStatus != AccountRequestStatus.REJECTED) { + EmailWrapper email = sqlEmailGenerator.generateAccountRequestRejectionEmail(accountRequest, + accountRequestRejectionRequest.getReasonTitle(), accountRequestRejectionRequest.getReasonBody()); + emailSender.sendEmail(email); + } + } catch (InvalidParametersException e) { + throw new InvalidHttpRequestBodyException(e); + } catch (EntityDoesNotExistException e) { + throw new EntityNotFoundException(e); + } + + return new JsonResult(new AccountRequestData(accountRequest)); + } +} diff --git a/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java b/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java index 59c95ce7ef1..0f1f679be35 100644 --- a/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java +++ b/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java @@ -17,8 +17,6 @@ */ public class UpdateAccountRequestAction extends AdminOnlyAction { - static final String ACCOUNT_REQUEST_NOT_FOUND = "Account request with id = %s not found"; - @Override public boolean isTransactionNeeded() { return false; @@ -38,7 +36,7 @@ public JsonResult execute() throws InvalidOperationException, InvalidHttpRequest AccountRequest accountRequest = sqlLogic.getAccountRequestWithTransaction(accountRequestId); if (accountRequest == null) { - String errorMessage = String.format(ACCOUNT_REQUEST_NOT_FOUND, accountRequestId.toString()); + String errorMessage = String.format(Const.ACCOUNT_REQUEST_NOT_FOUND, accountRequestId.toString()); throw new EntityNotFoundException(errorMessage); } diff --git a/src/test/java/teammates/ui/request/AccountRequestRejectionRequestTest.java b/src/test/java/teammates/ui/request/AccountRequestRejectionRequestTest.java new file mode 100644 index 00000000000..412bfcf1d87 --- /dev/null +++ b/src/test/java/teammates/ui/request/AccountRequestRejectionRequestTest.java @@ -0,0 +1,51 @@ +package teammates.ui.request; + +import org.testng.annotations.Test; + +import teammates.test.BaseTestCase; + +/** + * SUT: {@link AccountRequestRejectionRequest}. + */ +public class AccountRequestRejectionRequestTest extends BaseTestCase { + + private static final String TYPICAL_TITLE = "We are Unable to Create an Account for you"; + private static final String TYPICAL_BODY = new StringBuilder() + .append("

Hi, Example

\n") + .append("

Thanks for your interest in using TEAMMATES. ") + .append("We are unable to create a TEAMMATES instructor account for you.

\n\n") + .append("

\n") + .append(" Reason: The email address you provided ") + .append("is not an 'official' email address provided by your institution.
\n") + .append(" Remedy: ") + .append("Please re-submit an account request with your 'official' institution email address.\n") + .append("

\n\n") + .append("

If you need further clarification or would like to appeal this decision, ") + .append("please feel free to contact us at teammates@comp.nus.edu.sg.

\n") + .append("

Regards,
TEAMMATES Team.

\n") + .toString(); + + @Test + public void testValidate_withNonNullBodyAndNonNullTitle_shouldPass() throws Exception { + AccountRequestRejectionRequest request = new AccountRequestRejectionRequest(TYPICAL_TITLE, TYPICAL_BODY); + request.validate(); + } + + @Test + public void testValidate_withNullBodyAndNullTitle_shouldPass() throws Exception { + AccountRequestRejectionRequest request = new AccountRequestRejectionRequest(null, null); + request.validate(); + } + + @Test + public void testValidate_withNonNullBodyAndNullTitle_shouldFail() { + AccountRequestRejectionRequest request = new AccountRequestRejectionRequest(null, TYPICAL_BODY); + assertThrows(InvalidHttpRequestBodyException.class, request::validate); + } + + @Test + public void testValidate_withNullBodyAndNonNullTitle_shouldFail() { + AccountRequestRejectionRequest request = new AccountRequestRejectionRequest(TYPICAL_TITLE, null); + assertThrows(InvalidHttpRequestBodyException.class, request::validate); + } +} diff --git a/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java b/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java index 89ace4cdf78..fbcd4a0765c 100644 --- a/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java +++ b/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java @@ -88,6 +88,7 @@ protected void testExecute() { DeleteAccountRequestAction.class, GetAccountRequestsAction.class, UpdateAccountRequestAction.class, + RejectAccountRequestAction.class, GetAccountAction.class, GetAccountsAction.class, FeedbackSessionPublishedRemindersAction.class,