com.google.gerrit.acceptance.server.notedb.ChangeRebuilderIT.java Source code

Java tutorial

Introduction

Here is the source code for com.google.gerrit.acceptance.server.notedb.ChangeRebuilderIT.java

Source

// 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 BatchUpdate.Factory batchUpdateFactory;

    @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(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(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();
    }
}