diff --git a/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceIntegrationTest.kt b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceIntegrationTest.kt new file mode 100644 index 00000000..bd4fa167 --- /dev/null +++ b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceIntegrationTest.kt @@ -0,0 +1,106 @@ +package com.subtracks.data.source.subsonic + +import kotlinx.coroutines.runBlocking +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +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 org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class SubsonicSourceIntegrationTest( + private val server: Server, +) { + private lateinit var source: SubsonicSource + + @Before + fun setUp() { + val client = SubsonicClient( + baseUrl = server.baseUrl.toHttpUrl(), + username = server.username, + password = server.password, + useTokenAuth = false, + http = OkHttpClient(), + ) + source = SubsonicSource(1, client) + } + + @Test + fun ping() = runBlocking { + source.ping() + } + + @Test + fun artists() = runBlocking { + val artists = source.getArtists() + + assertEquals(2, artists.size) + assertNotNull(artists.first { it.name == "Ugress" }.starred) + assertNull(artists.first { it.name == "Brad Sucks" }.starred) + } + + @Test + fun albums() = runBlocking { + val albums = source.getAlbums() + + assertEquals(3, albums.size) + + val kosmo = albums.first { it.name == "Kosmonaut" } + assertTrue(kosmo.id.isNotEmpty()) + assertTrue(kosmo.artistId!!.isNotEmpty()) + assertEquals("Ugress", kosmo.albumArtist) + assertEquals(2006L, kosmo.year) + assertEquals("Electronic", kosmo.genre) + assertNotNull(kosmo.coverArt) + assertNotNull(kosmo.starred) + assertTrue(kosmo.created <= System.currentTimeMillis() / 1000) + assertTrue(kosmo.frequentRank != null || kosmo.recentRank != null) + + assertNull(albums.first { it.name == "Retroconnaissance EP" }.starred) + assertNull(albums.first { it.name == "I Don't Know What I'm Doing" }.starred) + } + + @Test + fun songs() = runBlocking { + assertEquals(20, source.getSongs().size) + } + + @Test + fun playlists() = runBlocking { + val playlists = source.getPlaylists() + + assertEquals(1, playlists.size) + assertEquals(7, source.getPlaylistSongs(playlists).size) + } + + @Test + fun albumArtistRelation() = runBlocking { + val artists = source.getArtists() + val albums = source.getAlbums() + + val ugressAlbums = albums.filter { it.artistId == artists.first { a -> a.name == "Ugress" }.id } + .map { it.name } + .sorted() + assertEquals(listOf("Kosmonaut", "Retroconnaissance EP"), ugressAlbums) + + val bradAlbums = albums.filter { it.artistId == artists.first { a -> a.name == "Brad Sucks" }.id } + .map { it.name } + assertEquals(listOf("I Don't Know What I'm Doing"), bradAlbums) + } + + data class Server(val name: String, val baseUrl: String, val username: String, val password: String) + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun servers(): List> = listOf( + arrayOf(Server("navidrome", "http://localhost:4533/", "admin", "password")), + arrayOf(Server("gonic", "http://localhost:4747/", "admin", "admin")), + ) + } +} diff --git a/tools/integration-test.nu b/tools/integration-test.nu new file mode 100755 index 00000000..6f9fb05d --- /dev/null +++ b/tools/integration-test.nu @@ -0,0 +1,172 @@ +#!/usr/bin/env nu + +const NAV_PORT = 4533 +const GONIC_PORT = 4747 +const LIB = ".integration/music" +const MUSIC = [ + { id: 197, name: "Ugress - Retroconnaissance EP", songs: 4 } + { id: 199, name: "Ugress - Kosmonaut", songs: 4 } + { id: 321, name: "Brad Sucks - I Don't Know What I'm Doing", songs: 12 } +] + +def song-count [dir: string] { + if not ($dir | path exists) { return 0 } + ls $dir | where type == file | where {|f| $f.name =~ '(?i)\.mp3$' } | length +} + +def ensure-music [] { + if ($MUSIC | all {|m| (song-count $"($LIB)/($m.id)") == $m.songs }) { + print $"using test music in ($LIB)" + return + } + print "downloading test music from demo.subsonic.org (one-time)" + mkdir $LIB + let tmp = (^mktemp -d | str trim) + for m in $MUSIC { + print $"($m.name)..." + ^curl -fsS -o $"($tmp)/($m.id).zip" $"http://demo.subsonic.org/rest/download.view?u=guest1&p=guest&v=1.13.0&c=subtracks-test&id=($m.id)" + ^unzip -q -o $"($tmp)/($m.id).zip" -d $"($LIB)/($m.id)" + } + rm -rf $tmp +} + +def sub-url [base: string, user: string, pass: string, method: string, params: list] { + let all = ([["u" $user] ["p" $pass] ["v" "1.13.0"] ["c" "subtracks-test"] ["f" "json"]] | append $params) + let query = ($all | each {|p| $"($p.0)=($p.1 | url encode)" } | str join "&") + $"($base)rest/($method).view?($query)" +} + +def sub-get [base: string, user: string, pass: string, method: string, params: list = []] { + let response = (http get -m 10sec (sub-url $base $user $pass $method $params) | get "subsonic-response") + if $response.status == "failed" { + error make { msg: $"($method) failed: ($response.error.message? | default 'unknown error')" } + } + $response +} + +def poll [label: string, check: closure] { + for attempt in 0..<120 { + let value = (try { do $check } catch { null }) + if $value != null { return $value } + if $attempt > 0 and ($attempt mod 5) == 0 { print $"waiting for ($label)..." } + sleep 1sec + } + error make { msg: $"timed out waiting for ($label)" } +} + +def port-in-use [port: int] { + (^curl -s -o /dev/null --max-time 1 $"http://127.0.0.1:($port)/" | complete).exit_code != 7 +} + +def album-id [albums: list, name: string] { + $albums | where {|a| $a.name == $name } | first | get id +} + +def song-id [base: string, user: string, pass: string, album: string, track: int] { + sub-get $base $user $pass "getAlbum" [["id" $album]] | get album.song | where {|s| $s.track == $track } | first | get id +} + +def has-tracks [base: string, user: string, pass: string, album: string, tracks: list] { + let found = sub-get $base $user $pass "getAlbum" [["id" $album]] | get album.song | get track + $tracks | all {|track| $found | any {|f| $f == $track } } +} + +def setup-data [base: string, user: string, pass: string] { + let artists = (poll "artists" {|| + let items = (sub-get $base $user $pass "getArtists" | get artists.index | each {|i| $i.artist } | flatten) + if (["Ugress" "Brad Sucks"] | all {|name| $items | any {|i| $i.name == $name } }) { $items } else { null } + }) + + let albums = (poll "albums" {|| + let items = (sub-get $base $user $pass "getAlbumList2" [["type" "newest"]] | get albumList2.album) + if (["Kosmonaut" "Retroconnaissance EP" "I Don't Know What I'm Doing"] | all {|name| $items | any {|i| $i.name == $name } }) { $items } else { null } + }) + + let ugress = (album-id $artists "Ugress") + let kosmo = (album-id $albums "Kosmonaut") + let retro = (album-id $albums "Retroconnaissance EP") + let dunno = (album-id $albums "I Don't Know What I'm Doing") + + poll "album songs" {|| + if (has-tracks $base $user $pass $retro [1 2]) and (has-tracks $base $user $pass $kosmo [2 4]) and (has-tracks $base $user $pass $dunno [9 10 11]) { + $albums + } else { + null + } + } | ignore + + sub-get $base $user $pass "star" [["albumId" $kosmo]] | ignore + sub-get $base $user $pass "star" [["artistId" $ugress]] | ignore + + let song_ids = [ + (song-id $base $user $pass $retro 2) + (song-id $base $user $pass $retro 1) + (song-id $base $user $pass $kosmo 2) + (song-id $base $user $pass $kosmo 4) + (song-id $base $user $pass $dunno 9) + (song-id $base $user $pass $dunno 10) + (song-id $base $user $pass $dunno 11) + ] + let playlist = ([["name" "Playlist 1"]] | append ($song_ids | each {|id| ["songId" $id] })) + sub-get $base $user $pass "createPlaylist" $playlist | ignore + + sub-get $base $user $pass "scrobble" [["id" (song-id $base $user $pass $retro 1)] ["submission" "true"]] | ignore + sleep 1sec + sub-get $base $user $pass "scrobble" [["id" (song-id $base $user $pass $retro 2)] ["submission" "true"]] | ignore + sleep 1sec + sub-get $base $user $pass "scrobble" [["id" (song-id $base $user $pass $kosmo 1)] ["submission" "true"]] | ignore + + poll "scrobbled album" {|| + let recent = (sub-get $base $user $pass "getAlbumList2" [["type" "recent"]] | get albumList2.album) + let frequent = (sub-get $base $user $pass "getAlbumList2" [["type" "frequent"]] | get albumList2.album) + if (($recent | any {|a| $a.id == $kosmo }) or ($frequent | any {|a| $a.id == $kosmo })) { $recent } else { null } + } | ignore +} + +def main [] { + ensure-music + + if (port-in-use $NAV_PORT) { error make { msg: $"port ($NAV_PORT) is already in use (leftover navidrome?); stop it and retry" } } + if (port-in-use $GONIC_PORT) { error make { msg: $"port ($GONIC_PORT) is already in use (leftover gonic?); stop it and retry" } } + + let work = (^mktemp -d | str trim) + for dir in ["navidrome" "gonic-cache" "gonic-playlists" "gonic-podcasts"] { + mkdir $"($work)/($dir)" + } + + print "starting navidrome" + let nav = (job spawn { ^navidrome --address 127.0.0.1 --port $NAV_PORT --musicfolder $LIB --datafolder $"($work)/navidrome" --loglevel warn }) + print "starting gonic" + let gonic = (job spawn { ^gonic -music-path $LIB -listen-addr $"127.0.0.1:($GONIC_PORT)" -db-path $"($work)/gonic.db" -cache-path $"($work)/gonic-cache" -playlists-path $"($work)/gonic-playlists" -podcast-path $"($work)/gonic-podcasts" -scan-at-start-enabled -http-log=false }) + + let outcome = (try { + poll "navidrome" {|| + http get -m 5sec $"http://127.0.0.1:($NAV_PORT)/rest/ping.view?u=x&p=x&v=1.13.0&c=test" | ignore + true + } + poll "gonic" {|| + http get -m 5sec $"http://127.0.0.1:($GONIC_PORT)/rest/ping.view?u=x&p=x&v=1.13.0&c=test" | ignore + true + } + + print "creating navidrome admin" + http post -m 10sec -t application/json $"http://127.0.0.1:($NAV_PORT)/auth/createAdmin" { username: "admin", password: "password" } | ignore + + print "setting up test data" + setup-data $"http://127.0.0.1:($NAV_PORT)/" "admin" "password" + setup-data $"http://127.0.0.1:($GONIC_PORT)/" "admin" "admin" + + print "running integration tests" + ^gradle :app:integrationTest --rerun --console=plain + $env.LAST_EXIT_CODE + } catch {|e| + print $"error: ($e.msg)" + ($env.LAST_EXIT_CODE? | default 1) + }) + + do -i { job kill $nav } + do -i { job kill $gonic } + rm -rf $work + + exit $outcome +}