diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 283487c..e6d695b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -84,7 +84,8 @@ android { dependencies { testImplementation(libs.kotlinx.serialization.json) - testImplementation("junit:junit:4.13.2") + testImplementation(libs.junit) + testImplementation(libs.kotlin.test) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) diff --git a/app/src/main/kotlin/com/wisp/app/nostr/Blossom.kt b/app/src/main/kotlin/com/wisp/app/nostr/Blossom.kt index 822f16c..7dc2d7f 100644 --- a/app/src/main/kotlin/com/wisp/app/nostr/Blossom.kt +++ b/app/src/main/kotlin/com/wisp/app/nostr/Blossom.kt @@ -1,6 +1,7 @@ package com.wisp.app.nostr import android.util.Base64 +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.security.MessageDigest object Blossom { @@ -8,10 +9,28 @@ object Blossom { const val KIND_AUTH = 24242 const val DEFAULT_SERVER = "https://blossom.primal.net" + /** + * Extract Blossom server URLs from the events tags and returns them as a list. + * + * The event must be a Blossom server list event (BUD-03)(10063). Tags that are not defined by + * BUD-03 or are invalid (i.e. invalid URL) are ignored. The returned list is deduplicated and + * ordered by first occurrence in the event. + * + * @throws IllegalArgumentException if [event] is not kind [KIND_SERVER_LIST] + */ fun parseServerList(event: NostrEvent): List { - return event.tags.mapNotNull { tag -> - if (tag.size >= 2 && tag[0] == "server") tag[1] else null + require(event.kind == KIND_SERVER_LIST) { + "Expected kind $KIND_SERVER_LIST event, got kind ${event.kind}" } + return event.tags.mapNotNull { tag -> + if (tag.size >= 2 && tag[0] == "server") { + val url = tag[1].toHttpUrlOrNull() + url?.toString() + } + else { + null + } + }.distinct() } fun buildServerListTags(urls: List): List> { diff --git a/app/src/test/kotlin/com/wisp/app/nostr/BlossomTest.kt b/app/src/test/kotlin/com/wisp/app/nostr/BlossomTest.kt new file mode 100644 index 0000000..29adbad --- /dev/null +++ b/app/src/test/kotlin/com/wisp/app/nostr/BlossomTest.kt @@ -0,0 +1,131 @@ +package com.wisp.app.nostr + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class BlossomTest { + private fun eventWith( + kind: Int = Blossom.KIND_SERVER_LIST, + tags: List> = emptyList() + ): NostrEvent { + return NostrEvent.createUnsigned( + pubkeyHex = "0".repeat(64), + kind = kind, + content = "", + tags = tags, + createdAt = 1L + ) + } + + @Test + fun `throws IllegalArgumentException when event kind is not server list`() { + val event = eventWith( + kind = 1, + tags = emptyList() + ) + + assertFailsWith { + Blossom.parseServerList(event) + } + } + + @Test + fun `returns empty list when event has no tags`() { + val event = eventWith( + kind = Blossom.KIND_SERVER_LIST, + tags = emptyList() + ) + + val result = Blossom.parseServerList(event) + + assertEquals(emptyList(), result) + } + + @Test + fun `ignores empty too short and non server tags`() { + val event = eventWith( + kind = Blossom.KIND_SERVER_LIST, + tags = listOf( + emptyList(), + listOf("server"), + listOf("p", "abc"), + listOf("serverish", "https://example.com") + ) + ) + + val result = Blossom.parseServerList(event) + + assertEquals(emptyList(), result) + } + + @Test + fun `ignores server tags with invalid urls`() { + val event = eventWith( + kind = Blossom.KIND_SERVER_LIST, + tags = listOf( + listOf("server", ""), + listOf("server", "not a url"), + listOf("server", "ftp://example.com") + ) + ) + + val result = Blossom.parseServerList(event) + + assertEquals(emptyList(), result) + } + + @Test + fun `returns url from valid server tag`() { + val event = eventWith( + kind = Blossom.KIND_SERVER_LIST, + tags = listOf( + listOf("server", "https://example.com") + ) + ) + + val result = Blossom.parseServerList(event) + + assertEquals(listOf("https://example.com/"), result) + } + + @Test + fun `returns url from valid server tag with extra fields`() { + val event = eventWith( + kind = Blossom.KIND_SERVER_LIST, + tags = listOf( + listOf("server", "https://example.com", "extra") + ) + ) + + val result = Blossom.parseServerList(event) + + assertEquals(listOf("https://example.com/"), result) + } + + @Test + fun `returns distinct valid urls in first occurrence order`() { + val event = eventWith( + kind = Blossom.KIND_SERVER_LIST, + tags = listOf( + listOf("server", "https://b.com"), + listOf("p", "abc"), + listOf("server", "not a url"), + listOf("server", "https://a.com"), + listOf("server", "https://b.com"), + listOf("server", "https://c.com") + ) + ) + + val result = Blossom.parseServerList(event) + + assertEquals( + listOf( + "https://b.com/", + "https://a.com/", + "https://c.com/" + ), + result + ) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 498d191..4beb508 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +junit = "4.13.2" kotlin = "2.0.21" agp = "8.13.2" compose-bom = "2024.12.01" @@ -34,6 +35,8 @@ compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } compose-material3 = { group = "androidx.compose.material3", name = "material3" } compose-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } +junit = { module = "junit:junit", version.ref = "junit" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" } lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }