test(nostr): improve and unit test Blossom.parseServerList (#613)

* chore(test): use version catalog for test deps

* refactor(nostr): require event kind 10063 in `Blossom.parseServerList`

`Blossom.parseServerList` currently has a single caller:
`BlossomRepository.updateFromEvent`. That caller already ignores
non-kind 10063 events before invoking the method, so the added `require`
does not change repository behavior.

The method already assumed kind 10063 input; by making this explicit,
the requirement is documented, testable, and prevents future callers
missing it.

* feat(nostr): ignore invalid urls in `Blossom.parseServerList`

`Blossom.parseServerList` returns a list of advertised servers which
must be valid urls as per BUD3.

* feat(nostr): return distinct blossom server urls

`Blossom.parseServerList` returns distinct server urls parsed from the
events tags. Duplicated list items are not useful and can lead to bugs
or unnecessary overhead if not anticipated.

* chore(nostr): document `Blossom.parseServerList`

* test(nostr): unit test Blossom.parseServerList
This commit is contained in:
Nicola Susca
2026-07-04 15:38:30 -04:00
committed by GitHub
parent bb0a196cc0
commit cf0a157674
4 changed files with 157 additions and 3 deletions
+2 -1
View File
@@ -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)
@@ -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<String> {
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<String>): List<List<String>> {
@@ -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<List<String>> = 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<IllegalArgumentException> {
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
)
}
}
+3
View File
@@ -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" }