diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6ca00a29..6b0322bf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -75,12 +75,16 @@ ksp { arg("room.schemaLocation", "$projectDir/schemas") } -val integrationTestClassName = "com.subtracks.data.source.subsonic.SubsonicSourceIntegrationTest" +val integrationTestClasses = + listOf( + "com.subtracks.data.source.subsonic.SubsonicSourceIntegrationTest", + "com.subtracks.data.sync.SyncServiceIntegrationTest", + ) tasks.withType().configureEach { if (name.endsWith("UnitTest")) { filter { - excludeTestsMatching(integrationTestClassName) + integrationTestClasses.forEach { excludeTestsMatching(it) } } } } @@ -93,7 +97,7 @@ tasks.register("integrationTest") { testClassesDirs = files(provider { unitTest.get().testClassesDirs }) classpath = files(provider { unitTest.get().classpath }) filter { - includeTestsMatching(integrationTestClassName) + integrationTestClasses.forEach { includeTestsMatching(it) } } } diff --git a/app/src/integrationTest/kotlin/com/subtracks/data/sync/SyncServiceIntegrationTest.kt b/app/src/integrationTest/kotlin/com/subtracks/data/sync/SyncServiceIntegrationTest.kt new file mode 100644 index 00000000..300358c5 --- /dev/null +++ b/app/src/integrationTest/kotlin/com/subtracks/data/sync/SyncServiceIntegrationTest.kt @@ -0,0 +1,75 @@ +package com.subtracks.data.sync + +import android.content.Context +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.model.Source +import com.subtracks.data.source.subsonic.SubsonicClient +import com.subtracks.data.source.subsonic.SubsonicSource +import kotlinx.coroutines.runBlocking +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SyncServiceIntegrationTest { + @Test + fun syncMirrorsTheLibraryIntoRoomForEveryServer() = + runBlocking { + servers.forEach { server -> + val context = ApplicationProvider.getApplicationContext() + val db = + Room + .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .build() + try { + db.sourcesDao().upsertSource( + Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0), + ) + val source = + SubsonicSource( + id = 1, + client = + SubsonicClient( + baseUrl = server.baseUrl.toHttpUrl(), + username = server.username, + password = server.password, + useTokenAuth = false, + http = OkHttpClient(), + ), + ) + + SyncService(db, source).sync() + + val library = db.libraryDao() + assertEquals(server.name, 2, library.artistIds(1).size) + assertEquals(server.name, 3, library.albumIds(1).size) + assertEquals(server.name, 20, library.songIdsAfter(1, "", 1000).size) + assertEquals(server.name, 1, library.playlistIds(1).size) + } finally { + db.close() + } + } + } + + private data class Server( + val name: String, + val baseUrl: String, + val username: String, + val password: String, + ) + + private companion object { + val servers = + listOf( + Server("navidrome", "http://localhost:4533/", "admin", "password"), + Server("gonic", "http://localhost:4747/", "admin", "admin"), + ) + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt b/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt index fb5d8aa2..4c81b7d4 100644 --- a/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt +++ b/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt @@ -14,6 +14,7 @@ import com.subtracks.data.repo.DownloadRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch /** @@ -42,6 +43,10 @@ class DownloadNotifier( } } + internal fun close() { + scope.cancel() + } + private fun build(content: DownloadNotification): Notification = NotificationCompat .Builder(context, CHANNEL_ID) diff --git a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt index de7699f0..9cdb1289 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -78,7 +79,9 @@ class DownloadRepository( } fun close() { + val job = scope.coroutineContext[Job] scope.cancel() + runBlocking { job?.join() } } fun states(): StateFlow> = statesFlow diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt index af679ced..b527120d 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.xml.sax.Attributes +import java.io.IOException @OptIn(ExperimentalCoroutinesApi::class) class SubsonicSource( @@ -272,6 +273,8 @@ class SubsonicSource( ) { input -> SubsonicXml.containsEntity(input, "song") } } catch (_: SubsonicException) { false + } catch (_: IOException) { + false } emptyQuerySearchSupported = supported return supported diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt index 71afa02a..2900c114 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -159,8 +159,8 @@ class LibraryViewModel( fun sync() = syncManager.requestSync() } -private fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name +internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name -private fun ListQuery.artistSort(): ArtistSort = ArtistSort.entries.firstOrNull { it.name == sort } ?: ArtistSort.Name +internal fun ListQuery.artistSort(): ArtistSort = ArtistSort.entries.firstOrNull { it.name == sort } ?: ArtistSort.Name -private fun ListQuery.playlistSort(): PlaylistSort = PlaylistSort.entries.firstOrNull { it.name == sort } ?: PlaylistSort.Name +internal fun ListQuery.playlistSort(): PlaylistSort = PlaylistSort.entries.firstOrNull { it.name == sort } ?: PlaylistSort.Name diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt index 5069c2a9..84b5ac39 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -224,6 +224,8 @@ class LibraryDaoTest { } } + // These plan assertions depend on the query planner in the pinned androidx.sqlite:sqlite-bundled + // (see gradle/libs.versions.toml); re-check the expected index names when that engine is bumped. @Test fun albumOrdersAreIndexBacked() = runTest { @@ -442,7 +444,11 @@ class LibraryDaoTest { @Test fun searchRunsThroughTheTrigramIndex() = runTest { - val plan = plan("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME LIMIT 20 OFFSET 0") + val plan = + plan( + "SELECT * ${ALBUMS_FILTER.replace(":search", "'abc'")} " + + "ORDER BY $ALBUM_ORDER_BY_NAME LIMIT 20 OFFSET 0", + ) assertTrue(plan, plan.contains("album_search")) } diff --git a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt index a4dd61ba..05b8597e 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt @@ -87,7 +87,7 @@ class MigrationsTest { migrated.execSQL( "INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, " + "starred, songCount, frequentRank, recentRank) " + - "VALUES (1, 'al1', 'ar1', 'The Album', 'The Artist', 101, NULL, NULL, 1999, NULL, 1, NULL, NULL)", + "VALUES (1, 'al1', 'ar1', 'The Album', 'The Artist', 101, NULL, NULL, 1999, 555, 1, NULL, NULL)", ) migrated.execSQL( "INSERT INTO playlists (sourceId, id, name, comment, coverArt, songCount, created) " + @@ -97,9 +97,24 @@ class MigrationsTest { MIGRATIONS.sortedBy { it.startVersion }.forEach { migration -> migration.migrate(migrated) when (migration.endVersion) { - 5 -> migrated.execSQL("UPDATE playlists SET duration = 987654 WHERE id = 'pl1'") - 8 -> migrated.execSQL("UPDATE playlists SET changed = 123456 WHERE id = 'pl1'") - 11 -> migrated.execSQL("INSERT INTO discs (sourceId, albumId, disc, title) VALUES (1, 'al1', 2, 'The Disc')") + 5 -> { + migrated.execSQL("UPDATE playlists SET duration = 987654 WHERE id = 'pl1'") + } + + 8 -> { + migrated.execSQL("UPDATE playlists SET changed = 123456 WHERE id = 'pl1'") + } + + 11 -> { + migrated.execSQL("INSERT INTO discs (sourceId, albumId, disc, title) VALUES (1, 'al1', 2, 'The Disc')") + } + + 18 -> { + migrated.execSQL( + "INSERT INTO song_downloads (sourceId, songId, status, bytes, total) " + + "VALUES (1, 's1', 'Completed', 10, 20)", + ) + } } } @@ -107,8 +122,11 @@ class MigrationsTest { assertEquals(123456L, long(migrated, "SELECT changed FROM playlists WHERE id = 'pl1'")) assertEquals(987654L, long(migrated, "SELECT duration FROM playlists WHERE id = 'pl1'")) assertEquals("The Album", text(migrated, "SELECT name FROM albums WHERE id = 'al1'")) + assertEquals(555L, long(migrated, "SELECT starred FROM albums WHERE id = 'al1'")) assertEquals("The Artist", text(migrated, "SELECT name FROM artists WHERE id = 'ar1'")) assertEquals("The Disc", text(migrated, "SELECT title FROM discs WHERE albumId = 'al1' AND disc = 2")) + assertEquals("Completed", text(migrated, "SELECT status FROM song_downloads WHERE songId = 's1'")) + assertEquals(10L, long(migrated, "SELECT bytes FROM song_downloads WHERE songId = 's1'")) } } diff --git a/app/src/test/kotlin/com/subtracks/data/download/SystemDownloadEngineTest.kt b/app/src/test/kotlin/com/subtracks/data/download/SystemDownloadEngineTest.kt index ba4e90f4..61c05327 100644 --- a/app/src/test/kotlin/com/subtracks/data/download/SystemDownloadEngineTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/download/SystemDownloadEngineTest.kt @@ -1,10 +1,30 @@ package com.subtracks.data.download import android.app.DownloadManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +@RunWith(AndroidJUnit4::class) class SystemDownloadEngineTest { + private lateinit var engine: SystemDownloadEngine + private lateinit var downloadManager: DownloadManager + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + engine = SystemDownloadEngine(context) + downloadManager = context.getSystemService(DownloadManager::class.java) + } + @Test fun successfulRunningAndFailedStatusesAreMapped() { assertEquals(EngineStatus.Completed, engineStatus(DownloadManager.STATUS_SUCCESSFUL)) @@ -25,4 +45,66 @@ class SystemDownloadEngineTest { assertEquals("Storage is unavailable", failureMessage(DownloadManager.ERROR_DEVICE_NOT_FOUND)) assertEquals("Download failed", failureMessage(DownloadManager.ERROR_UNKNOWN)) } + + @Test + fun enqueuedRequestIsStoredAndTargetsTheSourceAndSongDirectory() { + val id = + engine.enqueue( + EngineRequest( + uri = "https://server.example/rest/stream?id=song-9", + path = "downloads/7/song-9", + title = "A Song", + ), + ) + + val downloads = shadowOf(downloadManager) + assertEquals(1, downloads.requestCount) + + val request = shadowOf(downloads.getRequest(id)) + assertEquals(id, request.id) + assertEquals("https://server.example/rest/stream?id=song-9", request.uri.toString()) + assertEquals("A Song", request.title.toString()) + + val destination = request.destination + assertNotNull(destination) + val path = destination!!.path!! + assertTrue("destination $path is not under external files", path.contains("/external-files/")) + assertTrue( + "destination $path is not under the source/song downloads directory", + path.endsWith("/Music/downloads/7/song-9"), + ) + } + + @Test + fun meteredPreferenceIsAppliedForBothValues() { + val allowed = + engine.enqueue( + EngineRequest( + uri = "https://server.example/a", + path = "downloads/1/a", + title = "a", + allowMetered = true, + ), + ) + val blocked = + engine.enqueue( + EngineRequest( + uri = "https://server.example/b", + path = "downloads/1/b", + title = "b", + allowMetered = false, + ), + ) + + val downloads = shadowOf(downloadManager) + assertEquals(2, downloads.requestCount) + + val allowedRequest = shadowOf(downloads.getRequest(allowed)) + assertTrue(allowedRequest.allowedOverMetered) + assertTrue(allowedRequest.allowedOverRoaming) + + val blockedRequest = shadowOf(downloads.getRequest(blocked)) + assertFalse(blockedRequest.allowedOverMetered) + assertFalse(blockedRequest.allowedOverRoaming) + } } diff --git a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt index caf783cd..e088a833 100644 --- a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt @@ -81,6 +81,25 @@ class UserPreferencesTest { file.delete() } + @Test + fun downloadQualityAndMeteredRoundTrip() = + runTest { + val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() } + val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file } + val prefs = UserPreferences(store) + + assertEquals(StreamQuality(), prefs.downloadQuality().first()) + assertEquals(false, prefs.downloadOverMetered().first()) + + prefs.setDownloadQuality(StreamQuality(192, "opus")) + prefs.setDownloadOverMetered(true) + + assertEquals(StreamQuality(192, "opus"), prefs.downloadQuality().first()) + assertEquals(true, prefs.downloadOverMetered().first()) + + file.delete() + } + @Test fun syncConcurrencyDefaultsAndClampsToAtLeastOne() = runTest { diff --git a/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt index 4d2a94df..1d18fe32 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt @@ -89,11 +89,15 @@ class LibraryRepositoryTest { fun starringUpdatesTheRowBeforeTheServerResponds() = runBlocking { withSource { server -> - server.enqueue(ok().setBodyDelay(10, TimeUnit.SECONDS)) + server.enqueue(ok().setBodyDelay(250, TimeUnit.MILLISECONDS)) val call = launch { repository.setStar(StarType.Song, "s1", true) } withTimeout(3_000) { while (starred() == null) delay(10) } call.cancel() + call.join() + + assertNotNull(starred()) + assertEquals(emptyList(), messages) } } diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt index 01906f84..5e22631a 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -8,6 +8,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.model.Album +import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueKind @@ -734,6 +735,121 @@ class QueueRepositoryTest { } } + @Test + fun upNextCombinedIndexOfCountsFromTheAnchorIntoTheBlock() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + + repository.addUpNext(snapshot, currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false) + + val updated = repository.snapshot() + assertEquals(1L, updated.anchorPlay) + assertEquals(1L, updated.upNextSize) + assertEquals(2L, repository.combinedIndexOf(updated, "s4")) + assertEquals(1L, repository.combinedContextIndexOf(updated, "s2")) + assertEquals(3L, repository.combinedContextIndexOf(updated, "s3")) + } + + @Test + fun combinedContextIndexOfTracksTheAnchorAsItMoves() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + + val before = repository.snapshot() + assertEquals(0L, before.anchorPlay) + assertEquals(3L, repository.combinedContextIndexOf(before, "s3")) + + repository.setUpNextAnchor(2) + + val after = repository.snapshot() + assertEquals(2L, after.anchorPlay) + assertEquals(2L, repository.combinedContextIndexOf(after, "s3")) + assertEquals(3L, repository.combinedIndexOf(after, "s4")) + } + + @Test + fun combinedIndexOfPrefersTheUpNextCopyOverTheContextCopy() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.songEntry(1, "s4"))) + repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + + val updated = repository.snapshot() + assertEquals(0L, repository.combinedContextIndexOf(updated, "s4")) + assertEquals(1L, repository.combinedIndexOf(updated, "s4")) + } + + @Test + fun sourceNameReadsTheAlbumPlaylistOrSongAlbum() = + runTest { + seedLibrary() + db.libraryDao().upsertPlaylists(listOf(playlist("p1", "Road Trip"))) + + assertEquals("First Album", repository.sourceName(QueueKind.Album, 1, "al1")) + assertEquals("Road Trip", repository.sourceName(QueueKind.Playlist, 1, "p1")) + assertEquals("Second Album", repository.sourceName(QueueKind.Song, 1, "s4")) + assertEquals(null, repository.sourceName(QueueKind.Song, 1, "ghost")) + } + + @Test + fun aReorderedAlbumTrackKeepsItsAlbumAsSourceName() = + runTest { + seedLibrary() + repository.replace(listOf(repository.albumEntry(1, "al1"))) + + repository.move(repository.snapshot(), from = 0, to = 2) + + val (entry, _) = repository.snapshot().locate(2)!! + assertEquals(QueueKind.Song, entry.kind) + assertEquals("s1", entry.refId) + assertEquals("First Album", repository.sourceName(entry.kind, entry.sourceId, entry.refId)) + } + + @Test + fun anAlbumSplitByRemovalIsStillLabeledByItsAlbum() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + + repository.removeAt(snapshot, 1) + + val (entry, _) = repository.snapshot().locate(1)!! + assertEquals(QueueKind.Album, entry.kind) + assertEquals("First Album", repository.sourceName(entry.kind, entry.sourceId, entry.refId)) + } + + @Test + fun queueModesRoundTrip() = + runTest { + seedLibrary() + repository.replace(listOf(repository.albumEntry(1, "al1"))) + assertEquals(QueueModes(shuffle = false, repeat = 0), repository.modes()) + + repository.setShuffle(4321L) + repository.setRepeat(2) + assertEquals(QueueModes(shuffle = true, repeat = 2), repository.modes()) + + repository.setShuffle(null) + repository.setRepeat(0) + assertEquals(QueueModes(shuffle = false, repeat = 0), repository.modes()) + } + + private fun playlist( + id: String, + name: String, + ) = Playlist( + sourceId = 1, + id = id, + name = name, + comment = null, + coverArt = null, + songCount = 0, + created = 0, + ) + private suspend fun seedLibrary() { db.sourcesDao().upsertSource( Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0), diff --git a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt index b123d6ea..55061434 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt @@ -167,6 +167,8 @@ class SourceRepositoryTest { runBlocking { withServer { server -> val dispatches = AtomicInteger() + val inFlight = AtomicInteger() + val maxInFlight = AtomicInteger() val secondStarted = CountDownLatch(1) val release = CountDownLatch(1) server.dispatcher = @@ -175,7 +177,10 @@ class SourceRepositoryTest { val url = request.requestUrl!! if (url.encodedPath != "/rest/getPlaylist.view") return MockResponse().setResponseCode(404) if (dispatches.incrementAndGet() == 2) secondStarted.countDown() + val current = inFlight.incrementAndGet() + maxInFlight.updateAndGet { maxOf(it, current) } release.await(5, TimeUnit.SECONDS) + inFlight.decrementAndGet() val id = url.queryParameter("id")!! return MockResponse().setBody( "" + @@ -199,6 +204,7 @@ class SourceRepositoryTest { assertFalse(secondStarted.await(500, TimeUnit.MILLISECONDS)) release.countDown() assertEquals(9, fetching.await().size) + assertEquals("only one fetch may be in flight at a time", 1, maxInFlight.get()) } } diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt index 7752ff14..39134436 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt @@ -5,11 +5,13 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Test +import java.security.MessageDigest class SubsonicClientTest { private lateinit var server: MockWebServer @@ -57,6 +59,36 @@ class SubsonicClientTest { assertNull(url.queryParameter("p")) } + @Test + fun tokenHashIsMd5OfPasswordAndSalt() { + server.enqueue(MockResponse().setBody("")) + + client(tokenAuth = true).check("ping") + + val url = server.takeRequest().requestUrl!! + val salt = url.queryParameter("s")!! + assertEquals(md5Hex("secret$salt"), url.queryParameter("t")) + } + + @Test + fun tokenSaltIsFreshPerRequest() { + repeat(2) { server.enqueue(MockResponse().setBody("")) } + val client = client(tokenAuth = true) + + client.check("ping") + client.check("ping") + + val first = server.takeRequest().requestUrl!!.queryParameter("s") + val second = server.takeRequest().requestUrl!!.queryParameter("s") + assertNotEquals(first, second) + } + + private fun md5Hex(input: String): String = + MessageDigest + .getInstance("MD5") + .digest(input.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + @Test fun plaintextAuthSendsPassword() { server.enqueue(MockResponse().setBody("")) diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt index 8f5570e8..4bd3c2d8 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt @@ -12,6 +12,7 @@ import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest +import okhttp3.mockwebserver.SocketPolicy import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -74,6 +75,42 @@ class SubsonicSourceTest { ) } + @Test + fun getSongsFallsBackToAlbumsWhenEmptySearchProbeFails() = + runBlocking { + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + when { + request.path?.startsWith("/rest/search3.view") == true -> { + MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST) + } + + request.path?.startsWith("/rest/getAlbumList2.view") == true -> { + MockResponse().setBody(ALBUM_LIST) + } + + request.path?.startsWith("/rest/getAlbum.view") == true -> { + MockResponse().setBody(ALBUM) + } + + else -> { + MockResponse().setResponseCode(404) + } + } + } + val source = SubsonicSource(1, client()) + + assertEquals( + listOf("s1", "s2"), + source + .songs() + .toList() + .flatten() + .map { it.id }, + ) + } + @Test fun artistsAreEmittedInBoundedBatches() = runBlocking { @@ -353,6 +390,8 @@ class SubsonicSourceTest { val bound = 2 val started = CountDownLatch(bound) val beyondBound = CountDownLatch(bound + 1) + val inFlight = AtomicInteger() + val maxInFlight = AtomicInteger() val release = CountDownLatch(1) server.dispatcher = object : Dispatcher() { @@ -361,7 +400,10 @@ class SubsonicSourceTest { if (url.encodedPath != "/rest/getPlaylist.view") return MockResponse().setResponseCode(404) beyondBound.countDown() started.countDown() + val current = inFlight.incrementAndGet() + maxInFlight.updateAndGet { maxOf(it, current) } release.await(5, TimeUnit.SECONDS) + inFlight.decrementAndGet() return MockResponse().setBody(playlistResponse(url.queryParameter("id")!!)) } } @@ -384,6 +426,7 @@ class SubsonicSourceTest { val entries = fetching.await() assertEquals(12, entries.size) + assertEquals("the bound must cap concurrent fetches", bound, maxInFlight.get()) } @Test diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt index 82fec166..3724adc1 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt @@ -14,6 +14,11 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okhttp3.mockwebserver.SocketPolicy import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -22,6 +27,9 @@ import org.junit.Test import org.junit.runner.RunWith import java.io.File import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger @RunWith(AndroidJUnit4::class) class SyncManagerTest { @@ -30,6 +38,7 @@ class SyncManagerTest { private lateinit var queueRepository: QueueRepository private lateinit var manager: SyncManager private val messages = CopyOnWriteArrayList() + private lateinit var server: MockWebServer @Before fun setUp() { @@ -42,12 +51,15 @@ class SyncManagerTest { sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art"))) queueRepository = QueueRepository(db) manager = SyncManager(db, sourceRepository, queueRepository, showMessage = { messages += it }) + server = MockWebServer() + server.start() } @After fun tearDown() { sourceRepository.close() db.close() + server.shutdown() } @Test @@ -70,4 +82,146 @@ class SyncManagerTest { assertTrue(status is SyncStatus.Failed) assertEquals(before + 1, queueRepository.snapshot().version) } + + @Test + fun aSuccessfulSyncMirrorsTheLibraryIntoRoom() = + runBlocking { + server.dispatcher = healthyDispatcher() + sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false) + + manager.requestSync() + withTimeout(10_000) { manager.status.first { it is SyncStatus.Success } } + + assertEquals(listOf("ar1"), db.libraryDao().artistIds(1)) + assertEquals(listOf("al1"), db.libraryDao().albumIds(1)) + } + + @Test + fun aMidSyncFailureKeepsCommittedRowsAndRecovers() = + runBlocking { + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + when { + request.path?.startsWith("/rest/getArtists.view") == true -> { + MockResponse().setBody(ARTISTS) + } + + request.path?.startsWith("/rest/getAlbumList2.view") == true -> { + MockResponse().setBody(ALBUM_LIST) + } + + request.path?.startsWith("/rest/search3.view") == true -> { + MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START) + } + + request.path?.startsWith("/rest/getAlbum.view") == true -> { + MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START) + } + + else -> { + MockResponse().setResponseCode(404) + } + } + } + sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false) + + manager.requestSync() + withTimeout(10_000) { manager.status.first { it is SyncStatus.Failed } } + + assertEquals(listOf("ar1"), db.libraryDao().artistIds(1)) + + server.dispatcher = healthyDispatcher() + manager.requestSync() + withTimeout(10_000) { manager.status.first { it is SyncStatus.Success } } + + assertEquals(listOf("ar1"), db.libraryDao().artistIds(1)) + assertEquals(listOf("al1"), db.libraryDao().albumIds(1)) + } + + @Test + fun requestsWhileRunningCoalesceIntoOneSync() = + runBlocking { + val artistRequests = AtomicInteger() + val firstArtistStarted = CountDownLatch(1) + val secondArtistStarted = CountDownLatch(1) + val releaseFirst = CountDownLatch(1) + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + if (request.path?.startsWith("/rest/getArtists.view") == true) { + when (artistRequests.incrementAndGet()) { + 1 -> { + firstArtistStarted.countDown() + releaseFirst.await(10, TimeUnit.SECONDS) + } + + 2 -> { + secondArtistStarted.countDown() + } + } + } + return healthyDispatcher().dispatch(request) + } + } + sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false) + + manager.requestSync() + assertTrue(firstArtistStarted.await(10, TimeUnit.SECONDS)) + manager.requestSync() + manager.requestSync() + releaseFirst.countDown() + + withTimeout(10_000) { manager.status.first { it is SyncStatus.Success } } + assertTrue("the coalesced sync should start", secondArtistStarted.await(10, TimeUnit.SECONDS)) + assertEquals(2, artistRequests.get()) + } + + private fun healthyDispatcher() = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + when { + request.path?.startsWith("/rest/getArtists.view") == true -> { + MockResponse().setBody(ARTISTS) + } + + request.path?.startsWith("/rest/getAlbumList2.view") == true -> { + val type = request.requestUrl?.queryParameter("type") + MockResponse().setBody(if (type == "newest") ALBUM_LIST else emptyAlbumList()) + } + + request.path?.startsWith("/rest/search3.view") == true -> { + MockResponse().setBody(SONG) + } + + request.path?.startsWith("/rest/getPlaylists.view") == true -> { + MockResponse().setBody(emptyPlaylists()) + } + + else -> { + MockResponse().setResponseCode(404) + } + } + } + + private fun emptyAlbumList() = "" + + private fun emptyPlaylists() = "" + + private companion object { + const val ARTISTS = + "" + + "" + + "" + + const val ALBUM_LIST = + "" + + "" + + "" + + const val SONG = + "" + + "" + + "" + } } diff --git a/app/src/test/kotlin/com/subtracks/di/AppModuleTest.kt b/app/src/test/kotlin/com/subtracks/di/AppModuleTest.kt index 5386a34f..5c25969a 100644 --- a/app/src/test/kotlin/com/subtracks/di/AppModuleTest.kt +++ b/app/src/test/kotlin/com/subtracks/di/AppModuleTest.kt @@ -3,31 +3,49 @@ package com.subtracks.di import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.download.DownloadNotifier import com.subtracks.data.repo.DownloadRepository +import com.subtracks.data.repo.SourceRepository import com.subtracks.playback.PlaybackController import okhttp3.OkHttpClient import org.junit.After -import org.junit.Assert.assertNotNull +import org.junit.Assert.assertSame import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.koin.core.Koin import org.koin.core.context.startKoin import org.koin.core.context.stopKoin @RunWith(AndroidJUnit4::class) class AppModuleTest { + private lateinit var koin: Koin + @Before fun setUp() = stopKoin() @After - fun tearDown() = stopKoin() + fun tearDown() { + if (::koin.isInitialized) { + runCatching { + koin.get().close() + koin.get().close() + koin.get().close() + koin.get().close() + } + } + stopKoin() + } @Test fun theModuleResolvesThePlaybackGraph() { val context = ApplicationProvider.getApplicationContext() - val koin = startKoin { modules(appModule(context, OkHttpClient())) }.koin + koin = startKoin { modules(appModule(context, OkHttpClient())) }.koin - assertNotNull(koin.get()) - assertNotNull(koin.get()) + val downloads = koin.get() + val controller = koin.get() + + assertSame(downloads, koin.get()) + assertSame(controller, koin.get()) } } diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 2e3d7683..2a84f9b4 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.junit.After import org.junit.Assert.assertEquals @@ -1236,11 +1237,10 @@ class PlaybackControllerTest { await { controller.state.value.isPlaying } handle.startBuffering() - Thread.sleep(300) - assertFalse(controller.state.value.isBuffering) + runBlocking { withContext(dispatcher) { } } + assertFalse("buffering must be debounced, not reported immediately", controller.state.value.isBuffering) - Thread.sleep(1_200) - assertTrue(controller.state.value.isBuffering) + await { controller.state.value.isBuffering } handle.becomeReady() await { !controller.state.value.isBuffering } diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 1a72ee6a..83468f18 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -71,6 +71,11 @@ import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode import kotlin.random.Random +/** + * Render smoke tests: these render each screen and assert the awaited node exists, then write a PNG + * for local review. No golden images are committed and `verifyRoborazziDebug` is not run in CI, so + * pixel/layout regressions are NOT caught here. Use `recordRoborazziDebug` to inspect output. + */ @OptIn(DelicateCoilApi::class) @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsScreenshotTest.kt index b87ae080..2750f5d2 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsScreenshotTest.kt @@ -27,6 +27,7 @@ import org.junit.runner.RunWith import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +/** Render smoke test: asserts the awaited node exists and writes a PNG for local review; it does not verify pixels. */ @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(qualifiers = RobolectricDeviceQualifiers.PixelXL) diff --git a/app/src/test/kotlin/com/subtracks/ui/LibrarySearchScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibrarySearchScreenshotTest.kt index 9911eddc..6827ea79 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibrarySearchScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibrarySearchScreenshotTest.kt @@ -28,6 +28,7 @@ import org.junit.runner.RunWith import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +/** Render smoke test: asserts the awaited node exists and writes a PNG for local review; it does not verify pixels. */ @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(qualifiers = RobolectricDeviceQualifiers.PixelXL) diff --git a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt new file mode 100644 index 00000000..8e33bf2e --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt @@ -0,0 +1,179 @@ +package com.subtracks.ui.downloads + +import android.content.Context +import androidx.lifecycle.viewModelScope +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.download.ArtworkStore +import com.subtracks.data.download.FakeDownloadEngine +import com.subtracks.data.model.DownloadList +import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.Song +import com.subtracks.data.model.SongDownload +import com.subtracks.data.prefs.fakeUserPreferences +import com.subtracks.data.repo.DownloadRepository +import com.subtracks.data.repo.LibraryRepository +import com.subtracks.data.repo.NetworkServerActionSink +import com.subtracks.data.repo.QueueRepository +import com.subtracks.data.repo.SourceRepository +import com.subtracks.playback.FakePlayerConnection +import com.subtracks.playback.FakePlayerHandle +import com.subtracks.playback.PlaybackController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.concurrent.Executors + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class DownloadsViewModelTest { + private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + + private lateinit var db: SubtracksDatabase + private lateinit var sourceRepository: SourceRepository + private lateinit var downloadRepository: DownloadRepository + private lateinit var controller: PlaybackController + private lateinit var viewModel: DownloadsViewModel + private var sourceId: Long = 0 + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + val context = ApplicationProvider.getApplicationContext() + db = + Room + .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .build() + val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}")) + sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork) + downloadRepository = + DownloadRepository( + db, + sourceRepository, + FakeDownloadEngine(), + File(context.cacheDir, "downloads-${System.nanoTime()}"), + artworkStore = artwork, + artworkFetcher = { ByteArray(0) }, + ) + controller = + PlaybackController( + sourceRepository, + QueueRepository(db), + FakePlayerConnection(FakePlayerHandle()), + downloadRepository, + dispatcher = dispatcher, + ) + viewModel = + DownloadsViewModel( + LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository)), + downloadRepository, + controller, + ) + } + + @After + fun tearDown() { + viewModel.viewModelScope.cancel() + controller.close() + downloadRepository.close() + sourceRepository.close() + db.close() + Dispatchers.resetMain() + dispatcher.close() + } + + @Test + fun deleteAllRemovesEveryDownloadOfTheSource() { + seedDownloads() + + viewModel.deleteAll() + await { allRows().isEmpty() } + + assertTrue("every download row should be gone", allRows().isEmpty()) + } + + @Test + fun deleteRemovesOnlyTheSongsOfThatAlbum() { + seedDownloads() + + viewModel.delete(DownloadList.Album, "al1") + await { row("s1") == null && row("s2") == null } + + assertNull(row("s1")) + assertNull(row("s2")) + assertNotNull("a song from another album should survive", row("s3")) + } + + @Test + fun deleteSongsRemovesOnlyTheGivenSongs() { + seedDownloads() + + viewModel.deleteSongs(listOf("s1")) + await { row("s1") == null } + + assertNull(row("s1")) + assertNotNull("an unnamed song should survive", row("s2")) + } + + private fun seedDownloads() { + sourceId = + runBlocking { + val id = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false) + db.libraryDao().upsertSongs( + listOf( + song(id, "s1", "al1"), + song(id, "s2", "al1"), + song(id, "s3", "al2"), + ), + ) + listOf("s1", "s2", "s3").forEach { db.downloadDao().upsert(SongDownload(id, it, DownloadStatus.Completed)) } + id + } + } + + private fun song( + sourceId: Long, + songId: String, + albumId: String, + ) = Song( + sourceId = sourceId, + id = songId, + albumId = albumId, + artistId = "ar1", + title = songId, + album = "Album", + artist = "Artist", + duration = 100, + track = 1, + disc = 1, + starred = null, + genre = null, + ) + + private fun row(songId: String): SongDownload? = runBlocking { db.downloadDao().find(sourceId, songId) } + + private fun allRows(): List = runBlocking { db.downloadDao().all() } + + private fun await(predicate: () -> Boolean) { + val deadline = System.nanoTime() + 5_000_000_000L + while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10) + assertTrue("Timed out waiting for the expected state", predicate()) + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/library/LibraryViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/library/LibraryViewModelTest.kt new file mode 100644 index 00000000..94487c29 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/library/LibraryViewModelTest.kt @@ -0,0 +1,27 @@ +package com.subtracks.ui.library + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.prefs.AlbumSort +import com.subtracks.data.prefs.ArtistSort +import com.subtracks.data.prefs.ListQuery +import com.subtracks.data.prefs.PlaylistSort +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LibraryViewModelTest { + @Test + fun aKnownSortStringDecodesToItsEnum() { + assertEquals(AlbumSort.Year, ListQuery("Year").albumSort()) + assertEquals(ArtistSort.AlbumCount, ListQuery("AlbumCount").artistSort()) + assertEquals(PlaylistSort.Updated, ListQuery("Updated").playlistSort()) + } + + @Test + fun anUnknownSortStringFallsBackToName() { + assertEquals(AlbumSort.Name, ListQuery("LegacyAlbumOrder").albumSort()) + assertEquals(ArtistSort.Name, ListQuery("LegacyArtistOrder").artistSort()) + assertEquals(PlaylistSort.Name, ListQuery("LegacyPlaylistOrder").playlistSort()) + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt index f28fba8c..9ca37e95 100644 --- a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt @@ -1,6 +1,7 @@ package com.subtracks.ui.playback import android.content.Context +import androidx.lifecycle.viewModelScope import androidx.room3.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.test.core.app.ApplicationProvider @@ -22,6 +23,7 @@ import com.subtracks.playback.PlaybackController import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain @@ -43,6 +45,8 @@ class QueueViewModelTest { private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() private lateinit var db: SubtracksDatabase + private lateinit var sources: SourceRepository + private lateinit var downloads: DownloadRepository private lateinit var queues: QueueRepository private lateinit var controller: PlaybackController private lateinit var viewModel: QueueViewModel @@ -56,10 +60,10 @@ class QueueViewModelTest { .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) .setDriver(BundledSQLiteDriver()) .build() - val sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art"))) + sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art"))) queues = QueueRepository(db) val artwork = ArtworkStore(File(context.cacheDir, "art")) - val downloads = + downloads = DownloadRepository( db, sources, @@ -74,7 +78,10 @@ class QueueViewModelTest { @After fun tearDown() { + viewModel.viewModelScope.cancel() controller.close() + downloads.close() + sources.close() db.close() Dispatchers.resetMain() dispatcher.close() diff --git a/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt new file mode 100644 index 00000000..d732cb2e --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt @@ -0,0 +1,152 @@ +package com.subtracks.ui.settings + +import android.content.Context +import androidx.lifecycle.viewModelScope +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.download.ArtworkStore +import com.subtracks.data.download.FakeDownloadEngine +import com.subtracks.data.prefs.fakeUserPreferences +import com.subtracks.data.repo.DownloadRepository +import com.subtracks.data.repo.QueueRepository +import com.subtracks.data.repo.SourceRepository +import com.subtracks.data.sync.SyncManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.concurrent.Executors + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class AddSourceViewModelTest { + private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + + private lateinit var db: SubtracksDatabase + private lateinit var sourceRepository: SourceRepository + private lateinit var downloadRepository: DownloadRepository + private lateinit var server: MockWebServer + private val createdViewModels = mutableListOf() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + val context = ApplicationProvider.getApplicationContext() + db = + Room + .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .build() + val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}")) + sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork) + downloadRepository = + DownloadRepository( + db, + sourceRepository, + FakeDownloadEngine(), + File(context.cacheDir, "downloads-${System.nanoTime()}"), + artworkStore = artwork, + artworkFetcher = { ByteArray(0) }, + ) + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + createdViewModels.forEach { it.viewModelScope.cancel() } + server.shutdown() + downloadRepository.close() + sourceRepository.close() + db.close() + Dispatchers.resetMain() + dispatcher.close() + } + + @Test + fun aRejectedPingSetsTheErrorAndClearsBusy() { + val viewModel = viewModel() + viewModel.setAddress(server.url("/").toString()) + viewModel.setUsername("u") + viewModel.setPassword("p") + server.enqueue(failed()) + + viewModel.testConnection() + await { !viewModel.state.value.busy } + + val state = viewModel.state.value + assertFalse(state.busy) + assertTrue("a rejected ping should be an error", state.isError) + assertEquals("Failed: Wrong username", state.message) + } + + @Test + fun aSuccessfulPingClearsAnEarlierError() { + val viewModel = viewModel() + viewModel.setAddress(server.url("/").toString()) + viewModel.setUsername("u") + viewModel.setPassword("p") + server.enqueue(failed()) + viewModel.testConnection() + await { !viewModel.state.value.busy && viewModel.state.value.isError } + + server.enqueue(ok()) + viewModel.testConnection() + await { !viewModel.state.value.busy && !viewModel.state.value.isError } + + val state = viewModel.state.value + assertEquals("Connection OK", state.message) + assertFalse(state.isError) + } + + @Test + fun theActiveSourceCannotBeDeleted() = + runBlocking { + val sourceId = sourceRepository.addSource("nav", server.url("/").toString(), "u", "p", false) + val viewModel = viewModel(sourceId) + await { !viewModel.state.value.canDelete } + + viewModel.delete {} + await { !viewModel.state.value.busy && viewModel.state.value.message != null } + + val state = viewModel.state.value + assertTrue(state.isError) + assertTrue(state.message!!.contains("active server can't be deleted")) + assertNotNull("the active source must survive the refused delete", db.sourcesDao().sourceOnce(sourceId)) + } + + private fun viewModel(sourceId: Long? = null): AddSourceViewModel = + AddSourceViewModel(sourceRepository, SyncManager(db, sourceRepository, QueueRepository(db)), downloadRepository, sourceId) + .also { createdViewModels += it } + + private fun ok() = MockResponse().setBody("") + + private fun failed() = + MockResponse().setBody( + "", + ) + + private fun await(predicate: () -> Boolean) { + val deadline = System.nanoTime() + 5_000_000_000L + while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10) + assertTrue("Timed out waiting for the expected state", predicate()) + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt index 84893c98..cee4c823 100644 --- a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt @@ -65,7 +65,7 @@ class ArtworkExtractionTest { @Test fun resolvesColorsFromCoverArt() { - val ref = CoverArtRef(url = "art", cacheKey = "art-${System.nanoTime()}") + val ref = CoverArtRef(url = "art", cacheKey = "art") var colors: ArtworkColors? = null composeRule.setContent { colors = rememberArtworkColors(ref) @@ -113,7 +113,7 @@ class ArtworkExtractionTest { @Test fun keepsTheStoredSeedWhenTheArtCannotBeDecoded() { - val key = "missing-${System.nanoTime()}" + val key = "missing" val stored = ArtworkSeed(key, 0xFF3A7BD5.toInt(), 0xFFD53A3A.toInt(), null) ArtworkSeedCache.install( object : ArtworkSeedStore { diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties index 3f67ea5a..8b89e5ff 100644 --- a/app/src/test/resources/robolectric.properties +++ b/app/src/test/resources/robolectric.properties @@ -1 +1,5 @@ sdk=35 +# The real Application starts app-scoped Koin singletons (download repo/notifier) whose collectors +# are never cancelled between tests, so they query a torn-down database at teardown. Tests build +# their own graphs; AppModuleTest starts Koin explicitly. +application=android.app.Application