Migrates EventStore from Android's SQLLite to KMP

Fixes testing of libsodium between java and android
This commit is contained in:
Vitor Pamplona
2026-03-20 16:00:17 -04:00
parent c5066d89c3
commit d431b12f94
53 changed files with 22041 additions and 513 deletions
@@ -1,129 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.database.sqlite.SQLiteConstraintException
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail
import org.junit.Test
class AddressableTest : BaseDBTest() {
val signer = NostrSignerSync()
@Test
fun testReplacingAddressables() =
forEachDB { db ->
val time = TimeUtils.now()
val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version1)
db.assertQuery(version1, Filter(ids = listOf(version1.id)))
db.assertQuery(version1, addressableQuery)
db.insert(version2)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(version2, Filter(ids = listOf(version2.id)))
db.assertQuery(version2, addressableQuery)
db.insert(version3)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
}
@Test
fun testBlockingOldAddressables() =
forEachDB { db ->
val time = TimeUtils.now()
val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version3)
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
try {
db.insert(version2)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
try {
db.insert(version1)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(null, Filter(ids = listOf(version1.id)))
}
@Test
fun testTriggersIndexUsage() =
forEachDB { db ->
val explainer =
db.store.explainQuery(
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 30000 AND
event_headers.pubkey = 'aa' AND
event_headers.d_tag = 'test-tag' AND
event_headers.created_at < 1766686500 AND
event_headers.kind >= 30000 AND event_headers.kind < 40000
""".trimIndent(),
)
assertEquals(
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 30000 AND
event_headers.pubkey = 'aa' AND
event_headers.d_tag = 'test-tag' AND
event_headers.created_at < 1766686500 AND
event_headers.kind >= 30000 AND event_headers.kind < 40000
└── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
""".trimIndent(),
explainer,
)
}
}
@@ -1,83 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import junit.framework.TestCase
fun <T : Event> EventStore.assertQuery(
expected: T?,
filter: Filter,
) {
val queryResult = query<T>(filter)
val countResult = count(filter)
if (expected == null) {
TestCase.assertEquals(0, queryResult.size)
TestCase.assertEquals(0, countResult)
} else {
TestCase.assertEquals(1, queryResult.size)
TestCase.assertEquals(1, countResult)
TestCase.assertEquals(expected.toJson(), queryResult.first().toJson())
}
}
fun <T : Event> EventStore.assertQuery(
expected: List<T>,
filter: Filter,
) {
val queryResult = query<T>(filter)
val countResult = count(filter)
TestCase.assertEquals(expected.size, queryResult.size)
TestCase.assertEquals(expected.size, countResult)
expected.forEachIndexed { index, event ->
TestCase.assertEquals(event.toJson(), queryResult[index].toJson())
}
}
fun <T : Event> SQLiteEventStore.assertQuery(
expected: T?,
filter: Filter,
) {
val queryResult = query<T>(filter)
val countResult = count(filter)
if (expected == null) {
TestCase.assertEquals(0, queryResult.size)
TestCase.assertEquals(0, countResult)
} else {
TestCase.assertEquals(1, queryResult.size)
TestCase.assertEquals(1, countResult)
TestCase.assertEquals(expected.toJson(), queryResult.first().toJson())
}
}
fun <T : Event> SQLiteEventStore.assertQuery(
expected: List<T>,
filter: Filter,
) {
val queryResult = query<T>(filter)
val countResult = count(filter)
TestCase.assertEquals(expected.size, queryResult.size)
TestCase.assertEquals(expected.size, countResult)
expected.forEachIndexed { index, event ->
TestCase.assertEquals(event.toJson(), queryResult[index].toJson())
}
}
@@ -1,84 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.After
import org.junit.Before
open class BaseDBTest {
private lateinit var dbs: MutableMap<String, EventStore>
fun DefaultIndexingStrategy.name(): String =
"""
indexEventsByCreatedAtAlone=$indexEventsByCreatedAtAlone
indexTagsByCreatedAtAlone=$indexTagsByCreatedAtAlone
indexTagsWithKindAndPubkey=$indexTagsWithKindAndPubkey
useAndIndexIdOnOrderBy=$useAndIndexIdOnOrderBy
""".trimIndent()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
val booleans = listOf(true, false)
dbs = mutableMapOf<String, EventStore>()
// tests all possible DBs
for (indexEventsByCreatedAtAlone in booleans) {
for (indexTagsByCreatedAtAlone in booleans) {
for (indexTagsWithKindAndPubkey in booleans) {
for (useAndIndexIdOnOrderBy in booleans) {
val indexStrategy =
DefaultIndexingStrategy(
indexEventsByCreatedAtAlone,
indexTagsByCreatedAtAlone,
indexTagsWithKindAndPubkey,
useAndIndexIdOnOrderBy,
)
dbs[indexStrategy.name()] =
EventStore(
context = context,
dbName = null,
indexStrategy = indexStrategy,
)
}
}
}
}
}
@After
fun tearDown() {
dbs.forEach { it.value.close() }
}
fun forEachDB(action: (EventStore) -> Unit) {
dbs.forEach {
println("--------------------")
println(it.key)
println("--------------------")
action(it.value)
}
}
}
@@ -1,241 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class BasicTest : BaseDBTest() {
val signer = NostrSignerSync()
companion object {
val profile =
MetadataEvent(
id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe",
pubKey = "70a9b3c312a6b83e476739bd29d60ca700da1d5b982cbca87b5f3d27d4038d67",
createdAt = 1740669816,
tags =
arrayOf(
arrayOf("alt", "User profile for Vitor"),
arrayOf("name", "Vitor"),
),
content = "{\"name\":\"Vitor\"}",
sig = "977a6152199f17d103d8d56736ed1b7767054464cf9423d017c01c8cdd2344698f0a5e13da8dff98d01bb1f798837e3b6271e1fd1cac861bb90686f622ae6ef4",
)
val comment =
CommentEvent(
id = "fecb2ecf61a1433d417a784d10bd1e8ec19a916170a53ca8fb3a15fc666a6592",
pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
createdAt = 1747753115,
tags =
arrayOf(
arrayOf("alt", "Reply to geo:drt3n"),
arrayOf("I", "geo:drt3n"),
arrayOf("I", "geo:drt3"),
arrayOf("I", "geo:drt"),
arrayOf("I", "geo:dr"),
arrayOf("I", "geo:d"),
arrayOf("K", "geo"),
arrayOf("i", "geo:drt3n"),
arrayOf("i", "geo:drt3"),
arrayOf("i", "geo:drt"),
arrayOf("i", "geo:dr"),
arrayOf("i", "geo:d"),
arrayOf("k", "geo"),
),
content = "testing",
sig = "12070e663272f1227c639fb834eb2122fc7bb995f4c49e55ebb1dfe2135ef7347d44810bacd2e64fd26b8826fd47d2800ce6c3d3b579bb3afe39088ffd4faa60",
)
}
@Test
fun testInsertDeleteEvent() =
forEachDB { db ->
val note = signer.sign(TextNoteEvent.build("test1"))
db.store.insertEvent(note)
db.store.assertQuery(note, Filter(ids = listOf(note.id)))
db.store.delete(note.id)
db.store.assertQuery(null, Filter(ids = listOf(note.id)))
db.store.insertEvent(note)
db.store.assertQuery(note, Filter(ids = listOf(note.id)))
}
@Test
fun testEmptyFilter() =
forEachDB { db ->
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1))
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2))
db.store.insertEvent(note1)
db.store.assertQuery(note1, Filter())
db.store.insertEvent(note2)
db.store.assertQuery(listOf(note2, note1), Filter())
}
@Test
fun testLimitFilter() =
forEachDB { db ->
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1))
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2))
val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = 3))
val note4 = signer.sign(TextNoteEvent.build("test4", createdAt = 4))
db.store.insertEvent(note1)
db.store.assertQuery(note1, Filter(limit = 1))
db.store.insertEvent(note2)
db.store.insertEvent(note3)
db.store.insertEvent(note4)
db.store.assertQuery(listOf(note4), Filter(limit = 1))
}
@Test
fun testPubkeyTag() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.assertQuery(
comment,
Filter(authors = listOf(comment.pubKey), tags = mapOf("I" to listOf("geo:drt3n"))),
)
}
@Test
fun testTagOnly() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.assertQuery(comment, Filter(tags = mapOf("I" to listOf("geo:drt3n"))))
}
@Test
fun testTagWithSinceOnly() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt - 1),
)
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt),
)
db.store.assertQuery(
null,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt + 1),
)
}
@Test
fun testTagWithUntilOnly() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.assertQuery(
null,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt - 1),
)
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt),
)
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt + 1),
)
}
@Test
fun testTagWithUntilOnlyEmitting() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.query<Event>(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event ->
assertEquals(comment.toJson(), event.toJson())
}
}
@Test
fun hashCodeTest() =
forEachDB { db ->
val note1 =
signer.sign(
TextNoteEvent.build("test1") {
hashtag("AaAa")
},
)
val note2 =
signer.sign(
TextNoteEvent.build("test2") {
hashtag("AaAa")
},
)
val note3 =
signer.sign(
TextNoteEvent.build("test3") {
hashtag("BBBB")
},
)
db.store.insertEvent(note1)
db.store.insertEvent(note2)
db.store.insertEvent(note3)
val list =
db.query<TextNoteEvent>(
Filter(
tags = mapOf("t" to listOf("AaAa")),
),
)
assertEquals(2, list.size)
list.forEach {
assertTrue(it.isTaggedHash("AaAa"))
}
}
}
@@ -1,408 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.database.sqlite.SQLiteConstraintException
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase
import junit.framework.TestCase.fail
import org.junit.Assert.assertEquals
import org.junit.Test
class DeletionTest : BaseDBTest() {
val signer = NostrSignerSync()
@Test
fun testInsertDeleteEvent() =
forEachDB { db ->
val note1 = signer.sign(TextNoteEvent.build("test1"))
val note2 = signer.sign(TextNoteEvent.build("test2"))
val note3 = signer.sign(TextNoteEvent.build("test3"))
db.insert(note1)
db.insert(note2)
db.insert(note3)
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.build(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
}
@Test
fun testInsertDeleteEventOfAddressable() =
forEachDB { db ->
val time = TimeUtils.now()
val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
db.insert(note1)
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
db.insert(note2)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.insert(note3)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.build(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
}
@Test
fun testInsertDeleteEventOfAddressable2() =
forEachDB { db ->
val time = TimeUtils.now()
val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
db.insert(note1)
db.insert(note2)
db.insert(note3)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.buildAddressOnly(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
}
@Test
fun testInsertDeleteWrap() =
forEachDB { db ->
val me = NostrSignerSync()
val myFriend = NostrSignerSync()
val note1 = me.sign(TextNoteEvent.build("test1"))
val wrap1 = GiftWrapEvent.create(note1, me.pubKey)
val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey)
db.insert(wrap1)
db.insert(wrap2)
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val randomDeletionToWrap = signer.sign(DeletionEvent.build(listOf(wrap1)))
db.insert(randomDeletionToWrap)
db.assertQuery(randomDeletionToWrap, Filter(ids = listOf(randomDeletionToWrap.id)))
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val deletion = me.sign(DeletionEvent.build(listOf(wrap1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
// trying to insert again should fail.
try {
db.insert(wrap1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
}
@Test
fun testTriggersIndexUsage() =
forEachDB { db ->
var sql = db.store.deletionModule.rejectDeletedEventsSQLTemplate()
sql = sql.replace("NEW.etag_hash", "3221122")
sql = sql.replace("NEW.atag_hash", "223322")
sql = sql.replace("NEW.pubkey_owner_hash", "22332323")
sql = sql.replace("NEW.created_at", "1766686500")
val explainer = db.store.explainQuery(sql)
if (db.indexStrategy.indexTagsWithKindAndPubkey) {
TestCase.assertEquals(
"""
|$sql
|└── SEARCH event_tags USING COVERING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=? AND created_at>?)
""".trimMargin(),
explainer,
)
} else {
TestCase.assertEquals(
"""
|$sql
|└── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=? AND created_at>?)
""".trimMargin(),
explainer,
)
}
}
@Test
fun testDeleteById() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = listOf("ca29c211f", "ca29c211d"),
addresses = emptyList(),
hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE
id IN ("ca29c211f","ca29c211d") AND
pubkey_owner_hash = "1573573083296714675"
├── SEARCH event_headers USING INDEX event_headers_id (id=?)
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
@Test
fun testDeleteAddressable() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = emptyList(),
addresses =
listOf(
Address(30000, "key1", "a"),
),
hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE (
(kind = "30000" AND pubkey = "key1" AND d_tag = "a")
) AND
kind >= 30000 AND kind < 40000
├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
@Test
fun testDeleteAddressablesSingleKind() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = emptyList(),
addresses =
listOf(
Address(30000, "key1", "a"),
Address(30000, "key1", "b"),
Address(30000, "key1", "c"),
Address(30000, "key1", "d"),
),
hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE (
(kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d"))
) AND
kind >= 30000 AND kind < 40000
├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
@Test
fun testDeleteAddressablesMultipleKinds() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = emptyList(),
addresses =
listOf(
Address(30000, "key1", "a"),
Address(30000, "key1", "b"),
Address(30101, "key1", "c"),
Address(30101, "key1", "d"),
Address(30001, "key2", "e"),
Address(30001, "key2", "f"),
),
hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE (
(kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b"))
OR
(kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d"))
) AND
kind >= 30000 AND kind < 40000
├── MULTI-INDEX OR
│ ├── INDEX 1
│ │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
│ └── INDEX 2
│ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
@Test
fun testDeleteReplaceables() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = emptyList(),
addresses =
listOf(
Address(10000, "key1", ""),
Address(10000, "key1", ""),
Address(10001, "key1", ""),
Address(10001, "key1", ""),
Address(10001, "key2", ""),
Address(10001, "key2", ""),
),
hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE
kind IN ("10000","10001") AND
pubkey = "key1" AND
((kind in (0,3)) OR (kind >= 10000 AND kind < 20000))
├── SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?)
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
}
@@ -1,88 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.database.sqlite.SQLiteConstraintException
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase.fail
import org.junit.Assert.assertTrue
import org.junit.Test
class ExpirationTest : BaseDBTest() {
val signer = NostrSignerSync()
@Test
fun testDeletingExpiredEvents() =
forEachDB { db ->
val time = TimeUtils.now()
val noteSafe =
signer.sign(
TextNoteEvent.build("test1", createdAt = time + 1) {
expiration(time + 100)
},
)
db.insert(noteSafe)
val noteToExpire =
signer.sign(
TextNoteEvent.build("test1", createdAt = time + 1) {
expiration(time + 1)
},
)
db.insert(noteToExpire)
db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id)))
Thread.sleep(2000)
db.deleteExpiredEvents()
db.assertQuery(null, Filter(ids = listOf(noteToExpire.id)))
db.assertQuery(noteSafe, Filter(ids = listOf(noteSafe.id)))
}
@Test
fun testInsertingExpiredEvents() =
forEachDB { db ->
val time = TimeUtils.now()
val note1 =
signer.sign(
TextNoteEvent.build("test1", createdAt = time - 12) {
expiration(time - 10)
},
)
try {
db.insert(note1)
fail("Should not be able to insert expired events")
} catch (e: Exception) {
assertTrue(e is SQLiteConstraintException)
}
}
}
@@ -1,165 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import org.junit.Test
class FilterMatcherTest : BaseDBTest() {
val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758"
val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d"
val createdAt: Long = 1683596206
val kind = 1
val pTag1 = "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
val pTag2 = "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"
val pTag3 = "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"
val pTag4 = "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"
val pTag5 = "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"
val pTag6 = "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
val pTag7 = "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"
val pTag8 = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val rootETag = "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3"
val replyETag = "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc"
val note =
Event(
id,
pubkey,
createdAt,
kind,
arrayOf(
arrayOf("e", rootETag, "", "root"),
arrayOf("e", replyETag, "", "reply"),
arrayOf("p", pTag1),
arrayOf("p", pTag1),
arrayOf("p", pTag2),
arrayOf("p", pTag3),
arrayOf("p", pTag4),
arrayOf("p", pTag5),
arrayOf("p", pTag6),
arrayOf("p", pTag7),
arrayOf("p", pTag8),
),
"Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ",
"4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce",
)
@Test
fun matchIds() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(ids = listOf(id)))
db.assertQuery(note, Filter(ids = listOf(id, rootETag)))
db.assertQuery(null, Filter(ids = listOf(rootETag)))
}
@Test
fun matchPubkeys() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(authors = listOf(pubkey)))
db.assertQuery(note, Filter(authors = listOf(pubkey, rootETag)))
db.assertQuery(null, Filter(authors = listOf(rootETag)))
}
@Test
fun matchTags() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id))))
db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id))))
}
@Test
fun matchDualTags() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey))))
db.assertQuery(null, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey))))
db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id), "e" to listOf(rootETag))))
}
@Test
fun matchAllTags() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1))))
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id))))
}
@Test
fun matchDualAllTags() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag))))
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id), "e" to listOf(rootETag))))
}
@Test
fun matchKinds() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(kinds = listOf(kind)))
db.assertQuery(note, Filter(kinds = listOf(kind, 1221)))
db.assertQuery(null, Filter(kinds = listOf(1221)))
}
@Test
fun matchSince() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(since = createdAt))
db.assertQuery(note, Filter(since = createdAt - 1))
db.assertQuery(null, Filter(since = createdAt + 1))
}
@Test
fun matchUntil() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(until = createdAt))
db.assertQuery(note, Filter(until = createdAt + 1))
db.assertQuery(null, Filter(until = createdAt - 1))
}
}
@@ -1,102 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteException
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.utils.Log
import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import java.util.zip.GZIPInputStream
import kotlin.system.measureTimeMillis
@RunWith(AndroidJUnit4::class)
class LargeDBTests {
companion object {
fun getEventDB(): List<Event> {
// This file includes duplicates
val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_startup_data.json")
return JacksonMapper.mapper.readValue<ArrayList<Event>>(
GZIPInputStream(fullDBInputStream),
)
}
val events = getEventDB().distinctBy { it.id }.filter { !it.isExpired() }.sortedBy { it.createdAt }
}
private lateinit var db: EventStore
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test
fun insertHeavyEvent() {
events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event ->
try {
val measure =
measureTimeMillis {
db.insert(event)
}
if (measure > 1) {
println("Inserted event ${event.id} of kind ${event.kind} in $measure ms")
}
} catch (e: SQLiteException) {
Log.w("LargeDBTests", "Error inserting event: ${e.message} for event: ${event.toJson()}")
}
}
}
@Test
@Ignore("Not testing")
fun insertDatabase() {
events.forEach { event ->
try {
val measure =
measureTimeMillis {
db.insert(event)
}
if (measure > 1) {
println("Inserted event ${event.id} of kind ${event.kind} in $measure ms")
}
} catch (e: SQLiteException) {
Log.w("LargeDBTests", "Error inserting event: ${e.message} for event: ${event.toJson()}")
}
}
}
}
@@ -1,152 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.database.sqlite.SQLiteConstraintException
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail
import org.junit.Test
class ReplaceableTest : BaseDBTest() {
val signer = NostrSignerSync()
@Test
fun testReplacing() =
forEachDB { db ->
val time = TimeUtils.now()
val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time))
val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1))
val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version1)
db.assertQuery(version1, Filter(ids = listOf(version1.id)))
db.assertQuery(version1, addressableQuery)
db.insert(version2)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(version2, Filter(ids = listOf(version2.id)))
db.assertQuery(version2, addressableQuery)
db.insert(version3)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
}
@Test
fun testBlockingOldVersions() =
forEachDB { db ->
val time = TimeUtils.now()
val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time))
val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1))
val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version3)
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
try {
db.insert(version2)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
try {
db.insert(version1)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(null, Filter(ids = listOf(version1.id)))
}
@Test
fun testTriggersIndexUsageKind0() =
forEachDB { db ->
val sql =
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 0 AND
event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500
""".trimIndent()
val explainer = db.store.explainQuery(sql)
assertEquals(
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 0 AND
event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500
└── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at<?)
""".trimIndent(),
explainer,
)
}
@Test
fun testTriggersIndexUsageKind3() =
forEachDB { db ->
val sql =
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 3 AND
event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500
""".trimIndent()
val explainer = db.store.explainQuery(sql)
assertEquals(
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 3 AND
event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500
└── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at<?)
""".trimIndent(),
explainer,
)
}
}
@@ -1,121 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.database.sqlite.SQLiteConstraintException
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase.fail
import org.junit.Assert.assertEquals
import org.junit.Test
class RightToVanishTest : BaseDBTest() {
val signer = NostrSignerSync()
@Test
fun testInsertDeleteEvent() =
forEachDB { db ->
val time = TimeUtils.now()
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = time))
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = time + 1))
val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = time + 2))
db.insert(note1)
db.insert(note2)
db.insert(note3)
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val vanish = signer.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2))
db.insert(vanish)
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
}
@Test
fun testInsertDeleteGiftWrap() =
forEachDB { db ->
val time = TimeUtils.now()
val me = NostrSignerSync()
val myFriend = NostrSignerSync()
val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time))
val wrap1 = GiftWrapEvent.create(note1, me.pubKey)
val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey)
db.insert(wrap1)
db.insert(wrap2)
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val randomVanishToWrap = signer.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2))
db.insert(randomVanishToWrap)
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val vanish = me.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2))
db.insert(vanish)
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
// trying to insert again should fail.
try {
db.insert(wrap1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
}
}
@@ -1,92 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import org.junit.Test
class SearchTest : BaseDBTest() {
companion object {
val profile =
MetadataEvent(
id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe",
pubKey = "70a9b3c312a6b83e476739bd29d60ca700da1d5b982cbca87b5f3d27d4038d67",
createdAt = 1740669816,
tags =
arrayOf(
arrayOf("alt", "User profile for Vitor"),
arrayOf("name", "Vitor"),
),
content = "{\"name\":\"Vitor\"}",
sig = "977a6152199f17d103d8d56736ed1b7767054464cf9423d017c01c8cdd2344698f0a5e13da8dff98d01bb1f798837e3b6271e1fd1cac861bb90686f622ae6ef4",
)
val comment =
CommentEvent(
id = "fecb2ecf61a1433d417a784d10bd1e8ec19a916170a53ca8fb3a15fc666a6592",
pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
createdAt = 1747753115,
tags =
arrayOf(
arrayOf("alt", "Reply to geo:drt3n"),
arrayOf("I", "geo:drt3n"),
arrayOf("I", "geo:drt3"),
arrayOf("I", "geo:drt"),
arrayOf("I", "geo:dr"),
arrayOf("I", "geo:d"),
arrayOf("K", "geo"),
arrayOf("i", "geo:drt3n"),
arrayOf("i", "geo:drt3"),
arrayOf("i", "geo:drt"),
arrayOf("i", "geo:dr"),
arrayOf("i", "geo:d"),
arrayOf("k", "geo"),
),
content = "testing",
sig = "12070e663272f1227c639fb834eb2122fc7bb995f4c49e55ebb1dfe2135ef7347d44810bacd2e64fd26b8826fd47d2800ce6c3d3b579bb3afe39088ffd4faa60",
)
}
@Test
fun testTagWithSearch() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.assertQuery(null, Filter(search = "testing1"))
db.assertQuery(comment, Filter(search = "testing"))
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
db.assertQuery(null, Filter(kinds = listOf(TextNoteEvent.KIND), search = "testing"))
db.store.delete(comment.id)
db.assertQuery(null, Filter(search = "testing"))
db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
db.store.insertEvent(comment)
db.assertQuery(comment, Filter(search = "testing"))
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
}
}