test(quartz/sqlite): cover transaction rollback, deletion permissions, vanish scope, FTS rotation, vacuum, SQL DSL
Batch B test-coverage gaps from the review: - testTransactionRollsBackOnTriggerAbort: a multi-insert transaction whose middle statement is rejected by `reject_deleted_events` rolls back ALL inserts in the transaction, not just the failing one. - testDeletionByThirdPartyDoesNothing: NIP-09 author check — another user's deletion event must not remove the original. - testKind5CanBeDeletedByAnotherKind5OfSameAuthor: pins the current behavior that kind-5 events are not specially protected; a same-author follow-up deletion can remove a previous one, and re-insertion of the removed deletion is then blocked. - testGiftWrapDeletionRequiresRecipient: NIP-59 GiftWraps key on the recipient (p-tag), not the (encrypted) inner author. Sender and unrelated third parties cannot delete; the recipient can. - testVanishForDifferentRelayIsNoOp: a kind-62 vanish naming a different relay must be stored but must not delete events on this relay nor block new inserts (RightToVanishModule.shouldVanishFrom contract). - testFtsCleanedUpAfterReplaceableRotation: FTS rows are cleaned via the AFTER DELETE trigger when an addressable is superseded — old content must no longer match search. - testVacuumAndAnalyseSmoke: VACUUM and ANALYZE run on a populated DB without throwing and preserve existing rows. - SqlSelectionBuilderTest: pins NotEquals(null) → IS NOT NULL, empty IN → "1 = 0", equalsOrIn singleton/multiple paths. https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
This commit is contained in:
+46
@@ -20,16 +20,19 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import androidx.sqlite.SQLiteException
|
||||
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.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BasicTest : BaseDBTest() {
|
||||
@@ -253,6 +256,49 @@ class BasicTest : BaseDBTest() {
|
||||
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTransactionRollsBackOnTriggerAbort() =
|
||||
forEachDB { db ->
|
||||
// Pre-load a target event and a deletion that blocks re-insertion.
|
||||
val deletedTarget = signer.sign(TextNoteEvent.build("target", createdAt = 1))
|
||||
val deletion = signer.sign(DeletionEvent.build(listOf(deletedTarget), createdAt = 100))
|
||||
db.insert(deletion)
|
||||
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
|
||||
|
||||
val keptInThisTx = signer.sign(TextNoteEvent.build("kept-in-tx", createdAt = 2))
|
||||
|
||||
// A trigger ABORT in the middle of a multi-insert transaction must
|
||||
// roll BOTH inserts back — `reject_deleted_events` only undoes its
|
||||
// own statement, but the wrapping `connection.transaction` extension
|
||||
// is responsible for ROLLBACK-ing the whole batch.
|
||||
assertFailsWith<SQLiteException> {
|
||||
db.transaction {
|
||||
insert(keptInThisTx)
|
||||
insert(deletedTarget) // blocked by reject_deleted_events
|
||||
}
|
||||
}
|
||||
|
||||
db.assertQuery(null, Filter(ids = listOf(keptInThisTx.id)))
|
||||
db.assertQuery(null, Filter(ids = listOf(deletedTarget.id)))
|
||||
// The deletion stored before the failed transaction must remain.
|
||||
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVacuumAndAnalyseSmoke() =
|
||||
forEachDB { db ->
|
||||
// Smoke test: VACUUM and ANALYZE must not throw on a populated DB
|
||||
// and must leave existing rows intact. Also catches the comments
|
||||
// for these two functions getting swapped again.
|
||||
val note = signer.sign(TextNoteEvent.build("vacuum me"))
|
||||
db.insert(note)
|
||||
|
||||
db.store.analyse()
|
||||
db.store.vacuum()
|
||||
|
||||
db.assertQuery(note, Filter(ids = listOf(note.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSchemaRecreateIsIdempotent() =
|
||||
forEachDB { db ->
|
||||
|
||||
+93
@@ -415,6 +415,99 @@ class DeletionTest : BaseDBTest() {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeletionByThirdPartyDoesNothing() =
|
||||
forEachDB { db ->
|
||||
val owner = NostrSignerSync()
|
||||
val stranger = NostrSignerSync()
|
||||
|
||||
val target = owner.sign(TextNoteEvent.build("private"))
|
||||
db.insert(target)
|
||||
db.assertQuery(target, Filter(ids = listOf(target.id)))
|
||||
|
||||
// A deletion event from a *different* author must not remove the
|
||||
// target — only the original author can delete via NIP-09.
|
||||
val strangerDeletion = stranger.sign(DeletionEvent.build(listOf(target)))
|
||||
db.insert(strangerDeletion)
|
||||
|
||||
db.assertQuery(target, Filter(ids = listOf(target.id)))
|
||||
db.assertQuery(strangerDeletion, Filter(ids = listOf(strangerDeletion.id)))
|
||||
|
||||
// The owner's deletion still works after a failed third-party attempt.
|
||||
val ownerDeletion = owner.sign(DeletionEvent.build(listOf(target)))
|
||||
db.insert(ownerDeletion)
|
||||
db.assertQuery(null, Filter(ids = listOf(target.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKind5CanBeDeletedByAnotherKind5OfSameAuthor() =
|
||||
forEachDB { db ->
|
||||
// Pinning current behavior: a kind-5 event is treated like any
|
||||
// other event for ownership purposes — the same author can issue
|
||||
// a second deletion that removes the first. Cross-author
|
||||
// deletion of a kind-5 must NOT work.
|
||||
val target = signer.sign(TextNoteEvent.build("target"))
|
||||
val firstDeletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = 100))
|
||||
|
||||
db.insert(target)
|
||||
db.insert(firstDeletion)
|
||||
db.assertQuery(null, Filter(ids = listOf(target.id)))
|
||||
db.assertQuery(firstDeletion, Filter(ids = listOf(firstDeletion.id)))
|
||||
|
||||
// Stranger cannot delete the kind-5.
|
||||
val stranger = NostrSignerSync()
|
||||
val strangerDeletion =
|
||||
stranger.sign(DeletionEvent.build(listOf(firstDeletion), createdAt = 200))
|
||||
db.insert(strangerDeletion)
|
||||
db.assertQuery(firstDeletion, Filter(ids = listOf(firstDeletion.id)))
|
||||
|
||||
// Same author can delete their previous kind-5.
|
||||
val secondDeletion =
|
||||
signer.sign(DeletionEvent.build(listOf(firstDeletion), createdAt = 300))
|
||||
db.insert(secondDeletion)
|
||||
db.assertQuery(null, Filter(ids = listOf(firstDeletion.id)))
|
||||
db.assertQuery(secondDeletion, Filter(ids = listOf(secondDeletion.id)))
|
||||
|
||||
// First deletion now blocked from re-insertion (own e-tag in event_tags).
|
||||
assertFailsWith<SQLiteException> {
|
||||
db.insert(firstDeletion)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGiftWrapDeletionRequiresRecipient() =
|
||||
forEachDB { db ->
|
||||
// GiftWraps key their owner off the p-tag (recipient), since the
|
||||
// outer wrap is signed by an ephemeral random key per NIP-59. The
|
||||
// inner author isn't visible to the relay, so only the recipient
|
||||
// can delete the wrap.
|
||||
val recipient = NostrSignerSync()
|
||||
val sender = NostrSignerSync()
|
||||
val unrelated = NostrSignerSync()
|
||||
|
||||
val inner = sender.sign(TextNoteEvent.build("secret"))
|
||||
val wrap = GiftWrapEvent.create(inner, recipient.pubKey)
|
||||
|
||||
db.insert(wrap)
|
||||
db.assertQuery(wrap, Filter(ids = listOf(wrap.id)))
|
||||
|
||||
// Inner author (the "sender") cannot delete a wrap addressed to
|
||||
// someone else — sender pubkey != wrap's pubkey_owner_hash.
|
||||
val senderDeletion = sender.sign(DeletionEvent.build(listOf(wrap)))
|
||||
db.insert(senderDeletion)
|
||||
db.assertQuery(wrap, Filter(ids = listOf(wrap.id)))
|
||||
|
||||
// An unrelated third party can't delete it either.
|
||||
val unrelatedDeletion = unrelated.sign(DeletionEvent.build(listOf(wrap)))
|
||||
db.insert(unrelatedDeletion)
|
||||
db.assertQuery(wrap, Filter(ids = listOf(wrap.id)))
|
||||
|
||||
// The recipient (whose pubkey matches pubkey_owner_hash) can.
|
||||
val recipientDeletion = recipient.sign(DeletionEvent.build(listOf(wrap)))
|
||||
db.insert(recipientDeletion)
|
||||
db.assertQuery(null, Filter(ids = listOf(wrap.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeletionDoesNotRemoveNewerAddressable() =
|
||||
forEachDB { db ->
|
||||
|
||||
+31
@@ -69,6 +69,37 @@ class RightToVanishTest : BaseDBTest() {
|
||||
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVanishForDifferentRelayIsNoOp() =
|
||||
forEachDB { db ->
|
||||
// The default EventStore is bound to wss://quartz.local. A vanish
|
||||
// request that names a *different* relay must not delete the
|
||||
// user's events on this relay — RightToVanishModule.insert checks
|
||||
// shouldVanishFrom(relay) and returns without writing event_vanish.
|
||||
val time = TimeUtils.now()
|
||||
val note = signer.sign(TextNoteEvent.build("kept", createdAt = time))
|
||||
db.insert(note)
|
||||
db.assertQuery(note, Filter(ids = listOf(note.id)))
|
||||
|
||||
val vanishElsewhere =
|
||||
signer.sign(
|
||||
RequestToVanishEvent.build(
|
||||
"wss://some-other-relay.example".normalizeRelayUrl(),
|
||||
createdAt = time + 1,
|
||||
),
|
||||
)
|
||||
db.insert(vanishElsewhere)
|
||||
|
||||
// The vanish event itself is stored, but it must not delete
|
||||
// events from this relay nor block re-insertion of new ones.
|
||||
db.assertQuery(vanishElsewhere, Filter(ids = listOf(vanishElsewhere.id)))
|
||||
db.assertQuery(note, Filter(ids = listOf(note.id)))
|
||||
|
||||
val newNote = signer.sign(TextNoteEvent.build("still accepted", createdAt = time + 2))
|
||||
db.insert(newNote)
|
||||
db.assertQuery(newNote, Filter(ids = listOf(newNote.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsertDeleteGiftWrap() =
|
||||
forEachDB { db ->
|
||||
|
||||
+40
@@ -22,11 +22,16 @@ 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.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.test.Test
|
||||
|
||||
class SearchTest : BaseDBTest() {
|
||||
val signer = NostrSignerSync()
|
||||
|
||||
companion object {
|
||||
val profile =
|
||||
MetadataEvent(
|
||||
@@ -89,4 +94,39 @@ class SearchTest : BaseDBTest() {
|
||||
db.assertQuery(comment, Filter(search = "testing"))
|
||||
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFtsCleanedUpAfterReplaceableRotation() =
|
||||
forEachDB { db ->
|
||||
// The fts_foreign_key trigger fires on event_headers DELETE, so
|
||||
// when an addressable is superseded its FTS row should also be
|
||||
// removed. Otherwise stale content would keep matching searches.
|
||||
val time = TimeUtils.now()
|
||||
val v1 =
|
||||
signer.sign(
|
||||
LongTextNoteEvent.build(
|
||||
"first version uniqalpha",
|
||||
title = "blog title",
|
||||
dTag = "fts-rotation",
|
||||
createdAt = time,
|
||||
),
|
||||
)
|
||||
val v2 =
|
||||
signer.sign(
|
||||
LongTextNoteEvent.build(
|
||||
"second version uniqbeta",
|
||||
title = "blog title",
|
||||
dTag = "fts-rotation",
|
||||
createdAt = time + 1,
|
||||
),
|
||||
)
|
||||
|
||||
db.store.insertEvent(v1)
|
||||
db.assertQuery(v1, Filter(search = "uniqalpha"))
|
||||
|
||||
db.store.insertEvent(v2)
|
||||
// v1's FTS row must be gone; v2's content takes over.
|
||||
db.assertQuery(null, Filter(search = "uniqalpha"))
|
||||
db.assertQuery(v2, Filter(search = "uniqbeta"))
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.sql
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SqlSelectionBuilderTest {
|
||||
@Test
|
||||
fun equalsWithNullProducesIsNull() {
|
||||
val clause = where { equals("col", null) }
|
||||
assertEquals("col IS NULL", clause.conditions)
|
||||
assertEquals(emptyList(), clause.args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notEqualsWithNullProducesIsNotNull() {
|
||||
val clause = where { notEquals("col", null) }
|
||||
assertEquals("col IS NOT NULL", clause.conditions)
|
||||
assertEquals(emptyList(), clause.args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notEqualsWithValueProducesPlaceholder() {
|
||||
val clause = where { notEquals("col", "x") }
|
||||
assertEquals("col != ?", clause.conditions)
|
||||
assertEquals(listOf("x"), clause.args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyInBecomesAlwaysFalse() {
|
||||
val clause = where { isIn("col", emptyList()) }
|
||||
assertEquals("1 = 0", clause.conditions)
|
||||
assertEquals(emptyList(), clause.args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun equalsOrInPicksEqualsForSingleton() {
|
||||
val clause = where { equalsOrIn("col", listOf("x")) }
|
||||
assertEquals("col = ?", clause.conditions)
|
||||
assertEquals(listOf("x"), clause.args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun equalsOrInPicksInForMultiple() {
|
||||
val clause = where { equalsOrIn("col", listOf("a", "b", "c")) }
|
||||
assertEquals("col IN (?, ?, ?)", clause.conditions)
|
||||
assertEquals(listOf("a", "b", "c"), clause.args)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user