cover lms and nextcloud music in the integration suite

Run the integration harness from its own devshell so the server packages stay out of the everyday shell. Add lms and a Nextcloud Music instance to the harness and extend the suite with auth-fallback, media, error, write round-trip, multi-source and prune tests.

The auth test caught that lms answers OpenSubsonic error 42 (not 41) when it refuses token auth, so the client now falls back on both codes.
This commit is contained in:
austinried
2026-10-04 00:30:05 +00:00
parent 2bea7fbb38
commit d635f3b9a0
16 changed files with 802 additions and 93 deletions
+6 -3
View File
@@ -11,7 +11,8 @@ A native Android rewrite of Subtracks, a client for Subsonic-compatible servers
Everything comes from the Nix flake devshell; do not install toolchains by hand.
- Enter it with `nix develop`, or let direnv load it into your shell (`.envrc` runs `use flake`).
- The devshell provides JDK 21, Gradle 9, the Android SDK (platform 37, build-tools 37.0.0, platform-tools 37.0.1), nushell, navidrome and gonic.
- The default devshell provides JDK 21, Gradle 9, the Android SDK (platform 37, build-tools 37.0.0, platform-tools 37.0.1) and nushell.
- The integration harness needs real servers, so it lives in a separate `nix develop .#integration` shell (navidrome, gonic, lms, php, Nextcloud with the Music app, sqlite). Keeping them out of the default shell avoids pulling that closure into everyday builds.
- Build with the devshell `gradle`, not `./gradlew`. CI uses the nix Gradle pinned by `flake.lock`; the wrapper is kept only for people without Nix.
## Commands
@@ -21,7 +22,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
- Unit tests: `gradle :app:testDebugUnitTest`
- Lint: `gradle :app:ktlintCheck :app:lintDebug`
- Format: `gradle :app:ktlintFormat`
- Integration tests: `./tools/integration-test.nu` (starts navidrome and gonic, then runs `:app:integrationTest`)
- Integration tests: `nix develop .#integration --command nu tools/integration-test.nu` (starts navidrome, gonic, lms and a Nextcloud Music instance, then runs `:app:integrationTest`)
- Screenshots: `gradle :app:recordRoborazziDebug` renders the `*Screen` composables to `app/src/test/screenshots/` (gitignored) plus an HTML report in `app/build/reports/roborazzi/`, for local review. No golden images are committed and `verifyRoborazziDebug` is not part of CI.
## Testing layout
@@ -57,5 +58,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
- Robolectric creates a fresh `Application` per test in one JVM, so `SubtracksApp.onCreate` stops any running Koin before `startKoin`.
- `applicationId` is `com.subtracks.next` for the beta; change it to `com.subtracks` before any store release.
- The CI image provides `nix-ld`, `jq`, `sqlite`, `node`, `zstd` and a `runner` user that `cache-nix-action` expects. None of that is needed locally beyond the devshell.
- Integration servers: navidrome on 4533 (`admin`/`password`), gonic on 4747 (`admin`/`admin`). The test music is cached in `.integration/music` (gitignored).
- Integration servers: navidrome on 4533 (`admin`/`password`), gonic on 4747 (`admin`/`admin`), lms on 5082 (`admin`/`subtracks-lms`), Nextcloud Music on 8090 (`admin`/`subtracks-nextcloud`, base URL `http://localhost:8090/index.php/apps/music/subsonic/`). The test music is cached in `.integration/music` (gitignored).
- lms and Nextcloud Music authenticate the Subsonic API with per-user **API keys**, not passwords or the `t`/`s` token scheme (both reject token auth with error 41). The harness seeds an API key and the integration tests use `useTokenAuth = false`, so the app's token-auth fallback is currently not exercised by integration tests.
- The integration shell takes its server packages from a pinned stable nixpkgs (`nixpkgs-stable`), because lms 3.80 in nixpkgs-unstable segfaults at startup with wt 4.14.3.
- `nix develop` does not source your shell rc files (so tools like atuin are not active); use direnv if you want your normal interactive shell.
+8
View File
@@ -84,7 +84,13 @@ aboutLibraries {
val integrationTestClasses =
listOf(
"com.subtracks.data.source.subsonic.SubsonicAuthIntegrationTest",
"com.subtracks.data.source.subsonic.SubsonicErrorIntegrationTest",
"com.subtracks.data.source.subsonic.SubsonicMediaIntegrationTest",
"com.subtracks.data.source.subsonic.SubsonicSourceIntegrationTest",
"com.subtracks.data.source.subsonic.SubsonicWriteIntegrationTest",
"com.subtracks.data.sync.MultiSourceSyncIntegrationTest",
"com.subtracks.data.sync.PruneSyncIntegrationTest",
"com.subtracks.data.sync.SyncServiceIntegrationTest",
)
@@ -106,6 +112,8 @@ tasks.register<Test>("integrationTest") {
filter {
integrationTestClasses.forEach { includeTestsMatching(it) }
}
systemProperty("prune.baseUrl", providers.gradleProperty("pruneBaseUrl").getOrElse(""))
systemProperty("prune.musicDir", providers.gradleProperty("pruneMusicDir").getOrElse(""))
}
dependencies {
@@ -0,0 +1,39 @@
package com.subtracks.data.source.subsonic
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class SubsonicAuthIntegrationTest(
private val server: TestServer,
) {
@Test
fun tokenAuthFallsBackToPlaintextWhenTheServerRejectsIt() =
runBlocking {
var fallbacks = 0
val source = SubsonicSource(1, TestServers.client(server, useTokenAuth = true) { fallbacks++ })
source.ping()
assertEquals("$server: fallback fired once", !server.supportsTokenAuth, fallbacks == 1)
source.ping()
assertEquals("$server: fallback is not retried", if (server.supportsTokenAuth) 0 else 1, fallbacks)
}
@Test
fun plaintextAuthPings() =
runBlocking {
TestServers.source(server, useTokenAuth = false).ping()
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun servers(): List<Array<Any>> = TestServers.all.map { arrayOf<Any>(it) }
}
}
@@ -0,0 +1,42 @@
package com.subtracks.data.source.subsonic
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class SubsonicErrorIntegrationTest(
private val server: TestServer,
) {
@Test
fun wrongPasswordThrowsCode40() =
runBlocking {
val bad = server.copy(password = "${server.password}-wrong")
val source = SubsonicSource(1, TestServers.client(bad))
val failure = runCatching { source.ping() }
val error = failure.exceptionOrNull()
assertTrue("$server: $failure", error is SubsonicException)
assertEquals("$server", 40, (error as SubsonicException).code)
}
@Test
fun unknownMethodThrows() =
runBlocking {
val client = TestServers.client(server)
val failure = runCatching { client.check("definitelyNotASubsonicMethod") }
assertTrue("$server: $failure", failure.exceptionOrNull() is SubsonicException)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun servers(): List<Array<Any>> = TestServers.all.map { arrayOf<Any>(it) }
}
}
@@ -0,0 +1,96 @@
package com.subtracks.data.source.subsonic
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class SubsonicMediaIntegrationTest(
private val server: TestServer,
) {
private val http = OkHttpClient()
@Test
fun streamsOriginalAudio() =
runBlocking {
val source = TestServers.source(server)
val song =
source
.songs()
.toList()
.flatten()
.first()
get(source.streamUri(song.id).toString()).use { response ->
assertTrue("$server: HTTP ${response.code}", response.isSuccessful)
val bytes = response.body.bytes()
assertTrue("$server: ${bytes.size} bytes", bytes.size > 1000)
assertFalse("$server: stream returned a text body", bytes.startsWithText())
}
}
@Test
fun downloadsOriginalAudio() =
runBlocking {
val source = TestServers.source(server)
val song =
source
.songs()
.toList()
.flatten()
.first()
get(source.downloadUri(song.id).toString()).use { response ->
assertTrue("$server: HTTP ${response.code}", response.isSuccessful)
val bytes = response.body.bytes()
assertTrue("$server: ${bytes.size} bytes", bytes.size > 1000)
}
}
@Test
fun fetchesCoverArtImage() =
runBlocking {
val source = TestServers.source(server)
val album =
source
.albums()
.toList()
.flatten()
.first { it.coverArt != null }
get(source.coverArtUri(album.coverArt)!!.toString()).use { response ->
assertTrue("$server: HTTP ${response.code}", response.isSuccessful)
val bytes = response.body.bytes()
assertTrue("$server: ${bytes.size} bytes", bytes.size > 100)
assertTrue("$server: not a JPEG or PNG", bytes.isJpegOrPng())
}
}
private fun get(url: String): Response = http.newCall(Request.Builder().url(url).build()).execute()
private fun ByteArray.startsWithText(): Boolean = decodeToString(0, minOf(8, size)).trimStart().startsWith("<")
private fun ByteArray.isJpegOrPng(): Boolean {
val jpeg = size >= 2 && this[0] == 0xFF.toByte() && this[1] == 0xD8.toByte()
val png =
size >= 4 &&
this[0] == 0x89.toByte() &&
this[1] == 0x50.toByte() &&
this[2] == 0x4E.toByte() &&
this[3] == 0x47.toByte()
return jpeg || png
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun servers(): List<Array<Any>> = TestServers.all.map { arrayOf<Any>(it) }
}
}
@@ -3,8 +3,6 @@ package com.subtracks.data.source.subsonic
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.toList
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
@@ -16,21 +14,13 @@ import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class SubsonicSourceIntegrationTest(
private val server: Server,
private val server: TestServer,
) {
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)
source = TestServers.source(server)
}
@Test
@@ -107,20 +97,9 @@ class SubsonicSourceIntegrationTest(
private suspend fun <T> Flow<List<T>>.collectAll(): List<T> = toList().flatten()
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<Array<Any>> =
listOf(
arrayOf(Server("navidrome", "http://localhost:4533/", "admin", "password")),
arrayOf(Server("gonic", "http://localhost:4747/", "admin", "admin")),
)
fun servers(): List<Array<Any>> = TestServers.all.map { arrayOf<Any>(it) }
}
}
@@ -0,0 +1,144 @@
package com.subtracks.data.source.subsonic
import com.subtracks.data.source.StarType
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class SubsonicWriteIntegrationTest(
private val server: TestServer,
) {
@Test
fun starAndUnstarAlbumRoundTrips() =
runBlocking {
val source = TestServers.source(server)
val album =
source
.albums()
.toList()
.flatten()
.first { it.name == "Retroconnaissance EP" }
source.setStar(StarType.Album, album.id, true)
try {
assertNotNull(
"$server",
source
.albums()
.toList()
.flatten()
.first { it.id == album.id }
.starred,
)
} finally {
source.setStar(StarType.Album, album.id, false)
}
assertNull(
"$server",
source
.albums()
.toList()
.flatten()
.first { it.id == album.id }
.starred,
)
}
@Test
fun starAndUnstarArtistRoundTrips() =
runBlocking {
val source = TestServers.source(server)
val artist =
source
.artists()
.toList()
.flatten()
.first { it.name == "Brad Sucks" }
source.setStar(StarType.Artist, artist.id, true)
try {
assertNotNull(
"$server",
source
.artists()
.toList()
.flatten()
.first { it.id == artist.id }
.starred,
)
} finally {
source.setStar(StarType.Artist, artist.id, false)
}
assertNull(
"$server",
source
.artists()
.toList()
.flatten()
.first { it.id == artist.id }
.starred,
)
}
@Test
fun starAndUnstarSongRoundTrips() =
runBlocking {
val source = TestServers.source(server)
val song =
source
.songs()
.toList()
.flatten()
.first { it.album == "Retroconnaissance EP" }
source.setStar(StarType.Song, song.id, true)
try {
assertNotNull(
"$server",
source
.songs()
.toList()
.flatten()
.first { it.id == song.id }
.starred,
)
} finally {
source.setStar(StarType.Song, song.id, false)
}
assertNull(
"$server",
source
.songs()
.toList()
.flatten()
.first { it.id == song.id }
.starred,
)
}
@Test
fun scrobbleAcceptsNowPlayingAndSubmission() =
runBlocking {
val source = TestServers.source(server)
val song =
source
.songs()
.toList()
.flatten()
.first()
source.scrobble(song.id, submission = false)
source.scrobble(song.id, submission = true, time = System.currentTimeMillis())
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun servers(): List<Array<Any>> = TestServers.all.map { arrayOf<Any>(it) }
}
}
@@ -0,0 +1,50 @@
package com.subtracks.data.source.subsonic
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
data class TestServer(
val name: String,
val baseUrl: String,
val username: String,
val password: String,
val supportsTokenAuth: Boolean,
) {
override fun toString(): String = name
}
object TestServers {
val all =
listOf(
TestServer("navidrome", "http://localhost:4533/", "admin", "password", supportsTokenAuth = true),
TestServer("gonic", "http://localhost:4747/", "admin", "admin", supportsTokenAuth = true),
TestServer("lms", "http://localhost:5082/", "admin", "subtracks-lms", supportsTokenAuth = false),
TestServer(
"nextcloud",
"http://localhost:8090/index.php/apps/music/subsonic/",
"admin",
"subtracks-nextcloud",
supportsTokenAuth = false,
),
)
fun client(
server: TestServer,
useTokenAuth: Boolean = false,
onTokenAuthUnsupported: () -> Unit = {},
): SubsonicClient =
SubsonicClient(
baseUrl = server.baseUrl.toHttpUrl(),
username = server.username,
password = server.password,
useTokenAuth = useTokenAuth,
http = OkHttpClient(),
onTokenAuthUnsupported = onTokenAuthUnsupported,
)
fun source(
server: TestServer,
id: Long = 1,
useTokenAuth: Boolean = false,
): SubsonicSource = SubsonicSource(id, client(server, useTokenAuth))
}
@@ -0,0 +1,54 @@
package com.subtracks.data.sync
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Source
import com.subtracks.data.source.subsonic.TestServers
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MultiSourceSyncIntegrationTest {
@Test
fun syncsEveryServerIntoOneLibraryWithoutInterference() =
runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
try {
TestServers.all.forEachIndexed { index, server ->
val id = index + 1L
db.sourcesDao().upsertSource(
Source(id = id, name = server.name, address = server.baseUrl, isActive = id == 1L, createdAt = 0),
)
SyncService(db, TestServers.source(server, id = id)).sync()
}
val library = db.libraryDao()
TestServers.all.forEachIndexed { index, server ->
val id = index + 1L
assertEquals(server.name, 2, library.artistIds(id).size)
assertEquals(server.name, 3, library.albumIds(id).size)
assertEquals(server.name, 20, library.songIdsAfter(id, "", 1000).size)
assertEquals(server.name, 1, library.playlistIds(id).size)
}
SyncService(db, TestServers.source(TestServers.all.first(), id = 1)).sync()
TestServers.all.drop(1).forEachIndexed { index, server ->
assertEquals("$server survived a resync of another source", 3, library.albumIds(index + 2L).size)
}
} finally {
db.close()
}
}
}
@@ -0,0 +1,98 @@
package com.subtracks.data.sync
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Source
import com.subtracks.data.source.subsonic.SubsonicClient
import com.subtracks.data.source.subsonic.SubsonicSource
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class PruneSyncIntegrationTest {
@Test
fun resyncPrunesAlbumsAndSongsRemovedFromTheServer() =
runBlocking {
val baseUrl = requireProperty("prune.baseUrl")
val musicDir = requireProperty("prune.musicDir")
val removed = File(musicDir, REMOVED_ALBUM_DIR)
check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" }
val context = ApplicationProvider.getApplicationContext<Context>()
val db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
try {
db.sourcesDao().upsertSource(
Source(id = 1, name = "prune", address = baseUrl, isActive = true, createdAt = 0),
)
val source =
SubsonicSource(
1,
SubsonicClient(
baseUrl = baseUrl.toHttpUrl(),
username = "admin",
password = "password",
useTokenAuth = false,
http = OkHttpClient(),
),
)
SyncService(db, source).sync()
val library = db.libraryDao()
assertEquals(1, library.artistIds(1).size)
assertEquals(2, library.albumIds(1).size)
assertEquals(8, library.songIdsAfter(1, "", 1000).size)
removed.deleteRecursively()
rescan(baseUrl)
SyncService(db, source).sync()
assertEquals(1, library.artistIds(1).size)
assertEquals(1, library.albumIds(1).size)
assertEquals(4, library.songIdsAfter(1, "", 1000).size)
} finally {
db.close()
}
}
private fun rescan(baseUrl: String) {
val http = OkHttpClient()
val root = baseUrl.trimEnd('/')
fun call(
method: String,
extra: String = "",
): String {
val url = "$root/rest/$method.view?u=admin&p=password&v=1.16.1&c=subtracks-test&f=json$extra"
return http.newCall(Request.Builder().url(url).build()).execute().use { it.body.string() }
}
call("startScan")
repeat(120) {
if (!call("getScanStatus").contains("\"scanning\":true")) return
Thread.sleep(500)
}
error("the prune server did not finish rescanning")
}
private fun requireProperty(name: String): String =
System.getProperty(name)?.takeIf { it.isNotBlank() }
?: error("$name is not set; run the integration harness (tools/integration-test.nu)")
private companion object {
const val REMOVED_ALBUM_DIR = "197"
}
}
@@ -7,11 +7,8 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Source
import com.subtracks.data.source.subsonic.SubsonicClient
import com.subtracks.data.source.subsonic.SubsonicSource
import com.subtracks.data.source.subsonic.TestServers
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -32,20 +29,7 @@ class SyncServiceIntegrationTest {
db.sourcesDao().upsertSource(
Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0),
)
val source =
SubsonicSource(
id = 1,
client =
SubsonicClient(
baseUrl = server.baseUrl.toHttpUrl(),
username = server.username,
password = server.password,
useTokenAuth = false,
http = OkHttpClient(),
),
)
SyncService(db, source).sync()
SyncService(db, TestServers.source(server)).sync()
val library = db.libraryDao()
assertEquals(server.name, 2, library.artistIds(1).size)
@@ -58,18 +42,7 @@ class SyncServiceIntegrationTest {
}
}
private data class Server(
val name: String,
val baseUrl: String,
val username: String,
val password: String,
)
private companion object {
val servers =
listOf(
Server("navidrome", "http://localhost:4533/", "admin", "password"),
Server("gonic", "http://localhost:4747/", "admin", "admin"),
)
val servers = TestServers.all
}
}
@@ -58,7 +58,7 @@ class SubsonicClient(
return try {
execute(method, params, body)
} catch (failure: SubsonicException) {
if (failure.code != TOKEN_AUTH_UNSUPPORTED || !usedTokenAuth) throw failure
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) throw failure
disableTokenAuth()
execute(method, params, body)
}
@@ -95,7 +95,10 @@ class SubsonicClient(
// stays at the highest minor they accept. OpenSubsonic fields do not depend on it.
const val API_VERSION = "1.15.0"
const val CLIENT = "subtracks"
const val TOKEN_AUTH_UNSUPPORTED = 41
// 41 is the classic Subsonic code for "token authentication not supported"; OpenSubsonic
// servers (lms) answer 42 instead, so both must disable token auth and retry.
val TOKEN_AUTH_UNSUPPORTED_CODES = setOf(41, 42)
private fun randomSalt(): String = (1..4).map { ('a'..'z').random() }.joinToString("")
@@ -148,6 +148,21 @@ class SubsonicClientTest {
assertEquals(1, notified)
}
@Test
fun openSubsonicAuthUnsupportedCodeAlsoRetriesWithPassword() {
server.enqueue(MockResponse().setBody(failed(42, "Provided authentication mechanism not supported.")))
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
var notified = 0
client(tokenAuth = true, onTokenAuthUnsupported = { notified++ }).check("ping")
val first = server.takeRequest().requestUrl!!
assertNotNull(first.queryParameter("t"))
val second = server.takeRequest().requestUrl!!
assertEquals("secret", second.queryParameter("p"))
assertEquals(1, notified)
}
@Test
fun tokenAuthUnsupportedIsNotifiedOnceAndNotRetriedAgain() {
server.enqueue(MockResponse().setBody(failed(41, "Token authentication not supported for LDAP users.")))
Generated
+18 -1
View File
@@ -16,9 +16,26 @@
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1790635395,
"narHash": "sha256-bNyvoIyOCu7lzoCpWKWGIsHpTtERUWzqYWFPlN++WTw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7fc6f2c20af09cdcaf48b92ec3121860139ec668",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs",
"nixpkgs-stable": "nixpkgs-stable"
}
}
},
+48 -18
View File
@@ -3,10 +3,18 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
# The integration shell runs real servers; pin them to a stable release. lms 3.80 (the
# version in unstable) segfaults at startup with wt 4.14.3, so the server packages come from
# here instead. The default shell never touches this input.
nixpkgs-stable.url = "github:NixOS/nixpkgs/nixos-26.05";
};
outputs =
{ nixpkgs, ... }:
{
nixpkgs,
nixpkgs-stable,
...
}:
let
systems = [ "x86_64-linux" ];
forEachSystem = nixpkgs.lib.genAttrs systems;
@@ -22,6 +30,7 @@
allowUnfree = true;
};
};
servers = import nixpkgs-stable { inherit system; };
android = pkgs.androidenv.composeAndroidPackages {
platformVersions = [ "37" ];
buildToolsVersions = [ "37.0.0" ];
@@ -33,30 +42,51 @@
includeCmake = false;
};
androidSdk = android.androidsdk;
basePackages = [
pkgs.jdk21
pkgs.gradle_9
pkgs.git
pkgs.curl
pkgs.unzip
pkgs.nushell
androidSdk
android.platform-tools
];
shellHook = ''
echo "subtracks dev shell"
echo " java: $(java -version 2>&1 | head -n1)"
echo " ANDROID_HOME: $ANDROID_HOME"
'';
in
{
default = pkgs.mkShell {
packages = [
pkgs.jdk21
pkgs.gradle_9
pkgs.git
pkgs.curl
pkgs.unzip
pkgs.nushell
pkgs.navidrome
pkgs.gonic
androidSdk
android.platform-tools
];
packages = basePackages;
ANDROID_HOME = "${androidSdk}/libexec/android-sdk";
ANDROID_SDK_ROOT = "${androidSdk}/libexec/android-sdk";
shellHook = ''
echo "subtracks dev shell"
echo " java: $(java -version 2>&1 | head -n1)"
echo " ANDROID_HOME: $ANDROID_HOME"
'';
inherit shellHook;
};
# The integration harness starts real Subsonic servers, none of which the app build or
# unit tests need. Kept out of the default shell so those stay free of the server deps.
integration = pkgs.mkShell {
packages = basePackages ++ [
servers.navidrome
servers.gonic
servers.lms
servers.sqlite
servers.php83
];
ANDROID_HOME = "${androidSdk}/libexec/android-sdk";
ANDROID_SDK_ROOT = "${androidSdk}/libexec/android-sdk";
NEXTCLOUD_DIR = "${servers.nextcloud34}";
NEXTCLOUD_MUSIC_APP = "${servers.nextcloud34Packages.apps.music}";
inherit shellHook;
};
}
);
+173 -15
View File
@@ -2,6 +2,11 @@
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 }
@@ -30,9 +35,24 @@ def ensure-music [] {
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<any>] {
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 "&")
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)"
}
@@ -44,8 +64,8 @@ def sub-get [base: string, user: string, pass: string, method: string, params: l
$response
}
def poll [label: string, check: closure] {
for attempt in 0..<120 {
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)..." }
@@ -116,18 +136,137 @@ def setup-data [base: string, user: string, pass: string] {
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
# 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
}
def main [] {
if (($env.NEXTCLOUD_DIR? | default "") | is-empty) {
error make { msg: "integration servers are not on PATH; enter the integration shell with 'nix develop .#integration'" }
}
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" } }
for port in [$NAV_PORT $GONIC_PORT $LMS_PORT $NC_PORT $PRUNE_PORT] {
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"] {
@@ -135,11 +274,20 @@ def main [] {
}
print "starting navidrome"
let nav = (job spawn { ^navidrome --address 127.0.0.1 --port $NAV_PORT --musicfolder $LIB --datafolder $"($work)/navidrome" --loglevel warn })
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" })
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 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" })
mut lms = -1
mut nc = -1
mut prune = -1
let outcome = (try {
print "starting lms"
$lms = (start-lms $work)
print "starting nextcloud"
$nc = (start-nextcloud $work)
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
@@ -155,17 +303,27 @@ def main [] {
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"
^gradle :app:integrationTest --rerun --no-configuration-cache --console=plain
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)"
$env.LAST_EXIT_CODE
} catch {|e|
print $"error: ($e.msg)"
($env.LAST_EXIT_CODE? | default 1)
})
let lms_id = $lms
let nc_id = $nc
let prune_id = $prune
do -i { job kill $nav }
do -i { job kill $gonic }
if $lms_id >= 0 { do -i { job kill $lms_id } }
if $nc_id >= 0 { do -i { job kill $nc_id } }
if $prune_id >= 0 { do -i { job kill $prune_id } }
rm -rf $work
exit $outcome