|  | // Copyright (C) 2019 The Android Open Source Project | 
|  | // | 
|  | // Licensed under the Apache License, Version 2.0 (the "License"); | 
|  | // you may not use this file except in compliance with the License. | 
|  | // You may obtain a copy of the License at | 
|  | // | 
|  | // http://www.apache.org/licenses/LICENSE-2.0 | 
|  | // | 
|  | // Unless required by applicable law or agreed to in writing, software | 
|  | // distributed under the License is distributed on an "AS IS" BASIS, | 
|  | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 
|  | // See the License for the specific language governing permissions and | 
|  | // limitations under the License. | 
|  |  | 
|  | package com.google.gerrit.acceptance.api.change; | 
|  |  | 
|  | import static com.google.common.collect.ImmutableSet.toImmutableSet; | 
|  | import static com.google.common.truth.Truth.assertThat; | 
|  | import static com.google.gerrit.testing.GerritJUnit.assertThrows; | 
|  | import static java.util.stream.Collectors.toList; | 
|  | import static org.mockito.ArgumentMatchers.any; | 
|  | import static org.mockito.ArgumentMatchers.eq; | 
|  | import static org.mockito.Mockito.clearInvocations; | 
|  | import static org.mockito.Mockito.mock; | 
|  | import static org.mockito.Mockito.when; | 
|  | import static org.mockito.MockitoAnnotations.initMocks; | 
|  |  | 
|  | import com.google.common.collect.ImmutableList; | 
|  | import com.google.common.collect.ImmutableMap; | 
|  | import com.google.common.collect.ImmutableSet; | 
|  | import com.google.common.collect.Iterables; | 
|  | import com.google.common.truth.Correspondence; | 
|  | import com.google.gerrit.acceptance.AbstractDaemonTest; | 
|  | import com.google.gerrit.acceptance.ExtensionRegistry; | 
|  | import com.google.gerrit.acceptance.ExtensionRegistry.Registration; | 
|  | import com.google.gerrit.acceptance.PushOneCommit; | 
|  | import com.google.gerrit.acceptance.TestAccount; | 
|  | import com.google.gerrit.acceptance.config.GerritConfig; | 
|  | import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations; | 
|  | import com.google.gerrit.common.Nullable; | 
|  | import com.google.gerrit.entities.Account; | 
|  | import com.google.gerrit.entities.AttentionSetUpdate; | 
|  | import com.google.gerrit.entities.Change; | 
|  | import com.google.gerrit.entities.LabelId; | 
|  | import com.google.gerrit.entities.PatchSet; | 
|  | import com.google.gerrit.entities.SubmitRecord; | 
|  | import com.google.gerrit.extensions.annotations.Exports; | 
|  | import com.google.gerrit.extensions.api.changes.DraftInput; | 
|  | import com.google.gerrit.extensions.api.changes.ReviewInput; | 
|  | import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput; | 
|  | import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling; | 
|  | import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput; | 
|  | import com.google.gerrit.extensions.api.changes.ReviewResult; | 
|  | import com.google.gerrit.extensions.client.ReviewerState; | 
|  | import com.google.gerrit.extensions.client.Side; | 
|  | import com.google.gerrit.extensions.common.AccountInfo; | 
|  | import com.google.gerrit.extensions.common.ChangeMessageInfo; | 
|  | import com.google.gerrit.extensions.common.RobotCommentInfo; | 
|  | import com.google.gerrit.extensions.config.FactoryModule; | 
|  | import com.google.gerrit.extensions.events.CommentAddedListener; | 
|  | import com.google.gerrit.extensions.events.ReviewerAddedListener; | 
|  | import com.google.gerrit.extensions.events.ReviewerDeletedListener; | 
|  | import com.google.gerrit.extensions.restapi.BadRequestException; | 
|  | import com.google.gerrit.extensions.restapi.ResourceNotFoundException; | 
|  | import com.google.gerrit.extensions.restapi.RestApiException; | 
|  | import com.google.gerrit.extensions.validators.CommentForValidation; | 
|  | import com.google.gerrit.extensions.validators.CommentValidationContext; | 
|  | import com.google.gerrit.extensions.validators.CommentValidator; | 
|  | import com.google.gerrit.server.IdentifiedUser; | 
|  | import com.google.gerrit.server.change.EmailReviewComments; | 
|  | import com.google.gerrit.server.notedb.ChangeNotes; | 
|  | import com.google.gerrit.server.query.change.ChangeData; | 
|  | import com.google.gerrit.server.restapi.change.OnPostReview; | 
|  | import com.google.gerrit.server.restapi.change.PostReview; | 
|  | import com.google.gerrit.server.rules.SubmitRule; | 
|  | import com.google.gerrit.server.update.CommentsRejectedException; | 
|  | import com.google.gerrit.testing.FakeEmailSender; | 
|  | import com.google.gerrit.testing.TestCommentHelper; | 
|  | import com.google.inject.Inject; | 
|  | import com.google.inject.Module; | 
|  | import java.sql.Timestamp; | 
|  | import java.util.ArrayList; | 
|  | import java.util.Arrays; | 
|  | import java.util.Collection; | 
|  | import java.util.List; | 
|  | import java.util.Map; | 
|  | import java.util.Optional; | 
|  | import java.util.regex.Pattern; | 
|  | import java.util.stream.Collectors; | 
|  | import org.eclipse.jgit.lib.ObjectId; | 
|  | import org.junit.Before; | 
|  | import org.junit.Test; | 
|  | import org.mockito.ArgumentCaptor; | 
|  | import org.mockito.Captor; | 
|  |  | 
|  | /** Tests for comment validation in {@link PostReview}. */ | 
|  | public class PostReviewIT extends AbstractDaemonTest { | 
|  |  | 
|  | @Inject private CommentValidator mockCommentValidator; | 
|  | @Inject private TestCommentHelper testCommentHelper; | 
|  | @Inject private RequestScopeOperations requestScopeOperations; | 
|  | @Inject private ExtensionRegistry extensionRegistry; | 
|  |  | 
|  | private static final String COMMENT_TEXT = "The comment text"; | 
|  | private static final CommentForValidation FILE_COMMENT_FOR_VALIDATION = | 
|  | CommentForValidation.create( | 
|  | CommentForValidation.CommentSource.HUMAN, | 
|  | CommentForValidation.CommentType.FILE_COMMENT, | 
|  | COMMENT_TEXT, | 
|  | COMMENT_TEXT.length()); | 
|  | private static final CommentForValidation INLINE_COMMENT_FOR_VALIDATION = | 
|  | CommentForValidation.create( | 
|  | CommentForValidation.CommentSource.HUMAN, | 
|  | CommentForValidation.CommentType.INLINE_COMMENT, | 
|  | COMMENT_TEXT, | 
|  | COMMENT_TEXT.length()); | 
|  | private static final CommentForValidation CHANGE_MESSAGE_FOR_VALIDATION = | 
|  | CommentForValidation.create( | 
|  | CommentForValidation.CommentSource.HUMAN, | 
|  | CommentForValidation.CommentType.CHANGE_MESSAGE, | 
|  | COMMENT_TEXT, | 
|  | COMMENT_TEXT.length()); | 
|  |  | 
|  | @Captor private ArgumentCaptor<ImmutableList<CommentForValidation>> captor; | 
|  | @Captor private ArgumentCaptor<CommentValidationContext> captorCtx; | 
|  |  | 
|  | private static final Correspondence<CommentForValidation, CommentForValidation> | 
|  | COMMENT_CORRESPONDENCE = | 
|  | Correspondence.from( | 
|  | (left, right) -> | 
|  | left != null | 
|  | && right != null | 
|  | && left.getSource() == right.getSource() | 
|  | && left.getType() == right.getType() | 
|  | && left.getText().equals(right.getText()), | 
|  | "matches (ignoring size approximation)"); | 
|  |  | 
|  | @Override | 
|  | public Module createModule() { | 
|  | return new FactoryModule() { | 
|  | @Override | 
|  | public void configure() { | 
|  | CommentValidator mockCommentValidator = mock(CommentValidator.class); | 
|  | bind(CommentValidator.class) | 
|  | .annotatedWith(Exports.named(mockCommentValidator.getClass())) | 
|  | .toInstance(mockCommentValidator); | 
|  | bind(CommentValidator.class).toInstance(mockCommentValidator); | 
|  | } | 
|  | }; | 
|  | } | 
|  |  | 
|  | @Before | 
|  | public void resetMock() { | 
|  | initMocks(this); | 
|  | clearInvocations(mockCommentValidator); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void validateCommentsInInput_commentOK() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  | when(mockCommentValidator.validateComments(captorCtx.capture(), captor.capture())) | 
|  | .thenReturn(ImmutableList.of()); | 
|  |  | 
|  | ReviewInput input = new ReviewInput().message(COMMENT_TEXT); | 
|  | CommentInput comment = newComment(r.getChange().currentFilePaths().get(0)); | 
|  | comment.updated = new Timestamp(0); | 
|  | input.comments = ImmutableMap.of(comment.path, ImmutableList.of(comment)); | 
|  |  | 
|  | assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty(); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  |  | 
|  | assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION, FILE_COMMENT_FOR_VALIDATION); | 
|  | assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1); | 
|  | assertThat(captorCtx.getAllValues()).containsExactly(contextFor(r)); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void validateCommentsInInput_commentRejected() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  | when(mockCommentValidator.validateComments(eq(contextFor(r)), captor.capture())) | 
|  | .thenReturn(ImmutableList.of(FILE_COMMENT_FOR_VALIDATION.failValidation("Oh no!"))); | 
|  |  | 
|  | ReviewInput input = new ReviewInput().message(COMMENT_TEXT); | 
|  | CommentInput comment = newComment(r.getChange().currentFilePaths().get(0)); | 
|  | comment.updated = new Timestamp(0); | 
|  | input.comments = ImmutableMap.of(comment.path, ImmutableList.of(comment)); | 
|  |  | 
|  | assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty(); | 
|  | BadRequestException badRequestException = | 
|  | assertThrows( | 
|  | BadRequestException.class, | 
|  | () -> gApi.changes().id(r.getChangeId()).current().review(input)); | 
|  | assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION, FILE_COMMENT_FOR_VALIDATION); | 
|  | assertThat(badRequestException.getCause()).isInstanceOf(CommentsRejectedException.class); | 
|  | assertThat( | 
|  | Iterables.getOnlyElement( | 
|  | ((CommentsRejectedException) badRequestException.getCause()) | 
|  | .getCommentValidationFailures()) | 
|  | .getComment() | 
|  | .getText()) | 
|  | .isEqualTo(COMMENT_TEXT); | 
|  | assertThat(badRequestException.getCause()).hasMessageThat().contains("Oh no!"); | 
|  | assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty(); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void validateCommentsInInput_commentCleanedUp() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  | assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty(); | 
|  |  | 
|  | // posting a comment which is empty after trim is a no-op, as the empty comment is dropped | 
|  | // during comment cleanup | 
|  | ReviewInput input = new ReviewInput(); | 
|  | CommentInput comment = | 
|  | TestCommentHelper.populate( | 
|  | new CommentInput(), r.getChange().currentFilePaths().get(0), " "); | 
|  | comment.updated = new Timestamp(0); | 
|  | input.comments = ImmutableMap.of(comment.path, ImmutableList.of(comment)); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  |  | 
|  | assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty(); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void validateDrafts_draftOK() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  | when(mockCommentValidator.validateComments(eq(contextFor(r)), captor.capture())) | 
|  | .thenReturn(ImmutableList.of()); | 
|  |  | 
|  | DraftInput draft = | 
|  | testCommentHelper.newDraft( | 
|  | r.getChange().currentFilePaths().get(0), Side.REVISION, 1, COMMENT_TEXT); | 
|  | gApi.changes().id(r.getChangeId()).revision(r.getCommit().getName()).createDraft(draft); | 
|  | assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty(); | 
|  |  | 
|  | ReviewInput input = new ReviewInput().message(COMMENT_TEXT); | 
|  | input.drafts = DraftHandling.PUBLISH; | 
|  |  | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION, INLINE_COMMENT_FOR_VALIDATION); | 
|  | assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void validateDrafts_draftRejected() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  | when(mockCommentValidator.validateComments(eq(contextFor(r)), captor.capture())) | 
|  | .thenReturn(ImmutableList.of(INLINE_COMMENT_FOR_VALIDATION.failValidation("Oh no!"))); | 
|  |  | 
|  | DraftInput draft = | 
|  | testCommentHelper.newDraft( | 
|  | r.getChange().currentFilePaths().get(0), Side.REVISION, 1, COMMENT_TEXT); | 
|  | testCommentHelper.addDraft(r.getChangeId(), r.getCommit().getName(), draft); | 
|  | assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty(); | 
|  |  | 
|  | ReviewInput input = new ReviewInput().message(COMMENT_TEXT); | 
|  | input.drafts = DraftHandling.PUBLISH; | 
|  | BadRequestException badRequestException = | 
|  | assertThrows( | 
|  | BadRequestException.class, | 
|  | () -> gApi.changes().id(r.getChangeId()).current().review(input)); | 
|  | assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION, INLINE_COMMENT_FOR_VALIDATION); | 
|  | assertThat(badRequestException.getCause()).isInstanceOf(CommentsRejectedException.class); | 
|  | assertThat( | 
|  | Iterables.getOnlyElement( | 
|  | ((CommentsRejectedException) badRequestException.getCause()) | 
|  | .getCommentValidationFailures()) | 
|  | .getComment() | 
|  | .getText()) | 
|  | .isEqualTo(draft.message); | 
|  | assertThat(badRequestException.getCause()).hasMessageThat().contains("Oh no!"); | 
|  | assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty(); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void validateDrafts_inlineVsFileComments_allOK() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  | DraftInput draftInline = | 
|  | testCommentHelper.newDraft( | 
|  | r.getChange().currentFilePaths().get(0), Side.REVISION, 1, COMMENT_TEXT); | 
|  | testCommentHelper.addDraft(r.getChangeId(), r.getCommit().getName(), draftInline); | 
|  | DraftInput draftFile = testCommentHelper.newDraft(COMMENT_TEXT); | 
|  | testCommentHelper.addDraft(r.getChangeId(), r.getCommit().getName(), draftFile); | 
|  | assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty(); | 
|  |  | 
|  | when(mockCommentValidator.validateComments(any(), captor.capture())) | 
|  | .thenReturn(ImmutableList.of()); | 
|  |  | 
|  | ReviewInput input = new ReviewInput().message(COMMENT_TEXT); | 
|  | input.drafts = DraftHandling.PUBLISH; | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(2); | 
|  |  | 
|  | assertValidatorCalledWith( | 
|  | CHANGE_MESSAGE_FOR_VALIDATION, | 
|  | CommentForValidation.create( | 
|  | CommentForValidation.CommentSource.HUMAN, | 
|  | CommentForValidation.CommentType.INLINE_COMMENT, | 
|  | draftInline.message, | 
|  | draftInline.message.length()), | 
|  | CommentForValidation.create( | 
|  | CommentForValidation.CommentSource.HUMAN, | 
|  | CommentForValidation.CommentType.FILE_COMMENT, | 
|  | draftFile.message, | 
|  | draftFile.message.length())); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void validateCommentsInChangeMessage_messageOK() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  | when(mockCommentValidator.validateComments(eq(contextFor(r)), captor.capture())) | 
|  | .thenReturn(ImmutableList.of()); | 
|  |  | 
|  | ReviewInput input = new ReviewInput().message(COMMENT_TEXT); | 
|  | int numMessages = gApi.changes().id(r.getChangeId()).get().messages.size(); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION); | 
|  | assertThat(gApi.changes().id(r.getChangeId()).get().messages).hasSize(numMessages + 1); | 
|  | ChangeMessageInfo message = | 
|  | Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages); | 
|  | assertThat(message.message).contains(COMMENT_TEXT); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void validateCommentsInChangeMessage_messageRejected() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  | when(mockCommentValidator.validateComments(eq(contextFor(r)), captor.capture())) | 
|  | .thenReturn(ImmutableList.of(CHANGE_MESSAGE_FOR_VALIDATION.failValidation("Oh no!"))); | 
|  |  | 
|  | ReviewInput input = new ReviewInput().message(COMMENT_TEXT); | 
|  | assertThat(gApi.changes().id(r.getChangeId()).get().messages) | 
|  | .hasSize(1); // From the initial commit. | 
|  | BadRequestException badRequestException = | 
|  | assertThrows( | 
|  | BadRequestException.class, | 
|  | () -> gApi.changes().id(r.getChangeId()).current().review(input)); | 
|  | assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION); | 
|  | assertThat(badRequestException.getCause()).isInstanceOf(CommentsRejectedException.class); | 
|  | assertThat( | 
|  | Iterables.getOnlyElement( | 
|  | ((CommentsRejectedException) badRequestException.getCause()) | 
|  | .getCommentValidationFailures()) | 
|  | .getComment() | 
|  | .getText()) | 
|  | .isEqualTo(COMMENT_TEXT); | 
|  | assertThat(badRequestException.getCause()).hasMessageThat().contains("Oh no!"); | 
|  | assertThat(gApi.changes().id(r.getChangeId()).get().messages) | 
|  | .hasSize(1); // Unchanged from before. | 
|  | ChangeMessageInfo message = | 
|  | Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages); | 
|  | assertThat(message.message).doesNotContain(COMMENT_TEXT); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | @GerritConfig(name = "change.maxComments", value = "7") | 
|  | public void restrictNumberOfComments() throws Exception { | 
|  | when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of()); | 
|  |  | 
|  | PushOneCommit.Result r = createChange(); | 
|  | String filePath = r.getChange().currentFilePaths().get(0); | 
|  | CommentInput commentInput = new CommentInput(); | 
|  | commentInput.line = 1; | 
|  | commentInput.message = "foo"; | 
|  | commentInput.path = filePath; | 
|  | RobotCommentInput robotCommentInput = | 
|  | TestCommentHelper.createRobotCommentInputWithMandatoryFields(filePath); | 
|  | ReviewInput reviewInput = new ReviewInput(); | 
|  | reviewInput.comments = ImmutableMap.of(filePath, ImmutableList.of(commentInput)); | 
|  | reviewInput.robotComments = ImmutableMap.of(filePath, ImmutableList.of(robotCommentInput)); | 
|  | gApi.changes().id(r.getChangeId()).current().review(reviewInput); | 
|  | // Counting change messages plus comments we now have 4. | 
|  |  | 
|  | // reviewInput still has both a user and a robot comment (and deduplication is false). We also | 
|  | // create a draft, and there's the change message, so that in total there would be 8 comments. | 
|  | // The limit is set to 7, so this verifies that all new comments are considered. | 
|  | DraftInput draftInline = testCommentHelper.newDraft(filePath, Side.REVISION, 1, "a draft"); | 
|  | testCommentHelper.addDraft(r.getChangeId(), r.getPatchSetId().getId(), draftInline); | 
|  | reviewInput.drafts = DraftHandling.PUBLISH; | 
|  | reviewInput.omitDuplicateComments = false; | 
|  |  | 
|  | BadRequestException exception = | 
|  | assertThrows( | 
|  | BadRequestException.class, | 
|  | () -> gApi.changes().id(r.getChangeId()).current().review(reviewInput)); | 
|  | assertThat(exception) | 
|  | .hasMessageThat() | 
|  | .contains("Exceeding maximum number of comments: 4 (existing) + 4 (new) > 7"); | 
|  |  | 
|  | assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1); | 
|  | assertThat(getRobotComments(r.getChangeId())).hasSize(1); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "7k") | 
|  | public void validateCumulativeCommentSize() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  | when(mockCommentValidator.validateComments(eq(contextFor(r)), any())) | 
|  | .thenReturn(ImmutableList.of()); | 
|  |  | 
|  | // Use large sizes because autogenerated messages already have O(100) bytes. | 
|  | String commentText2000Bytes = new String(new char[2000]).replace("\0", "x"); | 
|  | String filePath = r.getChange().currentFilePaths().get(0); | 
|  | ReviewInput reviewInput = new ReviewInput().message(commentText2000Bytes); | 
|  | CommentInput commentInput = new CommentInput(); | 
|  | commentInput.line = 1; | 
|  | commentInput.message = commentText2000Bytes; | 
|  | commentInput.path = filePath; | 
|  | reviewInput.comments = ImmutableMap.of(filePath, ImmutableList.of(commentInput)); | 
|  |  | 
|  | // Use up ~4000 bytes. | 
|  | gApi.changes().id(r.getChangeId()).current().review(reviewInput); | 
|  |  | 
|  | // Hit the limit when trying that again. | 
|  | BadRequestException exception = | 
|  | assertThrows( | 
|  | BadRequestException.class, | 
|  | () -> gApi.changes().id(r.getChangeId()).current().review(reviewInput)); | 
|  | assertThat(exception) | 
|  | .hasMessageThat() | 
|  | .contains("Exceeding maximum cumulative size of comments"); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void ccToReviewer() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  | // User adds themselves and changes state | 
|  | requestScopeOperations.setApiUser(user.id()); | 
|  |  | 
|  | ReviewInput input = new ReviewInput().reviewer(user.id().toString(), ReviewerState.CC, false); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  |  | 
|  | Map<ReviewerState, Collection<AccountInfo>> reviewers = | 
|  | gApi.changes().id(r.getChangeId()).get().reviewers; | 
|  | assertThat(reviewers).hasSize(1); | 
|  | AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.CC)); | 
|  | assertThat(reviewer._accountId).isEqualTo(user.id().get()); | 
|  |  | 
|  | // CC -> Reviewer | 
|  | ReviewInput input2 = new ReviewInput().reviewer(user.id().toString()); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input2); | 
|  |  | 
|  | Map<ReviewerState, Collection<AccountInfo>> reviewers2 = | 
|  | gApi.changes().id(r.getChangeId()).get().reviewers; | 
|  | assertThat(reviewers2).hasSize(1); | 
|  | AccountInfo reviewer2 = Iterables.getOnlyElement(reviewers2.get(ReviewerState.REVIEWER)); | 
|  | assertThat(reviewer2._accountId).isEqualTo(user.id().get()); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void reviewerToCc() throws Exception { | 
|  | // Admin owns the change | 
|  | PushOneCommit.Result r = createChange(); | 
|  | // User adds themselves and changes state | 
|  | requestScopeOperations.setApiUser(user.id()); | 
|  |  | 
|  | ReviewInput input = new ReviewInput().reviewer(user.id().toString()); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  |  | 
|  | Map<ReviewerState, Collection<AccountInfo>> reviewers = | 
|  | gApi.changes().id(r.getChangeId()).get().reviewers; | 
|  | assertThat(reviewers).hasSize(1); | 
|  | AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.REVIEWER)); | 
|  | assertThat(reviewer._accountId).isEqualTo(user.id().get()); | 
|  |  | 
|  | // Reviewer -> CC | 
|  | ReviewInput input2 = new ReviewInput().reviewer(user.id().toString(), ReviewerState.CC, false); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input2); | 
|  |  | 
|  | Map<ReviewerState, Collection<AccountInfo>> reviewers2 = | 
|  | gApi.changes().id(r.getChangeId()).get().reviewers; | 
|  | assertThat(reviewers2).hasSize(1); | 
|  | AccountInfo reviewer2 = Iterables.getOnlyElement(reviewers2.get(ReviewerState.CC)); | 
|  | assertThat(reviewer2._accountId).isEqualTo(user.id().get()); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void votingMakesCallerReviewer() throws Exception { | 
|  | // Admin owns the change | 
|  | PushOneCommit.Result r = createChange(); | 
|  | // User adds themselves and changes state | 
|  | requestScopeOperations.setApiUser(user.id()); | 
|  |  | 
|  | ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  |  | 
|  | Map<ReviewerState, Collection<AccountInfo>> reviewers = | 
|  | gApi.changes().id(r.getChangeId()).get().reviewers; | 
|  | assertThat(reviewers).hasSize(1); | 
|  | AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.REVIEWER)); | 
|  | assertThat(reviewer._accountId).isEqualTo(user.id().get()); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void commentingMakesUserCC() throws Exception { | 
|  | // Admin owns the change | 
|  | PushOneCommit.Result r = createChange(); | 
|  | // User adds themselves and changes state | 
|  | requestScopeOperations.setApiUser(user.id()); | 
|  |  | 
|  | ReviewInput input = new ReviewInput().message("Foo bar!"); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  |  | 
|  | Map<ReviewerState, Collection<AccountInfo>> reviewers = | 
|  | gApi.changes().id(r.getChangeId()).get().reviewers; | 
|  | assertThat(reviewers).hasSize(1); | 
|  | AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.CC)); | 
|  | assertThat(reviewer._accountId).isEqualTo(user.id().get()); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void extendChangeMessageFromPlugin() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  |  | 
|  | String testMessage = "hello from plugin"; | 
|  | TestOnPostReview testOnPostReview = new TestOnPostReview(testMessage); | 
|  | try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) { | 
|  | ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages; | 
|  | assertThat(Iterables.getLast(messages).message) | 
|  | .isEqualTo(String.format("Patch Set 1: Code-Review+1\n\n%s\n", testMessage)); | 
|  | } | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void extendChangeMessageFromMultiplePlugins() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  |  | 
|  | String testMessage1 = "hello from plugin 1"; | 
|  | String testMessage2 = "message from plugin 2"; | 
|  | TestOnPostReview testOnPostReview1 = new TestOnPostReview(testMessage1); | 
|  | TestOnPostReview testOnPostReview2 = new TestOnPostReview(testMessage2); | 
|  | try (Registration registration = | 
|  | extensionRegistry.newRegistration().add(testOnPostReview1).add(testOnPostReview2)) { | 
|  | ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages; | 
|  | assertThat(Iterables.getLast(messages).message) | 
|  | .isEqualTo( | 
|  | String.format( | 
|  | "Patch Set 1: Code-Review+1\n\n%s\n\n%s\n", testMessage1, testMessage2)); | 
|  | } | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void onPostReviewExtensionThatDoesntExtendTheChangeMessage() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  |  | 
|  | TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null); | 
|  | try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) { | 
|  | ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages; | 
|  | assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review+1"); | 
|  | } | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void onPostReviewCallbackGetsCorrectChangeAndPatchSet() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  | amendChange(r.getChangeId()); | 
|  |  | 
|  | TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null); | 
|  | try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) { | 
|  | ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1); | 
|  |  | 
|  | // Vote on current patch set. | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | testOnPostReview.assertChangeAndPatchSet(r.getChange().getId(), 2); | 
|  |  | 
|  | // Vote on old patch set. | 
|  | gApi.changes().id(r.getChangeId()).revision(1).review(input); | 
|  | testOnPostReview.assertChangeAndPatchSet(r.getChange().getId(), 1); | 
|  | } | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void onPostReviewCallbackGetsCorrectUser() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  |  | 
|  | TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null); | 
|  | try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) { | 
|  | ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1); | 
|  |  | 
|  | // Vote from admin. | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | testOnPostReview.assertUser(admin); | 
|  |  | 
|  | // Vote from user. | 
|  | requestScopeOperations.setApiUser(user.id()); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | testOnPostReview.assertUser(user); | 
|  | } | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void onPostReviewCallbackGetsCorrectApprovals() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  |  | 
|  | TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null); | 
|  | try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) { | 
|  | // Add a new vote. | 
|  | ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | testOnPostReview.assertApproval( | 
|  | LabelId.CODE_REVIEW, /* expectedOldValue= */ 0, /* expectedNewValue= */ 1); | 
|  |  | 
|  | // Update an existing vote. | 
|  | input = new ReviewInput().label(LabelId.CODE_REVIEW, 2); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | testOnPostReview.assertApproval( | 
|  | LabelId.CODE_REVIEW, /* expectedOldValue= */ 1, /* expectedNewValue= */ 2); | 
|  |  | 
|  | // Post without changing the vote. | 
|  | input = new ReviewInput().label(LabelId.CODE_REVIEW, 2); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | testOnPostReview.assertApproval( | 
|  | LabelId.CODE_REVIEW, /* expectedOldValue= */ null, /* expectedNewValue= */ 2); | 
|  |  | 
|  | // Delete the vote. | 
|  | input = new ReviewInput().label(LabelId.CODE_REVIEW, 0); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | testOnPostReview.assertApproval( | 
|  | LabelId.CODE_REVIEW, /* expectedOldValue= */ 2, /* expectedNewValue= */ 0); | 
|  | } | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void votingTheSameVoteSecondTime() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  |  | 
|  | gApi.changes().id(r.getChangeId()).addReviewer(user.email()); | 
|  | sender.clear(); | 
|  |  | 
|  | // Add a new vote. | 
|  | ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | assertThat(r.getChange().approvals().values()).hasSize(1); | 
|  |  | 
|  | // Post without changing the vote. | 
|  | ChangeNotes notes = notesFactory.create(project, r.getChange().getId()); | 
|  | ObjectId metaId = notes.getMetaId(); | 
|  | assertAttentionSet(notes.getAttentionSet(), user.id()); | 
|  | input = new ReviewInput().label(LabelId.CODE_REVIEW, 2); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | notes = notesFactory.create(project, r.getChange().getId()); | 
|  | // Change meta ID did not change since the update is No/Op. Attention set is same. | 
|  | assertThat(notes.getMetaId()).isEqualTo(metaId); | 
|  | assertAttentionSet(notes.getAttentionSet(), user.id()); | 
|  |  | 
|  | // Second vote replaced the original vote, so still only one vote. | 
|  | assertThat(r.getChange().approvals().values()).hasSize(1); | 
|  | List<ChangeMessageInfo> changeMessages = gApi.changes().id(r.getChangeId()).messages(); | 
|  |  | 
|  | // Only the last change message is about Code-Review+2 | 
|  | assertThat(Iterables.getLast(changeMessages).message).isEqualTo("Patch Set 1: Code-Review+2"); | 
|  | changeMessages.remove(changeMessages.size() - 1); | 
|  | assertThat(Iterables.getLast(changeMessages).message) | 
|  | .isNotEqualTo("Patch Set 1: Code-Review+2"); | 
|  |  | 
|  | // Only one email is about Code-Review +2 was sent. | 
|  | assertThat(Iterables.getOnlyElement(sender.getMessages()).body()) | 
|  | .contains("Patch Set 1: Code-Review+2"); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void votingTheSameVoteSecondTimeExtendsOnPostReviewWithOldNullValue() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  |  | 
|  | // Add a new vote. | 
|  | ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | assertThat(r.getChange().approvals().values()).hasSize(1); | 
|  |  | 
|  | TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null); | 
|  | try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) { | 
|  | // Post without changing the vote. | 
|  | input = new ReviewInput().label(LabelId.CODE_REVIEW, 2); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  |  | 
|  | testOnPostReview.assertApproval( | 
|  | LabelId.CODE_REVIEW, /* expectedOldValue= */ null, /* expectedNewValue= */ 2); | 
|  | } | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void votingTheSameVoteSecondTimeDoesNotFireOnCommentAdded() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  |  | 
|  | // Add a new vote. | 
|  | ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | assertThat(r.getChange().approvals().values()).hasSize(1); | 
|  |  | 
|  | TestListener testListener = new TestListener(); | 
|  | try (Registration registration = extensionRegistry.newRegistration().add(testListener)) { | 
|  | // Post without changing the vote. | 
|  | input = new ReviewInput().label(LabelId.CODE_REVIEW, 2); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  |  | 
|  | // Event not fired. | 
|  | assertThat(testListener.lastCommentAddedEvent).isNull(); | 
|  | } | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void submitRulesAreInvokedOnlyOnce() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  |  | 
|  | TestSubmitRule testSubmitRule = new TestSubmitRule(); | 
|  | try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) { | 
|  | ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1); | 
|  | gApi.changes().id(r.getChangeId()).current().review(input); | 
|  | } | 
|  |  | 
|  | assertThat(testSubmitRule.count).isEqualTo(1); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void addingReviewers() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  |  | 
|  | TestAccount user2 = accountCreator.user2(); | 
|  |  | 
|  | TestReviewerAddedListener testReviewerAddedListener = new TestReviewerAddedListener(); | 
|  | try (Registration registration = | 
|  | extensionRegistry.newRegistration().add(testReviewerAddedListener)) { | 
|  | // add user and user2 | 
|  | ReviewResult reviewResult = | 
|  | gApi.changes() | 
|  | .id(r.getChangeId()) | 
|  | .current() | 
|  | .review(ReviewInput.create().reviewer(user.email()).reviewer(user2.email())); | 
|  |  | 
|  | assertThat( | 
|  | reviewResult.reviewers.values().stream() | 
|  | .filter(a -> a.reviewers != null) | 
|  | .map(a -> Iterables.getOnlyElement(a.reviewers).name) | 
|  | .collect(toImmutableSet())) | 
|  | .containsExactly(user.fullName(), user2.fullName()); | 
|  | } | 
|  |  | 
|  | assertThat( | 
|  | gApi.changes().id(r.getChangeId()).reviewers().stream() | 
|  | .map(a -> a.name) | 
|  | .collect(toImmutableSet())) | 
|  | .containsExactly(user.fullName(), user2.fullName()); | 
|  |  | 
|  | // Ensure only one batch email was sent for this operation | 
|  | FakeEmailSender.Message message = Iterables.getOnlyElement(sender.getMessages()); | 
|  | assertThat(message.body()) | 
|  | .containsMatch( | 
|  | Pattern.quote("Hello ") | 
|  | + "(" | 
|  | + Pattern.quote(String.format("%s, %s", user.fullName(), user2.fullName())) | 
|  | + "|" | 
|  | + Pattern.quote(String.format("%s, %s", user2.fullName(), user.fullName())) | 
|  | + ")"); | 
|  | assertThat(message.htmlBody()) | 
|  | .containsMatch( | 
|  | "(" | 
|  | + Pattern.quote(String.format("%s and %s", user.fullName(), user2.fullName())) | 
|  | + "|" | 
|  | + Pattern.quote(String.format("%s and %s", user2.fullName(), user.fullName())) | 
|  | + ")" | 
|  | + Pattern.quote(" to <strong>review</strong> this change")); | 
|  |  | 
|  | // Ensure that a batch event has been sent: | 
|  | // * 1 batch event for adding user and user2 as reviewers | 
|  | assertThat(testReviewerAddedListener.receivedEvents).hasSize(1); | 
|  | assertThat(testReviewerAddedListener.getReviewerIds()).containsExactly(user.id(), user2.id()); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void deletingReviewers() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  |  | 
|  | TestAccount user2 = accountCreator.user2(); | 
|  |  | 
|  | // add user and user2 | 
|  | gApi.changes() | 
|  | .id(r.getChangeId()) | 
|  | .current() | 
|  | .review(ReviewInput.create().reviewer(user.email()).reviewer(user2.email())); | 
|  |  | 
|  | sender.clear(); | 
|  |  | 
|  | TestReviewerDeletedListener testReviewerDeletedListener = new TestReviewerDeletedListener(); | 
|  | try (Registration registration = | 
|  | extensionRegistry.newRegistration().add(testReviewerDeletedListener)) { | 
|  | // remove user and user2 | 
|  | ReviewResult reviewResult = | 
|  | gApi.changes() | 
|  | .id(r.getChangeId()) | 
|  | .current() | 
|  | .review( | 
|  | ReviewInput.create() | 
|  | .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true) | 
|  | .reviewer(user2.email(), ReviewerState.REMOVED, /* confirmed= */ true)); | 
|  |  | 
|  | assertThat( | 
|  | reviewResult.reviewers.values().stream() | 
|  | .map(a -> a.removed.name) | 
|  | .collect(toImmutableSet())) | 
|  | .containsExactly(user.fullName(), user2.fullName()); | 
|  | } | 
|  |  | 
|  | assertThat(gApi.changes().id(r.getChangeId()).reviewers()).isEmpty(); | 
|  |  | 
|  | // Ensure only one batch email was sent for this operation | 
|  | FakeEmailSender.Message message = Iterables.getOnlyElement(sender.getMessages()); | 
|  | assertThat(message.body()) | 
|  | .containsMatch( | 
|  | Pattern.quote("removed ") | 
|  | + "(" | 
|  | + Pattern.quote(String.format("%s, %s", user.fullName(), user2.fullName())) | 
|  | + "|" | 
|  | + Pattern.quote(String.format("%s, %s", user2.fullName(), user.fullName())) | 
|  | + ")"); | 
|  | assertThat(message.htmlBody()) | 
|  | .containsMatch( | 
|  | Pattern.quote("removed ") | 
|  | + "(" | 
|  | + Pattern.quote(String.format("%s and %s", user.fullName(), user2.fullName())) | 
|  | + "|" | 
|  | + Pattern.quote(String.format("%s and %s", user2.fullName(), user.fullName())) | 
|  | + ")"); | 
|  |  | 
|  | // Ensure that events have been sent: | 
|  | // * 2 events for removing user and user2 as reviewers (one event per removed reviewer, batch | 
|  | //   event not available for reviewer removal) | 
|  | assertThat(testReviewerDeletedListener.receivedEvents).hasSize(2); | 
|  | assertThat(testReviewerDeletedListener.getReviewerIds()).containsExactly(user.id(), user2.id()); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void addingAndDeletingReviewers() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  |  | 
|  | TestAccount user2 = accountCreator.user2(); | 
|  | TestAccount user3 = accountCreator.create("user3", "user3@email.com", "user3", "user3"); | 
|  | TestAccount user4 = accountCreator.create("user4", "user4@email.com", "user4", "user4"); | 
|  |  | 
|  | // add user and user2 | 
|  | gApi.changes() | 
|  | .id(r.getChangeId()) | 
|  | .current() | 
|  | .review(ReviewInput.create().reviewer(user.email()).reviewer(user2.email())); | 
|  |  | 
|  | sender.clear(); | 
|  |  | 
|  | TestReviewerAddedListener testReviewerAddedListener = new TestReviewerAddedListener(); | 
|  | TestReviewerDeletedListener testReviewerDeletedListener = new TestReviewerDeletedListener(); | 
|  | try (Registration registration = | 
|  | extensionRegistry | 
|  | .newRegistration() | 
|  | .add(testReviewerAddedListener) | 
|  | .add(testReviewerDeletedListener)) { | 
|  | // remove user and user2 while adding user3 and user4 | 
|  | ReviewResult reviewResult = | 
|  | gApi.changes() | 
|  | .id(r.getChangeId()) | 
|  | .current() | 
|  | .review( | 
|  | ReviewInput.create() | 
|  | .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true) | 
|  | .reviewer(user2.email(), ReviewerState.REMOVED, /* confirmed= */ true) | 
|  | .reviewer(user3.email()) | 
|  | .reviewer(user4.email())); | 
|  |  | 
|  | assertThat( | 
|  | reviewResult.reviewers.values().stream() | 
|  | .filter(a -> a.removed != null) | 
|  | .map(a -> a.removed.name) | 
|  | .collect(toImmutableSet())) | 
|  | .containsExactly(user.fullName(), user2.fullName()); | 
|  | assertThat( | 
|  | reviewResult.reviewers.values().stream() | 
|  | .filter(a -> a.reviewers != null) | 
|  | .map(a -> Iterables.getOnlyElement(a.reviewers).name) | 
|  | .collect(toImmutableSet())) | 
|  | .containsExactly(user3.fullName(), user4.fullName()); | 
|  | } | 
|  |  | 
|  | assertThat( | 
|  | gApi.changes().id(r.getChangeId()).reviewers().stream() | 
|  | .map(a -> a.name) | 
|  | .collect(toImmutableSet())) | 
|  | .containsExactly(user3.fullName(), user4.fullName()); | 
|  |  | 
|  | // Ensure only one batch email was sent for this operation | 
|  | FakeEmailSender.Message message = Iterables.getOnlyElement(sender.getMessages()); | 
|  | assertThat(message.body()) | 
|  | .containsMatch( | 
|  | Pattern.quote("Hello ") | 
|  | + "(" | 
|  | + Pattern.quote(String.format("%s, %s", user3.fullName(), user4.fullName())) | 
|  | + "|" | 
|  | + Pattern.quote(String.format("%s, %s", user4.fullName(), user3.fullName())) | 
|  | + ")"); | 
|  | assertThat(message.htmlBody()) | 
|  | .containsMatch( | 
|  | "(" | 
|  | + Pattern.quote(String.format("%s and %s", user3.fullName(), user4.fullName())) | 
|  | + "|" | 
|  | + Pattern.quote(String.format("%s and %s", user4.fullName(), user3.fullName())) | 
|  | + ")" | 
|  | + Pattern.quote(" to <strong>review</strong> this change")); | 
|  |  | 
|  | assertThat(message.body()) | 
|  | .containsMatch( | 
|  | Pattern.quote("removed ") | 
|  | + "(" | 
|  | + Pattern.quote(String.format("%s, %s", user.fullName(), user2.fullName())) | 
|  | + "|" | 
|  | + Pattern.quote(String.format("%s, %s", user2.fullName(), user.fullName())) | 
|  | + ")"); | 
|  | assertThat(message.htmlBody()) | 
|  | .containsMatch( | 
|  | Pattern.quote("removed ") | 
|  | + "(" | 
|  | + Pattern.quote(String.format("%s and %s", user.fullName(), user2.fullName())) | 
|  | + "|" | 
|  | + Pattern.quote(String.format("%s and %s", user2.fullName(), user.fullName())) | 
|  | + ")"); | 
|  |  | 
|  | // Ensure that events have been sent: | 
|  | // * 1 batch event for adding user3 and user4 as reviewers | 
|  | // * 2 events for removing user and user2 as reviewers (one event per removed reviewer, batch | 
|  | //   event not available for reviewer removal) | 
|  | assertThat(testReviewerAddedListener.receivedEvents).hasSize(1); | 
|  | assertThat(testReviewerAddedListener.getReviewerIds()).containsExactly(user3.id(), user4.id()); | 
|  | assertThat(testReviewerDeletedListener.receivedEvents).hasSize(2); | 
|  | assertThat(testReviewerDeletedListener.getReviewerIds()).containsExactly(user.id(), user2.id()); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void deletingNonExistingReviewerFails() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  |  | 
|  | ResourceNotFoundException resourceNotFoundException = | 
|  | assertThrows( | 
|  | ResourceNotFoundException.class, | 
|  | () -> | 
|  | gApi.changes() | 
|  | .id(r.getChangeId()) | 
|  | .current() | 
|  | .review( | 
|  | ReviewInput.create() | 
|  | .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true))); | 
|  | assertThat(resourceNotFoundException) | 
|  | .hasMessageThat() | 
|  | .isEqualTo( | 
|  | String.format( | 
|  | "Reviewer %s doesn't exist in the change, hence can't delete it", user.fullName())); | 
|  | } | 
|  |  | 
|  | @Test | 
|  | public void addingAndDeletingSameReviewerFails() throws Exception { | 
|  | PushOneCommit.Result r = createChange(); | 
|  |  | 
|  | ResourceNotFoundException resourceNotFoundException = | 
|  | assertThrows( | 
|  | ResourceNotFoundException.class, | 
|  | () -> | 
|  | gApi.changes() | 
|  | .id(r.getChangeId()) | 
|  | .current() | 
|  | .review( | 
|  | ReviewInput.create() | 
|  | .reviewer(user.email()) | 
|  | .reviewer(user.email(), ReviewerState.REMOVED, true))); | 
|  | assertThat(resourceNotFoundException) | 
|  | .hasMessageThat() | 
|  | .isEqualTo( | 
|  | String.format( | 
|  | "Reviewer %s doesn't exist in the change," + " hence can't delete it", | 
|  | user.fullName())); | 
|  | } | 
|  |  | 
|  | private static class TestListener implements CommentAddedListener { | 
|  | public CommentAddedListener.Event lastCommentAddedEvent; | 
|  |  | 
|  | @Override | 
|  | public void onCommentAdded(Event event) { | 
|  | lastCommentAddedEvent = event; | 
|  | } | 
|  | } | 
|  |  | 
|  | private List<RobotCommentInfo> getRobotComments(String changeId) throws RestApiException { | 
|  | return gApi.changes().id(changeId).robotComments().values().stream() | 
|  | .flatMap(Collection::stream) | 
|  | .collect(toList()); | 
|  | } | 
|  |  | 
|  | private static CommentInput newComment(String path) { | 
|  | return TestCommentHelper.populate(new CommentInput(), path, PostReviewIT.COMMENT_TEXT); | 
|  | } | 
|  |  | 
|  | private static CommentValidationContext contextFor(PushOneCommit.Result result) { | 
|  | return CommentValidationContext.create( | 
|  | result.getChange().getId().get(), | 
|  | result.getChange().project().get(), | 
|  | result.getChange().change().getDest().branch()); | 
|  | } | 
|  |  | 
|  | private void assertValidatorCalledWith(CommentForValidation... commentsForValidation) { | 
|  | assertThat(captor.getAllValues()).hasSize(1); | 
|  | assertThat(captor.getValue()) | 
|  | .comparingElementsUsing(COMMENT_CORRESPONDENCE) | 
|  | .containsExactly(commentsForValidation); | 
|  | } | 
|  |  | 
|  | private static class TestOnPostReview implements OnPostReview { | 
|  | private final Optional<String> message; | 
|  |  | 
|  | private Change.Id changeId; | 
|  | private PatchSet.Id patchSetId; | 
|  | private Account.Id accountId; | 
|  | private Map<String, Short> oldApprovals; | 
|  | private Map<String, Short> approvals; | 
|  |  | 
|  | TestOnPostReview(@Nullable String message) { | 
|  | this.message = Optional.ofNullable(message); | 
|  | } | 
|  |  | 
|  | @Override | 
|  | public Optional<String> getChangeMessageAddOn( | 
|  | IdentifiedUser user, | 
|  | ChangeNotes changeNotes, | 
|  | PatchSet patchSet, | 
|  | Map<String, Short> oldApprovals, | 
|  | Map<String, Short> approvals) { | 
|  | this.changeId = changeNotes.getChangeId(); | 
|  | this.patchSetId = patchSet.id(); | 
|  | this.accountId = user.getAccountId(); | 
|  | this.oldApprovals = oldApprovals; | 
|  | this.approvals = approvals; | 
|  | return message; | 
|  | } | 
|  |  | 
|  | public void assertChangeAndPatchSet(Change.Id expectedChangeId, int expectedPatchSetNum) { | 
|  | assertThat(changeId).isEqualTo(expectedChangeId); | 
|  | assertThat(patchSetId.get()).isEqualTo(expectedPatchSetNum); | 
|  | } | 
|  |  | 
|  | public void assertUser(TestAccount expectedUser) { | 
|  | assertThat(accountId).isEqualTo(expectedUser.id()); | 
|  | } | 
|  |  | 
|  | public void assertApproval( | 
|  | String labelName, @Nullable Integer expectedOldValue, int expectedNewValue) { | 
|  | assertThat(oldApprovals) | 
|  | .containsExactly( | 
|  | labelName, expectedOldValue != null ? expectedOldValue.shortValue() : null); | 
|  | assertThat(approvals).containsExactly(labelName, (short) expectedNewValue); | 
|  | } | 
|  | } | 
|  |  | 
|  | private static class TestSubmitRule implements SubmitRule { | 
|  | int count; | 
|  |  | 
|  | @Override | 
|  | public Optional<SubmitRecord> evaluate(ChangeData changeData) { | 
|  | if (!isAsyncCallForSendingReviewCommentsEmail()) { | 
|  | count++; | 
|  | } | 
|  | return Optional.empty(); | 
|  | } | 
|  |  | 
|  | private boolean isAsyncCallForSendingReviewCommentsEmail() { | 
|  | return Arrays.stream(Thread.currentThread().getStackTrace()) | 
|  | .map(StackTraceElement::getClassName) | 
|  | .anyMatch(className -> EmailReviewComments.class.getName().equals(className)); | 
|  | } | 
|  | } | 
|  |  | 
|  | private static class TestReviewerAddedListener implements ReviewerAddedListener { | 
|  | List<ReviewerAddedListener.Event> receivedEvents = new ArrayList<>(); | 
|  |  | 
|  | @Override | 
|  | public void onReviewersAdded(ReviewerAddedListener.Event event) { | 
|  | receivedEvents.add(event); | 
|  | } | 
|  |  | 
|  | public ImmutableSet<Account.Id> getReviewerIds() { | 
|  | return receivedEvents.stream() | 
|  | .flatMap(e -> e.getReviewers().stream()) | 
|  | .map(accountInfo -> Account.id(accountInfo._accountId)) | 
|  | .collect(toImmutableSet()); | 
|  | } | 
|  | } | 
|  |  | 
|  | private static class TestReviewerDeletedListener implements ReviewerDeletedListener { | 
|  | List<ReviewerDeletedListener.Event> receivedEvents = new ArrayList<>(); | 
|  |  | 
|  | @Override | 
|  | public void onReviewerDeleted(ReviewerDeletedListener.Event event) { | 
|  | receivedEvents.add(event); | 
|  | } | 
|  |  | 
|  | public ImmutableSet<Account.Id> getReviewerIds() { | 
|  | return receivedEvents.stream() | 
|  | .map(ReviewerDeletedListener.Event::getReviewer) | 
|  | .map(accountInfo -> Account.id(accountInfo._accountId)) | 
|  | .collect(toImmutableSet()); | 
|  | } | 
|  | } | 
|  |  | 
|  | private static void assertAttentionSet( | 
|  | ImmutableSet<AttentionSetUpdate> attentionSet, Account.Id... accounts) { | 
|  | assertThat(attentionSet.stream().map(AttentionSetUpdate::account).collect(Collectors.toList())) | 
|  | .containsExactlyElementsIn(accounts); | 
|  | } | 
|  | } |