mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +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
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user