add unit tests for the data layer

MockWebServer client tests, XML mapping (including timestamp parsing), source fallback, and sync/search against an in-memory Room DB using the bundled SQLite JVM driver.
This commit is contained in:
austinried
2026-10-04 00:29:51 +00:00
parent 2c377f0f22
commit 7bc1bd9a19
5 changed files with 430 additions and 1 deletions
@@ -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("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
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("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
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("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
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(
"<subsonic-response status=\"failed\"><error code=\"40\" message=\"Wrong username\"/></subsonic-response>",
),
)
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("<html><body>Sign in</body></html>"))
assertThrows(SubsonicException::class.java) { client(tokenAuth = true).get("ping") }
}
}
@@ -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) =
"<subsonic-response status=\"failed\"><error code=\"$code\" message=\"$message\"/></subsonic-response>"
private companion object {
const val ALBUM_LIST =
"<subsonic-response status=\"ok\" version=\"1.16.1\"><albumList2>" +
"<album id=\"al1\" name=\"Album\" artist=\"Artist\" artistId=\"ar1\" songCount=\"2\"/>" +
"</albumList2></subsonic-response>"
const val ALBUM =
"<subsonic-response status=\"ok\" version=\"1.16.1\"><album id=\"al1\" name=\"Album\" artist=\"Artist\" artistId=\"ar1\">" +
"<song id=\"s1\" title=\"One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" +
"<song id=\"s2\" title=\"Two\" albumId=\"al1\" artistId=\"ar1\" track=\"2\"/>" +
"</album></subsonic-response>"
}
}
@@ -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(
"""
<subsonic-response status="ok">
<artists>
<artist id="ar1" name="Radiohead" albumCount="9" starred="2020-01-02T03:04:05.000Z"/>
<artist id="ar2" name="Portishead" albumCount="3"/>
</artists>
</subsonic-response>
""".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(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="OK Computer" artist="Radiohead" artistId="ar1" created="1997-05-21T00:00:00.000Z" songCount="12" coverArt="al1"/>
<album id="al2" name="Kid A" artist="Radiohead" artistId="ar1" created="2000-10-02T00:00:00.000Z" songCount="10" coverArt="al2"/>
</albumList2>
</subsonic-response>
""".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(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="sg2" title="Idioteque" artist="Radiohead" album="Kid A" albumId="al2" artistId="ar1" duration="300" track="8" discNumber="1"/>
</searchResult3>
<playlist id="pl1" name="Favourites" songCount="1" created="2021-02-03T04:05:06.000Z">
<entry id="sg1" title="Everything In Its Right Place" artist="Radiohead" album="Kid A" albumId="al2" artistId="ar1" duration="251" track="1" discNumber="1"/>
</playlist>
</subsonic-response>
""".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)
}
}
@@ -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<Context>()
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<Artist> = emptyList(),
var albums: List<Album> = emptyList(),
var songs: List<Song> = emptyList(),
var playlists: List<Playlist> = emptyList(),
var playlistSongs: List<PlaylistSong> = emptyList(),
) : MusicSource {
override val id: Long = 1
override suspend fun ping() = Unit
override suspend fun getArtists(): List<Artist> = artists
override suspend fun getAlbums(): List<Album> = albums
override suspend fun getSongs(): List<Song> = songs
override suspend fun getPlaylists(): List<Playlist> = playlists
override suspend fun getPlaylistSongs(playlists: List<Playlist>): List<PlaylistSong> = playlistSongs
}
@@ -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