/* * Copyright 2022 Google LLC * * 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.android.libraries.mobiledatadownload.internal; import static com.google.common.truth.Truth.assertThat; import static com.google.common.util.concurrent.Futures.immediateFuture; import static com.google.common.util.concurrent.Futures.immediateVoidFuture; import static java.util.concurrent.TimeUnit.DAYS; import static org.junit.Assert.assertThrows; import static org.junit.Assume.assumeTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import android.content.Context; import android.content.SharedPreferences; import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import com.google.mobiledatadownload.internal.MetadataProto.DataFile; import com.google.mobiledatadownload.internal.MetadataProto.DataFile.ChecksumType; import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal; import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions; import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions.DeviceNetworkPolicy; import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions.DeviceStoragePolicy; import com.google.mobiledatadownload.internal.MetadataProto.GroupKey; import com.google.android.libraries.mobiledatadownload.DownloadException; import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode; import com.google.android.libraries.mobiledatadownload.FileSource; import com.google.android.libraries.mobiledatadownload.SilentFeedback; import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri; import com.google.android.libraries.mobiledatadownload.internal.FileGroupManager.GroupDownloadStatus; import com.google.android.libraries.mobiledatadownload.internal.Migrations.FileKeyVersion; import com.google.android.libraries.mobiledatadownload.internal.collect.GroupKeyAndGroup; import com.google.android.libraries.mobiledatadownload.internal.experimentation.DownloadStageManager; import com.google.android.libraries.mobiledatadownload.internal.experimentation.NoOpDownloadStageManager; import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger; import com.google.android.libraries.mobiledatadownload.internal.logging.FileGroupStatsLogger; import com.google.android.libraries.mobiledatadownload.internal.logging.LoggingStateStore; import com.google.android.libraries.mobiledatadownload.internal.logging.NetworkLogger; import com.google.android.libraries.mobiledatadownload.internal.logging.StorageLogger; import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil; import com.google.android.libraries.mobiledatadownload.internal.util.SharedPreferencesUtil; import com.google.android.libraries.mobiledatadownload.testing.FakeTimeSource; import com.google.android.libraries.mobiledatadownload.testing.MddTestDependencies; import com.google.android.libraries.mobiledatadownload.testing.TestFlags; import com.google.common.base.Optional; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.labs.concurrent.LabsFutures; import com.google.common.util.concurrent.AsyncFunction; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.mobiledatadownload.LogEnumsProto.MddClientEvent; import com.google.mobiledatadownload.TransformProto.CompressTransform; import com.google.mobiledatadownload.TransformProto.Transform; import com.google.mobiledatadownload.TransformProto.Transforms; import com.google.mobiledatadownload.TransformProto.ZipTransform; import com.google.protobuf.ByteString; import java.io.IOException; import java.util.List; import java.util.Random; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.LooperMode; // The LooperMode Mode.PAUSED fixes buggy behavior in the legacy looper implementation that can lead // to deadlock in some cases. See documentation at: // http://robolectric.org/javadoc/4.3/org/robolectric/annotation/LooperMode.Mode.html for more // information. @RunWith(RobolectricTestRunner.class) @LooperMode(LooperMode.Mode.PAUSED) public class MobileDataDownloadManagerTest { private static final String TEST_GROUP = "test-group"; private static final GroupKey TEST_KEY = FileGroupUtil.createGroupKey(TEST_GROUP, "com.google.android.gms"); private static final Executor CONTROL_EXECUTOR = Executors.newCachedThreadPool(); private static final int DEFAULT_DAYS_SINCE_LAST_LOG = 1; // Note: We can't make those android uris static variable since the Uri.parse will fail // with initialization. private final Uri fileUri1 = Uri.parse(MddTestUtil.FILE_URI + "1"); private final Uri fileUri2 = Uri.parse(MddTestUtil.FILE_URI + "2"); private static final String HOST_APP_LOG_SOURCE = "HOST_APP_LOG_SOURCE"; private static final String HOST_APP_PRIMES_LOG_SOURCE = "HOST_APP_PRIMES_LOG_SOURCE"; private Context context; private MobileDataDownloadManager mddManager; private final TestFlags flags = new TestFlags(); @Rule(order = 2) public final TemporaryUri tmpUri = new TemporaryUri(); @Rule(order = 3) public final MockitoRule mocks = MockitoJUnit.rule(); @Mock EventLogger mockLogger; @Mock SharedFileManager mockSharedFileManager; @Mock SharedFilesMetadata mockSharedFilesMetadata; @Mock FileGroupManager mockFileGroupManager; @Mock FileGroupsMetadata mockFileGroupsMetadata; @Mock ExpirationHandler mockExpirationHandler; @Mock SilentFeedback mockSilentFeedback; @Mock StorageLogger mockStorageLogger; @Mock FileGroupStatsLogger mockFileGroupStatsLogger; @Mock NetworkLogger mockNetworkLogger; private LoggingStateStore loggingStateStore; private DownloadStageManager downloadStageManager; private FakeTimeSource testClock; @Captor ArgumentCaptor> groupKeyListCaptor; @Before public void setUp() throws Exception { context = ApplicationProvider.getApplicationContext(); this.testClock = new FakeTimeSource(); testClock.advance(1, DAYS); loggingStateStore = MddTestDependencies.LoggingStateStoreImpl.SHARED_PREFERENCES.loggingStateStore( context, Optional.absent(), testClock, CONTROL_EXECUTOR, new Random()); loggingStateStore.getAndResetDaysSinceLastMaintenance().get(); testClock.advance(1, DAYS); // The next call into logging state store will return 1 downloadStageManager = new NoOpDownloadStageManager(); mddManager = new MobileDataDownloadManager( context, mockLogger, mockSharedFileManager, mockSharedFilesMetadata, mockFileGroupManager, mockFileGroupsMetadata, mockExpirationHandler, mockSilentFeedback, mockStorageLogger, mockFileGroupStatsLogger, mockNetworkLogger, Optional.absent(), CONTROL_EXECUTOR, flags, loggingStateStore, downloadStageManager); // Enable migrations so that init doesn't run all migrations before each test. setMigrationState(MobileDataDownloadManager.MDD_MIGRATED_TO_OFFROAD, true); when(mockSharedFileManager.init()).thenReturn(Futures.immediateFuture(true)); when(mockSharedFileManager.clear()).thenReturn(Futures.immediateFuture(null)); when(mockSharedFileManager.cancelDownload(any())).thenReturn(Futures.immediateFuture(null)); when(mockSharedFileManager.cancelDownloadAndClear()).thenReturn(Futures.immediateFuture(null)); when(mockSharedFilesMetadata.init()).thenReturn(Futures.immediateFuture(true)); when(mockSharedFilesMetadata.clear()).thenReturn(immediateVoidFuture()); when(mockFileGroupsMetadata.init()).thenReturn(Futures.immediateFuture(null)); when(mockFileGroupsMetadata.clear()).thenReturn(Futures.immediateFuture(null)); when(mockFileGroupsMetadata.getAllStaleGroups()) .thenReturn(Futures.immediateFuture(ImmutableList.of())); when(mockFileGroupsMetadata.getAllFreshGroups()) .thenReturn(Futures.immediateFuture(ImmutableList.of())); } @After public void tearDown() throws Exception { mddManager.clear().get(); } @Test public void init_offroadDownloaderMigration() throws Exception { setMigrationState(MobileDataDownloadManager.MDD_MIGRATED_TO_OFFROAD, false); mddManager.init().get(); verify(mockSharedFileManager).clear(); } @Test public void init_offroadDownloaderMigration_onlyOnce() throws Exception { setMigrationState(MobileDataDownloadManager.MDD_MIGRATED_TO_OFFROAD, false); mddManager.init().get(); mddManager.init().get(); verify(mockSharedFileManager, times(1)).clear(); } @Test public void initDoesNotClearsIfInternalInitSucceeds() throws Exception { when(mockSharedFileManager.init()).thenReturn(Futures.immediateFuture(true)); mddManager.init().get(); verify(mockSharedFileManager, times(0)).clear(); } @Test public void initClearsIfInternalInitFails() throws Exception { when(mockSharedFileManager.init()).thenReturn(Futures.immediateFuture(false)); mddManager.init().get(); verify(mockSharedFileManager).clear(); } @Test public void testAddGroupForDownload() throws Exception { // This tests that the default value of {allowed_readers, allowed_readers_enum} is to allow // access to all 1p google apps. DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1); when(mockFileGroupManager.addGroupForDownload(eq(TEST_KEY), eq(dataFileGroup))) .thenReturn(Futures.immediateFuture(true)); when(mockFileGroupManager.getFileGroup(eq(TEST_KEY), anyBoolean())) .thenReturn(immediateFuture(dataFileGroup)); when(mockFileGroupManager.verifyGroupDownloaded( eq(TEST_KEY), eq(dataFileGroup), anyBoolean(), any(), any())) .thenReturn(Futures.immediateFuture(GroupDownloadStatus.PENDING)); when(mockFileGroupsMetadata.getAllFreshGroups()) .thenReturn( immediateFuture(ImmutableList.of(GroupKeyAndGroup.create(TEST_KEY, dataFileGroup)))); assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue(); verify(mockFileGroupManager).addGroupForDownload(TEST_KEY, dataFileGroup); verify(mockFileGroupManager) .verifyGroupDownloaded(eq(TEST_KEY), eq(dataFileGroup), anyBoolean(), any(), any()); verifyNoInteractions(mockLogger); assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue(); } @Test public void testAddGroupForDownload_compressedFile() throws Exception { Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY); DataFileGroupInternal.Builder fileGroupBuilder = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder(); DataFileGroupInternal dataFileGroup = fileGroupBuilder .setFile( 0, fileGroupBuilder.getFile(0).toBuilder() .setDownloadedFileChecksum("downloadchecksum") .setDownloadTransforms( Transforms.newBuilder() .addTransform( Transform.newBuilder() .setCompress(CompressTransform.getDefaultInstance())))) .build(); when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup)) .thenReturn(Futures.immediateFuture(true)); when(mockFileGroupManager.getFileGroup(eq(TEST_KEY), anyBoolean())) .thenReturn(immediateFuture(dataFileGroup)); when(mockFileGroupManager.verifyGroupDownloaded( eq(TEST_KEY), eq(dataFileGroup), anyBoolean(), any(), any())) .thenReturn(Futures.immediateFuture(GroupDownloadStatus.PENDING)); when(mockFileGroupsMetadata.getAllFreshGroups()) .thenReturn( immediateFuture(ImmutableList.of(GroupKeyAndGroup.create(TEST_KEY, dataFileGroup)))); assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue(); verify(mockFileGroupManager).addGroupForDownload(TEST_KEY, dataFileGroup); verify(mockFileGroupManager) .verifyGroupDownloaded(eq(TEST_KEY), eq(dataFileGroup), anyBoolean(), any(), any()); verifyNoInteractions(mockLogger); assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue(); } @Test public void testAddGroupForDownload_deltaFile() throws Exception { Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY); DataFileGroupInternal dataFileGroup = MddTestUtil.createFileGroupInternalWithDeltaFile(TEST_GROUP); when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup)) .thenReturn(Futures.immediateFuture(true)); when(mockFileGroupManager.getFileGroup(eq(TEST_KEY), anyBoolean())) .thenReturn(immediateFuture(dataFileGroup)); when(mockFileGroupManager.verifyGroupDownloaded( eq(TEST_KEY), eq(dataFileGroup), anyBoolean(), any(), any())) .thenReturn(Futures.immediateFuture(GroupDownloadStatus.PENDING)); when(mockFileGroupsMetadata.getAllFreshGroups()) .thenReturn( immediateFuture(ImmutableList.of(GroupKeyAndGroup.create(TEST_KEY, dataFileGroup)))); assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue(); verify(mockFileGroupManager).addGroupForDownload(TEST_KEY, dataFileGroup); verify(mockFileGroupManager) .verifyGroupDownloaded(eq(TEST_KEY), eq(dataFileGroup), anyBoolean(), any(), any()); verifyNoInteractions(mockLogger); assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue(); } @Test public void testAddGroupForDownload_downloadImmediate() throws Exception { // This tests that the default value of {allowed_readers, allowed_readers_enum} is to allow // access to all 1p google apps. DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder() .setVariantId("testVariant") .setBuildId(10) .build(); when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup)) .thenReturn(Futures.immediateFuture(true)); when(mockFileGroupManager.getFileGroup(eq(TEST_KEY), anyBoolean())) .thenReturn(immediateFuture(dataFileGroup)); when(mockFileGroupManager.verifyGroupDownloaded( eq(TEST_KEY), eq(dataFileGroup), anyBoolean(), any(), any())) .thenReturn(Futures.immediateFuture(GroupDownloadStatus.DOWNLOADED)); when(mockFileGroupsMetadata.getAllFreshGroups()) .thenReturn( immediateFuture(ImmutableList.of(GroupKeyAndGroup.create(TEST_KEY, dataFileGroup)))); assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue(); verify(mockFileGroupManager).addGroupForDownload(TEST_KEY, dataFileGroup); verify(mockFileGroupManager) .verifyGroupDownloaded(eq(TEST_KEY), eq(dataFileGroup), anyBoolean(), any(), any()); verify(mockLogger) .logEventSampled( MddClientEvent.Code.EVENT_CODE_UNSPECIFIED, TEST_GROUP, /* fileGroupVersionNumber= */ 0, /* buildId= */ dataFileGroup.getBuildId(), /* variantId= */ dataFileGroup.getVariantId()); assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue(); } @Test public void testAddGroupForDownload_throwsIOException() throws Exception { DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1); when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup)) .thenThrow(new IOException()); // assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isFalse(); ExecutionException exception = assertThrows( ExecutionException.class, () -> mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()); assertThat(exception).hasCauseThat().isInstanceOf(IOException.class); verify(mockFileGroupManager).addGroupForDownload(TEST_KEY, dataFileGroup); verify(mockSilentFeedback).send(isA(IOException.class), isA(String.class)); verifyNoInteractions(mockLogger); } @Test public void testAddGroupForDownload_throwsUninstalledAppException() throws Exception { DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1); when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup)) .thenThrow(new UninstalledAppException()); // assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isFalse(); ExecutionException exception = assertThrows( ExecutionException.class, () -> mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()); assertThat(exception).hasCauseThat().isInstanceOf(UninstalledAppException.class); verify(mockFileGroupManager).addGroupForDownload(TEST_KEY, dataFileGroup); verifyNoInteractions(mockLogger); } @Test public void testAddGroupForDownload_throwsExpiredFileGroupException() throws Exception { DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1); when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup)) .thenThrow(new ExpiredFileGroupException()); // assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isFalse(); ExecutionException exception = assertThrows( ExecutionException.class, () -> mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()); assertThat(exception).hasCauseThat().isInstanceOf(ExpiredFileGroupException.class); verify(mockFileGroupManager).addGroupForDownload(TEST_KEY, dataFileGroup); verifyNoInteractions(mockLogger); } @Test public void testAddGroupForDownload_multipleCallsSameGroup() throws Exception { DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1); when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup)) .thenReturn(Futures.immediateFuture(true), Futures.immediateFuture(false)); when(mockFileGroupManager.getFileGroup(eq(TEST_KEY), anyBoolean())) .thenReturn(immediateFuture(dataFileGroup)); when(mockFileGroupManager.verifyGroupDownloaded( eq(TEST_KEY), eq(dataFileGroup), anyBoolean(), any(), any())) .thenReturn(Futures.immediateFuture(GroupDownloadStatus.PENDING)); when(mockFileGroupsMetadata.getAllFreshGroups()) .thenReturn( immediateFuture(ImmutableList.of(GroupKeyAndGroup.create(TEST_KEY, dataFileGroup)))); assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue(); assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue(); verify(mockFileGroupManager, times(2)).addGroupForDownload(TEST_KEY, dataFileGroup); verify(mockFileGroupManager, times(1)) .verifyGroupDownloaded(eq(TEST_KEY), eq(dataFileGroup), anyBoolean(), any(), any()); verifyNoInteractions(mockExpirationHandler); verifyNoInteractions(mockLogger); } @Test public void testAddGroupForDownload_isValidGroup() throws Exception { DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder() .setGroupName("") .setVariantId("testVariant") .setBuildId(10) .build(); assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isFalse(); verifyNoInteractions(mockFileGroupManager); verify(mockLogger) .logEventSampled( MddClientEvent.Code.EVENT_CODE_UNSPECIFIED, "", /* fileGroupVersionNumber= */ 0, /* buildId= */ dataFileGroup.getBuildId(), /* variantId= */ dataFileGroup.getVariantId()); } @Test public void testAddGroupForDownload_noChecksum() throws Exception { DataFileGroupInternal.Builder fileGroupBuilder = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder(); DataFileGroupInternal dataFileGroup = fileGroupBuilder .setFile( 0, fileGroupBuilder.getFile(0).toBuilder() .setChecksumType(ChecksumType.NONE) .setChecksum("")) .build(); ArgumentCaptor dataFileGroupCaptor = ArgumentCaptor.forClass(DataFileGroupInternal.class); when(mockFileGroupManager.addGroupForDownload(eq(TEST_KEY), dataFileGroupCaptor.capture())) .thenReturn(Futures.immediateFuture(true)); when(mockFileGroupManager.getFileGroup(eq(TEST_KEY), anyBoolean())) .thenReturn(Futures.immediateFuture(dataFileGroup)); when(mockFileGroupManager.verifyGroupDownloaded( eq(TEST_KEY), eq(dataFileGroup), anyBoolean(), any(), any())) .thenReturn(Futures.immediateFuture(GroupDownloadStatus.PENDING)); when(mockFileGroupsMetadata.getAllFreshGroups()) .thenReturn( immediateFuture(ImmutableList.of(GroupKeyAndGroup.create(TEST_KEY, dataFileGroup)))); assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue(); verifyNoInteractions(mockLogger); DataFileGroupInternal capturedDataFileGroup = dataFileGroupCaptor.getValue(); assertThat(capturedDataFileGroup.getFileCount()).isEqualTo(1); DataFile dataFile = capturedDataFileGroup.getFile(0); // Checksum of the Url. assertThat(dataFile.getChecksum()).isEqualTo("0d79849a839d83fbc53e3bfe794ec38a305b7220"); } @Test public void testAddGroupForDownload_noChecksumWithZipTransform() throws Exception { DataFileGroupInternal.Builder fileGroupBuilder = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder(); DataFileGroupInternal dataFileGroup = fileGroupBuilder .setFile( 0, fileGroupBuilder.getFile(0).toBuilder() .setChecksumType(ChecksumType.NONE) .setChecksum("") .setDownloadedFileChecksum("") .setDownloadTransforms( Transforms.newBuilder() .addTransform( Transform.newBuilder() .setZip(ZipTransform.newBuilder().setTarget("*"))))) .build(); ArgumentCaptor dataFileGroupCaptor = ArgumentCaptor.forClass(DataFileGroupInternal.class); when(mockFileGroupManager.addGroupForDownload(eq(TEST_KEY), dataFileGroupCaptor.capture())) .thenReturn(Futures.immediateFuture(true)); when(mockFileGroupManager.getFileGroup(eq(TEST_KEY), anyBoolean())) .thenReturn(immediateFuture(dataFileGroup)); when(mockFileGroupManager.verifyGroupDownloaded( eq(TEST_KEY), eq(dataFileGroup), anyBoolean(), any(), any())) .thenReturn(Futures.immediateFuture(GroupDownloadStatus.PENDING)); when(mockFileGroupsMetadata.getAllFreshGroups()) .thenReturn( immediateFuture(ImmutableList.of(GroupKeyAndGroup.create(TEST_KEY, dataFileGroup)))); assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue(); verifyNoInteractions(mockLogger); DataFileGroupInternal capturedDataFileGroup = dataFileGroupCaptor.getValue(); assertThat(capturedDataFileGroup.getFileCount()).isEqualTo(1); DataFile dataFile = capturedDataFileGroup.getFile(0); // Checksum of url is propagated to downloaded file checksum if data file has zip transform. assertThat(dataFile.getChecksum()).isEmpty(); assertThat(dataFile.getDownloadedFileChecksum()) .isEqualTo("0d79849a839d83fbc53e3bfe794ec38a305b7220"); } @Test public void testAddGroupForDownload_noChecksumAndNotSetChecksumType() throws Exception { DataFileGroupInternal.Builder fileGroupBuilder = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder(); // Not setting ChecksumType.NONE DataFileGroupInternal dataFileGroup = fileGroupBuilder .setFile(0, fileGroupBuilder.getFile(0).toBuilder().setChecksum("")) .build(); assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isFalse(); verify(mockLogger) .logEventSampled( MddClientEvent.Code.EVENT_CODE_UNSPECIFIED, TEST_GROUP, /* fileGroupVersionNumber= */ 0, /* buildId= */ 0, /* variantId= */ ""); verifyNoInteractions(mockFileGroupManager); } @Test public void testAddGroupForDownload_sideloadedFile_onlyWhenSideloadingIsEnabled() throws Exception { // Create sideloaded group DataFileGroupInternal sideloadedGroup = DataFileGroupInternal.newBuilder() .setGroupName(TEST_GROUP) .addFile( DataFile.newBuilder() .setFileId("sideloaded_file") .setUrlToDownload("file:/test") .setChecksumType(DataFile.ChecksumType.NONE) .build()) .build(); when(mockFileGroupManager.addGroupForDownload(eq(TEST_KEY), any())) .thenReturn(Futures.immediateFuture(true)); when(mockFileGroupManager.getFileGroup(eq(TEST_KEY), anyBoolean())) .thenReturn(immediateFuture(sideloadedGroup)); when(mockFileGroupManager.verifyGroupDownloaded( eq(TEST_KEY), eq(sideloadedGroup), anyBoolean(), any(), any())) .thenReturn(Futures.immediateFuture(GroupDownloadStatus.PENDING)); when(mockFileGroupsMetadata.getAllFreshGroups()) .thenReturn( immediateFuture(ImmutableList.of(GroupKeyAndGroup.create(TEST_KEY, sideloadedGroup)))); { // Force sideloading off flags.enableSideloading = Optional.of(false); assertThat(mddManager.addGroupForDownload(TEST_KEY, sideloadedGroup).get()).isFalse(); } { // Force sideloading on flags.enableSideloading = Optional.of(true); assertThat(mddManager.addGroupForDownload(TEST_KEY, sideloadedGroup).get()).isTrue(); } } @Test public void testRemoveFileGroup() throws Exception { GroupKey groupKey = GroupKey.newBuilder() .setGroupName(TEST_GROUP) .setOwnerPackage(context.getPackageName()) .build(); when(mockFileGroupManager.removeFileGroup(eq(groupKey), eq(false))) .thenReturn(Futures.immediateFuture(null /* Void */)); mddManager.removeFileGroup(groupKey, /* pendingOnly= */ false).get(); verify(mockFileGroupManager).removeFileGroup(groupKey, /* pendingOnly= */ false); verifyNoMoreInteractions(mockFileGroupManager); verifyNoInteractions(mockLogger); } @Test public void testRemoveFileGroup_onFailure() throws Exception { GroupKey groupKey = GroupKey.newBuilder() .setGroupName(TEST_GROUP) .setOwnerPackage(context.getPackageName()) .build(); doThrow(new IOException()) .when(mockFileGroupManager) .removeFileGroup(groupKey, /* pendingOnly= */ false); ExecutionException ex = assertThrows( ExecutionException.class, mddManager.removeFileGroup(groupKey, /* pendingOnly= */ false)::get); assertThat(ex).hasCauseThat().isInstanceOf(IOException.class); verify(mockFileGroupManager).removeFileGroup(groupKey, /* pendingOnly= */ false); verifyNoMoreInteractions(mockFileGroupManager); verifyNoInteractions(mockLogger); } @Test public void testRemoveFileGroups() throws Exception { GroupKey groupKey1 = GroupKey.newBuilder() .setGroupName(TEST_GROUP) .setOwnerPackage(context.getPackageName()) .build(); GroupKey groupKey2 = GroupKey.newBuilder() .setGroupName(TEST_GROUP + "_2") .setOwnerPackage(context.getPackageName()) .build(); when(mockFileGroupManager.removeFileGroups(groupKeyListCaptor.capture())) .thenReturn(Futures.immediateVoidFuture()); mddManager.removeFileGroups(ImmutableList.of(groupKey1, groupKey2)).get(); verify(mockFileGroupManager).removeFileGroups(anyList()); List groupKeyListCapture = groupKeyListCaptor.getValue(); assertThat(groupKeyListCapture).hasSize(2); assertThat(groupKeyListCapture).contains(groupKey1); assertThat(groupKeyListCapture).contains(groupKey2); } @Test public void testRemoveFileGroups_onFailure() throws Exception { GroupKey groupKey1 = GroupKey.newBuilder() .setGroupName(TEST_GROUP) .setOwnerPackage(context.getPackageName()) .build(); GroupKey groupKey2 = GroupKey.newBuilder() .setGroupName(TEST_GROUP + "_2") .setOwnerPackage(context.getPackageName()) .build(); when(mockFileGroupManager.removeFileGroups(groupKeyListCaptor.capture())) .thenReturn(Futures.immediateFailedFuture(new Exception("Test failure"))); ExecutionException ex = assertThrows( ExecutionException.class, () -> mddManager.removeFileGroups(ImmutableList.of(groupKey1, groupKey2)).get()); assertThat(ex).hasMessageThat().contains("Test failure"); verify(mockFileGroupManager).removeFileGroups(anyList()); List groupKeyListCapture = groupKeyListCaptor.getValue(); assertThat(groupKeyListCapture).hasSize(2); assertThat(groupKeyListCapture).contains(groupKey1); assertThat(groupKeyListCapture).contains(groupKey2); } @Test public void testGetDownloadedGroup() throws Exception { DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1); when(mockFileGroupManager.getFileGroup(TEST_KEY, true)) .thenReturn(Futures.immediateFuture(dataFileGroup)); DataFileGroupInternal completedDataFileGroup = mddManager.getFileGroup(TEST_KEY, true).get(); MddTestUtil.assertMessageEquals(dataFileGroup, completedDataFileGroup); verifyNoInteractions(mockLogger); } @Test public void testGetDataFileUri() throws Exception { DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2); when(mockFileGroupManager.getOnDeviceUris(dataFileGroup)) .thenReturn( Futures.immediateFuture( ImmutableMap.of( dataFileGroup.getFile(0), fileUri1, dataFileGroup.getFile(1), fileUri2))); assertThat( mddManager .getDataFileUri( dataFileGroup.getFile(0), dataFileGroup, /* verifyIsolatedStructure= */ true) .get()) .isEqualTo(fileUri1); assertThat( mddManager .getDataFileUri( dataFileGroup.getFile(1), dataFileGroup, /* verifyIsolatedStructure= */ true) .get()) .isEqualTo(fileUri2); } @Test public void testGetDataFileUri_readTransform() throws Exception { DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2); Transforms compressTransform = Transforms.newBuilder() .addTransform( Transform.newBuilder().setCompress(CompressTransform.getDefaultInstance())) .build(); dataFileGroup = dataFileGroup.toBuilder() .setFile(0, dataFileGroup.getFile(0).toBuilder().setReadTransforms(compressTransform)) .build(); when(mockFileGroupManager.getOnDeviceUris(dataFileGroup)) .thenReturn( Futures.immediateFuture( ImmutableMap.of( dataFileGroup.getFile(0), fileUri1, dataFileGroup.getFile(1), fileUri2))); assertThat( mddManager .getDataFileUri( dataFileGroup.getFile(0), dataFileGroup, /* verifyIsolatedStructure= */ true) .get()) .isEqualTo(fileUri1.buildUpon().encodedFragment("transform=compress").build()); assertThat( mddManager .getDataFileUri( dataFileGroup.getFile(1), dataFileGroup, /* verifyIsolatedStructure= */ true) .get()) .isEqualTo(fileUri2); } @Test public void testGetDataFileUri_relativeFilePaths() throws Exception { DataFile relativePathFile = MddTestUtil.createRelativePathDataFile("file", 1, "test"); DataFileGroupInternal testFileGroup = DataFileGroupInternal.newBuilder() .setGroupName(TEST_GROUP) .setPreserveFilenamesAndIsolateFiles(true) .addFile(relativePathFile) .build(); Uri symlinkedUri = FileGroupUtil.getIsolatedFileUri( context, Optional.absent(), relativePathFile, testFileGroup); when(mockFileGroupManager.getOnDeviceUris(testFileGroup)) .thenReturn(Futures.immediateFuture(ImmutableMap.of(testFileGroup.getFile(0), fileUri1))); when(mockFileGroupManager.getIsolatedFileUris(testFileGroup)) .thenReturn(ImmutableMap.of(testFileGroup.getFile(0), symlinkedUri)); when(mockFileGroupManager.verifyIsolatedFileUris(any(), any())) .thenReturn(ImmutableMap.of(testFileGroup.getFile(0), symlinkedUri)); assertThat( mddManager .getDataFileUri( relativePathFile, testFileGroup, /* verifyIsolatedStructure= */ true) .get()) .isEqualTo(symlinkedUri); } @Test public void testGetDataFileUri_whenSymlinkRequiredButNotPresent_returnsNull() throws Exception { DataFile relativePathFile = MddTestUtil.createRelativePathDataFile("file", 1, "test"); DataFileGroupInternal testFileGroup = DataFileGroupInternal.newBuilder() .setGroupName(TEST_GROUP) .setPreserveFilenamesAndIsolateFiles(true) .addFile(relativePathFile) .build(); when(mockFileGroupManager.getOnDeviceUris(testFileGroup)) .thenReturn(Futures.immediateFuture(ImmutableMap.of(testFileGroup.getFile(0), fileUri1))); when(mockFileGroupManager.getIsolatedFileUris(testFileGroup)).thenReturn(ImmutableMap.of()); when(mockFileGroupManager.verifyIsolatedFileUris(any(), any())).thenReturn(ImmutableMap.of()); assertThat( mddManager .getDataFileUri( relativePathFile, testFileGroup, /* verifyIsolatedStructure= */ true) .get()) .isNull(); } @Test public void testImportFiles_failed() throws Exception { ImmutableList updatedDataFileList = ImmutableList.of( MddTestUtil.createDataFile("inline-file", 0).toBuilder() .setUrlToDownload("inlinefile:sha1:abcdef") .build()); ImmutableMap inlineFileMap = ImmutableMap.of( "inline-file", FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT"))); when(mockFileGroupManager.importFilesIntoFileGroup( eq(TEST_KEY), anyLong(), any(), any(), any(), any(), any())) .thenReturn(Futures.immediateFailedFuture(new Exception("Test failure"))); ExecutionException ex = assertThrows( ExecutionException.class, () -> mddManager .importFiles( TEST_KEY, 1, "testvariant", updatedDataFileList, inlineFileMap, Optional.absent(), noCustomValidation()) .get()); assertThat(ex).hasMessageThat().contains("Test failure"); verify(mockFileGroupManager) .importFilesIntoFileGroup( eq(TEST_KEY), anyLong(), any(), eq(updatedDataFileList), eq(inlineFileMap), any(), any()); } @Test public void testImportFiles_succeeds() throws Exception { ImmutableList updatedDataFileList = ImmutableList.of( MddTestUtil.createDataFile("inline-file", 0).toBuilder() .setUrlToDownload("inlinefile:sha1:abcdef") .build()); ImmutableMap inlineFileMap = ImmutableMap.of( "inline-file", FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT"))); when(mockFileGroupManager.importFilesIntoFileGroup( eq(TEST_KEY), anyLong(), any(), any(), any(), any(), any())) .thenReturn(immediateVoidFuture()); mddManager .importFiles( TEST_KEY, 1, "testvariant", updatedDataFileList, inlineFileMap, Optional.absent(), noCustomValidation()) .get(); verify(mockFileGroupManager) .importFilesIntoFileGroup( eq(TEST_KEY), anyLong(), any(), eq(updatedDataFileList), eq(inlineFileMap), any(), any()); } @Test public void testDownloadPendingGroup_failed() { when(mockFileGroupManager.downloadFileGroup(eq(TEST_KEY), isNull(), any())) .thenReturn( Futures.immediateFailedFuture( DownloadException.builder() .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR) .setMessage("Fail") .build())); ListenableFuture downloadFuture = mddManager.downloadFileGroup(TEST_KEY, Optional.absent(), noCustomValidation()); assertThrows(ExecutionException.class, downloadFuture::get); DownloadException unused = LabsFutures.getFailureCauseAs(downloadFuture, DownloadException.class); verify(mockFileGroupManager).downloadFileGroup(eq(TEST_KEY), isNull(), any()); } @Test public void testDownloadPendingGroup_downloadCondition_absent() throws Exception { DataFileGroupInternal pendingGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1); when(mockFileGroupManager.downloadFileGroup(eq(TEST_KEY), isNull(), any())) .thenReturn(Futures.immediateFuture(pendingGroup)); assertThat( mddManager.downloadFileGroup(TEST_KEY, Optional.absent(), noCustomValidation()).get()) .isEqualTo(pendingGroup); verify(mockFileGroupManager).downloadFileGroup(eq(TEST_KEY), isNull(), any()); } @Test public void testDownloadPendingGroup_downloadCondition_present() throws Exception { DataFileGroupInternal pendingGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1); Optional downloadConditionsOptional = Optional.of( DownloadConditions.newBuilder() .setDeviceNetworkPolicy(DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK) .setDeviceStoragePolicy(DeviceStoragePolicy.BLOCK_DOWNLOAD_IN_LOW_STORAGE) .build()); when(mockFileGroupManager.downloadFileGroup( eq(TEST_KEY), eq(downloadConditionsOptional.get()), any())) .thenReturn(Futures.immediateFuture(pendingGroup)); assertThat( mddManager .downloadFileGroup(TEST_KEY, downloadConditionsOptional, noCustomValidation()) .get()) .isEqualTo(pendingGroup); verify(mockFileGroupManager) .downloadFileGroup(eq(TEST_KEY), eq(downloadConditionsOptional.get()), any()); } @Test public void testDownloadAllPendingGroups() throws Exception { when(mockFileGroupManager.scheduleAllPendingGroupsForDownload(eq(true), any())) .thenReturn(Futures.immediateFuture(null)); mddManager.downloadAllPendingGroups(true, noCustomValidation()).get(); verify(mockLogger).logEventSampled(MddClientEvent.Code.EVENT_CODE_UNSPECIFIED); verify(mockFileGroupManager).scheduleAllPendingGroupsForDownload(eq(true), any()); verifyNoMoreInteractions(mockLogger); } @Test public void testVerifyPendingGroups() throws Exception { when(mockFileGroupManager.verifyAllPendingGroupsDownloaded(any())) .thenReturn(Futures.immediateFuture(null)); mddManager.verifyAllPendingGroups(noCustomValidation()).get(); verify(mockFileGroupManager).verifyAllPendingGroupsDownloaded(any()); verify(mockLogger).logEventSampled(MddClientEvent.Code.EVENT_CODE_UNSPECIFIED); verifyNoMoreInteractions(mockLogger); } @Test public void testMaintenance_mddFileExpiration() throws Exception { assumeTrue(flags.mddEnableGarbageCollection()); setupMaintenanceTasks(); mddManager.maintenance().get(); verify(mockFileGroupManager).deleteUninstalledAppGroups(); verify(mockExpirationHandler).updateExpiration(); verify(mockFileGroupStatsLogger).log(DEFAULT_DAYS_SINCE_LAST_LOG); verify(mockLogger).logEventSampled(MddClientEvent.Code.EVENT_CODE_UNSPECIFIED); } @Test public void testMaintenance_gcFlagControlsGcDuringMaintenance() throws Exception { setupMaintenanceTasks(); flags.mddEnableGarbageCollection = Optional.of(false); mddManager.maintenance().get(); verify(mockExpirationHandler, never()).updateExpiration(); } @Test public void testMaintenance_logStorage() throws Exception { setupMaintenanceTasks(); mddManager.maintenance().get(); verify(mockStorageLogger).logStorageStats(DEFAULT_DAYS_SINCE_LAST_LOG); } @Test public void testMaintenance_logNetwork() throws Exception { setupMaintenanceTasks(); mddManager.maintenance().get(); verify(mockNetworkLogger).log(); } @Test public void maintenance_triggerSync_absentSpe() throws Exception { mddManager = new MobileDataDownloadManager( context, mockLogger, mockSharedFileManager, mockSharedFilesMetadata, mockFileGroupManager, mockFileGroupsMetadata, mockExpirationHandler, mockSilentFeedback, mockStorageLogger, mockFileGroupStatsLogger, mockNetworkLogger, Optional.absent(), CONTROL_EXECUTOR, flags, loggingStateStore, downloadStageManager); setupMaintenanceTasks(); mddManager.maintenance().get(); // With absent SPE, no triggerSync was called. verify(mockFileGroupManager, never()).triggerSyncAllPendingGroups(); } @Test public void testMaintenance_deleteRemovedAccountGroups() throws Exception { setupMaintenanceTasks(); flags.mddDeleteGroupsRemovedAccounts = Optional.of(true); mddManager.maintenance().get(); verify(mockFileGroupManager).deleteRemovedAccountGroups(); } void setupMaintenanceTasks() { flags.enableDaysSinceLastMaintenanceTracking = Optional.of(true); when(mockStorageLogger.logStorageStats(DEFAULT_DAYS_SINCE_LAST_LOG)) .thenReturn(Futures.immediateVoidFuture()); when(mockExpirationHandler.updateExpiration()).thenReturn(Futures.immediateVoidFuture()); when(mockFileGroupStatsLogger.log(DEFAULT_DAYS_SINCE_LAST_LOG)) .thenReturn(Futures.immediateVoidFuture()); when(mockNetworkLogger.log()).thenReturn(Futures.immediateVoidFuture()); when(mockFileGroupManager.logAndDeleteForMissingSharedFiles()) .thenReturn(Futures.immediateVoidFuture()); when(mockFileGroupManager.deleteUninstalledAppGroups()) .thenReturn(Futures.immediateVoidFuture()); when(mockFileGroupManager.deleteRemovedAccountGroups()) .thenReturn(Futures.immediateVoidFuture()); when(mockFileGroupManager.triggerSyncAllPendingGroups()).thenReturn(immediateVoidFuture()); when(mockFileGroupManager.verifyAndAttemptToRepairIsolatedFiles()) .thenReturn(immediateVoidFuture()); } @Test public void testRemoveExpiredGroupsAndFiles() throws Exception { setupMaintenanceTasks(); mddManager.removeExpiredGroupsAndFiles().get(); verify(mockExpirationHandler).updateExpiration(); } @Test public void testClear() throws Exception { mddManager.clear().get(); verify(mockSharedFileManager).cancelDownloadAndClear(); verifyNoInteractions(mockLogger); } @Test public void testCheckResetTrigger_resetTrigger_noIncrement() throws Exception { setSavedResetValue(1); flags.mddResetTrigger = Optional.of(1); mddManager.checkResetTrigger().get(); verify(mockSharedFileManager, never()).clear(); verifyNoInteractions(mockLogger); // saved reset value should not have changed checkSavedResetValue(1); verifyNoInteractions(mockLogger); } @Test public void testCheckResetTrigger_resetTrigger_singleIncrement() throws Exception { setSavedResetValue(1); flags.mddResetTrigger = Optional.of(2); mddManager.checkResetTrigger().get(); verify(mockSharedFileManager).cancelDownloadAndClear(); verify(mockLogger).logEventSampled(MddClientEvent.Code.EVENT_CODE_UNSPECIFIED); // saved reset value should be set to 2 checkSavedResetValue(2); verifyNoMoreInteractions(mockLogger); } @Test public void testCheckResetTrigger_resetTrigger_singleIncrementMultipleChecks() throws Exception { setSavedResetValue(1); flags.mddResetTrigger = Optional.of(2); mddManager.checkResetTrigger().get(); // The second check should have no effect - clear should only be called once. mddManager.checkResetTrigger().get(); verify(mockSharedFileManager).cancelDownloadAndClear(); verify(mockLogger).logEventSampled(MddClientEvent.Code.EVENT_CODE_UNSPECIFIED); // saved reset value should be set to 2 checkSavedResetValue(2); verifyNoMoreInteractions(mockLogger); } @Test public void testCheckResetTrigger_resetTrigger_multipleIncrementMultipleChecks() throws Exception { setSavedResetValue(1); flags.mddResetTrigger = Optional.of(2); mddManager.checkResetTrigger().get(); flags.mddResetTrigger = Optional.of(3); mddManager.checkResetTrigger().get(); verify(mockSharedFileManager, times(2)).cancelDownloadAndClear(); verify(mockLogger, times(2)).logEventSampled(MddClientEvent.Code.EVENT_CODE_UNSPECIFIED); // saved reset value should be set to 2 checkSavedResetValue(3); verifyNoMoreInteractions(mockLogger); } @Test public void testClear_resetsExperimentIds() throws Exception { flags.enableDownloadStageExperimentIdPropagation = Optional.of(true); long buildId = 999L; int experimentId = 12345; DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder() .setBuildId(buildId) .build(); when(mockFileGroupsMetadata.getAllFreshGroups()) .thenReturn( immediateFuture( ImmutableList.of( GroupKeyAndGroup.create( GroupKey.newBuilder().setGroupName(TEST_GROUP).build(), dataFileGroup)))); when(mockFileGroupsMetadata.getAllStaleGroups()) .thenReturn(immediateFuture(ImmutableList.of())); mddManager.clear().get(); InOrder inOrder = inOrder(mockFileGroupsMetadata); inOrder.verify(mockFileGroupsMetadata).getAllFreshGroups(); inOrder.verify(mockFileGroupsMetadata).clear(); } private void setMigrationState(String key, boolean value) { SharedPreferences sharedPreferences = SharedPreferencesUtil.getSharedPreferences( context, MobileDataDownloadManager.MDD_MANAGER_METADATA, Optional.absent()); sharedPreferences.edit().putBoolean(key, value).commit(); } private void setSavedResetValue(int value) { SharedPreferences prefs = SharedPreferencesUtil.getSharedPreferences( context, MobileDataDownloadManager.MDD_MANAGER_METADATA, Optional.absent()); SharedPreferences.Editor editor = prefs.edit(); editor.putInt(MobileDataDownloadManager.RESET_TRIGGER, value); editor.commit(); } private void checkSavedResetValue(int expected) { SharedPreferences prefs = SharedPreferencesUtil.getSharedPreferences( context, MobileDataDownloadManager.MDD_MANAGER_METADATA, Optional.absent()); assertThat(prefs.getInt(MobileDataDownloadManager.RESET_TRIGGER, expected - 1)) .isEqualTo(expected); } private AsyncFunction noCustomValidation() { return unused -> Futures.immediateFuture(true); } }