package com.atlassian.bitbucket.internal.mirroring.mirror.sync;

import com.atlassian.bitbucket.concurrent.BucketedExecutor;
import com.atlassian.bitbucket.concurrent.BucketedExecutorSettings;
import com.atlassian.bitbucket.concurrent.ConcurrencyService;
import com.atlassian.bitbucket.hamcrest.SimpleTypeSafeMatcher;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.internal.mirroring.mirror.*;
import com.atlassian.bitbucket.internal.mirroring.mirror.client.InternalUpstreamClient;
import com.atlassian.bitbucket.internal.mirroring.mirror.client.InternalUpstreamClientFactory;
import com.atlassian.bitbucket.internal.mirroring.mirror.dao.AoProjectMapping;
import com.atlassian.bitbucket.internal.mirroring.mirror.dao.AoRepositoryMapping;
import com.atlassian.bitbucket.internal.mirroring.mirror.dao.StubProjectMappingDao;
import com.atlassian.bitbucket.internal.mirroring.mirror.dao.StubRepositoryMappingDao;
import com.atlassian.bitbucket.internal.mirroring.repositories.RepositoryContentHashService;
import com.atlassian.bitbucket.mirroring.mirror.*;
import com.atlassian.bitbucket.project.Project;
import com.atlassian.bitbucket.project.ProjectCreateRequest;
import com.atlassian.bitbucket.project.ProjectService;
import com.atlassian.bitbucket.project.ProjectUpdateRequest;
import com.atlassian.bitbucket.repository.*;
import com.atlassian.bitbucket.scm.*;
import com.atlassian.bitbucket.scm.mirror.MirrorUpdateRefsCommandParameters;
import com.atlassian.bitbucket.scm.mirror.ScmMirrorCommandFactory;
import com.atlassian.bitbucket.transaction.SimpleSalTransactionTemplate;
import com.atlassian.bitbucket.util.PageRequest;
import com.atlassian.bitbucket.util.PageUtils;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.util.concurrent.Promises;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures;
import net.java.ao.ActiveObjectsException;
import org.hamcrest.Description;
import org.hamcrest.TypeSafeMatcher;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.runners.MockitoJUnitRunner;
import org.mockito.stubbing.Answer;

import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.atlassian.bitbucket.internal.mirroring.mirror.MirrorConstants.DEFAULT_SYNC_MAX_FAILURES;
import static com.atlassian.bitbucket.internal.mirroring.mirror.MirroringTestHelper.*;
import static com.atlassian.bitbucket.mirroring.mirror.MirroringMode.ALL_PROJECTS;
import static com.atlassian.bitbucket.mirroring.mirror.MirroringMode.SELECTED_PROJECTS;
import static com.atlassian.bitbucket.mirroring.mirror.SyncLevel.DEFAULT;
import static com.atlassian.bitbucket.mirroring.mirror.SyncLevel.INCLUDE_FAILED;
import static com.atlassian.bitbucket.util.MoreStreams.streamIterable;
import static com.atlassian.bitbucket.util.PageUtils.createEmptyPage;
import static com.atlassian.bitbucket.util.PageUtils.newRequest;
import static com.atlassian.util.concurrent.Promises.promise;
import static java.util.Optional.empty;
import static java.util.Optional.of;
import static org.junit.Assert.assertTrue;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.*;

@SuppressWarnings("ALL")
@RunWith(MockitoJUnitRunner.class)
public class DefaultMirrorSynchronizationHelperTest {
    private static final String EXTERNAL_REPO_ID = "22";
    private static final String EXTERNAL_PROJECT_ID = "99";
    private static final String GIT = "git";
    private static final String LOCAL_CONTENT_HASH = "localcontenthash";
    private static final int LOCAL_REPO_ID = 456;
    private static final int LOCAL_PROJECT_ID = 123;
    private static final String PROJ_KEY = "proj";
    private static final String PROJ_NAME = "proj";
    private static final String REMOTE_CONTENT_HASH = "remotecontenthash";
    private static final String REPO_NAME = "repo";
    private static final String UPSTREAM_ID = "upstream-id";
    private static final String UPSTREAM_NAMESPACE = "up-namespace";

    @Mock
    private InternalUpstreamClientFactory clientFactory;
    @Mock
    private InternalUpstreamClient client;
    @Mock
    private ConcurrencyService concurrencyService;
    @Mock
    private Command<Void> commandBulkUpdateRefs;
    @Mock
    private ScmMirrorCommandFactory commandFactory;
    @Mock
    private MirroringConfig config;
    @Mock
    private RepositoryContentHashService contentHashService;
    @Mock
    private EventPublisher eventPublisher;
    private ExecutorService executorService;
    @Spy
    private StubProjectMappingDao projectMappingDao;
    @Mock
    private ProjectService projectService;
    @Spy
    private StubRepositoryMappingDao repositoryMappingDao;
    @Mock
    private RepositoryFetchProcessor repositoryFetchProcessor;
    @Mock
    private RepositoryService repositoryService;
    @Mock
    private ScmService scmService;
    @Mock
    private BucketedExecutor<RepositoryFetchTask> fetchExecutor;
    @Mock
    private UpstreamServer upstream;
    @Mock
    private UpstreamSettingsService upstreamSettingsService;
    @Mock
    private MirroringValidationHelper validationHelper;

    private DefaultMirrorSynchronizationHelper helper;
    private AtomicInteger nextProjectId;
    private AtomicInteger nextRepoId;
    private Map<Integer, Project> localProjects;
    private Map<Integer, Repository> localRepositories;

    @SuppressWarnings("unchecked")
    @Before
    public void setUp() throws Throwable {
        nextProjectId = new AtomicInteger(1000);
        nextRepoId = new AtomicInteger(5000);

        when(clientFactory.create(upstream)).thenReturn(client);
        when(config.getMaxSyncFailedCount()).thenReturn(DEFAULT_SYNC_MAX_FAILURES);
        when(upstream.getId()).thenReturn(UPSTREAM_ID);
        when(upstream.getAccount()).thenReturn(empty());
        when(upstream.getNamespace()).thenReturn(UPSTREAM_NAMESPACE);
        when(upstream.getBaseUrl()).thenReturn(UPSTREAM_BASE_URL);

        localProjects = new HashMap<>();
        localRepositories = new HashMap<>();
        when(projectService.create(any(ProjectCreateRequest.class))).thenAnswer(invocation -> {
            ProjectCreateRequest request = (ProjectCreateRequest) invocation.getArguments()[0];

            Integer id = nextProjectId.incrementAndGet();
            Project project = mockProject(id, request.getNamespace(), request.getKey(), request.getName());
            localProjects.put(id, project);

            return project;
        });

        when(projectService.update(any(ProjectUpdateRequest.class))).thenAnswer(invocation -> {
            ProjectUpdateRequest request = (ProjectUpdateRequest) invocation.getArguments()[0];

            Project project = mockProject(request.getId(), request.getNamespace(), request.getKey(), request.getName());
            localProjects.put(request.getId(), project);

            return project;
        });
        //noinspection SuspiciousMethodCalls
        when(projectService.getById(anyInt())).thenAnswer(invocation -> localProjects.get(invocation.getArguments()[0]));
        when(projectService.findAll(any(PageRequest.class))).thenReturn(createEmptyPage(newRequest(0, 1)));

        when(repositoryService.create(any(RepositoryCreateRequest.class)))
                .thenAnswer(invocation -> createRepo((RepositoryCreateRequest) invocation.getArguments()[0]));
        when(repositoryService.update(any(RepositoryUpdateRequest.class))).thenAnswer(invocation -> {
            RepositoryUpdateRequest request = (RepositoryUpdateRequest) invocation.getArguments()[0];

            Repository repo = repository(
                    request.getId(), request.getName(), request.getName(),
                    ((RepositoryUpdateRequest) invocation.getArguments()[0]).getProject());
            localRepositories.put(request.getId(), repo);
            return repo;
        });
        when(repositoryService.fork(any(RepositoryForkRequest.class))).thenAnswer(invocation -> {
            RepositoryForkRequest request = (RepositoryForkRequest) invocation.getArguments()[0];

            Integer id = nextRepoId.incrementAndGet();
            Repository repo = repository(
                    id, request.getName(), request.getName(),
                    ((RepositoryForkRequest) invocation.getArguments()[0]).getProject());
            when(repo.getOrigin()).thenReturn(request.getParent());
            localRepositories.put(id, repo);
            return repo;
        });
        // all projects have at least one repo by default to avoid project deletion unless that's what we're testing
        Repository lastRepoInProject = mock(Repository.class);
        when(repositoryService.findByProjectKey(eq(UPSTREAM_NAMESPACE), anyString(), any(PageRequest.class)))
                .thenReturn(PageUtils.createPage(Collections.singleton(lastRepoInProject), PageUtils.newRequest(0, 1)));

        doAnswer(invocation -> {
            localRepositories.remove(((Repository) invocation.getArguments()[0]).getId());
            return null;
        }).when(repositoryService).delete(any(Repository.class));

        //noinspection SuspiciousMethodCalls
        when(repositoryService.getById(anyInt())).thenAnswer(invocation -> localRepositories.get(invocation.getArguments()[0]));

        when(concurrencyService.<RepositoryFetchTask>getBucketedExecutor(anyString(), any(BucketedExecutorSettings.class)))
                .thenReturn(fetchExecutor);
        when(scmService.isSupported(GIT, ScmFeature.MIRRORS)).thenReturn(true);
        when(scmService.isSupported(any(Repository.class), eq(ScmFeature.MIRRORS))).thenReturn(true);
        when(scmService.getMirrorCommandFactory(any(Repository.class))).thenReturn(commandFactory);
        when(commandFactory.updateRefs(any(MirrorUpdateRefsCommandParameters.class))).thenReturn(commandBulkUpdateRefs);

        when(contentHashService.getHash(any(Repository.class))).thenReturn(of(LOCAL_CONTENT_HASH));

        executorService = mock(ExecutorService.class);
        when(executorService.submit(any(Runnable.class))).thenAnswer(invocationOnMock -> {
            ((Runnable) invocationOnMock.getArguments()[0]).run();
            return Futures.immediateCheckedFuture(null);
        });
        when(upstreamSettingsService.getSettings(UPSTREAM_ID)).thenReturn(of(upstreamSettings(ALL_PROJECTS)));

        helper = newHelper();
    }

    @After
    public void tearDown() {
        executorService.shutdown();
    }

    @Test
    public void testFullSynchronizationOfEmptyUpstream() {
        when(client.getRepositories()).thenAnswer(invocation -> Stream.empty());

        helper.startFullSynchronization(upstream, DEFAULT);

        verify(validationHelper).checkIsUpstreamUserOrAdmin(UPSTREAM_ID);
        verify(client).getRepositories();
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(projectMappingDao, never()).create(anyInt(), anyString(), anyString());
        verify(repositoryMappingDao, never()).create(anyInt(), anyString(), anyInt(), anyString());
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());
        verify(fetchExecutor, never()).submit(isA(RepositoryFetchTask.class));
    }

    @Test
    public void testFullSynchronizationCreatesLocalEntities() {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        when(client.getRepositories()).thenAnswer(invocation -> Stream.of(repository));

        helper.startFullSynchronization(upstream, DEFAULT);

        verify(validationHelper).checkIsUpstreamUserOrAdmin(UPSTREAM_ID);
        verify(client).getRepositories();
        verify(fetchExecutor).submit(isA(RepositoryFetchTask.class));
        verify(projectService).create(any(ProjectCreateRequest.class));
        verify(repositoryService).create(any(RepositoryCreateRequest.class));
        verify(projectMappingDao).create(nextProjectId.get(), UPSTREAM_ID, EXTERNAL_PROJECT_ID);
        verify(repositoryMappingDao).create(nextRepoId.get(), UPSTREAM_ID, nextProjectId.get(), EXTERNAL_REPO_ID);
    }

    /**
     * Tests a full sync for repo1 through repo5 where repo4 fails to sync and is synced individually at the end.
     * <p>
     * Additionally a repo, deletedrepo, in project emptyproject, has been deleted upstream so should be deleted
     * locally but, crucially, *before* the repos that failed to sync.
     * <p>
     * Once this deletedrepo is deleted locally its project, emptyproject, now *actually* empty, should be deleted
     * but before repo4 is individually synced. This is to clear as many clashes in slugs/keys/names as possible
     * before retrying repos that failed to sync.
     */
    @Test
    public void testFullSynchronizationBehaviourWhenMissingOrFailedRepos() {
        // pick up the config change
        helper = newHelper();

        ExternalProject project = mockExternalProjectWithDefaults();
        ExternalRepository repo1 = mockExternalRepository("repo1", "repo1", project, "remote-hash");
        ExternalRepository repo2 = mockExternalRepository("repo2", "repo2", project, "remote-hash");
        ExternalRepository repo3 = mockExternalRepository("repo3", "repo3", project, "remote-hash");
        ExternalRepository repo4 = mockExternalRepository("repo4", "repo4", project, "remote-hash");
        ExternalRepository repo5 = mockExternalRepository("repo5", "repo5", project, "remote-hash");

        // an additional repo is mapped locally that no longer exist upstream
        // its project will be empty once it is deleted
        int emptyProjectLocalId = 100;
        Project emptyProject = mockMappedProject("emptyproject", emptyProjectLocalId, "emptyprojectkey");
        Repository repoDeletedUpstream = mockMappedRepo(emptyProject, "deletedrepo");

        when(client.getRepositories()).thenReturn(Stream.of(repo1, repo2, repo3, repo4, repo5));
        // individual GETs should succeed
        mockGetRepositoryUpstream("repo4", repo4);
        mockGetRepositoryUpstream("deletedrepo", null);

        // the sync with repo4 should fail due to a DB error
        when(repositoryMappingDao.getByUpstreamId(UPSTREAM_ID, "repo4"))
                .thenAnswer(new Answer<AoRepositoryMapping>() {
                    int callCount = 0;

                    @Override
                    public AoRepositoryMapping answer(InvocationOnMock invocation) throws Throwable {
                        if (callCount++ == 0) {
                            // fail once
                            throw new ActiveObjectsException();
                        }
                        return (AoRepositoryMapping) invocation.callRealMethod();
                    }
        }); // succeed on individual retry

        // the empty project contains no repos when queried (which will be after deletedrepo is deleted)
        when(repositoryService.findByProjectKey(eq(UPSTREAM_NAMESPACE), eq("emptyprojectkey"), any(PageRequest.class)))
                .thenReturn(PageUtils.createEmptyPage(PageUtils.newRequest(0, 1)));

        helper.startFullSynchronization(upstream, DEFAULT);

        verify(validationHelper).checkIsUpstreamUserOrAdmin(UPSTREAM_ID);

        //verify everything that should be created was and everything that should have been deleted was
        verify(projectService).create(projectCreateRequest(PROJ_KEY, PROJ_NAME, false));
        verify(projectMappingDao).create(nextProjectId.get(), UPSTREAM_ID, EXTERNAL_PROJECT_ID);
        verify(repositoryMappingDao).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("repo1"));
        verify(repositoryService).create(repoCreateRequest("repo1", false));
        verify(repositoryMappingDao).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("repo2"));
        verify(repositoryService).create(repoCreateRequest("repo2", false));
        verify(repositoryMappingDao).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("repo3"));
        verify(repositoryService).create(repoCreateRequest("repo3", false));
        verify(repositoryMappingDao).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("repo4"));
        verify(repositoryService).create(repoCreateRequest("repo4", false));
        verify(repositoryMappingDao).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("repo5"));
        verify(repositoryService).create(repoCreateRequest("repo5", false));
        verify(projectMappingDao).delete(emptyProjectLocalId);
        verify(projectService).delete(emptyProject);
        verify(repositoryMappingDao).delete(UPSTREAM_ID, "deletedrepo");
        verify(repositoryService).delete(repoDeletedUpstream);

        // all repos should be fetched
        verify(fetchExecutor, times(5)).submit(isA(RepositoryFetchTask.class));

        // verify the order of operations in the full sync

        // first get all repositories then get the repos that look like they were deleted, then get any that failed
        // in the full sync ie repo4.
        InOrder inOrder = inOrder(client);
        inOrder.verify(client).getRepositories();
        inOrder.verify(client).getRepository("deletedrepo");
        inOrder.verify(client).getRepository("repo4");

        // the repo deleted upstream should have been deleted in the mirror and its empty project deleted too
        InOrder emptyProjectDeletionInOrder = inOrder(projectMappingDao, projectService, repositoryMappingDao, repositoryService);
        emptyProjectDeletionInOrder.verify(repositoryMappingDao).delete(UPSTREAM_ID, "deletedrepo");
        emptyProjectDeletionInOrder.verify(repositoryService).delete(repoDeletedUpstream);
        emptyProjectDeletionInOrder.verify(projectMappingDao).delete(emptyProjectLocalId);
        emptyProjectDeletionInOrder.verify(projectService).delete(emptyProject);

        // repo4 should only be created and fetched after the empty project was deleted
        InOrder repo4CreateAfterEmptyProjectDeleted = inOrder(projectService, repositoryMappingDao, repositoryService, fetchExecutor);

        repo4CreateAfterEmptyProjectDeleted.verify(projectService).delete(emptyProject);
        repo4CreateAfterEmptyProjectDeleted.verify(repositoryService).create(repoCreateRequest("repo4", false));
        repo4CreateAfterEmptyProjectDeleted.verify(repositoryMappingDao).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("repo4"));
        repo4CreateAfterEmptyProjectDeleted.verify(fetchExecutor).submit(isA(RepositoryFetchTask.class));
    }

    @Test
    public void testFullSynchronizationTerminatesWhenRateLimitExceeded() {
        helper = newHelper();

        ExternalProject externalProject = mockExternalProjectWithDefaults();
        ExternalRepository externalRepository1 = mockExternalRepoWithDefaults(
                EXTERNAL_REPO_ID, REPO_NAME + "1", externalProject, REMOTE_CONTENT_HASH);
        ExternalRepository externalRepository2 = mockExternalRepoWithDefaults(
                EXTERNAL_REPO_ID + 1, REPO_NAME + "2", externalProject, REMOTE_CONTENT_HASH);

        mockGetRepositoryUpstream(EXTERNAL_REPO_ID, externalRepository1);
        mockGetRepositoryUpstream(EXTERNAL_REPO_ID + 1, externalRepository2);

        when(client.getRepositories()).thenReturn(
                Stream.concat(
                    Stream.concat(
                        Stream.of(externalRepository1),
                        Stream.generate(() -> {
                            throw new RepositoryRequestRateExceededException(new KeyedMessage("", "", ""));
                        })),
                        Stream.of(externalRepository2)));

        helper.startFullSynchronization(upstream, DEFAULT);

        verify(validationHelper).checkIsUpstreamUserOrAdmin(UPSTREAM_ID);
        verify(client).getRepositories();
        verify(projectService).create(any(ProjectCreateRequest.class));
        verify(repositoryService).create(any(RepositoryCreateRequest.class));
        verify(projectMappingDao).create(nextProjectId.get(), UPSTREAM_ID, EXTERNAL_PROJECT_ID);
        verify(repositoryMappingDao).create(nextRepoId.get(), UPSTREAM_ID, nextProjectId.get(), EXTERNAL_REPO_ID);
        verify(fetchExecutor, never()).submit(isA(RepositoryFetchTask.class));
        verify(executorService, never()).submit(isA(DefaultMirrorSynchronizationHelper.RepositorySynchronizationTask.class));
    }

    @Test
    public void testFullSynchronizationFetchesRepoStreamUntilFailure() {
        helper = newHelper();

        ExternalProject externalProject = mockExternalProjectWithDefaults();
        ExternalRepository externalRepository1 = mockExternalRepoWithDefaults(
                EXTERNAL_REPO_ID, REPO_NAME + "1", externalProject, REMOTE_CONTENT_HASH);
        ExternalRepository externalRepository2 = mockExternalRepoWithDefaults(
                EXTERNAL_REPO_ID + 1, REPO_NAME + "2", externalProject, REMOTE_CONTENT_HASH);

        mockGetRepositoryUpstream(EXTERNAL_REPO_ID, externalRepository1);
        mockGetRepositoryUpstream(EXTERNAL_REPO_ID + 1, externalRepository2);

        when(client.getRepositories()).thenReturn(
                Stream.concat(
                    Stream.concat(
                        Stream.of(externalRepository1),
                        Stream.generate(() -> {
                            throw new NullPointerException();
                        })),
                        Stream.of(externalRepository2)));

        helper.startFullSynchronization(upstream, DEFAULT);

        verify(validationHelper).checkIsUpstreamUserOrAdmin(UPSTREAM_ID);
        verify(client).getRepositories();
        verify(projectService).create(any(ProjectCreateRequest.class));
        verify(repositoryService).create(any(RepositoryCreateRequest.class));
        verify(projectMappingDao).create(nextProjectId.get(), UPSTREAM_ID, EXTERNAL_PROJECT_ID);
        verify(repositoryMappingDao).create(nextRepoId.get(), UPSTREAM_ID, nextProjectId.get(), EXTERNAL_REPO_ID);
        verify(fetchExecutor).submit(isA(RepositoryFetchTask.class));
    }

    @Test
    public void testFullSynchronizationCreatesForks() {
        ExternalProject rootProject = mockExternalProject("root-project", "root-project-key");
        ExternalProject personalProject = mockExternalProject("~personal", "personal-project-key");

        ExternalRepository root = mockExternalRepository("root-id", "root-repo", rootProject, "remote-hash");
        ExternalRepository fork = mockExternalRepository("fork-id", "root-repo", personalProject, "remote-hash", root);

        when(client.getRepositories()).thenAnswer(invocation -> Stream.of(root, fork));

        helper.startFullSynchronization(upstream, DEFAULT);

        verify(client).getRepositories();
        verify(projectService, times(2)).create(any(ProjectCreateRequest.class));
        verify(repositoryService).create(any(RepositoryCreateRequest.class));
        verify(repositoryService).fork(any(RepositoryForkRequest.class));
        verify(projectMappingDao).create(anyInt(), eq(UPSTREAM_ID), eq("root-project"));
        verify(projectMappingDao).create(anyInt(), eq(UPSTREAM_ID), eq("~personal"));
        verify(repositoryMappingDao).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("root-id"));
        verify(repositoryMappingDao).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("fork-id"));
        verify(fetchExecutor, times(2)).submit(isA(RepositoryFetchTask.class));
    }

    @Test
    public void testFullSynchronizationSkipsReposBelongingToOtherTeams() {
        UpstreamAccount teamAAccount = mock(UpstreamAccount.class);
        when(teamAAccount.getId()).thenReturn("team-a");

        UpstreamAccount teamBAccount = mock(UpstreamAccount.class);
        when(teamBAccount.getId()).thenReturn("team-b");

        when(upstream.getAccount()).thenReturn(of(teamBAccount));

        ExternalProject teamAProject = mockExternalProject("team-a-project-id", "team-a-project");
        ExternalProject teamBProject = mockExternalProject("team-b-project-id", "team-b-project");

        ExternalRepository teamARepo = mockExternalRepository("team-a-repo-id", "team-a-repo-name", teamAProject, "remote-hash");
        when(teamARepo.getOwner()).thenReturn(of(teamAAccount));
        ExternalRepository teamBRepoForkOfTeamARepo = mockExternalRepository("team-b-repo-id", "team-b-repo-name", teamBProject, "remote-hash", teamARepo);
        when(teamBRepoForkOfTeamARepo.getOwner()).thenReturn(of(teamBAccount));

        when(client.getRepositories()).thenAnswer(invocation -> Stream.of(teamBRepoForkOfTeamARepo));

        helper.startFullSynchronization(upstream, DEFAULT);

        verify(client).getRepositories();

        verify(projectService, never()).create(projectCreateRequest("team-a-project", "team-a-project-name", false));
        verify(projectMappingDao, never()).create(anyInt(), eq(UPSTREAM_ID), eq("team-a-project-id"));
        verify(repositoryService, never()).create(repoCreateRequest("team-a-repo-name", false));
        verify(repositoryMappingDao, never()).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("team-a-repo-id"));

        verify(projectService).create(projectCreateRequest("team-b-project", "team-b-project-name", false));
        verify(projectMappingDao).create(anyInt(), eq(UPSTREAM_ID), eq("team-b-project-id"));
        verify(repositoryService).create(repoCreateRequest("team-b-repo-name", false));
        verify(repositoryMappingDao).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("team-b-repo-id"));

        verify(fetchExecutor).submit(isA(RepositoryFetchTask.class));
    }

    @Test
    public void testFullSynchronizationDoesNotCreateLocalResourcesWhenInSync() {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        when(client.getRepositories()).thenAnswer(invocation -> Stream.of(repository));

        // mock an existing project mapping but no repo mapping
        mockMappedProject(EXTERNAL_PROJECT_ID);

        helper.startFullSynchronization(upstream, DEFAULT);

        verify(client).getRepositories();
        // project or mapping should not be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        // repo should be created
        verify(repositoryService).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());
        verify(fetchExecutor).submit(isA(RepositoryFetchTask.class));
    }

    @Test
    public void testFullSynchronizationDoesNotCreateUnsupportedRepositories() {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        when(repository.getScmId()).thenReturn("unsupported-scm");
        when(scmService.isSupported("unsupported-scm", ScmFeature.MIRRORS)).thenThrow(new UnsupportedScmException(
                new KeyedMessage("", "", ""), "unsupported"));
        when(client.getRepositories()).thenAnswer(invocation -> Stream.of(repository));

        helper.startFullSynchronization(upstream, DEFAULT);

        verify(client).getRepositories();

        // full sync must have completed
        verify(eventPublisher).publish(any(FullSynchronizationEvent.class));
        // no repo, project or mapping should be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        verifyNoFetchTaskSubmitted();
    }

    @Test
    public void testFullSynchronizationDoesNotCreateNonMirroredProjects() {
        ExternalRepository repository1 = mockExternalRepository("22", "repo1", mockExternalProject("100", "proj100"), REMOTE_CONTENT_HASH);
        ExternalRepository repository2 = mockExternalRepository("23", "repo2", mockExternalProject("99", "proj99"), REMOTE_CONTENT_HASH);
        ExternalRepository repository3 = mockExternalRepository("24", "repo3", mockExternalProject("100", "proj100"), REMOTE_CONTENT_HASH);
        when(client.getRepositories()).thenAnswer(invocation -> Stream.of(repository1, repository2, repository3));
        when(upstreamSettingsService.getSettings(UPSTREAM_ID)).thenReturn(
                of(upstreamSettings(MirroringMode.SELECTED_PROJECTS, "99")));

        helper.startFullSynchronization(upstream, DEFAULT);

        verify(client).getRepositories();
        verify(projectService).create(any(ProjectCreateRequest.class));
        verify(repositoryService).create(any(RepositoryCreateRequest.class));
        verify(projectMappingDao).create(nextProjectId.get(), UPSTREAM_ID, "99");
        verify(projectMappingDao, never()).create(anyInt(), eq(UPSTREAM_ID), eq("100"));
        verify(repositoryMappingDao).create(nextRepoId.get(), UPSTREAM_ID, nextProjectId.get(), "23");
        verify(repositoryMappingDao, never()).create(anyInt(), eq(UPSTREAM_ID), eq(nextProjectId.get()), eq("22"));
        verify(repositoryMappingDao, never()).create(anyInt(), eq(UPSTREAM_ID), eq(nextProjectId.get()), eq("24"));
        verify(fetchExecutor).submit(isA(RepositoryFetchTask.class));
    }

    @Test
    public void testFullSynchronizationTooManyFetchFailures() {
        ExternalRepository externalRepository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        Project project = mockMappedProject(EXTERNAL_PROJECT_ID);
        mockMappedRepo(project, EXTERNAL_REPO_ID, DEFAULT_SYNC_MAX_FAILURES);
        when(client.getRepositories()).thenAnswer(invocation -> Stream.of(externalRepository));
        when(upstreamSettingsService.getSettings(UPSTREAM_ID)).thenReturn(of(upstreamSettings(ALL_PROJECTS)));

        helper.startFullSynchronization(upstream, DEFAULT);

        verifyNoFetchTaskSubmitted();

        helper.startFullSynchronization(upstream, INCLUDE_FAILED);

        verify(fetchExecutor).submit(isA(RepositoryFetchTask.class));
    }

    @Test
    public void testFullSynchronizationWithMultiRepositories() {
        mockRepositories(33);

        helper.startFullSynchronization(upstream, DEFAULT);

        verify(client).getRepositories();
        verify(projectService, times(6)).create(any(ProjectCreateRequest.class));
        verify(repositoryService, times(33)).create(any(RepositoryCreateRequest.class));
        verify(projectMappingDao, times(6)).create(any(Integer.class), eq(UPSTREAM_ID), anyString());
        verify(repositoryMappingDao, times(33)).create(anyInt(), eq(UPSTREAM_ID), anyInt(), anyString());
        verify(fetchExecutor, times(33)).submit(isA(RepositoryFetchTask.class));
    }

    @Test
    public void testFullSynchronizationWhenReposAlreadyCreatedDoesNotCreateNewRepos() {
        mockRepositories(33);

        helper.startFullSynchronization(upstream, DEFAULT);
        helper.startFullSynchronization(upstream, DEFAULT);
        helper.startFullSynchronization(upstream, DEFAULT);
        helper.startFullSynchronization(upstream, DEFAULT);

        verify(client, times(4)).getRepositories();
        verify(projectService, times(6)).create(any(ProjectCreateRequest.class));
        verify(repositoryService, times(33)).create(any(RepositoryCreateRequest.class));
        verify(projectMappingDao, times(6)).create(any(Integer.class), eq(UPSTREAM_ID), anyString());
        verify(repositoryMappingDao, times(33)).create(anyInt(), eq(UPSTREAM_ID), anyInt(), anyString());
        verify(fetchExecutor, times(4 * 33)).submit(isA(RepositoryFetchTask.class));
    }

    @Test
    public void testFullSynchronizationWhenContentHashesMatchDoesNotFetch() {
        ExternalRepository externalRepository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        when(client.getRepositories()).thenAnswer(invocation -> Stream.of(externalRepository));

        // mock existing project and repository mappings
        mockMappedRepoAndProject();

        // mock the local and remote repos having the same hash
        when(contentHashService.getHash(any(Repository.class))).thenReturn(of(REMOTE_CONTENT_HASH));

        helper.startFullSynchronization(upstream, DEFAULT);

        verify(client).getRepositories();
        // project or mapping should not be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        // repo and mapping should not be created
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        verifyNoFetchTaskSubmitted();
    }

    @Test
    public void testFullSynchronizationWhenUpstreamProjectAndRepoRenamed() {
        ExternalRepository externalRepository = mockExternalRepository(EXTERNAL_REPO_ID, "newreponame",
                mockExternalProject(EXTERNAL_PROJECT_ID, "newprojkey", PROJ_NAME), REMOTE_CONTENT_HASH);

        when(client.getRepositories()).thenAnswer(invocation -> Stream.of(externalRepository));

        // mock existing project and repository mappings
        mockMappedRepoAndProject();

        helper.startFullSynchronization(upstream, DEFAULT);

        verify(client).getRepositories();
        // project/repo and mappings should not be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        // repo should not have been deleted
        verify(repositoryMappingDao, never()).delete(UPSTREAM_ID, EXTERNAL_REPO_ID);

        // repo should have been renamed
        verify(repositoryService).update(repoUpdateRequest(LOCAL_REPO_ID, "newreponame", false));

        // project should have been renamed
        verify(projectService, atLeastOnce()).update(projectUpdateRequest(LOCAL_PROJECT_ID, "newprojkey", PROJ_NAME, false));

        // sync should proceed because the hashes don't match
        verify(fetchExecutor).submit(isA(RepositoryFetchTask.class));
    }

    @Test
    public void testFullSynchronizationWhenUpstreamRepoDeleted() {
        // first we scan all repositories
        when(client.getRepositories()).thenAnswer(invocation -> Stream.empty());

        // then a deleted repository is specifically queried
        mockGetRepositoryUpstream(eq(EXTERNAL_REPO_ID), null);

        // mock existing project and repository mappings
        mockMappedRepoAndProject();

        helper.startFullSynchronization(upstream, DEFAULT);

        verify(client).getRepositories();
        // project/repo and mappings should not be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        // sync should not proceed because there is nothing upstream to sync with
        verifyNoFetchTaskSubmitted();

        // repo should be deleted
        verify(client).getRepository(eq(EXTERNAL_REPO_ID));
        verify(repositoryMappingDao).delete(UPSTREAM_ID, EXTERNAL_REPO_ID);
    }

    @Test
    public void testFullSynchronizationWithForks() throws Exception {
        ExternalProject rootProject = mockExternalProject("root-project", "root-project-key");
        ExternalProject personalProject = mockExternalProject("~personal", "personal-project-key");

        ExternalRepository unrelated = mockExternalRepository("unrelated-id", "unrelated-repo", rootProject, "remote-hash");
        ExternalRepository root = mockExternalRepository("root-id", "root-repo", rootProject, "remote-hash");
        ExternalRepository fork = mockExternalRepository("fork-id", "fork-repo", personalProject, "remote-hash", root);
        ExternalRepository fork2 = mockExternalRepository("fork2-id", "fork2-repo", rootProject, "remote-hash", root);
        ExternalRepository fork3 = mockExternalRepository("fork3-id", "fork3-repo", personalProject, "remote-hash", fork);

        // for fun let's just make the page order not consistent with origin/fork order
        when(client.getRepositories()).thenAnswer(invocation -> Stream.of(fork, fork3, unrelated, fork2, root));
        helper.startFullSynchronization(upstream, DEFAULT);

        verify(client).getRepositories();
        verify(projectService, times(2)).create(any(ProjectCreateRequest.class));
        verify(repositoryService, times(2)).create(any(RepositoryCreateRequest.class));
        verify(repositoryService, times(3)).fork(any(RepositoryForkRequest.class));
        verify(projectMappingDao).create(anyInt(), eq(UPSTREAM_ID), eq("root-project"));
        verify(projectMappingDao).create(anyInt(), eq(UPSTREAM_ID), eq("~personal"));
        verify(repositoryMappingDao).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("unrelated-id"));
        verify(repositoryMappingDao).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("root-id"));
        verify(repositoryMappingDao).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("fork-id"));
        verify(repositoryMappingDao).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("fork2-id"));
        verify(repositoryMappingDao).create(anyInt(), eq(UPSTREAM_ID), anyInt(), eq("fork3-id"));
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());

        ArgumentCaptor<RepositoryFetchTask> fetchTaskCaptor = ArgumentCaptor.forClass(RepositoryFetchTask.class);
        verify(fetchExecutor, times(5)).submit(fetchTaskCaptor.capture());

        List<String> cloneUrlsFetched = fetchTaskCaptor.getAllValues().stream().map(RepositoryFetchTask::getCloneUrl).collect(Collectors.toList());
        assertTrue(cloneUrlsFetched.contains(cloneUrlFor("root-project-key", "unrelated-repo")));
        assertTrue(cloneUrlsFetched.contains(cloneUrlFor("root-project-key", "root-repo")));
        assertTrue(cloneUrlsFetched.contains(cloneUrlFor("personal-project-key", "fork-repo")));
        assertTrue(cloneUrlsFetched.contains(cloneUrlFor("root-project-key", "fork2-repo")));
        assertTrue(cloneUrlsFetched.indexOf(cloneUrlFor("root-project-key", "root-repo")) < cloneUrlsFetched.indexOf(cloneUrlFor("personal-project-key", "fork-repo")));
        assertTrue(cloneUrlsFetched.indexOf(cloneUrlFor("root-project-key", "root-repo")) < cloneUrlsFetched.indexOf(cloneUrlFor("root-project-key", "fork2-repo")));
        assertTrue(cloneUrlsFetched.indexOf(cloneUrlFor("personal-project-key", "fork-repo")) < cloneUrlsFetched.indexOf(cloneUrlFor("personal-project-key", "fork3-repo")));
    }

    @Test
    public void testSingleRepoSynchronizationAppliesWithoutFetch() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(validationHelper).checkIsUpstreamUser(UPSTREAM_ID);
        verify(client).getRepository(EXTERNAL_REPO_ID);
        verify(projectService).create(any(ProjectCreateRequest.class));
        verify(repositoryService).create(any(RepositoryCreateRequest.class));
        verify(projectMappingDao).create(nextProjectId.get(), UPSTREAM_ID, EXTERNAL_PROJECT_ID);
        verify(repositoryMappingDao).create(nextRepoId.get(), UPSTREAM_ID, nextProjectId.get(), EXTERNAL_REPO_ID);
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());
        verify(contentHashService).setHash(any(Repository.class), eq(REMOTE_CONTENT_HASH));
        verifyNoFetchTaskSubmitted();
    }

    @Test
    public void testSingleRepoSynchronizationSchedulesFetchWhenIllegalRefFound() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = ImmutableList.of(mockRefUpdate("aae12345", ""));

        helper.startRepositorySynchronization(upstream, repository, changes);

        verifyZeroInteractions(commandBulkUpdateRefs);

        verify(fetchExecutor).submit(isA(RepositoryFetchTask.class));
        verify(contentHashService, never()).setHash(any(Repository.class), eq(REMOTE_CONTENT_HASH));
    }

    @Test
    public void testSingleRepoSynchronizationSchedulesFetchIfNotAppliedLocally() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        when(commandBulkUpdateRefs.call()).thenThrow(CommandFailedException.class);

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(validationHelper).checkIsUpstreamUser(UPSTREAM_ID);
        verify(client).getRepository(EXTERNAL_REPO_ID);
        verify(projectService).create(any(ProjectCreateRequest.class));
        verify(repositoryService).create(any(RepositoryCreateRequest.class));
        verify(projectMappingDao).create(nextProjectId.get(), UPSTREAM_ID, EXTERNAL_PROJECT_ID);
        verify(repositoryMappingDao).create(nextRepoId.get(), UPSTREAM_ID, nextProjectId.get(), EXTERNAL_REPO_ID);
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());

        verify(fetchExecutor).submit(isA(RepositoryFetchTask.class));
        verify(contentHashService, never()).setHash(any(Repository.class), eq(REMOTE_CONTENT_HASH));
    }

    @Test
    public void testSingleRepoSynchronizationStopsWhenRateLimitExceeded() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaults();
        when(client.getRepository(EXTERNAL_REPO_ID)).thenThrow(new RepositoryRequestRateExceededException(new KeyedMessage("", "", "")));

        helper.startRepositorySynchronization(upstream, repository, mockRefChanges());

        verify(validationHelper).checkIsUpstreamUser(UPSTREAM_ID);
        verify(client).getRepository(EXTERNAL_REPO_ID);
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(projectMappingDao, never()).create(nextProjectId.get(), UPSTREAM_ID, EXTERNAL_PROJECT_ID);
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, nextProjectId.get(), EXTERNAL_REPO_ID);
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());
        verify(fetchExecutor, never()).submit(isA(RepositoryFetchTask.class));
    }

    @Test
    public void testSingleRepoSynchronizationDoesNotCreateLocalResourcesWhenInSync() {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        // mock an existing project mapping
        mockMappedProject(EXTERNAL_PROJECT_ID);

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(client).getRepository(EXTERNAL_REPO_ID);
        // project or mapping should not be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        // repo should be created
        verify(repositoryService).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());
        verify(commandBulkUpdateRefs).call();
        verify(contentHashService).setHash(any(Repository.class), eq(REMOTE_CONTENT_HASH));
    }

    @Test
    public void testSingleRepoSynchronizationDoesNotCreateUnsupportedRepositories() {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        when(repository.getScmId()).thenReturn("unsupported-scm");

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(client, never()).getRepository(EXTERNAL_REPO_ID);

        // no repo, project or mapping should be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());
        verifyNoLocalRefUpdate();
    }

    @Test
    public void testSingleRepoSynchronizationWhenRepoAlreadyCreatedDoesNotCreateNewRepo() {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        // mock existing project and repository mappings
        mockMappedRepoAndProject();

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(client).getRepository(EXTERNAL_REPO_ID);
        // project or mapping should not be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        // repo and mapping should not be created
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());
        verify(commandBulkUpdateRefs).call();
    }

    @Test
    public void testSingleRepoSynchronizationWhenContentHashesMatchDoesNotFetch() {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        // mock existing project and repository mappings
        mockMappedRepoAndProject();
        // mock the local and remote repos having the same hash
        when(contentHashService.getHash(any(Repository.class))).thenReturn(of(REMOTE_CONTENT_HASH));

        helper.startRepositorySynchronization(upstream, repository.getId(), SyncLevel.DEFAULT);

        verify(client).getRepository(EXTERNAL_REPO_ID);
        // project or mapping should not be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        // repo and mapping should not be created
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());
        verifyNoLocalRefUpdate();
        verifyZeroInteractions(repositoryFetchProcessor);
    }

    @Test
    public void testSingleRepoSynchronizationWhenContentHashesMatchUpdatesRefs() {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        // mock existing project and repository mappings
        mockMappedRepoAndProject();
        // mock the local and remote repos having the same hash
        when(contentHashService.getHash(any(Repository.class))).thenReturn(of(REMOTE_CONTENT_HASH));

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(client).getRepository(EXTERNAL_REPO_ID);
        // project or mapping should not be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        // repo and mapping should not be created
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());
        // refs should still be updated if ref changes are provided
        verify(commandBulkUpdateRefs).call();
    }

    @Test
    public void testSingleRepoSynchronizationWhenLocalContentHashCantBeComputedDoesntFetch() {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        // mock existing project and repository mappings
        mockMappedRepoAndProject();
        // mock the local hash failing to compute
        when(contentHashService.getHash(any(Repository.class))).thenReturn(empty());

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(client).getRepository(EXTERNAL_REPO_ID);
        // project or mapping should not be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        // repo and mapping should not be created
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());
        // refs should still be updated if ref changes are provided
        verify(commandBulkUpdateRefs).call();
    }

    @Test
    public void testSingleRepoSynchronizationWhenRemoteContentHashAbsentDoesntFetch() {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        when(repository.getContentHash()).thenReturn(empty());
        // mock existing project and repository mappings
        mockMappedRepoAndProject();
        when(contentHashService.getHash(any(Repository.class))).thenReturn(of(LOCAL_CONTENT_HASH));

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(client).getRepository(EXTERNAL_REPO_ID);
        // project or mapping should not be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        // repo and mapping should not be created
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());
        // refs should still be updated if ref changes are provided
        verify(commandBulkUpdateRefs).call();
    }

    @Test
    public void testSingleRepoSynchronizationWhenRemoteCloneUrlAbsentDoesntFetch() {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        when(repository.getCloneUrl()).thenReturn(empty());
        // mock existing project and repository mappings
        mockMappedRepoAndProject();
        // mock the local and remote repos having different hash
        when(contentHashService.getHash(any(Repository.class))).thenReturn(of(LOCAL_CONTENT_HASH));

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(client).getRepository(EXTERNAL_REPO_ID);
        // project or mapping should not be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        // repo and mapping should not be created
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());
        // refs should still be updated if ref changes are provided
        verify(commandBulkUpdateRefs).call();
    }

    @Test
    public void testSingleRepoSynchronizationWhenUpstreamDeletedDeletesLocal() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        // mock existing project and repository mappings
        mockMappedRepoAndProject();
        mockGetRepositoryUpstream(EXTERNAL_REPO_ID, null);

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(client).getRepository(EXTERNAL_REPO_ID);
        // project/repo and mappings should not be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        // local repo should be deleted
        verify(repositoryMappingDao).delete(UPSTREAM_ID, EXTERNAL_REPO_ID);
        // sync should not be performed
        verifyNoLocalRefUpdate();
    }

    @Test
    public void testSingleRepoSynchronizationWithMinimalRepresentationDeletesLocalWhenUpstreamNoLongerMirrored() throws Exception {
        MinimalExternalRepository minimalRepository = mockMinimalRepositoryWithDefaults();
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        // mock existing project and repository mappings
        mockMappedRepoAndProject();
        mockGetRepositoryUpstream(EXTERNAL_REPO_ID, repository);
        when(upstreamSettingsService.getSettings(UPSTREAM_ID))
                .thenReturn(of(upstreamSettings(MirroringMode.ALL_PROJECTS)))
                .thenReturn(of(upstreamSettings(MirroringMode.SELECTED_PROJECTS, EXTERNAL_PROJECT_ID + "abc")));

        helper.startRepositorySynchronization(upstream, minimalRepository, changes);

        verify(client).getRepository(EXTERNAL_REPO_ID);
        // project/repo and mappings should not be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        // local repo should be deleted
        verify(repositoryMappingDao).delete(UPSTREAM_ID, EXTERNAL_REPO_ID);
        // sync should not be performed
        verifyNoLocalRefUpdate();
    }

    @Test
    public void testSingleRepoSynchronizationWhenUpstreamProjectAndRepoRenamed() throws Exception {
        // mock existing project and repository mappings
        mockMappedRepoAndProject();
        List<RefChange> changes = mockRefChanges();
        ExternalRepository repository = mockExternalRepository(EXTERNAL_REPO_ID, "newreponame",
                mockExternalProject(EXTERNAL_PROJECT_ID, "newprojkey", PROJ_NAME), REMOTE_CONTENT_HASH);
        mockGetRepositoryUpstream(EXTERNAL_REPO_ID, repository);

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(client).getRepository(EXTERNAL_REPO_ID);
        // project/repo and mappings should not be created
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(any(Integer.class), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID));
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        // repo should not have been deleted
        verify(repositoryMappingDao, never()).delete(UPSTREAM_ID, EXTERNAL_REPO_ID);
        // repo should have been renamed
        verify(repositoryService).update(repoUpdateRequest(LOCAL_REPO_ID, "newreponame", false));
        // project should have been renamed
        verify(projectService, atLeastOnce()).update(projectUpdateRequest(LOCAL_PROJECT_ID, "newprojkey", PROJ_NAME, false));
        // sync should proceed because the hashes don't match
        verify(commandBulkUpdateRefs).call();
    }

    @Test
    public void testSingleRepoSynchronizationWhenUpstreamRepositoryMoved() throws Exception {
        // mock existing project and repository mappings
        Repository repository = mockMappedRepoAndProject();
        Project project = repository.getProject();
        List<RefChange> changes = mockRefChanges();
        ExternalRepository externalRepository = mockExternalRepository(EXTERNAL_REPO_ID, REPO_NAME,
                mockExternalProject(EXTERNAL_PROJECT_ID + 1, "otherKey", "otherName"), REMOTE_CONTENT_HASH);
        mockGetRepositoryUpstream(EXTERNAL_REPO_ID, externalRepository);

        // the repository moved from the original project was the last
        when(repositoryService.findByProjectKey(eq(UPSTREAM_NAMESPACE), eq(PROJ_KEY), any(PageRequest.class)))
                .thenReturn(PageUtils.createEmptyPage(PageUtils.newRequest(0, 1)));

        helper.startRepositorySynchronization(upstream, externalRepository, changes);

        verify(client).getRepository(EXTERNAL_REPO_ID);
        // repo and mapping should not be created
        verify(repositoryService, never()).delete(any(Repository.class));
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao, never()).create(nextRepoId.get(), UPSTREAM_ID, LOCAL_PROJECT_ID, EXTERNAL_REPO_ID);
        // repo should not have been deleted
        verify(repositoryService, never()).delete(any(Repository.class));
        verify(repositoryMappingDao, never()).delete(UPSTREAM_ID, EXTERNAL_REPO_ID);
        // project should have been created
        verify(projectService).create(projectCreateRequest("otherKey", "otherName", false));
        verify(projectMappingDao).create(eq(nextProjectId.get()), eq(UPSTREAM_ID), eq(EXTERNAL_PROJECT_ID + 1));
        // old project should have been deleted
        verify(projectService).delete(project);
        verify(projectMappingDao).delete(LOCAL_PROJECT_ID);
        // repo should have been moved to the new project
        verify(repositoryService).update(repoUpdateRequestWithMoveTo(LOCAL_REPO_ID, nextProjectId.get()));
        verify(repositoryMappingDao).updateProject(LOCAL_REPO_ID, nextProjectId.get());
        // sync should proceed because the hashes don't match
        verify(commandBulkUpdateRefs).call();
    }

    @Test
    public void testSingleRepoSynchronizationFailsIfEmptyLocalProjectExistsWithClashingKey() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        Project clashingLocalProject = mock(Project.class);
        when(clashingLocalProject.getKey()).thenReturn(PROJ_KEY);
        when(clashingLocalProject.getNamespace()).thenReturn(UPSTREAM_NAMESPACE);
        when(projectService.getByKey(UPSTREAM_NAMESPACE, PROJ_KEY)).thenReturn(clashingLocalProject);

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(validationHelper).checkIsUpstreamUser(UPSTREAM_ID);
        verify(client).getRepository(EXTERNAL_REPO_ID);
        verify(projectService).getByKey(UPSTREAM_NAMESPACE, PROJ_KEY);
        verify(repositoryService).findByProjectKey(eq(UPSTREAM_NAMESPACE), eq(PROJ_KEY), any(PageRequest.class));
        verifyNoMappedProjectDeletion();
        verifyNoSync();
    }

    @Test
    public void testSingleRepoSynchronizationFetchesEvenIfFailedTooOften() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();

        // mock existing repo & project mappings
        Project project = mockMappedProject(EXTERNAL_PROJECT_ID);
        Repository localRepository = mockMappedRepo(project, EXTERNAL_REPO_ID, 100);

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(client).getRepository(EXTERNAL_REPO_ID);
        verify(commandBulkUpdateRefs).call();
    }

    @Test
    public void testSingleRepoSynchronizationFailsIfEmptyLocalProjectExistsWithClashingName() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        Project clashingLocalProject = mock(Project.class);
        when(clashingLocalProject.getKey()).thenReturn(PROJ_KEY);
        when(clashingLocalProject.getNamespace()).thenReturn(UPSTREAM_NAMESPACE);
        when(projectService.getByName(UPSTREAM_NAMESPACE, PROJ_NAME)).thenReturn(clashingLocalProject);

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(validationHelper).checkIsUpstreamUser(UPSTREAM_ID);
        verify(client).getRepository(EXTERNAL_REPO_ID);
        verify(projectService).getByName(UPSTREAM_NAMESPACE, PROJ_NAME);
        verify(repositoryService).findByProjectKey(eq(UPSTREAM_NAMESPACE), eq(PROJ_KEY), any(PageRequest.class));
        verifyNoMappedProjectDeletion();
        verifyNoSync();
    }

    @Test
    public void testSingleRepoSynchronizationDeletesEmptyLocalProjectWithClashingKeyAndContinues() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        Project clashingLocalProject = mock(Project.class);
        when(clashingLocalProject.getId()).thenReturn(198);
        when(clashingLocalProject.getKey()).thenReturn(PROJ_KEY);
        when(clashingLocalProject.getNamespace()).thenReturn(UPSTREAM_NAMESPACE);
        when(projectService.getByKey(UPSTREAM_NAMESPACE, PROJ_KEY)).thenReturn(clashingLocalProject);
        when(repositoryService.findByProjectKey(eq(UPSTREAM_NAMESPACE), eq(PROJ_KEY), any(PageRequest.class)))
                .thenReturn(PageUtils.createEmptyPage(PageUtils.newRequest(0, 1)));

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(validationHelper).checkIsUpstreamUser(UPSTREAM_ID);
        verify(client).getRepository(EXTERNAL_REPO_ID);
        verify(projectService).getByKey(UPSTREAM_NAMESPACE, PROJ_KEY);
        verify(repositoryService).findByProjectKey(eq(UPSTREAM_NAMESPACE), eq(PROJ_KEY), any(PageRequest.class));
        verify(projectMappingDao).delete(198);
        verify(projectService).delete(clashingLocalProject);
        verify(projectService).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao).create(nextProjectId.get(), UPSTREAM_ID, EXTERNAL_PROJECT_ID);
        verify(repositoryService).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao).create(nextRepoId.get(), UPSTREAM_ID, nextProjectId.get(), EXTERNAL_REPO_ID);
        verify(commandBulkUpdateRefs).call();
    }

    @Test
    public void testSingleRepoSynchronizationDeletesEmptyLocalProjectWithClashingNameAndContinues() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        Project clashingLocalProject = mock(Project.class);
        when(clashingLocalProject.getId()).thenReturn(198);
        when(clashingLocalProject.getKey()).thenReturn(PROJ_KEY);
        when(clashingLocalProject.getNamespace()).thenReturn(UPSTREAM_NAMESPACE);
        when(projectService.getByName(UPSTREAM_NAMESPACE, PROJ_NAME)).thenReturn(clashingLocalProject);
        when(repositoryService.findByProjectKey(eq(UPSTREAM_NAMESPACE), eq(PROJ_KEY), any(PageRequest.class)))
                .thenReturn(PageUtils.createEmptyPage(PageUtils.newRequest(0, 1)));

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(validationHelper).checkIsUpstreamUser(UPSTREAM_ID);
        verify(client).getRepository(EXTERNAL_REPO_ID);
        verify(projectService).getByKey(UPSTREAM_NAMESPACE, PROJ_KEY);
        verify(repositoryService).findByProjectKey(eq(UPSTREAM_NAMESPACE), eq(PROJ_KEY), any(PageRequest.class));
        verify(projectMappingDao).delete(198);
        verify(projectService).delete(clashingLocalProject);
        verify(projectService).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao).create(nextProjectId.get(), UPSTREAM_ID, EXTERNAL_PROJECT_ID);
        verify(repositoryService).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao).create(nextRepoId.get(), UPSTREAM_ID, nextProjectId.get(), EXTERNAL_REPO_ID);
        verify(commandBulkUpdateRefs).call();
    }

    @Test
    public void testSingleRepoSynchronizationFailsWhenLocalRepoExistsWithClashingSlug() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        List<RefChange> changes = mockRefChanges();
        Repository clashingLocalRepository = mock(Repository.class);
        when(repositoryService.getBySlug(UPSTREAM_NAMESPACE, PROJ_KEY, REPO_NAME))
                .thenReturn(clashingLocalRepository);

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(validationHelper).checkIsUpstreamUser(UPSTREAM_ID);
        verify(client).getRepository(EXTERNAL_REPO_ID);
        verify(projectService).create(projectCreateRequest(PROJ_KEY, PROJ_NAME, false));
        verify(projectMappingDao).create(nextProjectId.get(), UPSTREAM_ID, EXTERNAL_PROJECT_ID);
        verifyNoMappedRepoCreation();
        verifyNoMappedRepoDeletion();
        verifyNoLocalRefUpdate();
        verifyNoFetchTaskSubmitted();
    }

    @Test
    public void testSingleRepoSynchronizationSkippedWhenProjectNotMirrored() {
        ExternalRepository repository = mockExternalRepository("22", "repo", mockExternalProject("99", "proj"), REMOTE_CONTENT_HASH);
        List<RefChange> changes = mockRefChanges();
        when(client.getRepository(eq("22"))).thenReturn(Promises.promise(repository));
        when(upstreamSettingsService.getSettings(UPSTREAM_ID)).thenReturn(of(upstreamSettings(MirroringMode.SELECTED_PROJECTS)));

        helper.startRepositorySynchronization(upstream, repository, changes);

        verify(client, never()).getRepository(eq("22"));
        // project or mapping should not be created
        verifyNoMappedProjectCreation();
        // repo and mapping should not be created
        verifyNoMappedRepoCreation();
        verifyNoLocalRefUpdate();
    }

    @Test
    public void testSingleRepoSynchronizationWithMinimalRepresentationSchedulesFetch() throws Exception {
        mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        mockMappedRepoAndProject();

        when(commandBulkUpdateRefs.call()).thenThrow(CommandFailedException.class);

        helper.startRepositorySynchronization(upstream, mockMinimalRepositoryWithDefaults(), mockRefChanges());

        verify(validationHelper).checkIsUpstreamUser(UPSTREAM_ID);
        // sync task runs and that causes a GET of the repository and then a subsequent fetch task
        verify(executorService).submit(isA(DefaultMirrorSynchronizationHelper.RepositorySynchronizationTask.class));
        verify(fetchExecutor).submit(isA(RepositoryFetchTask.class));

        verify(client).getRepository(EXTERNAL_REPO_ID);
        // project or mapping should not be created - they already exist
        verifyNoMappedProjectCreation();
        // repo and mapping should not be created - they already exist
        verifyNoMappedRepoCreation();
    }

    @Test
    public void testCreateRepository() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        when(upstreamSettingsService.getSettings(UPSTREAM_ID)).thenReturn(of(upstreamSettings(ALL_PROJECTS)));

        helper.createLocalRepository(upstream, repository);

        verify(executorService).submit(isA(DefaultMirrorSynchronizationHelper.RepositorySynchronizationTask.class));
        verify(projectService).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao).create(any(Integer.class), eq(UPSTREAM_ID), eq("99"));
        verify(repositoryService).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao).create(nextRepoId.get(), UPSTREAM_ID, nextProjectId.get(), "22");
    }

    @Test
    public void testCreateRepositoryWithMinimalRepresentation() throws Exception {
        mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();

        helper.createLocalRepository(upstream, mockMinimalRepositoryWithDefaults());

        verify(executorService).submit(isA(DefaultMirrorSynchronizationHelper.RepositorySynchronizationTask.class));
        // the full representation should be retrieved
        verify(client).getRepository(EXTERNAL_REPO_ID);
        // and the project/repo should be created
        verify(projectService).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao).create(any(Integer.class), eq(UPSTREAM_ID), eq("99"));
        verify(repositoryService).create(any(RepositoryCreateRequest.class));
        verify(repositoryMappingDao).create(nextRepoId.get(), UPSTREAM_ID, nextProjectId.get(), "22");
    }

    @Test
    public void testCreateForkedRepositoryIsFetched() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        ExternalRepository origin = mockExternalRepository(
                "1", REPO_NAME, mockExternalProjectWithDefaults(), REMOTE_CONTENT_HASH);
        when(repository.getOrigin()).thenReturn(of(origin));

        when(upstreamSettingsService.getSettings(UPSTREAM_ID)).thenReturn(of(upstreamSettings(ALL_PROJECTS)));

        helper.createLocalRepository(upstream, repository);

        verify(validationHelper).checkIsUpstreamUser(UPSTREAM_ID);
        // a forked repo will be done as a task to ensure origins are fetched where needed first
        verify(executorService).submit(isA(DefaultMirrorSynchronizationHelper.RepositorySynchronizationTask.class));
        verify(projectService).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao).create(any(Integer.class), eq(UPSTREAM_ID), eq("99"));
        verify(repositoryService).create(any(RepositoryCreateRequest.class));
        verify(repositoryService).fork(any(RepositoryForkRequest.class));
        verify(repositoryMappingDao).create(nextRepoId.get() - 1, UPSTREAM_ID, nextProjectId.get(), "1");
        verify(repositoryMappingDao).create(nextRepoId.get(), UPSTREAM_ID, nextProjectId.get(), "22");
        // and we need to test the fetch task is submitted
        verify(fetchExecutor, times(2)).submit(isA(RepositoryFetchTask.class));
    }

    @Test
    public void testDeleteRepository() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        Repository localRepository = mockMappedRepoAndProject();

        helper.deleteLocalRepository(upstream, repository);

        verify(repositoryService).delete(localRepository);
        verify(repositoryMappingDao).delete(UPSTREAM_ID, EXTERNAL_REPO_ID);
        verifyNoMappedProjectDeletion();
    }

    @Test
    public void testDeleteRepositoryWhenLastRepositoryInProject() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaults();
        Repository localRepository = mockMappedRepoAndProject();
        Project localProject = localRepository.getProject();

        when(repositoryService.findByProjectKey(eq(UPSTREAM_NAMESPACE), anyString(), any(PageRequest.class)))
                .thenReturn(PageUtils.createEmptyPage(PageUtils.newRequest(0, 1)));

        helper.deleteLocalRepository(upstream, repository);

        verify(repositoryService).findByProjectKey(eq(UPSTREAM_NAMESPACE), eq(PROJ_KEY), any(PageRequest.class));
        verify(repositoryService).delete(localRepository);
        verify(repositoryMappingDao).delete(UPSTREAM_ID, EXTERNAL_REPO_ID);
        verify(projectService).delete(localProject);
        verify(projectMappingDao).delete(LOCAL_PROJECT_ID);
    }

    @Test
    public void testDeleteRepositoryWhenRepoDeletedButMappingStillExists() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaults();
        AoRepositoryMapping repositoryMapping = mock(AoRepositoryMapping.class);
        when(repositoryMapping.getLocalId()).thenReturn(LOCAL_REPO_ID);
        when(repositoryMapping.getExternalId()).thenReturn(EXTERNAL_REPO_ID);
        when(repositoryMapping.getUpstreamId()).thenReturn(UPSTREAM_ID);
        when(repositoryMapping.getFailedSyncCount()).thenReturn(0);
        repositoryMappingDao.add(repositoryMapping);

        helper.deleteLocalRepository(upstream, repository);

        verify(repositoryService, never()).delete(any(Repository.class));
        verify(repositoryMappingDao).delete(UPSTREAM_ID, EXTERNAL_REPO_ID);
        verifyNoMappedProjectDeletion();
    }

    @Test
    public void testProjectPublicFlagChange() {
        // local project is not public to begin with
        mockMappedRepoAndProject();
        ExternalProject externalProject = mockExternalProjectWithDefaults();
        when(externalProject.isPublic()).thenReturn(true);

        helper.updateLocalProject(upstream, externalProject);

        verify(validationHelper).checkIsUpstreamUser(UPSTREAM_ID);
        verify(projectService).update(projectUpdateRequest(LOCAL_PROJECT_ID, PROJ_KEY, PROJ_NAME, true));
    }

    @Test
    public void testProjectRekeyAndRename() throws Exception {
        String updatedKey = PROJ_KEY + "newkey";
        String updatedName = PROJ_NAME + "newname";
        ExternalProject externalProject = mockExternalProject(EXTERNAL_PROJECT_ID, updatedKey, updatedName);
        mockMappedRepoAndProject();

        helper.updateLocalProject(upstream, externalProject);

        verify(projectService).update(projectUpdateRequest(LOCAL_PROJECT_ID, updatedKey, updatedName, false));
    }

    @Test
    public void testRepositoryNotCreatedIfProjectNotMirrored() throws Exception {
        ExternalRepository repository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        when(upstreamSettingsService.getSettings(UPSTREAM_ID))
                .thenReturn(of(upstreamSettings(SELECTED_PROJECTS, EXTERNAL_PROJECT_ID + 1)));

        helper.createLocalRepository(upstream, repository);

        verifyNoMappedProjectDeletion();
        verifyNoMappedRepoDeletion();
    }

    @Test
    public void testRepositoryPublicFlagChange() throws Exception {
        ExternalRepository externalRepository = mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream();
        when(externalRepository.isPublic()).thenReturn(true);

        mockMappedRepoAndProject();

        helper.updateLocalRepository(upstream, externalRepository);

        verify(validationHelper).checkIsUpstreamUser(UPSTREAM_ID);
        verify(repositoryService).update(repoUpdateRequest(LOCAL_REPO_ID, REPO_NAME, true));
    }

    @Test
    public void testRepositoryRename() throws Exception {
        ExternalRepository externalRepository =
                mockExternalRepository(EXTERNAL_REPO_ID, REPO_NAME + "new",
                        mockExternalProjectWithDefaults(), REMOTE_CONTENT_HASH);
        mockGetRepositoryUpstream(EXTERNAL_REPO_ID, externalRepository);

        mockMappedRepoAndProject();

        helper.updateLocalRepository(upstream, externalRepository);

        verify(repositoryService).update(repoUpdateRequest(LOCAL_REPO_ID, REPO_NAME + "new", false));
    }

    @Test
    public void testRepositoryRenameWithMinimalRepresentation() throws Exception {
        ExternalRepository externalRepository = mockExternalRepository(
                EXTERNAL_REPO_ID, REPO_NAME + "new", mockExternalProjectWithDefaults(), REMOTE_CONTENT_HASH);
        mockGetRepositoryUpstream(EXTERNAL_REPO_ID, externalRepository);

        mockMappedRepoAndProject();

        MinimalExternalRepository minExternalRepository = mockMinimalRepositoryWithDefaults();
        when(minExternalRepository.getSlug()).thenReturn(REPO_NAME + "new");

        helper.updateLocalRepository(upstream, minExternalRepository);

        // the full repository representation is fetched
        verify(repositoryService).update(repoUpdateRequest(LOCAL_REPO_ID, REPO_NAME + "new", false));
    }

    private Repository createRepo(RepositoryCreateRequest request) {
        Integer id = nextRepoId.incrementAndGet();
        Repository repo = repository(
                id, request.getName(), request.getName(),
                request.getProject());
        localRepositories.put(id, repo);
        return repo;
    }

    private ExternalProject mockExternalProjectWithDefaults() {
        return mockExternalProject(EXTERNAL_PROJECT_ID, PROJ_KEY, PROJ_NAME);
    }

    private ExternalRepository mockExternalRepoAndProjectWithDefaults() {
        return mockExternalRepoWithDefaults(EXTERNAL_REPO_ID, REPO_NAME, mockExternalProjectWithDefaults(), REMOTE_CONTENT_HASH);
    }

    private ExternalRepository mockExternalRepoAndProjectWithDefaultsAndMockGetUpstream() {
        ExternalRepository externalRepository = mockExternalRepoAndProjectWithDefaults();
        mockGetRepositoryUpstream(EXTERNAL_REPO_ID, externalRepository);
        return externalRepository;
    }

    private ExternalRepository mockExternalRepoWithDefaults(String externalRepoId, String repoName, ExternalProject project, String remoteContentHash) {
        return mockExternalRepository(externalRepoId, repoName, project, remoteContentHash);
    }

    private void mockGetRepositoryUpstream(String id, ExternalRepository repository) {
        when(client.getRepository(id)).thenReturn(promise(repository));
    }

    private Repository mockMappedRepoAndProject() {
        return mockMappedRepo(mockMappedProject(EXTERNAL_PROJECT_ID), EXTERNAL_REPO_ID);
    }

    private Repository mockMappedRepo(Project project, String externalRepositoryId) {
        return mockMappedRepo(project, externalRepositoryId, 0);
    }

    private Repository mockMappedRepo(Project project, String externalRepositoryId, int failureCount) {
        AoRepositoryMapping repositoryMapping = mock(AoRepositoryMapping.class);
        Repository localRepo = repository(LOCAL_REPO_ID, REPO_NAME, REPO_NAME, project);
        when(localRepo.getProject()).thenReturn(project);
        when(repositoryMapping.getLocalId()).thenReturn(LOCAL_REPO_ID);
        when(repositoryMapping.getLocalProjectId()).thenReturn(LOCAL_PROJECT_ID);
        when(repositoryMapping.getExternalId()).thenReturn(externalRepositoryId);
        when(repositoryMapping.getUpstreamId()).thenReturn(UPSTREAM_ID);
        when(repositoryMapping.getFailedSyncCount()).thenReturn(failureCount);
        repositoryMappingDao.add(repositoryMapping);
        when(repositoryService.getById(LOCAL_REPO_ID)).thenReturn(localRepo);
        return localRepo;
    }

    private Project mockMappedProject(String externalProjectId) {
        return mockMappedProject(externalProjectId, LOCAL_PROJECT_ID, PROJ_KEY);
    }

    private Project mockMappedProject(String externalProjectId, int localProjectId, String projectKey) {
        AoProjectMapping projectMapping = mock(AoProjectMapping.class);
        when(projectMapping.getLocalId()).thenReturn(localProjectId);
        when(projectMapping.getExternalId()).thenReturn(externalProjectId);
        when(projectMapping.getUpstreamId()).thenReturn(UPSTREAM_ID);
        projectMappingDao.add(projectMapping);

        Project project = mockProject(localProjectId, UPSTREAM_NAMESPACE, projectKey, projectKey);
        when(projectService.getById(localProjectId)).thenReturn(project);
        return project;
    }

    private MinimalExternalRepository mockMinimalRepositoryWithDefaults() {
        ExternalProject project = mockExternalProjectWithDefaults();
        MinimalExternalRepository minRepository = mock(MinimalExternalRepository.class);
        when(minRepository.getId()).thenReturn(EXTERNAL_REPO_ID);
        when(minRepository.getName()).thenReturn(REPO_NAME);
        when(minRepository.getSlug()).thenReturn(REPO_NAME);
        when(minRepository.getProject()).thenReturn(project);
        return minRepository;
    }

    private void mockRepositories(int totalSize) {
        // do a test with 10 pages of repositories, where each project has 6 repos
        ExternalProject project = null;
        List<ExternalRepository> repositories = new ArrayList<>();
        int projectIndex = 0;
        for (int repoIndex = 0; repoIndex < totalSize; ++repoIndex) {
            if (repoIndex % 6 == 0) {
                project = mockExternalProject(Integer.toString(projectIndex), PROJ_KEY + projectIndex, PROJ_NAME + projectIndex);
                projectIndex++;
            }
            repositories.add(mockExternalRepository(Integer.toString(repoIndex), REPO_NAME + repoIndex, project, REMOTE_CONTENT_HASH));
        }

        when(client.getRepositories()).thenAnswer(invocation -> streamIterable(repositories));
    }

    private Project mockProject(Integer id, String namespace, String key, String name) {
        Project project = mock(Project.class);
        when(project.getId()).thenReturn(id);
        when(project.getKey()).thenReturn(key);
        when(project.getName()).thenReturn(name);
        when(project.getNamespace()).thenReturn(namespace);
        when(project.isPublic()).thenReturn(false);
        localProjects.put(id, project);
        return project;
    }

    private List<RefChange> mockRefChanges() {
        return ImmutableList.of(mockRefUpdate("55ac93650f83fff45cd030b0e3ecc0d107f2b08b",
                "57cf15ed0a5779256bcd4f77dd79398b9144bc21"));
    }

    private RefChange mockRefUpdate(String fromHash, String toHash) {
        return new SimpleRefChange.Builder()
                .ref(new SimpleMinimalRef.Builder()
                        .type(StandardRefType.BRANCH)
                        .id("refs/heads/master")
                        .displayId("master")
                        .build())
                .fromHash(fromHash)
                .toHash(toHash)
                .type(RefChangeType.UPDATE)
                .build();
    }

    private DefaultMirrorSynchronizationHelper newHelper() {
        return new DefaultMirrorSynchronizationHelper(concurrencyService, config, contentHashService, eventPublisher,
                executorService, projectMappingDao, projectService, repositoryFetchProcessor, repositoryMappingDao,
                repositoryService, scmService, new SimpleSalTransactionTemplate(),
                clientFactory, upstreamSettingsService, validationHelper);
    }

    private ProjectCreateRequest projectCreateRequest(final String key, final String name, boolean isPublic) {
        return argThat(new TypeSafeMatcher<ProjectCreateRequest>() {
            @Override
            protected boolean matchesSafely(ProjectCreateRequest request) {
                boolean rekeyed = request.getKey().equals(key);
                boolean renamed = request.getName().equals(name);
                return rekeyed && renamed && request.isPublic() == isPublic;
            }

            @Override
            public void describeTo(Description description) {
                description.appendText("Create request for project with: key = \"" + key + "\", name = \"" + name + "\", public = " + isPublic);
            }
        });
    }

    private ProjectUpdateRequest projectUpdateRequest(int localProjectId, String key, String name, boolean isPublic) {
        return argThat(new TypeSafeMatcher<ProjectUpdateRequest>() {
            @Override
            protected boolean matchesSafely(ProjectUpdateRequest request) {
                boolean rekeyed = request.getKey().equals(key);
                boolean renamed = request.getName().equals(name);
                return request.getId() == localProjectId && rekeyed && renamed && request.isPublic() == isPublic;
            }

            @Override
            public void describeTo(Description description) {
                description.appendText("Update request for project \"" + localProjectId + "\" with: key = \"" + key +
                        "\", name = \"" + name + "\", public = " + isPublic);
            }
        });
    }

    private Repository repository(Integer id, String slug, String name, Project project) {
        Repository repo = mock(Repository.class);
        when(repo.getId()).thenReturn(id);
        when(repo.getHierarchyId()).thenReturn("0");
        when(repo.getScmId()).thenReturn(GIT);
        when(repo.getName()).thenReturn(name);
        when(repo.getSlug()).thenReturn(slug);
        when(repo.getProject()).thenReturn(project);
        localRepositories.put(id, repo);
        return repo;
    }

    private RepositoryCreateRequest repoCreateRequest(String name, boolean isPublic) {
        return argThat(new TypeSafeMatcher<RepositoryCreateRequest>() {
            @Override
            protected boolean matchesSafely(RepositoryCreateRequest request) {
                return request.getName().equals(name) &&
                        request.isPublic() == isPublic;
            }

            @Override
            public void describeTo(Description description) {
                description.appendText("Create request for repository with: name = \"" +
                        name + "\", public = " + isPublic);
            }
        });
    }

    private RepositoryUpdateRequest repoUpdateRequest(int localRepoId, String newName, boolean isPublic) {
        return argThat(new TypeSafeMatcher<RepositoryUpdateRequest>() {
            @Override
            protected boolean matchesSafely(RepositoryUpdateRequest request) {
                return request.getId() == localRepoId &&
                        request.getName().equals(newName) &&
                        request.isPublic() == isPublic;
            }

            @Override
            public void describeTo(Description description) {
                description.appendText("Update request for repository \"" + localRepoId + "\" with: name = \"" +
                        newName + "\", public = " + isPublic);
            }
        });
    }

    private RepositoryUpdateRequest repoUpdateRequestWithMoveTo(int localRepoId, int localProjectId) {
        return argThat(new SimpleTypeSafeMatcher<RepositoryUpdateRequest>() {
            @Override
            protected boolean matchesSafely(RepositoryUpdateRequest request) {
                return request.getId() == localRepoId && request.getProject() != null &&
                        request.getProject().getId() == localProjectId;
            }
        });
    }

    private UpstreamSettings upstreamSettings(MirroringMode mode, String... projectIds) {
        return new SimpleUpstreamSettings.Builder()
                .mode(mode)
                .projectIds(Arrays.asList(projectIds))
                .build();
    }

    private void verifyNoMappedProjectCreation() {
        verify(projectService, never()).create(any(ProjectCreateRequest.class));
        verify(projectMappingDao, never()).create(anyInt(), anyString(), anyString());
    }

    private void verifyNoLocalRefUpdate() {
        verify(commandBulkUpdateRefs, never()).call();
    }

    private void verifyNoMappedProjectDeletion() {
        verify(projectMappingDao, never()).delete(anyString(), anyString());
        verify(projectService, never()).delete(any(Project.class));
    }

    private void verifyNoMappedRepoCreation() {
        verify(repositoryMappingDao, never()).create(anyInt(), anyString(), anyInt(), anyString());
        verify(repositoryService, never()).create(any(RepositoryCreateRequest.class));
    }

    private void verifyNoMappedRepoDeletion() {
        verify(repositoryMappingDao, never()).delete(anyString(), anyString());
        verify(repositoryService, never()).delete(any(Repository.class));
    }

    private void verifyNoSync() {
        verifyNoMappedProjectCreation();
        verifyNoMappedRepoCreation();
        verifyNoLocalRefUpdate();
        verifyNoFetchTaskSubmitted();
    }

    private void verifyNoFetchTaskSubmitted() {
        verify(fetchExecutor, never()).submit(isA(RepositoryFetchTask.class));
    }
}