// Copyright (C) 2016 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.server.notedb; import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assert_; import static com.google.common.truth.TruthJUnit.assume; import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef; import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments; import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.concurrent.TimeUnit.DAYS; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.stream.Collectors.toList; import static org.eclipse.jgit.lib.Constants.OBJ_BLOB; import static org.junit.Assert.fail; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Ordering; import com.google.gerrit.acceptance.AbstractDaemonTest; import com.google.gerrit.acceptance.AcceptanceTestRequestScope; import com.google.gerrit.acceptance.PushOneCommit; import com.google.gerrit.acceptance.TestAccount; import com.google.gerrit.common.TimeUtil; import com.google.gerrit.common.data.GlobalCapability; 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.client.Side; import com.google.gerrit.extensions.common.CommentInfo; import com.google.gerrit.extensions.restapi.ResourceNotFoundException; import com.google.gerrit.extensions.restapi.RestApiException; import com.google.gerrit.reviewdb.client.Account; import com.google.gerrit.reviewdb.client.Change; import com.google.gerrit.reviewdb.client.ChangeMessage; import com.google.gerrit.reviewdb.client.Patch; import com.google.gerrit.reviewdb.client.PatchLineComment; import com.google.gerrit.reviewdb.client.PatchSet; import com.google.gerrit.reviewdb.client.PatchSetApproval; import com.google.gerrit.reviewdb.client.Project; import com.google.gerrit.reviewdb.client.RefNames; import com.google.gerrit.reviewdb.server.ReviewDb; import com.google.gerrit.reviewdb.server.ReviewDbUtil; import com.google.gerrit.server.ChangeUtil; import com.google.gerrit.server.CommentsUtil; import com.google.gerrit.server.Sequences; import com.google.gerrit.server.change.PostReview; import com.google.gerrit.server.change.Rebuild; import com.google.gerrit.server.change.RevisionResource; import com.google.gerrit.server.config.AllUsersName; import com.google.gerrit.server.git.ProjectConfig; import com.google.gerrit.server.git.RepoRefCache; import com.google.gerrit.server.notedb.ChangeBundle; import com.google.gerrit.server.notedb.ChangeBundleReader; import com.google.gerrit.server.notedb.ChangeNotes; import com.google.gerrit.server.notedb.NoteDbChangeState; import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage; import com.google.gerrit.server.notedb.NoteDbUpdateManager; import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper; import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException; import com.google.gerrit.server.patch.PatchSetInfoFactory; import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException; import com.google.gerrit.server.project.Util; import com.google.gerrit.server.query.change.ChangeData; import com.google.gerrit.server.update.BatchUpdate; import com.google.gerrit.server.update.BatchUpdateOp; import com.google.gerrit.server.update.ChangeContext; import com.google.gerrit.server.update.UpdateException; import com.google.gerrit.testutil.ConfigSuite; import com.google.gerrit.testutil.NoteDbChecker; import com.google.gerrit.testutil.NoteDbMode; import com.google.gerrit.testutil.TestChanges; import com.google.gerrit.testutil.TestTimeUtil; import com.google.gwtorm.server.OrmException; import com.google.gwtorm.server.OrmRuntimeException; import com.google.inject.Inject; import com.google.inject.Provider; import java.sql.Timestamp; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; import org.apache.http.Header; import org.apache.http.message.BasicHeader; import org.eclipse.jgit.junit.TestRepository; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectInserter; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.RefUpdate; import org.eclipse.jgit.lib.Repository; import org.junit.After; import org.junit.Before; import org.junit.Test; public class ChangeRebuilderIT extends AbstractDaemonTest { @ConfigSuite.Default public static Config defaultConfig() { Config cfg = new Config(); cfg.setBoolean("noteDb", null, "testRebuilderWrapper", true); // Disable async reindex-if-stale check after index update. This avoids // unintentional auto-rebuilding of the change in NoteDb during the read // path of the reindex-if-stale check. For the purposes of this test, we // want precise control over when auto-rebuilding happens. cfg.setBoolean("index", null, "testAutoReindexIfStale", false); return cfg; } @Inject private AllUsersName allUsers; @Inject private NoteDbChecker checker; @Inject private Rebuild rebuildHandler; @Inject private Provider<ReviewDb> dbProvider; @Inject private CommentsUtil commentsUtil; @Inject private Provider<PostReview> postReview; @Inject private TestChangeRebuilderWrapper rebuilderWrapper; @Inject private Sequences seq; @Inject private ChangeBundleReader bundleReader; @Inject private PatchSetInfoFactory patchSetInfoFactory; @Before public void setUp() throws Exception { assume().that(NoteDbMode.readWrite()).isFalse(); TestTimeUtil.resetWithClockStep(1, SECONDS); setNotesMigration(false, false); } @After public void tearDown() { TestTimeUtil.useSystemTime(); } @SuppressWarnings("deprecation") private void setNotesMigration(boolean writeChanges, boolean readChanges) throws Exception { notesMigration.setWriteChanges(writeChanges); notesMigration.setReadChanges(readChanges); db = atrScope.reopenDb().getReviewDbProvider().get(); if (notesMigration.readChangeSequence()) { // Copy next ReviewDb ID to NoteDb. seq.getChangeIdRepoSequence().set(db.nextChangeId()); } else { // Copy next NoteDb ID to ReviewDb. while (db.nextChangeId() < seq.getChangeIdRepoSequence().next()) {} } } @Test public void changeFields() throws Exception { PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); gApi.changes().id(id.get()).topic(name("a-topic")); checker.rebuildAndCheckChanges(id); } @Test public void patchSets() throws Exception { PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); r = amendChange(r.getChangeId()); checker.rebuildAndCheckChanges(id); } @Test public void publishedComment() throws Exception { PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); putComment(user, id, 1, "comment", null); checker.rebuildAndCheckChanges(id); } @Test public void publishedCommentAndReply() throws Exception { PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); putComment(user, id, 1, "comment", null); Map<String, List<CommentInfo>> comments = getPublishedComments(id); String parentUuid = comments.get("a.txt").get(0).id; putComment(user, id, 1, "comment", parentUuid); checker.rebuildAndCheckChanges(id); } @Test public void patchSetWithNullGroups() throws Exception { Timestamp ts = TimeUtil.nowTs(); Change c = TestChanges.newChange(project, user.getId(), seq.nextChangeId()); c.setCreatedOn(ts); c.setLastUpdatedOn(ts); PatchSet ps = TestChanges.newPatchSet( c.currentPatchSetId(), "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", user.getId()); ps.setCreatedOn(ts); db.changes().insert(Collections.singleton(c)); db.patchSets().insert(Collections.singleton(ps)); assertThat(ps.getGroups()).isEmpty(); checker.rebuildAndCheckChanges(c.getId()); } @Test public void draftComment() throws Exception { PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); putDraft(user, id, 1, "comment", null); checker.rebuildAndCheckChanges(id); } @Test public void draftAndPublishedComment() throws Exception { PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); putDraft(user, id, 1, "draft comment", null); putComment(user, id, 1, "published comment", null); checker.rebuildAndCheckChanges(id); } @Test public void publishDraftComment() throws Exception { PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); putDraft(user, id, 1, "draft comment", null); publishDrafts(user, id); checker.rebuildAndCheckChanges(id); } @Test public void nullAccountId() throws Exception { PushOneCommit.Result r = createChange(); PatchSet.Id psId = r.getPatchSetId(); Change.Id id = psId.getParentKey(); // Events need to be otherwise identical for the account ID to be compared. ChangeMessage msg1 = insertMessage(id, psId, user.getId(), TimeUtil.nowTs(), "message 1"); insertMessage(id, psId, null, msg1.getWrittenOn(), "message 2"); checker.rebuildAndCheckChanges(id); } @Test public void nullPatchSetId() throws Exception { PushOneCommit.Result r = createChange(); PatchSet.Id psId1 = r.getPatchSetId(); Change.Id id = psId1.getParentKey(); // Events need to be otherwise identical for the PatchSet.ID to be compared. ChangeMessage msg1 = insertMessage(id, null, user.getId(), TimeUtil.nowTs(), "message 1"); insertMessage(id, null, user.getId(), msg1.getWrittenOn(), "message 2"); PatchSet.Id psId2 = amendChange(r.getChangeId()).getPatchSetId(); ChangeMessage msg3 = insertMessage(id, null, user.getId(), TimeUtil.nowTs(), "message 3"); insertMessage(id, null, user.getId(), msg3.getWrittenOn(), "message 4"); checker.rebuildAndCheckChanges(id); setNotesMigration(true, true); ChangeNotes notes = notesFactory.create(db, project, id); Map<String, PatchSet.Id> psIds = new HashMap<>(); for (ChangeMessage msg : notes.getChangeMessages()) { PatchSet.Id psId = msg.getPatchSetId(); assertThat(psId).named("patchset for " + msg).isNotNull(); psIds.put(msg.getMessage(), psId); } // Patch set IDs were replaced during conversion process. assertThat(psIds).containsEntry("message 1", psId1); assertThat(psIds).containsEntry("message 2", psId1); assertThat(psIds).containsEntry("message 3", psId2); assertThat(psIds).containsEntry("message 4", psId2); } @Test public void noWriteToNewRef() throws Exception { PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); checker.assertNoChangeRef(project, id); setNotesMigration(true, false); gApi.changes().id(id.get()).topic(name("a-topic")); // First write doesn't create the ref, but rebuilding works. checker.assertNoChangeRef(project, id); assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isNull(); checker.rebuildAndCheckChanges(id); // Now that there is a ref, writes are "turned on" for this change, and // NoteDb stays up to date without explicit rebuilding. gApi.changes().id(id.get()).topic(name("new-topic")); assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isNotNull(); checker.checkChanges(id); } @Test public void restApiNotFoundWhenNoteDbDisabled() throws Exception { PushOneCommit.Result r = createChange(); exception.expect(ResourceNotFoundException.class); rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Rebuild.Input()); } @Test public void rebuildViaRestApi() throws Exception { PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); setNotesMigration(true, false); checker.assertNoChangeRef(project, id); rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Rebuild.Input()); checker.checkChanges(id); } @Test public void writeToNewRefForNewChange() throws Exception { PushOneCommit.Result r1 = createChange(); Change.Id id1 = r1.getPatchSetId().getParentKey(); setNotesMigration(true, false); gApi.changes().id(id1.get()).topic(name("a-topic")); PushOneCommit.Result r2 = createChange(); Change.Id id2 = r2.getPatchSetId().getParentKey(); // Second change was created after NoteDb writes were turned on, so it was // allowed to write to a new ref. checker.checkChanges(id2); // First change was created before NoteDb writes were turned on, so its meta // ref doesn't exist until a manual rebuild. checker.assertNoChangeRef(project, id1); checker.rebuildAndCheckChanges(id1); } @Test public void noteDbChangeState() throws Exception { setNotesMigration(true, true); PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); ObjectId changeMetaId = getMetaRef(project, changeMetaRef(id)); assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(changeMetaId.name()); putDraft(user, id, 1, "comment by user", null); ObjectId userDraftsId = getMetaRef(allUsers, refsDraftComments(id, user.getId())); assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()) .isEqualTo(changeMetaId.name() + "," + user.getId() + "=" + userDraftsId.name()); putDraft(admin, id, 2, "comment by admin", null); ObjectId adminDraftsId = getMetaRef(allUsers, refsDraftComments(id, admin.getId())); assertThat(admin.getId().get()).isLessThan(user.getId().get()); assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()) .isEqualTo( changeMetaId.name() + "," + admin.getId() + "=" + adminDraftsId.name() + "," + user.getId() + "=" + userDraftsId.name()); putDraft(admin, id, 2, "revised comment by admin", null); adminDraftsId = getMetaRef(allUsers, refsDraftComments(id, admin.getId())); assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()) .isEqualTo( changeMetaId.name() + "," + admin.getId() + "=" + adminDraftsId.name() + "," + user.getId() + "=" + userDraftsId.name()); } @Test public void rebuildAutomaticallyWhenChangeOutOfDate() throws Exception { setNotesMigration(true, true); PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); assertChangeUpToDate(true, id); // Make a ReviewDb change behind NoteDb's back and ensure it's detected. setNotesMigration(false, false); gApi.changes().id(id.get()).topic(name("a-topic")); setInvalidNoteDbState(id); assertChangeUpToDate(false, id); // On next NoteDb read, the change is transparently rebuilt. setNotesMigration(true, true); assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic")); assertChangeUpToDate(true, id); // Check that the bundles are equal. ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notesFactory.create(dbProvider.get(), project, id)); ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id); assertThat(actual.differencesFrom(expected)).isEmpty(); } @Test public void rebuildAutomaticallyWithinBatchUpdate() throws Exception { setNotesMigration(true, true); PushOneCommit.Result r = createChange(); final Change.Id id = r.getPatchSetId().getParentKey(); assertChangeUpToDate(true, id); // Update ReviewDb and NoteDb, then revert the corresponding NoteDb change // to simulate it failing. NoteDbChangeState oldState = NoteDbChangeState.parse(getUnwrappedDb().changes().get(id)); String topic = name("a-topic"); gApi.changes().id(id.get()).topic(topic); try (Repository repo = repoManager.openRepository(project)) { new TestRepository<>(repo).update(RefNames.changeMetaRef(id), oldState.getChangeMetaId()); } assertChangeUpToDate(false, id); // Next NoteDb read comes inside the transaction started by BatchUpdate. In // reality this could be caused by a failed update happening between when // the change is parsed by ChangesCollection and when the BatchUpdate // executes. We simulate it here by using BatchUpdate directly and not going // through an API handler. final String msg = "message from BatchUpdate"; try (BatchUpdate bu = batchUpdateFactory.create( db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs())) { bu.addOp( id, new BatchUpdateOp() { @Override public boolean updateChange(ChangeContext ctx) throws OrmException { PatchSet.Id psId = ctx.getChange().currentPatchSetId(); ChangeMessage cm = new ChangeMessage( new ChangeMessage.Key(id, ChangeUtil.messageUuid()), ctx.getAccountId(), ctx.getWhen(), psId); cm.setMessage(msg); ctx.getDb().changeMessages().insert(Collections.singleton(cm)); ctx.getUpdate(psId).setChangeMessage(msg); return true; } }); try { bu.execute(); fail("expected update to fail"); } catch (UpdateException e) { assertThat(e.getMessage()).contains("cannot copy ChangeNotesState"); } } // TODO(dborowitz): Re-enable these assertions once we fix auto-rebuilding // in the BatchUpdate path. //// As an implementation detail, change wasn't actually rebuilt inside the //// BatchUpdate transaction, but it was rebuilt during read for the //// subsequent reindex. Thus it's impossible to actually observe an //// out-of-date state in the caller. //assertChangeUpToDate(true, id); //// Check that the bundles are equal. //ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id); //ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes); //ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id); //assertThat(actual.differencesFrom(expected)).isEmpty(); //assertThat( // Iterables.transform( // notes.getChangeMessages(), // ChangeMessage::getMessage)) // .contains(msg); //assertThat(actual.getChange().getTopic()).isEqualTo(topic); } @Test public void rebuildIgnoresErrorIfChangeIsUpToDateAfter() throws Exception { setNotesMigration(true, true); PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); assertChangeUpToDate(true, id); // Make a ReviewDb change behind NoteDb's back and ensure it's detected. setNotesMigration(false, false); gApi.changes().id(id.get()).topic(name("a-topic")); setInvalidNoteDbState(id); assertChangeUpToDate(false, id); // Force the next rebuild attempt to fail but also rebuild the change in the // background. rebuilderWrapper.stealNextUpdate(); setNotesMigration(true, true); assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic")); assertChangeUpToDate(true, id); // Check that the bundles are equal. ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notesFactory.create(dbProvider.get(), project, id)); ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id); assertThat(actual.differencesFrom(expected)).isEmpty(); } @Test public void rebuildReturnsCorrectResultEvenIfSavingToNoteDbFailed() throws Exception { setNotesMigration(true, true); PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); assertChangeUpToDate(true, id); ObjectId oldMetaId = getMetaRef(project, changeMetaRef(id)); // Make a ReviewDb change behind NoteDb's back. setNotesMigration(false, false); gApi.changes().id(id.get()).topic(name("a-topic")); setInvalidNoteDbState(id); assertChangeUpToDate(false, id); assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId); // Force the next rebuild attempt to fail. rebuilderWrapper.failNextUpdate(); setNotesMigration(true, true); ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id); // Not up to date, but the actual returned state matches anyway. assertChangeUpToDate(false, id); assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId); ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes); ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id); assertThat(actual.differencesFrom(expected)).isEmpty(); assertChangeUpToDate(false, id); // Another rebuild attempt succeeds notesFactory.create(dbProvider.get(), project, id); assertThat(getMetaRef(project, changeMetaRef(id))).isNotEqualTo(oldMetaId); assertChangeUpToDate(true, id); } @Test public void rebuildReturnsDraftResultWhenRebuildingInChangeNotesFails() throws Exception { setNotesMigration(true, true); PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); putDraft(user, id, 1, "comment by user", null); assertChangeUpToDate(true, id); ObjectId oldMetaId = getMetaRef(allUsers, refsDraftComments(id, user.getId())); // Add a draft behind NoteDb's back. setNotesMigration(false, false); putDraft(user, id, 1, "second comment by user", null); setInvalidNoteDbState(id); assertDraftsUpToDate(false, id, user); assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId); // Force the next rebuild attempt to fail (in ChangeNotes). rebuilderWrapper.failNextUpdate(); setNotesMigration(true, true); ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id); notes.getDraftComments(user.getId()); assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId); // Not up to date, but the actual returned state matches anyway. assertDraftsUpToDate(false, id, user); ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes); ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id); assertThat(actual.differencesFrom(expected)).isEmpty(); // Another rebuild attempt succeeds notesFactory.create(dbProvider.get(), project, id); assertChangeUpToDate(true, id); assertDraftsUpToDate(true, id, user); assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isNotEqualTo(oldMetaId); } @Test public void rebuildReturnsDraftResultWhenRebuildingInDraftCommentNotesFails() throws Exception { setNotesMigration(true, true); PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); putDraft(user, id, 1, "comment by user", null); assertChangeUpToDate(true, id); ObjectId oldMetaId = getMetaRef(allUsers, refsDraftComments(id, user.getId())); // Add a draft behind NoteDb's back. setNotesMigration(false, false); putDraft(user, id, 1, "second comment by user", null); ReviewDb db = getUnwrappedDb(); Change c = db.changes().get(id); // Leave change meta ID alone so DraftCommentNotes does the rebuild. ObjectId badSha = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"); NoteDbChangeState bogusState = new NoteDbChangeState( id, PrimaryStorage.REVIEW_DB, Optional.of( NoteDbChangeState.RefState.create( NoteDbChangeState.parse(c).getChangeMetaId(), ImmutableMap.of(user.getId(), badSha))), Optional.empty()); c.setNoteDbState(bogusState.toString()); db.changes().update(Collections.singleton(c)); assertDraftsUpToDate(false, id, user); assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId); // Force the next rebuild attempt to fail (in DraftCommentNotes). rebuilderWrapper.failNextUpdate(); setNotesMigration(true, true); ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id); notes.getDraftComments(user.getId()); assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId); // Not up to date, but the actual returned state matches anyway. assertChangeUpToDate(true, id); assertDraftsUpToDate(false, id, user); ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes); ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id); assertThat(actual.differencesFrom(expected)).isEmpty(); // Another rebuild attempt succeeds notesFactory.create(dbProvider.get(), project, id).getDraftComments(user.getId()); assertChangeUpToDate(true, id); assertDraftsUpToDate(true, id, user); assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isNotEqualTo(oldMetaId); } @Test public void rebuildAutomaticallyWhenDraftsOutOfDate() throws Exception { setNotesMigration(true, true); setApiUser(user); PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); putDraft(user, id, 1, "comment", null); assertDraftsUpToDate(true, id, user); // Make a ReviewDb change behind NoteDb's back and ensure it's detected. setNotesMigration(false, false); putDraft(user, id, 1, "comment", null); setInvalidNoteDbState(id); assertDraftsUpToDate(false, id, user); // On next NoteDb read, the drafts are transparently rebuilt. setNotesMigration(true, true); assertThat(gApi.changes().id(id.get()).current().drafts()).containsKey(PushOneCommit.FILE_NAME); assertDraftsUpToDate(true, id, user); } @Test public void pushCert() throws Exception { // We don't have the code in our test harness to do signed pushes, so just // use a hard-coded cert. This cert was actually generated by C git 2.2.0 // (albeit not for sending to Gerrit). String cert = "certificate version 0.1\n" + "pusher Dave Borowitz <dborowitz@google.com> 1433954361 -0700\n" + "pushee git://localhost/repo.git\n" + "nonce 1433954361-bde756572d665bba81d8\n" + "\n" + "0000000000000000000000000000000000000000" + "b981a177396fb47345b7df3e4d3f854c6bea7" + "s/heads/master\n" + "-----BEGIN PGP SIGNATURE-----\n" + "Version: GnuPG v1\n" + "\n" + "iQEcBAABAgAGBQJVeGg5AAoJEPfTicJkUdPkUggH/RKAeI9/i/LduuiqrL/SSdIa\n" + "9tYaSqJKLbXz63M/AW4Sp+4u+dVCQvnAt/a35CVEnpZz6hN4Kn/tiswOWVJf4CO7\n" + "htNubGs5ZMwvD6sLYqKAnrM3WxV/2TbbjzjZW6Jkidz3jz/WRT4SmjGYiEO7aA+V\n" + "4ZdIS9f7sW5VsHHYlNThCA7vH8Uu48bUovFXyQlPTX0pToSgrWV3JnTxDNxfn3iG\n" + "IL0zTY/qwVCdXgFownLcs6J050xrrBWIKqfcWr3u4D2aCLyR0v+S/KArr7ulZygY\n" + "+SOklImn8TAZiNxhWtA6ens66IiammUkZYFv7SSzoPLFZT4dC84SmGPWgf94NoQ=\n" + "=XFeC\n" + "-----END PGP SIGNATURE-----\n"; PushOneCommit.Result r = createChange(); PatchSet.Id psId = r.getPatchSetId(); Change.Id id = psId.getParentKey(); PatchSet ps = db.patchSets().get(psId); ps.setPushCertificate(cert); db.patchSets().update(Collections.singleton(ps)); indexer.index(db, project, id); checker.rebuildAndCheckChanges(id); } @Test public void emptyTopic() throws Exception { PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); Change c = db.changes().get(id); assertThat(c.getTopic()).isNull(); c.setTopic(""); db.changes().update(Collections.singleton(c)); checker.rebuildAndCheckChanges(id); setNotesMigration(true, true); // Rebuild and check was successful, but NoteDb doesn't support storing an // empty topic, so it comes out as null. ChangeNotes notes = notesFactory.create(db, project, id); assertThat(notes.getChange().getTopic()).isNull(); } @Test public void commentBeforeFirstPatchSet() throws Exception { PushOneCommit.Result r = createChange(); PatchSet.Id psId = r.getPatchSetId(); Change.Id id = psId.getParentKey(); Change c = db.changes().get(id); c.setCreatedOn(new Timestamp(c.getCreatedOn().getTime() - 5000)); db.changes().update(Collections.singleton(c)); indexer.index(db, project, id); ReviewInput rin = new ReviewInput(); rin.message = "comment"; Timestamp ts = new Timestamp(c.getCreatedOn().getTime() + 2000); assertThat(ts).isGreaterThan(c.getCreatedOn()); assertThat(ts).isLessThan(db.patchSets().get(psId).getCreatedOn()); RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId()); postReview.get().apply(batchUpdateFactory, revRsrc, rin, ts); checker.rebuildAndCheckChanges(id); } @Test public void commentPredatingChangeBySomeoneOtherThanOwner() throws Exception { PushOneCommit.Result r = createChange(); PatchSet.Id psId = r.getPatchSetId(); Change.Id id = psId.getParentKey(); Change c = db.changes().get(id); ReviewInput rin = new ReviewInput(); rin.message = "comment"; Timestamp ts = new Timestamp(c.getCreatedOn().getTime() - 10000); RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId()); setApiUser(user); postReview.get().apply(batchUpdateFactory, revRsrc, rin, ts); checker.rebuildAndCheckChanges(id); } @Test public void noteDbUsesOriginalSubjectFromPatchSetAndIgnoresChangeField() throws Exception { PushOneCommit.Result r = createChange(); String orig = r.getChange().change().getSubject(); r = pushFactory .create( db, admin.getIdent(), testRepo, orig + " v2", PushOneCommit.FILE_NAME, "new contents", r.getChangeId()) .to("refs/for/master"); r.assertOkStatus(); PatchSet.Id psId = r.getPatchSetId(); Change.Id id = psId.getParentKey(); Change c = db.changes().get(id); c.setCurrentPatchSet(psId, c.getSubject(), "Bogus original subject"); db.changes().update(Collections.singleton(c)); checker.rebuildAndCheckChanges(id); setNotesMigration(true, true); ChangeNotes notes = notesFactory.create(db, project, id); Change nc = notes.getChange(); assertThat(nc.getSubject()).isEqualTo(c.getSubject()); assertThat(nc.getSubject()).isEqualTo(orig + " v2"); assertThat(nc.getOriginalSubject()).isNotEqualTo(c.getOriginalSubject()); assertThat(nc.getOriginalSubject()).isEqualTo(orig); } @Test public void deleteDraftPS1WithNoOtherEntities() throws Exception { PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo); PushOneCommit.Result r = push.to("refs/drafts/master"); push = pushFactory.create( db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "4711", r.getChangeId()); r = push.to("refs/drafts/master"); PatchSet.Id psId = r.getPatchSetId(); Change.Id id = psId.getParentKey(); gApi.changes().id(r.getChangeId()).revision(1).delete(); checker.rebuildAndCheckChanges(id); setNotesMigration(true, true); ChangeNotes notes = notesFactory.create(db, project, id); assertThat(notes.getPatchSets().keySet()).containsExactly(psId); } @Test public void ignorePatchLineCommentsOnPatchSet0() throws Exception { PushOneCommit.Result r = createChange(); Change change = r.getChange().change(); Change.Id id = change.getId(); PatchLineComment comment = new PatchLineComment( new PatchLineComment.Key( new Patch.Key(new PatchSet.Id(id, 0), PushOneCommit.FILE_NAME), "uuid"), 0, user.getId(), null, TimeUtil.nowTs()); comment.setSide((short) 1); comment.setMessage("message"); comment.setStatus(PatchLineComment.Status.PUBLISHED); db.patchComments().insert(Collections.singleton(comment)); indexer.index(db, change.getProject(), id); checker.rebuildAndCheckChanges(id); setNotesMigration(true, true); ChangeNotes notes = notesFactory.create(db, project, id); assertThat(notes.getComments()).isEmpty(); } @Test public void leadingSpacesInSubject() throws Exception { String subj = " " + PushOneCommit.SUBJECT; PushOneCommit push = pushFactory.create( db, admin.getIdent(), testRepo, subj, PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT); PushOneCommit.Result r = push.to("refs/for/master"); r.assertOkStatus(); Change change = r.getChange().change(); assertThat(change.getSubject()).isEqualTo(subj); Change.Id id = r.getPatchSetId().getParentKey(); checker.rebuildAndCheckChanges(id); setNotesMigration(true, true); ChangeNotes notes = notesFactory.create(db, project, id); assertThat(notes.getChange().getSubject()).isNotEqualTo(subj); assertThat(notes.getChange().getSubject()).isEqualTo(PushOneCommit.SUBJECT); } @Test public void createWithAutoRebuildingDisabled() throws Exception { ReviewDb oldDb = db; setNotesMigration(true, true); PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); ChangeNotes oldNotes = notesFactory.create(db, project, id); // Make a ReviewDb change behind NoteDb's back. Change c = oldDb.changes().get(id); assertThat(c.getTopic()).isNull(); String topic = name("a-topic"); c.setTopic(topic); oldDb.changes().update(Collections.singleton(c)); c = oldDb.changes().get(c.getId()); ChangeNotes newNotes = notesFactory.createWithAutoRebuildingDisabled(c, null); assertThat(newNotes.getChange().getTopic()).isNotEqualTo(topic); assertThat(newNotes.getChange().getTopic()).isEqualTo(oldNotes.getChange().getTopic()); } @Test public void rebuildDeletesOldDraftRefs() throws Exception { PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); putDraft(user, id, 1, "comment", null); Account.Id otherAccountId = new Account.Id(user.getId().get() + 1234); String otherDraftRef = refsDraftComments(id, otherAccountId); try (Repository repo = repoManager.openRepository(allUsers); ObjectInserter ins = repo.newObjectInserter()) { ObjectId sha = ins.insert(OBJ_BLOB, "garbage data".getBytes(UTF_8)); ins.flush(); RefUpdate ru = repo.updateRef(otherDraftRef); ru.setExpectedOldObjectId(ObjectId.zeroId()); ru.setNewObjectId(sha); assertThat(ru.update()).isEqualTo(RefUpdate.Result.NEW); } checker.rebuildAndCheckChanges(id); try (Repository repo = repoManager.openRepository(allUsers)) { assertThat(repo.exactRef(otherDraftRef)).isNull(); } } @Test public void failWhenWritesDisabled() throws Exception { setNotesMigration(true, true); PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); assertChangeUpToDate(true, id); assertThat(gApi.changes().id(id.get()).info().topic).isNull(); // Turning off writes causes failure. setNotesMigration(false, true); try { gApi.changes().id(id.get()).topic(name("a-topic")); fail("Expected write to fail"); } catch (RestApiException e) { assertChangesReadOnly(e); } // Update was not written. assertThat(gApi.changes().id(id.get()).info().topic).isNull(); assertChangeUpToDate(true, id); } @Test public void rebuildWhenWritesDisabledWorksButDoesNotWrite() throws Exception { setNotesMigration(true, true); PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); assertChangeUpToDate(true, id); // Make a ReviewDb change behind NoteDb's back and ensure it's detected. setNotesMigration(false, false); gApi.changes().id(id.get()).topic(name("a-topic")); setInvalidNoteDbState(id); assertChangeUpToDate(false, id); // On next NoteDb read, change is rebuilt in-memory but not stored. setNotesMigration(false, true); assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic")); assertChangeUpToDate(false, id); // Attempting to write directly causes failure. try { gApi.changes().id(id.get()).topic(name("other-topic")); fail("Expected write to fail"); } catch (RestApiException e) { assertChangesReadOnly(e); } // Update was not written. assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic")); assertChangeUpToDate(false, id); } @Test public void rebuildChangeWithNoPatchSets() throws Exception { PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); db.changes().beginTransaction(id); try { db.patchSets().delete(db.patchSets().byChange(id)); db.commit(); } finally { db.rollback(); } exception.expect(NoPatchSetsException.class); checker.rebuildAndCheckChanges(id); } @Test public void rebuildEntitiesCreatedByImpersonation() throws Exception { PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); PatchSet.Id psId = new PatchSet.Id(id, 1); String prefix = "/changes/" + id + "/revisions/current/"; // For each of the entities that have a real user field, create one entity // without impersonation and one with. CommentInput ci = new CommentInput(); ci.path = Patch.COMMIT_MSG; ci.side = Side.REVISION; ci.line = 1; ci.message = "comment without impersonation"; ReviewInput ri = new ReviewInput(); ri.label("Code-Review", -1); ri.message = "message without impersonation"; ri.drafts = DraftHandling.KEEP; ri.comments = ImmutableMap.of(ci.path, ImmutableList.of(ci)); userRestSession.post(prefix + "review", ri).assertOK(); DraftInput di = new DraftInput(); di.path = Patch.COMMIT_MSG; di.side = Side.REVISION; di.line = 1; di.message = "draft without impersonation"; userRestSession.put(prefix + "drafts", di).assertCreated(); allowRunAs(); try { Header runAs = new BasicHeader("X-Gerrit-RunAs", user.id.toString()); ci.message = "comment with impersonation"; ri.message = "message with impersonation"; ri.label("Code-Review", 1); adminRestSession.postWithHeader(prefix + "review", ri, runAs).assertOK(); di.message = "draft with impersonation"; adminRestSession.putWithHeader(prefix + "drafts", runAs, di).assertCreated(); } finally { removeRunAs(); } List<ChangeMessage> msgs = Ordering.natural() .onResultOf(ChangeMessage::getWrittenOn) .sortedCopy(db.changeMessages().byChange(id)); assertThat(msgs).hasSize(3); assertThat(msgs.get(1).getMessage()).endsWith("message without impersonation"); assertThat(msgs.get(1).getAuthor()).isEqualTo(user.id); assertThat(msgs.get(1).getRealAuthor()).isEqualTo(user.id); assertThat(msgs.get(2).getMessage()).endsWith("message with impersonation"); assertThat(msgs.get(2).getAuthor()).isEqualTo(user.id); assertThat(msgs.get(2).getRealAuthor()).isEqualTo(admin.id); List<PatchSetApproval> psas = db.patchSetApprovals().byChange(id).toList(); assertThat(psas).hasSize(1); assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review"); assertThat(psas.get(0).getValue()).isEqualTo(1); assertThat(psas.get(0).getAccountId()).isEqualTo(user.id); assertThat(psas.get(0).getRealAccountId()).isEqualTo(admin.id); Ordering<PatchLineComment> commentOrder = Ordering.natural().onResultOf(PatchLineComment::getWrittenOn); List<PatchLineComment> drafts = commentOrder.sortedCopy(db.patchComments().draftByPatchSetAuthor(psId, user.id)); assertThat(drafts).hasSize(2); assertThat(drafts.get(0).getMessage()).isEqualTo("draft without impersonation"); assertThat(drafts.get(0).getAuthor()).isEqualTo(user.id); assertThat(drafts.get(0).getRealAuthor()).isEqualTo(user.id); assertThat(drafts.get(1).getMessage()).isEqualTo("draft with impersonation"); assertThat(drafts.get(1).getAuthor()).isEqualTo(user.id); assertThat(drafts.get(1).getRealAuthor()).isEqualTo(admin.id); List<PatchLineComment> pub = commentOrder.sortedCopy(db.patchComments().publishedByPatchSet(psId)); assertThat(pub).hasSize(2); assertThat(pub.get(0).getMessage()).isEqualTo("comment without impersonation"); assertThat(pub.get(0).getAuthor()).isEqualTo(user.id); assertThat(pub.get(0).getRealAuthor()).isEqualTo(user.id); assertThat(pub.get(1).getMessage()).isEqualTo("comment with impersonation"); assertThat(pub.get(1).getAuthor()).isEqualTo(user.id); assertThat(pub.get(1).getRealAuthor()).isEqualTo(admin.id); } @Test public void laterEventsDependingOnEarlierPatchSetDontIntefereWithOtherPatchSets() throws Exception { PushOneCommit.Result r1 = createChange(); ChangeData cd = r1.getChange(); Change.Id id = cd.getId(); amendChange(cd.change().getKey().get()); TestTimeUtil.incrementClock(90, TimeUnit.DAYS); ReviewInput rin = ReviewInput.approve(); rin.message = "Some very late message on PS1"; gApi.changes().id(id.get()).revision(1).review(rin); checker.rebuildAndCheckChanges(id); } @Test public void ignoreChangeMessageBeyondCurrentPatchSet() throws Exception { PushOneCommit.Result r = createChange(); PatchSet.Id psId1 = r.getPatchSetId(); Change.Id id = psId1.getParentKey(); gApi.changes().id(id.get()).current().review(ReviewInput.recommend()); r = amendChange(r.getChangeId()); PatchSet.Id psId2 = r.getPatchSetId(); assertThat(db.patchSets().byChange(id)).hasSize(2); assertThat(db.changeMessages().byPatchSet(psId2)).hasSize(1); db.patchSets().deleteKeys(Collections.singleton(psId2)); checker.rebuildAndCheckChanges(psId2.getParentKey()); setNotesMigration(true, true); ChangeData cd = changeDataFactory.create(db, project, id); assertThat(cd.change().currentPatchSetId()).isEqualTo(psId1); assertThat(cd.patchSets().stream().map(ps -> ps.getId()).collect(toList())) .containsExactly(psId1); PatchSet ps = cd.currentPatchSet(); assertThat(ps).isNotNull(); assertThat(ps.getId()).isEqualTo(psId1); } @Test public void highestNumberedPatchSetIsNotCurrent() throws Exception { PushOneCommit.Result r1 = createChange(); PatchSet.Id psId1 = r1.getPatchSetId(); Change.Id id = psId1.getParentKey(); PushOneCommit.Result r2 = amendChange(r1.getChangeId()); PatchSet.Id psId2 = r2.getPatchSetId(); try (BatchUpdate bu = batchUpdateFactory.create( db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs())) { bu.addOp( id, new BatchUpdateOp() { @Override public boolean updateChange(ChangeContext ctx) throws PatchSetInfoNotAvailableException { ctx.getChange() .setCurrentPatchSet(patchSetInfoFactory.get(ctx.getDb(), ctx.getNotes(), psId1)); return true; } }); bu.execute(); } ChangeNotes notes = notesFactory.create(db, project, id); assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(psId1, psId2); assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId1); assertThat(db.changes().get(id).currentPatchSetId()).isEqualTo(psId1); checker.rebuildAndCheckChanges(id); setNotesMigration(true, true); notes = notesFactory.create(db, project, id); assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(psId1, psId2); assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId1); } @Test public void resolveCommentsInheritsValueFromParentWhenUnspecified() throws Exception { PushOneCommit.Result r = createChange(); Change.Id id = r.getPatchSetId().getParentKey(); putDraft(user, id, 1, "comment", true); putDraft(user, id, 1, "newComment", null); Map<String, List<CommentInfo>> comments = gApi.changes().id(id.get()).current().drafts(); for (List<CommentInfo> cList : comments.values()) { for (CommentInfo ci : cList) { assertThat(ci.unresolved).isEqualTo(true); } } } @Test public void rebuilderRespectsReadOnlyInNoteDbChangeState() throws Exception { TestTimeUtil.resetWithClockStep(1, SECONDS); PushOneCommit.Result r = createChange(); PatchSet.Id psId1 = r.getPatchSetId(); Change.Id id = psId1.getParentKey(); checker.rebuildAndCheckChanges(id); setNotesMigration(true, true); ReviewDb db = getUnwrappedDb(); Change c = db.changes().get(id); NoteDbChangeState state = NoteDbChangeState.parse(c); Timestamp until = new Timestamp(TimeUtil.nowMs() + MILLISECONDS.convert(1, DAYS)); state = state.withReadOnlyUntil(until); c.setNoteDbState(state.toString()); db.changes().update(Collections.singleton(c)); try { rebuilderWrapper.rebuild(db, id); assert_().fail("expected rebuild to fail"); } catch (OrmRuntimeException e) { assertThat(e.getMessage()).contains("read-only until"); } TestTimeUtil.setClock(new Timestamp(until.getTime() + MILLISECONDS.convert(1, SECONDS))); rebuilderWrapper.rebuild(db, id); } @Test public void commitWithCrLineEndings() throws Exception { PushOneCommit.Result r = createChange("Subject\r\rBody\r", PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT); Change c = r.getChange().change(); // This assertion demonstrates an arguable bug in JGit's commit subject // parsing, and shows how this kind of data might have gotten into // ReviewDb. If that bug ever gets fixed upstream, this assert may start // failing. If that happens, this test can be rewritten to directly set the // subject field in ReviewDb. assertThat(c.getSubject()).isEqualTo("Subject\r\rBody"); checker.rebuildAndCheckChanges(c.getId()); } @Test public void patchSetsOutOfOrder() throws Exception { String id = createChange().getChangeId(); amendChange(id); PushOneCommit.Result r = amendChange(id); ChangeData cd = r.getChange(); PatchSet.Id psId3 = cd.change().currentPatchSetId(); assertThat(psId3.get()).isEqualTo(3); PatchSet ps1 = db.patchSets().get(new PatchSet.Id(cd.getId(), 1)); PatchSet ps3 = db.patchSets().get(psId3); assertThat(ps1.getCreatedOn()).isLessThan(ps3.getCreatedOn()); // Simulate an old Gerrit bug by setting the created timestamp of the latest // patch set ID to the timestamp of PS1. ps3.setCreatedOn(ps1.getCreatedOn()); db.patchSets().update(Collections.singleton(ps3)); checker.rebuildAndCheckChanges(cd.getId()); setNotesMigration(true, true); cd = changeDataFactory.create(db, project, cd.getId()); assertThat(cd.change().currentPatchSetId()).isEqualTo(psId3); List<PatchSet> patchSets = ImmutableList.copyOf(cd.patchSets()); assertThat(patchSets).hasSize(3); PatchSet newPs1 = patchSets.get(0); assertThat(newPs1.getId()).isEqualTo(ps1.getId()); assertThat(newPs1.getCreatedOn()).isEqualTo(ps1.getCreatedOn()); PatchSet newPs2 = patchSets.get(1); assertThat(newPs2.getCreatedOn()).isGreaterThan(newPs1.getCreatedOn()); PatchSet newPs3 = patchSets.get(2); assertThat(newPs3.getId()).isEqualTo(ps3.getId()); // Migrated with a newer timestamp than the original, to preserve ordering. assertThat(newPs3.getCreatedOn()).isAtLeast(newPs2.getCreatedOn()); assertThat(newPs3.getCreatedOn()).isGreaterThan(ps1.getCreatedOn()); } private void assertChangesReadOnly(RestApiException e) throws Exception { Throwable cause = e.getCause(); assertThat(cause).isInstanceOf(UpdateException.class); assertThat(cause.getCause()).isInstanceOf(OrmException.class); assertThat(cause.getCause()).hasMessageThat().isEqualTo(NoteDbUpdateManager.CHANGES_READ_ONLY); } private void setInvalidNoteDbState(Change.Id id) throws Exception { ReviewDb db = getUnwrappedDb(); Change c = db.changes().get(id); // In reality we would have NoteDb writes enabled, which would write a real // state into this field. For tests however, we turn NoteDb writes off, so // just use a dummy state to force ChangeNotes to view the notes as // out-of-date. c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"); db.changes().update(Collections.singleton(c)); } private void assertChangeUpToDate(boolean expected, Change.Id id) throws Exception { try (Repository repo = repoManager.openRepository(project)) { Change c = getUnwrappedDb().changes().get(id); assertThat(c).isNotNull(); assertThat(c.getNoteDbState()).isNotNull(); assertThat(NoteDbChangeState.parse(c).isChangeUpToDate(new RepoRefCache(repo))) .isEqualTo(expected); } } private void assertDraftsUpToDate(boolean expected, Change.Id changeId, TestAccount account) throws Exception { try (Repository repo = repoManager.openRepository(allUsers)) { Change c = getUnwrappedDb().changes().get(changeId); assertThat(c).isNotNull(); assertThat(c.getNoteDbState()).isNotNull(); NoteDbChangeState state = NoteDbChangeState.parse(c); assertThat(state.areDraftsUpToDate(new RepoRefCache(repo), account.getId())) .isEqualTo(expected); } } private ObjectId getMetaRef(Project.NameKey p, String name) throws Exception { try (Repository repo = repoManager.openRepository(p)) { Ref ref = repo.exactRef(name); return ref != null ? ref.getObjectId() : null; } } private void putDraft(TestAccount account, Change.Id id, int line, String msg, Boolean unresolved) throws Exception { DraftInput in = new DraftInput(); in.line = line; in.message = msg; in.path = PushOneCommit.FILE_NAME; in.unresolved = unresolved; AcceptanceTestRequestScope.Context old = setApiUser(account); try { gApi.changes().id(id.get()).current().createDraft(in); } finally { atrScope.set(old); } } private void putComment(TestAccount account, Change.Id id, int line, String msg, String inReplyTo) throws Exception { CommentInput in = new CommentInput(); in.line = line; in.message = msg; in.inReplyTo = inReplyTo; ReviewInput rin = new ReviewInput(); rin.comments = new HashMap<>(); rin.comments.put(PushOneCommit.FILE_NAME, ImmutableList.of(in)); rin.drafts = ReviewInput.DraftHandling.KEEP; AcceptanceTestRequestScope.Context old = setApiUser(account); try { gApi.changes().id(id.get()).current().review(rin); } finally { atrScope.set(old); } } private void publishDrafts(TestAccount account, Change.Id id) throws Exception { ReviewInput rin = new ReviewInput(); rin.drafts = ReviewInput.DraftHandling.PUBLISH_ALL_REVISIONS; AcceptanceTestRequestScope.Context old = setApiUser(account); try { gApi.changes().id(id.get()).current().review(rin); } finally { atrScope.set(old); } } private ChangeMessage insertMessage( Change.Id id, PatchSet.Id psId, Account.Id author, Timestamp ts, String message) throws Exception { ChangeMessage msg = new ChangeMessage(new ChangeMessage.Key(id, ChangeUtil.messageUuid()), author, ts, psId); msg.setMessage(message); db.changeMessages().insert(Collections.singleton(msg)); Change c = db.changes().get(id); if (ts.compareTo(c.getLastUpdatedOn()) > 0) { c.setLastUpdatedOn(ts); db.changes().update(Collections.singleton(c)); } return msg; } private ReviewDb getUnwrappedDb() { ReviewDb db = dbProvider.get(); return ReviewDbUtil.unwrapDb(db); } private void allowRunAs() throws Exception { ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig(); Util.allow( cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(REGISTERED_USERS).getUUID()); saveProjectConfig(allProjects, cfg); } private void removeRunAs() throws Exception { ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig(); Util.remove( cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(REGISTERED_USERS).getUUID()); saveProjectConfig(allProjects, cfg); } private Map<String, List<CommentInfo>> getPublishedComments(Change.Id id) throws Exception { return gApi.changes().id(id.get()).current().comments(); } }