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 new file mode 100644 index 00000000..0474b874 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt @@ -0,0 +1,92 @@ +package com.subtracks.data.source.subsonic + +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test + +class SubsonicClientTest { + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun client(tokenAuth: Boolean) = SubsonicClient( + baseUrl = server.url("/"), + username = "guest", + password = "secret", + useTokenAuth = tokenAuth, + http = OkHttpClient(), + ) + + @Test + fun tokenAuthSendsSaltAndHash() { + server.enqueue(MockResponse().setBody("")) + + client(tokenAuth = true).get("ping") + + val url = server.takeRequest().requestUrl!! + assertEquals("/rest/ping.view", url.encodedPath) + assertEquals("guest", url.queryParameter("u")) + assertEquals("subtracks", url.queryParameter("c")) + assertNotNull(url.queryParameter("s")) + assertNotNull(url.queryParameter("t")) + assertNull(url.queryParameter("p")) + } + + @Test + fun plaintextAuthSendsPassword() { + server.enqueue(MockResponse().setBody("")) + + client(tokenAuth = false).get("ping") + + val url = server.takeRequest().requestUrl!! + assertEquals("secret", url.queryParameter("p")) + assertNull(url.queryParameter("t")) + } + + @Test + fun paramsAreForwarded() { + server.enqueue(MockResponse().setBody("")) + + client(tokenAuth = true).get("getCoverArt", mapOf("id" to "cov", "size" to "256")) + + val url = server.takeRequest().requestUrl!! + assertEquals("cov", url.queryParameter("id")) + assertEquals("256", url.queryParameter("size")) + } + + @Test + fun failedStatusThrowsWithCode() { + server.enqueue( + MockResponse().setBody( + "", + ), + ) + + val error = assertThrows(SubsonicException::class.java) { client(tokenAuth = true).get("ping") } + assertEquals(40, error.code) + assertEquals("Wrong username", error.message) + } + + @Test + fun nonSubsonicResponseThrows() { + server.enqueue(MockResponse().setBody("Sign in")) + + assertThrows(SubsonicException::class.java) { client(tokenAuth = true).get("ping") } + } +} 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 new file mode 100644 index 00000000..ab51aac0 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt @@ -0,0 +1,72 @@ +package com.subtracks.data.source.subsonic + +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class SubsonicSourceTest { + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun getSongsFallsBackToAlbumsWhenEmptySearchUnsupported() = runBlocking { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = when { + request.path?.startsWith("/rest/search3.view") == true -> + MockResponse().setBody(failed(10, "Required parameter is missing")) + + 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.getSongs().map { it.id }) + } + + private fun client() = SubsonicClient( + baseUrl = server.url("/"), + username = "u", + password = "p", + useTokenAuth = false, + http = OkHttpClient(), + ) + + private fun failed(code: Int, message: String) = + "" + + private companion object { + const val ALBUM_LIST = + "" + + "" + + "" + + const val ALBUM = + "" + + "" + + "" + + "" + } +} diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt new file mode 100644 index 00000000..437f4edf --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt @@ -0,0 +1,94 @@ +package com.subtracks.data.source.subsonic + +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertEquals +import org.junit.Test +import org.w3c.dom.Document + +class SubsonicXmlTest { + private fun parse(xml: String): Document = + DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(xml.byteInputStream(Charsets.UTF_8)) + + @Test + fun mapsArtists() { + val document = parse( + """ + + + + + + + """.trimIndent(), + ) + + val artists = SubsonicXml.artists(7, document) + + assertEquals(2, artists.size) + assertEquals("Radiohead", artists[0].name) + assertEquals(7L, artists[0].sourceId) + assertEquals(9L, artists[0].albumCount) + assertEquals(1577934245L, artists[0].starred) + assertEquals(null, artists[1].starred) + } + + @Test + fun mapsAlbumList() { + val document = parse( + """ + + + + + + + """.trimIndent(), + ) + + val albums = SubsonicXml.albums(1, document) + + assertEquals(2, albums.size) + assertEquals("OK Computer", albums[0].name) + assertEquals("Kid A", albums[1].name) + assertEquals("ar1", albums[0].artistId) + assertEquals("Radiohead", albums[0].albumArtist) + assertEquals(12L, albums[0].songCount) + } + + @Test + fun parsesTimestampsWithFractionsAndOffsets() { + assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z")) + assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370Z")) + assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37Z")) + assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37")) + assertEquals(1789906657L, IsoDate.parse("2026-09-20T14:17:37.370827413+02:00")) + assertEquals(null, IsoDate.parse("not a date")) + } + + @Test + fun mapsSongsAndPlaylistEntries() { + val document = parse( + """ + + + + + + + + + """.trimIndent(), + ) + + val songs = SubsonicXml.songs(1, document) + val entries = SubsonicXml.playlistSongs(1, "pl1", document) + + assertEquals(1, songs.size) + assertEquals("Idioteque", songs[0].title) + assertEquals(300L, songs[0].duration) + assertEquals(0L, entries[0].position) + assertEquals("sg1", entries[0].songId) + assertEquals(251L, SubsonicXml.song(1, document.getElementsByTagName("entry").item(0) as org.w3c.dom.Element).duration) + } +} diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt new file mode 100644 index 00000000..ecc94942 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -0,0 +1,171 @@ +package com.subtracks.data.sync + +import android.content.Context +import androidx.room3.Room +import androidx.test.core.app.ApplicationProvider +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Playlist +import com.subtracks.data.model.PlaylistSong +import com.subtracks.data.model.Song +import com.subtracks.data.model.Source +import com.subtracks.data.source.MusicSource +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SyncServiceTest { + private lateinit var db: SubtracksDatabase + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .build() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun syncInsertsAndPrunesRemovedRows() = runTest { + insertSource() + val source = FakeMusicSource( + artists = listOf(artist("a1"), artist("a2")), + albums = listOf(album("al1")), + songs = listOf(song("s1"), song("s2")), + playlists = listOf(playlist("p1")), + playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0)), + ) + + SyncService(db, source).sync() + + assertEquals(2, db.libraryDao().artists(1).first().size) + assertEquals(2, db.libraryDao().songs(1).first().size) + assertEquals(1, db.libraryDao().songsByPlaylist(1, "p1").first().size) + + source.artists = listOf(artist("a1")) + source.songs = listOf(song("s1")) + + SyncService(db, source).sync() + + assertEquals(listOf("a1"), db.libraryDao().artists(1).first().map { it.id }) + assertEquals(listOf("s1"), db.libraryDao().songs(1).first().map { it.id }) + } + + @Test + fun searchIndexMatchesSubstrings() = runTest { + insertSource() + val source = FakeMusicSource( + artists = listOf(artist("a1", name = "Radiohead")), + songs = listOf(song("s1")), + ) + + SyncService(db, source).sync() + + val results = db.searchDao().search("1", "adio", 10) + assertEquals(1, results.size) + assertEquals("a1", results.single().itemId) + assertEquals("artist", results.single().type) + } + + @Test + fun searchIgnoresTooShortQueries() = runTest { + insertSource() + SyncService(db, FakeMusicSource(artists = listOf(artist("a1", name = "Radiohead")))).sync() + + assertEquals(0, db.searchDao().search("1", "ad", 10).size) + assertEquals(0, db.searchDao().search("1", "", 10).size) + assertEquals(1, db.searchDao().search("1", "adi", 10).size) + } + + @Test + fun syncUpdatesExistingRows() = runTest { + insertSource() + val source = FakeMusicSource(artists = listOf(artist("a1", name = "Old"))) + + SyncService(db, source).sync() + assertEquals("Old", db.libraryDao().artists(1).first().single().name) + + source.artists = listOf(artist("a1", name = "New")) + SyncService(db, source).sync() + + assertEquals("New", db.libraryDao().artists(1).first().single().name) + } + + private suspend fun insertSource() { + db.sourcesDao().upsertSource( + Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0), + ) + } + + private fun artist(id: String, name: String = "Artist $id") = + Artist(sourceId = 1, id = id, name = name, albumCount = 1, starred = null) + + private fun album(id: String) = Album( + sourceId = 1, + id = id, + artistId = "a1", + name = "Album $id", + albumArtist = "Artist", + created = 0, + coverArt = null, + genre = null, + year = null, + starred = null, + songCount = 1, + frequentRank = null, + recentRank = null, + ) + + private fun song(id: String) = Song( + sourceId = 1, + id = id, + albumId = "al1", + artistId = "a1", + title = "Song $id", + album = "Album", + artist = "Artist", + duration = 100, + track = 1, + disc = 1, + starred = null, + genre = null, + ) + + private fun playlist(id: String) = + Playlist(sourceId = 1, id = id, name = "Playlist $id", comment = null, coverArt = null, songCount = 1, created = 0) +} + +private class FakeMusicSource( + var artists: List = emptyList(), + var albums: List = emptyList(), + var songs: List = emptyList(), + var playlists: List = emptyList(), + var playlistSongs: List = emptyList(), +) : MusicSource { + override val id: Long = 1 + + override suspend fun ping() = Unit + + override suspend fun getArtists(): List = artists + + override suspend fun getAlbums(): List = albums + + override suspend fun getSongs(): List = songs + + override suspend fun getPlaylists(): List = playlists + + override suspend fun getPlaylistSongs(playlists: List): List = playlistSongs +} diff --git a/app/src/test/kotlin/com/subtracks/ui/HelloScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/HelloScreenshotTest.kt index 35400aec..1e3c6993 100644 --- a/app/src/test/kotlin/com/subtracks/ui/HelloScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/HelloScreenshotTest.kt @@ -1,7 +1,7 @@ package com.subtracks.ui import androidx.activity.ComponentActivity -import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onRoot import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers