diff options
-rw-r--r-- | core/build.gradle | 10 | ||||
-rw-r--r-- | core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java | 15 | ||||
-rw-r--r-- | core/src/test/assets/local-feed1/track1.mp3 | bin | 0 -> 43341 bytes | |||
-rw-r--r-- | core/src/test/assets/local-feed2/folder.png | bin | 0 -> 1589 bytes | |||
-rw-r--r-- | core/src/test/assets/local-feed2/track1.mp3 | bin | 0 -> 43341 bytes | |||
-rw-r--r-- | core/src/test/assets/local-feed2/track2.mp3 | bin | 0 -> 43497 bytes | |||
-rw-r--r-- | core/src/test/java/androidx/documentfile/provider/AssetsDocumentFile.java | 138 | ||||
-rw-r--r-- | core/src/test/java/de/danoeh/antennapod/core/feed/LocalFeedUpdaterTest.java | 208 | ||||
-rw-r--r-- | core/src/test/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculatorTest.java | 6 |
9 files changed, 373 insertions, 4 deletions
diff --git a/core/build.gradle b/core/build.gradle index fe130063e..96f0f7f04 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -52,6 +52,12 @@ android { dimension "market" } } + + testOptions { + unitTests { + includeAndroidResources = true + } + } } dependencies { @@ -94,7 +100,9 @@ dependencies { testImplementation "org.awaitility:awaitility:$awaitilityVersion" testImplementation 'junit:junit:4.13' - testImplementation 'org.mockito:mockito-core:1.10.19' + testImplementation 'org.mockito:mockito-inline:3.5.13' + testImplementation 'org.robolectric:robolectric:4.3.1' + testImplementation 'javax.inject:javax.inject:1' androidTestImplementation "com.jayway.android.robotium:robotium-solo:$robotiumSoloVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" androidTestImplementation "androidx.test:runner:$runnerVersion" diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java b/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java index 142763d75..4c594783a 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java @@ -358,6 +358,21 @@ public class PodDBAdapter { // do nothing } + /** + * <p>Resets all database connections to ensure new database connections for + * the next test case. Call method only for unit tests.</p> + * + * <p>That's a workaround for a Robolectric issue in ShadowSQLiteConnection + * that leads to an error <tt>IllegalStateException: Illegal connection + * pointer</tt> if several threads try to use the same database connection. + * For more information see + * <a href="https://github.com/robolectric/robolectric/issues/1890">robolectric/robolectric#1890</a>.</p> + */ + public static void tearDownTests() { + db = null; + SingletonHolder.dbHelper.close(); + } + public static boolean deleteDatabase() { PodDBAdapter adapter = getInstance(); adapter.open(); diff --git a/core/src/test/assets/local-feed1/track1.mp3 b/core/src/test/assets/local-feed1/track1.mp3 Binary files differnew file mode 100644 index 000000000..b1f993c3f --- /dev/null +++ b/core/src/test/assets/local-feed1/track1.mp3 diff --git a/core/src/test/assets/local-feed2/folder.png b/core/src/test/assets/local-feed2/folder.png Binary files differnew file mode 100644 index 000000000..9e522a986 --- /dev/null +++ b/core/src/test/assets/local-feed2/folder.png diff --git a/core/src/test/assets/local-feed2/track1.mp3 b/core/src/test/assets/local-feed2/track1.mp3 Binary files differnew file mode 100644 index 000000000..b1f993c3f --- /dev/null +++ b/core/src/test/assets/local-feed2/track1.mp3 diff --git a/core/src/test/assets/local-feed2/track2.mp3 b/core/src/test/assets/local-feed2/track2.mp3 Binary files differnew file mode 100644 index 000000000..310cddd6b --- /dev/null +++ b/core/src/test/assets/local-feed2/track2.mp3 diff --git a/core/src/test/java/androidx/documentfile/provider/AssetsDocumentFile.java b/core/src/test/java/androidx/documentfile/provider/AssetsDocumentFile.java new file mode 100644 index 000000000..8a8205c10 --- /dev/null +++ b/core/src/test/java/androidx/documentfile/provider/AssetsDocumentFile.java @@ -0,0 +1,138 @@ +package androidx.documentfile.provider; + +import android.content.res.AssetManager; +import android.net.Uri; +import android.webkit.MimeTypeMap; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; + +/** + * <p>Wraps an Android assets file or folder as a DocumentFile object.</p> + * + * <p>This is used to emulate access to the external storage.</p> + */ +public class AssetsDocumentFile extends DocumentFile { + + /** + * Absolute file path in the assets folder. + */ + @NonNull + private final String fileName; + + @NonNull + private final AssetManager assetManager; + + public AssetsDocumentFile(@NonNull String fileName, @NonNull AssetManager assetManager) { + super(null); + this.fileName = fileName; + this.assetManager = assetManager; + } + + @Nullable + @Override + public DocumentFile createFile(@NonNull String mimeType, @NonNull String displayName) { + return null; + } + + @Nullable + @Override + public DocumentFile createDirectory(@NonNull String displayName) { + return null; + } + + @NonNull + @Override + public Uri getUri() { + return Uri.parse(fileName); + } + + @Nullable + @Override + public String getName() { + int pos = fileName.indexOf('/'); + if (pos >= 0) { + return fileName.substring(pos + 1); + } else { + return fileName; + } + } + + @Nullable + @Override + public String getType() { + String extension = MimeTypeMap.getFileExtensionFromUrl(fileName); + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + } + + @Override + public boolean isDirectory() { + return false; + } + + @Override + public boolean isFile() { + return true; + } + + @Override + public boolean isVirtual() { + return false; + } + + @Override + public long lastModified() { + return 0; + } + + @Override + public long length() { + return 0; + } + + @Override + public boolean canRead() { + return true; + } + + @Override + public boolean canWrite() { + return false; + } + + @Override + public boolean delete() { + return false; + } + + @Override + public boolean exists() { + return true; + } + + @NonNull + @Override + public DocumentFile[] listFiles() { + try { + String[] files = assetManager.list(fileName); + if (files == null) { + return new DocumentFile[0]; + } + DocumentFile[] result = new DocumentFile[files.length]; + for (int i = 0; i < files.length; i++) { + String subFileName = fileName + '/' + files[i]; + result[i] = new AssetsDocumentFile(subFileName, assetManager); + } + return result; + } catch (IOException e) { + return new DocumentFile[0]; + } + } + + @Override + public boolean renameTo(@NonNull String displayName) { + return false; + } +} diff --git a/core/src/test/java/de/danoeh/antennapod/core/feed/LocalFeedUpdaterTest.java b/core/src/test/java/de/danoeh/antennapod/core/feed/LocalFeedUpdaterTest.java new file mode 100644 index 000000000..90bf59e92 --- /dev/null +++ b/core/src/test/java/de/danoeh/antennapod/core/feed/LocalFeedUpdaterTest.java @@ -0,0 +1,208 @@ +package de.danoeh.antennapod.core.feed; + +import android.app.Application; +import android.content.Context; +import android.media.MediaMetadataRetriever; +import android.webkit.MimeTypeMap; + +import androidx.annotation.NonNull; +import androidx.documentfile.provider.AssetsDocumentFile; +import androidx.documentfile.provider.DocumentFile; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowMediaMetadataRetriever; + +import java.io.IOException; +import java.util.List; + +import de.danoeh.antennapod.core.ApplicationCallbacks; +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.storage.PodDBAdapter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +/** + * Test local feeds handling in class LocalFeedUpdater. + */ +@RunWith(RobolectricTestRunner.class) +public class LocalFeedUpdaterTest { + + /** + * URL to locate the local feed media files on the external storage (SD card). + * The exact URL doesn't matter here as access to external storage is mocked + * (seems not to be supported by Robolectric). + */ + private static final String FEED_URL = + "content://com.android.externalstorage.documents/tree/primary%3ADownload%2Flocal-feed"; + private static final String LOCAL_FEED_DIR1 = "local-feed1"; + private static final String LOCAL_FEED_DIR2 = "local-feed2"; + + private Context context; + + @Before + public void setUp() throws Exception { + // Initialize environment + context = InstrumentationRegistry.getInstrumentation().getContext(); + UserPreferences.init(context); + + Application app = (Application) context; + ClientConfig.applicationCallbacks = mock(ApplicationCallbacks.class); + when(ClientConfig.applicationCallbacks.getApplicationInstance()).thenReturn(app); + + // Initialize database + PodDBAdapter.init(context); + PodDBAdapter.deleteDatabase(); + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + adapter.close(); + + mapDummyMetadata(LOCAL_FEED_DIR1); + mapDummyMetadata(LOCAL_FEED_DIR2); + shadowOf(MimeTypeMap.getSingleton()).addExtensionMimeTypMapping("mp3", "audio/mp3"); + } + + @After + public void tearDown() { + PodDBAdapter.tearDownTests(); + } + + /** + * Test adding a new local feed. + */ + @Test + public void testUpdateFeed_AddNewFeed() { + // check for empty database + List<Feed> feedListBefore = DBReader.getFeedList(); + assertTrue(feedListBefore.isEmpty()); + + callUpdateFeed(LOCAL_FEED_DIR2); + + // verify new feed in database + verifySingleFeedInDatabaseAndItemCount(2); + Feed feedAfter = verifySingleFeedInDatabase(); + assertEquals(FEED_URL, feedAfter.getDownload_url()); + } + + /** + * Test adding further items to an existing local feed. + */ + @Test + public void testUpdateFeed_AddMoreItems() { + // add local feed with 1 item (localFeedDir1) + callUpdateFeed(LOCAL_FEED_DIR1); + + // now add another item (by changing to local feed folder localFeedDir2) + callUpdateFeed(LOCAL_FEED_DIR2); + + verifySingleFeedInDatabaseAndItemCount(2); + } + + /** + * Test removing items from an existing local feed without a corresponding media file. + */ + @Test + public void testUpdateFeed_RemoveItems() { + // add local feed with 2 items (localFeedDir1) + callUpdateFeed(LOCAL_FEED_DIR2); + + // now remove an item (by changing to local feed folder localFeedDir1) + callUpdateFeed(LOCAL_FEED_DIR1); + + verifySingleFeedInDatabaseAndItemCount(1); + } + + /** + * Test feed icon defined in the local feed media folder. + */ + @Test + public void testUpdateFeed_FeedIconFromFolder() { + callUpdateFeed(LOCAL_FEED_DIR2); + + Feed feedAfter = verifySingleFeedInDatabase(); + assertTrue(feedAfter.getImageUrl().contains("local-feed2/folder.png")); + } + + /** + * Test default feed icon if there is no matching file in the local feed media folder. + */ + @Test + public void testUpdateFeed_FeedIconDefault() { + callUpdateFeed(LOCAL_FEED_DIR1); + + Feed feedAfter = verifySingleFeedInDatabase(); + String resourceEntryName = context.getResources().getResourceEntryName(R.raw.local_feed_default_icon); + assertTrue(feedAfter.getImageUrl().contains(resourceEntryName)); + } + + /** + * Fill ShadowMediaMetadataRetriever with dummy duration and title. + * + * @param localFeedDir assets local feed folder with media files + */ + private void mapDummyMetadata(@NonNull String localFeedDir) throws IOException { + String[] fileNames = context.getAssets().list(localFeedDir); + for (String fileName : fileNames) { + String path = localFeedDir + '/' + fileName; + ShadowMediaMetadataRetriever.addMetadata(path, + MediaMetadataRetriever.METADATA_KEY_DURATION, "10"); + ShadowMediaMetadataRetriever.addMetadata(path, + MediaMetadataRetriever.METADATA_KEY_TITLE, fileName); + } + + } + + /** + * Calls the method {@link LocalFeedUpdater#updateFeed(Feed, Context)} with + * the given local feed folder. + * + * @param localFeedDir assets local feed folder with media files + */ + private void callUpdateFeed(@NonNull String localFeedDir) { + DocumentFile documentFile = new AssetsDocumentFile(localFeedDir, context.getAssets()); + try (MockedStatic<DocumentFile> dfMock = Mockito.mockStatic(DocumentFile.class)) { + // mock external storage + dfMock.when(() -> DocumentFile.fromTreeUri(any(), any())).thenReturn(documentFile); + + // call method to test + Feed feed = new Feed(FEED_URL, null); + LocalFeedUpdater.updateFeed(feed, context); + } + } + + /** + * Verify that the database contains exactly one feed and return that feed. + */ + @NonNull + private static Feed verifySingleFeedInDatabase() { + List<Feed> feedListAfter = DBReader.getFeedList(); + assertEquals(1, feedListAfter.size()); + return feedListAfter.get(0); + } + + /** + * Verify that the database contains exactly one feed and the number of + * items in the feed. + * + * @param expectedItemCount expected number of items in the feed + */ + private static void verifySingleFeedInDatabaseAndItemCount(int expectedItemCount) { + Feed feed = verifySingleFeedInDatabase(); + List<FeedItem> feedItems = DBReader.getFeedItemList(feed); + assertEquals(expectedItemCount, feedItems.size()); + } +} diff --git a/core/src/test/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculatorTest.java b/core/src/test/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculatorTest.java index ed7d2fa75..6c5a9daf1 100644 --- a/core/src/test/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculatorTest.java +++ b/core/src/test/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculatorTest.java @@ -32,7 +32,7 @@ import static java.util.Collections.emptyList; import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.any; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.stub; +import static org.mockito.Mockito.when; public class ItemEnqueuePositionCalculatorTest { @@ -189,7 +189,7 @@ public class ItemEnqueuePositionCalculatorTest { // ItemEnqueuePositionCalculator calculator = new ItemEnqueuePositionCalculator(options); DownloadStateProvider stubDownloadStateProvider = mock(DownloadStateProvider.class); - stub(stubDownloadStateProvider.isDownloadingFile(any(FeedMedia.class))).toReturn(false); + when(stubDownloadStateProvider.isDownloadingFile(any(FeedMedia.class))).thenReturn(false); calculator.downloadStateProvider = stubDownloadStateProvider; // Setup initial data @@ -232,7 +232,7 @@ public class ItemEnqueuePositionCalculatorTest { private static FeedItem setAsDownloading(FeedItem item, DownloadStateProvider stubDownloadStateProvider, boolean isDownloading) { - stub(stubDownloadStateProvider.isDownloadingFile(item.getMedia())).toReturn(isDownloading); + when(stubDownloadStateProvider.isDownloadingFile(item.getMedia())).thenReturn(isDownloading); return item; } |