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:
Claude
2026-04-25 13:56:28 +00:00
parent e9e994fff4
commit 9f83feff47
5 changed files with 278 additions and 0 deletions
@@ -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 ->
@@ -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 ->
@@ -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 ->
@@ -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"))
}
}
@@ -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)
}
}