mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
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:
@@ -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
|
package com.subtracks.ui
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
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.compose.ui.test.onRoot
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||||
|
|||||||
Reference in New Issue
Block a user