From f79f26fed87d755e0d6b541bf1835dcb763f8052 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:58:54 +0000 Subject: [PATCH] split the integration harness into server, api and util modules tools/integration-test.nu is now just the entrypoint. The server lifecycle and fixture preparation live in tools/integration/servers.nu, the Subsonic request helpers in subsonic.nu, and shared waiting/port helpers in util.nu. No behaviour change; the assertions still live in the Kotlin tests. --- tools/integration-test.nu | 292 ++-------------------------------- tools/integration/servers.nu | 234 +++++++++++++++++++++++++++ tools/integration/subsonic.nu | 43 +++++ tools/integration/util.nu | 15 ++ 4 files changed, 305 insertions(+), 279 deletions(-) create mode 100644 tools/integration/servers.nu create mode 100644 tools/integration/subsonic.nu create mode 100644 tools/integration/util.nu diff --git a/tools/integration-test.nu b/tools/integration-test.nu index 312be0f7..6c355a18 100755 --- a/tools/integration-test.nu +++ b/tools/integration-test.nu @@ -1,261 +1,12 @@ #!/usr/bin/env nu -const NAV_PORT = 4533 -const GONIC_PORT = 4747 -const LMS_PORT = 5082 -const NC_PORT = 8090 -const PRUNE_PORT = 4534 -const LMS_KEY = "subtracks-lms" -const NC_KEY = "subtracks-nextcloud" -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 } -] +# Starts the test servers, prepares the fixture data the Kotlin integration tests expect, runs +# them, then tears everything down. Enter the integration shell first: +# +# nix develop .#integration --command nu tools/integration-test.nu -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 -} - -# Nextcloud's Music app reads repeated (array) parameters straight from the raw query string -# without URL-decoding them, so ids must be sent with their unreserved characters intact. -def url-escape [value: string] { - $value - | url encode - | str replace --all '%2D' '-' - | str replace --all '%2d' '-' - | str replace --all '%2E' '.' - | str replace --all '%2e' '.' - | str replace --all '%5F' '_' - | str replace --all '%5f' '_' - | str replace --all '%7E' '~' - | str replace --all '%7e' '~' -} - -def sub-url [base: string, user: string, pass: string, method: string, params: list] { - let all = ([["u" $user] ["p" $pass] ["v" "1.16.1"] ["c" "subtracks-test"] ["f" "json"]] | append $params) - let query = ($all | each {|p| $"($p.0)=(url-escape ($p.1 | into string))" } | 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, --attempts (-n): int = 120] { - for attempt in 0..<$attempts { - 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 - - # Not every server maintains "recent"/"frequent" album lists; when they are missing the - # scrobble still succeeded, so this is only a best-effort check. - let observed = (try { - poll -n 15 "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 } - } - true - } catch { false }) - if not $observed { print " (no recent/frequent album list on this server; skipping scrobble check)" } -} - -def start-lms [work: string] { - let lms_bin = (which lms | get 0.path) - let lms_home = ($lms_bin | path dirname | path dirname) - let dir = ($work | path join "lms") - mkdir ($dir | path join "work") - let conf = ($dir | path join "lms.conf") - (open ($lms_home | path join "share/lms/lms.conf")) - | str replace --all 'working-dir = "/var/lms"' $"working-dir = \"($dir)/work\"" - | str replace --all 'listen-addr = "0.0.0.0"' 'listen-addr = "127.0.0.1"' - | save -f $conf - - # The first start creates and migrates the database; stop it before seeding, then run for real. - let bootstrap = (job spawn { ^$lms_bin $conf out+err> ($dir | path join "bootstrap.log") }) - let ready = (try { - poll -n 60 "lms database" {|| - try { http get -m 2sec $"http://127.0.0.1:($LMS_PORT)/rest/ping.view?u=x&p=x&v=1.16.0&c=subtracks-test" | ignore; true } catch { null } - } - true - } catch { false }) - job kill $bootstrap - if not $ready { error make { msg: "lms failed to initialise its database" } } - - let db = ($dir | path join "work/lms.db") - let user_sql = (r#'INSERT INTO user (version,type,login_name,bcrypt_round_count,password_salt,password_hash,subsonic_enable_transcoding_by_default,subsonic_default_transcode_format,subsonic_default_transcode_bitrate,subsonic_artist_list_mode,ui_theme,ui_artist_release_sort_method,ui_enable_inline_artist_relationships,ui_inline_artist_relationships,feedback_backend,scrobbling_backend,listenbrainz_token,lastfm_api_key,lastfm_api_secret,lastfm_session_key) VALUES (1,1,'admin',7,'salt','$2y$07$R6aUyqKU1HM4uX6dtpnh0OIsgqa5JID8qaH4syPlFokL3WoATtYim',0,2,128000,0,1,7,0,0,0,0,'','','','');'#) - ^sqlite3 $db $user_sql - let lib_sql = (r#'INSERT INTO media_library (version,path,name) VALUES (0,'MUSIC','Main');'# | str replace 'MUSIC' ((pwd) | path join $LIB)) - ^sqlite3 $db $lib_sql - let token_sql = (r#'INSERT INTO auth_token (version,domain,value,expiry,use_count,last_used,max_use_count,user_id) VALUES (1,'subsonic','KEY',NULL,0,NULL,NULL,(SELECT id FROM user WHERE login_name='admin'));'# | str replace 'KEY' $LMS_KEY) - ^sqlite3 $db $token_sql - - let daemon = (job spawn { ^$lms_bin $conf out+err> ($dir | path join "lms.log") }) - let started = (try { - poll "lms" {|| - let response = (try { sub-get $"http://127.0.0.1:($LMS_PORT)/" "admin" $LMS_KEY "ping" } catch { null }) - if $response == null { null } else { true } - } - sub-get $"http://127.0.0.1:($LMS_PORT)/" "admin" $LMS_KEY "startScan" | ignore - poll "lms scan" {|| - let scanning = (sub-get $"http://127.0.0.1:($LMS_PORT)/" "admin" $LMS_KEY "getScanStatus" | get scanStatus.scanning) - if (($scanning | into string | str lowercase) == "false") { true } else { null } - } - true - } catch { false }) - if not $started { do -i { job kill $daemon }; error make { msg: "lms did not become ready" } } - $daemon -} - -def start-nextcloud [work: string] { - let dir = ($work | path join "nextcloud") - mkdir $dir - ^cp -r $"($env.NEXTCLOUD_DIR)/." $dir - ^chmod -R u+w $dir - mkdir ($dir | path join "custom_apps") - ^cp -r $env.NEXTCLOUD_MUSIC_APP ($dir | path join "custom_apps/music") - ^chmod -R u+w ($dir | path join "custom_apps/music") - - let occ = ($dir | path join "occ") - ^php -d "memory_limit=512M" $occ "maintenance:install" "--database" "sqlite" "--admin-user" "admin" "--admin-pass" "password" "--data-dir" ($dir | path join "data") out+err> ($dir | path join "install.log") - ^php -d "memory_limit=512M" $occ "app:enable" "music" out+err> ($dir | path join "enable.log") - mkdir ($dir | path join "data/admin/files/Music") - ^cp -r $"((pwd) | path join $LIB)/." ($dir | path join "data/admin/files/Music") - ^php -d "memory_limit=512M" $occ "files:scan" "admin" out+err> ($dir | path join "files-scan.log") - ^php -d "memory_limit=512M" $occ "music:scan" "admin" out+err> ($dir | path join "music-scan.log") - - let hash = ($NC_KEY | hash sha256) - let key_sql = (r#'INSERT INTO oc_music_ampache_users (user_id, hash, description) VALUES ('admin','HASH','subtracks');'# | str replace 'HASH' $hash) - ^sqlite3 ($dir | path join "data/owncloud.db") $key_sql - - let daemon = (job spawn { ^php -d "memory_limit=512M" -S $"127.0.0.1:($NC_PORT)" -t $dir out+err> ($dir | path join "php.log") }) - let started = (try { - poll "nextcloud" {|| - try { http get -m 3sec $"http://127.0.0.1:($NC_PORT)/status.php" | ignore; true } catch { null } - } - true - } catch { false }) - if not $started { do -i { job kill $daemon }; error make { msg: "nextcloud did not become ready" } } - $daemon -} - -# A second, disposable navidrome over a private copy of two albums. The prune test deletes one -# of them and rescans, so this must not share a library with the servers above. -def start-prune [work: string] { - let dir = ($work | path join "prune") - let music = ($dir | path join "music") - mkdir ($dir | path join "data") - mkdir $music - ^cp -r $"((pwd) | path join $LIB)/197" $music - ^cp -r $"((pwd) | path join $LIB)/199" $music - - let daemon = (job spawn { ^navidrome --address 127.0.0.1 --port $PRUNE_PORT --musicfolder $music --datafolder ($dir | path join "data") --loglevel warn out+err> ($dir | path join "navidrome.log") }) - let ready = (try { - poll "prune navidrome" {|| - try { http get -m 2sec $"http://127.0.0.1:($PRUNE_PORT)/rest/ping.view?u=x&p=x&v=1.16.1&c=test" | ignore; true } catch { null } - } - http post -m 10sec -t application/json $"http://127.0.0.1:($PRUNE_PORT)/auth/createAdmin" { username: "admin", password: "password" } | ignore - poll "prune library" {|| - let albums = (try { - http get -m 5sec $"http://127.0.0.1:($PRUNE_PORT)/rest/getAlbumList2.view?u=admin&p=password&v=1.16.1&c=test&f=json&type=newest&size=500" - | get subsonic-response.albumList2.album - } catch { [] }) - if (($albums | length) == 2) { true } else { null } - } - true - } catch { false }) - if not $ready { do -i { job kill $daemon }; error make { msg: "prune navidrome did not become ready" } } - $daemon -} +use ./integration/util.nu [port-in-use] +use ./integration/servers.nu * def main [] { if (($env.NEXTCLOUD_DIR? | default "") | is-empty) { @@ -264,19 +15,17 @@ def main [] { ensure-music - for port in [$NAV_PORT $GONIC_PORT $LMS_PORT $NC_PORT $PRUNE_PORT] { + for port in $ports { if (port-in-use $port) { error make { msg: $"port ($port) is already in use; stop the leftover server 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 out+err> $"($work)/navidrome.log" }) + let nav = (start-navidrome $work) 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 out+err> $"($work)/gonic.log" }) + let gonic = (start-gonic $work) + mut lms = -1 mut nc = -1 mut prune = -1 @@ -288,25 +37,10 @@ def main [] { print "starting prune navidrome" $prune = (start-prune $work) - 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 "preparing fixtures" + for server in (test-servers) { prepare-fixtures $server } - 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" - setup-data $"http://127.0.0.1:($LMS_PORT)/" "admin" $LMS_KEY - setup-data $"http://127.0.0.1:($NC_PORT)/index.php/apps/music/subsonic/" "admin" $NC_KEY - - print "running integration tests" + print "running kotlin integration tests" let prune_base = $"http://localhost:($PRUNE_PORT)/" let prune_music = ($work | path join "prune/music") ^gradle :app:integrationTest --rerun --no-configuration-cache --console=plain $"-PpruneBaseUrl=($prune_base)" $"-PpruneMusicDir=($prune_music)" diff --git a/tools/integration/servers.nu b/tools/integration/servers.nu new file mode 100644 index 00000000..bfeb29e7 --- /dev/null +++ b/tools/integration/servers.nu @@ -0,0 +1,234 @@ +# Test servers: fixtures, lifecycle and the credentials the tests rely on. + +use ./util.nu * +use ./subsonic.nu * + +export const NAV_PORT = 4533 +export const GONIC_PORT = 4747 +export const LMS_PORT = 5082 +export const NC_PORT = 8090 +export const PRUNE_PORT = 4534 +export const LMS_KEY = "subtracks-lms" +export const NC_KEY = "subtracks-nextcloud" +export const LIB = ".integration/music" +export const ports = [$NAV_PORT $GONIC_PORT $LMS_PORT $NC_PORT $PRUNE_PORT] + +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 } +] + +# The servers the Kotlin integration tests and the fixtures below share. Keep this in step with +# TestServers.kt. +export def test-servers [] { + [ + { name: "navidrome", base: $"http://localhost:($NAV_PORT)/", user: "admin", pass: "password" } + { name: "gonic", base: $"http://localhost:($GONIC_PORT)/", user: "admin", pass: "admin" } + { name: "lms", base: $"http://localhost:($LMS_PORT)/", user: "admin", pass: $LMS_KEY } + { name: "nextcloud", base: $"http://localhost:($NC_PORT)/index.php/apps/music/subsonic/", user: "admin", pass: $NC_KEY } + ] +} + +def song-count [dir: string] { + if not ($dir | path exists) { return 0 } + ls $dir | where type == file | where {|f| $f.name =~ '(?i)\.mp3$' } | length +} + +export 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 +} + +export def start-navidrome [work: string] { + let data = ($work | path join "navidrome") + mkdir $data + let daemon = (job spawn { ^navidrome --address 127.0.0.1 --port $NAV_PORT --musicfolder $LIB --datafolder $data --loglevel warn out+err> ($work | path join "navidrome.log") }) + poll "navidrome" {|| + try { 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 } catch { null } + } + http post -m 10sec -t application/json $"http://127.0.0.1:($NAV_PORT)/auth/createAdmin" { username: "admin", password: "password" } | ignore + $daemon +} + +export def start-gonic [work: string] { + for dir in ["gonic-cache" "gonic-playlists" "gonic-podcasts"] { mkdir ($work | path join $dir) } + let daemon = (job spawn { + ^gonic -music-path $LIB -listen-addr $"127.0.0.1:($GONIC_PORT)" -db-path ($work | path join "gonic.db") -cache-path ($work | path join "gonic-cache") -playlists-path ($work | path join "gonic-playlists") -podcast-path ($work | path join "gonic-podcasts") -scan-at-start-enabled -http-log=false out+err> ($work | path join "gonic.log") + }) + poll "gonic" {|| + try { 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 } catch { null } + } + $daemon +} + +export def start-lms [work: string] { + let lms_bin = (which lms | get 0.path) + let lms_home = ($lms_bin | path dirname | path dirname) + let dir = ($work | path join "lms") + mkdir ($dir | path join "work") + let conf = ($dir | path join "lms.conf") + (open ($lms_home | path join "share/lms/lms.conf")) + | str replace --all 'working-dir = "/var/lms"' $"working-dir = \"($dir)/work\"" + | str replace --all 'listen-addr = "0.0.0.0"' 'listen-addr = "127.0.0.1"' + | save -f $conf + + # The first start creates and migrates the database; stop it before seeding, then run for real. + let bootstrap = (job spawn { ^$lms_bin $conf out+err> ($dir | path join "bootstrap.log") }) + let ready = (try { + poll -n 60 "lms database" {|| + try { http get -m 2sec $"http://127.0.0.1:($LMS_PORT)/rest/ping.view?u=x&p=x&v=1.16.0&c=subtracks-test" | ignore; true } catch { null } + } + true + } catch { false }) + job kill $bootstrap + if not $ready { error make { msg: "lms failed to initialise its database" } } + + let db = ($dir | path join "work/lms.db") + let user_sql = (r#'INSERT INTO user (version,type,login_name,bcrypt_round_count,password_salt,password_hash,subsonic_enable_transcoding_by_default,subsonic_default_transcode_format,subsonic_default_transcode_bitrate,subsonic_artist_list_mode,ui_theme,ui_artist_release_sort_method,ui_enable_inline_artist_relationships,ui_inline_artist_relationships,feedback_backend,scrobbling_backend,listenbrainz_token,lastfm_api_key,lastfm_api_secret,lastfm_session_key) VALUES (1,1,'admin',7,'salt','$2y$07$R6aUyqKU1HM4uX6dtpnh0OIsgqa5JID8qaH4syPlFokL3WoATtYim',0,2,128000,0,1,7,0,0,0,0,'','','','');'#) + ^sqlite3 $db $user_sql + let lib_sql = (r#'INSERT INTO media_library (version,path,name) VALUES (0,'MUSIC','Main');'# | str replace 'MUSIC' ((pwd) | path join $LIB)) + ^sqlite3 $db $lib_sql + let token_sql = (r#'INSERT INTO auth_token (version,domain,value,expiry,use_count,last_used,max_use_count,user_id) VALUES (1,'subsonic','KEY',NULL,0,NULL,NULL,(SELECT id FROM user WHERE login_name='admin'));'# | str replace 'KEY' $LMS_KEY) + ^sqlite3 $db $token_sql + + let daemon = (job spawn { ^$lms_bin $conf out+err> ($dir | path join "lms.log") }) + let started = (try { + poll "lms" {|| + let response = (try { sub-get $"http://127.0.0.1:($LMS_PORT)/" "admin" $LMS_KEY "ping" } catch { null }) + if $response == null { null } else { true } + } + sub-get $"http://127.0.0.1:($LMS_PORT)/" "admin" $LMS_KEY "startScan" | ignore + poll "lms scan" {|| + let scanning = (sub-get $"http://127.0.0.1:($LMS_PORT)/" "admin" $LMS_KEY "getScanStatus" | get scanStatus.scanning) + if (($scanning | into string | str lowercase) == "false") { true } else { null } + } + true + } catch { false }) + if not $started { do -i { job kill $daemon }; error make { msg: "lms did not become ready" } } + $daemon +} + +export def start-nextcloud [work: string] { + let dir = ($work | path join "nextcloud") + mkdir $dir + ^cp -r $"($env.NEXTCLOUD_DIR)/." $dir + ^chmod -R u+w $dir + mkdir ($dir | path join "custom_apps") + ^cp -r $env.NEXTCLOUD_MUSIC_APP ($dir | path join "custom_apps/music") + ^chmod -R u+w ($dir | path join "custom_apps/music") + + let occ = ($dir | path join "occ") + ^php -d "memory_limit=512M" $occ "maintenance:install" "--database" "sqlite" "--admin-user" "admin" "--admin-pass" "password" "--data-dir" ($dir | path join "data") out+err> ($dir | path join "install.log") + ^php -d "memory_limit=512M" $occ "app:enable" "music" out+err> ($dir | path join "enable.log") + mkdir ($dir | path join "data/admin/files/Music") + ^cp -r $"((pwd) | path join $LIB)/." ($dir | path join "data/admin/files/Music") + ^php -d "memory_limit=512M" $occ "files:scan" "admin" out+err> ($dir | path join "files-scan.log") + ^php -d "memory_limit=512M" $occ "music:scan" "admin" out+err> ($dir | path join "music-scan.log") + + let hash = ($NC_KEY | hash sha256) + let key_sql = (r#'INSERT INTO oc_music_ampache_users (user_id, hash, description) VALUES ('admin','HASH','subtracks');'# | str replace 'HASH' $hash) + ^sqlite3 ($dir | path join "data/owncloud.db") $key_sql + + let daemon = (job spawn { ^php -d "memory_limit=512M" -S $"127.0.0.1:($NC_PORT)" -t $dir out+err> ($dir | path join "php.log") }) + let started = (try { + poll "nextcloud" {|| + try { http get -m 3sec $"http://127.0.0.1:($NC_PORT)/status.php" | ignore; true } catch { null } + } + true + } catch { false }) + if not $started { do -i { job kill $daemon }; error make { msg: "nextcloud did not become ready" } } + $daemon +} + +# A second, disposable navidrome over a private copy of two albums. The prune test deletes one +# of them and rescans, so this must not share a library with the servers above. +export def start-prune [work: string] { + let dir = ($work | path join "prune") + let music = ($dir | path join "music") + mkdir ($dir | path join "data") + mkdir $music + ^cp -r $"((pwd) | path join $LIB)/197" $music + ^cp -r $"((pwd) | path join $LIB)/199" $music + + let daemon = (job spawn { ^navidrome --address 127.0.0.1 --port $PRUNE_PORT --musicfolder $music --datafolder ($dir | path join "data") --loglevel warn out+err> ($dir | path join "navidrome.log") }) + let ready = (try { + poll "prune navidrome" {|| + try { http get -m 2sec $"http://127.0.0.1:($PRUNE_PORT)/rest/ping.view?u=x&p=x&v=1.16.1&c=test" | ignore; true } catch { null } + } + http post -m 10sec -t application/json $"http://127.0.0.1:($PRUNE_PORT)/auth/createAdmin" { username: "admin", password: "password" } | ignore + poll "prune library" {|| + let albums = (try { + http get -m 5sec $"http://127.0.0.1:($PRUNE_PORT)/rest/getAlbumList2.view?u=admin&p=password&v=1.16.1&c=test&f=json&type=newest&size=500" + | get subsonic-response.albumList2.album + } catch { [] }) + if (($albums | length) == 2) { true } else { null } + } + true + } catch { false }) + if not $ready { do -i { job kill $daemon }; error make { msg: "prune navidrome did not become ready" } } + $daemon +} + +# Wait for a server to index the fixture music, then star an album and artist and add a playlist +# and scrobbles. The Kotlin integration tests assert against this state. +export def prepare-fixtures [server: record] { + let base = $server.base + let user = $server.user + let pass = $server.pass + + 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 = (named-id $artists "Ugress") + let kosmo = (named-id $albums "Kosmonaut") + let retro = (named-id $albums "Retroconnaissance EP") + let dunno = (named-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 +} diff --git a/tools/integration/subsonic.nu b/tools/integration/subsonic.nu new file mode 100644 index 00000000..7b6aec9f --- /dev/null +++ b/tools/integration/subsonic.nu @@ -0,0 +1,43 @@ +# Thin Subsonic API client used to inspect and prepare the test servers. + +# Nextcloud's Music app reads repeated (array) parameters straight from the raw query string +# without URL-decoding them, so ids must be sent with their unreserved characters intact. +export def url-escape [value: string] { + $value + | url encode + | str replace --all '%2D' '-' + | str replace --all '%2d' '-' + | str replace --all '%2E' '.' + | str replace --all '%2e' '.' + | str replace --all '%5F' '_' + | str replace --all '%5f' '_' + | str replace --all '%7E' '~' + | str replace --all '%7e' '~' +} + +export def sub-url [base: string, user: string, pass: string, method: string, params: list] { + let all = ([["u" $user] ["p" $pass] ["v" "1.16.1"] ["c" "subtracks-test"] ["f" "json"]] | append $params) + let query = ($all | each {|p| $"($p.0)=(url-escape ($p.1 | into string))" } | str join "&") + $"($base)rest/($method).view?($query)" +} + +export 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 +} + +export def named-id [entities: list, name: string] { + $entities | where {|e| $e.name == $name } | first | get id +} + +export 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 +} + +export 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 } } +} diff --git a/tools/integration/util.nu b/tools/integration/util.nu new file mode 100644 index 00000000..371cda03 --- /dev/null +++ b/tools/integration/util.nu @@ -0,0 +1,15 @@ +# Shared helpers for the integration harness: waiting and port checks. + +export def poll [label: string, check: closure, --attempts (-n): int = 120] { + for attempt in 0..<$attempts { + 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)" } +} + +export def port-in-use [port: int] { + (^curl -s -o /dev/null --max-time 1 $"http://127.0.0.1:($port)/" | complete).exit_code != 7 +}