Files
amethyst/desktopApp/plans/2026-05-12-feat-desktop-wallet-zapping-test-coverage-plan.md
Claude 6579b5f656 docs: audit, status-stamp, and index all module plans
Audited all 143 plan files across the 10 plans/ folders. Each plan now
carries a Status header (shipped | in-progress | queued | abandoned)
backed by codebase evidence, and every folder has a README.md index
grouping plans by status.

Shipped plans were moved into a per-folder plans/archive/ (via git mv,
history preserved) so each plans/ folder surfaces only live work:

  shipped (archived): 122   in-progress: 8   queued: 7   abandoned: 4

docs/plans/ is the frozen legacy folder; its plans were stamped and
indexed in place (48 of 52 archived) but it remains closed to new plans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
2026-06-30 15:35:38 +00:00

18 KiB

title, type, status, date, origin
title type status date origin
feat: Desktop Wallet & Zapping Test Coverage feat active 2026-05-12 desktopApp/plans/2026-05-12-wallet-zapping-phase1-2-plan.md

Desktop Wallet & Zapping Test Coverage

Status: in-progress — Phase 1 (NwcPaymentHandlerTest) and Phase 4 (NwcRpcIntegrationTest) shipped, but Phase 2 (WalletColumnStateTest) and Phase 3 (ZapDialogLogicTest) are absent from the codebase. Audited 2026-06-30.

Enhancement Summary (Deepened 2026-05-12)

Key implementation insights from deepening research:

  1. mockk callback capture: Use slot<(Event, NormalizedRelayUrl) -> Unit>() + capture(slot) to intercept relay subscription callbacks and feed simulated wallet responses
  2. GlobalScope.launch problem: NwcPaymentHandler uses GlobalScope.launch(Dispatchers.IO) inside callbacks — tests need real time via withTimeout(5.seconds) to await (or refactor to inject scope)
  3. LnZapPaymentResponseEvent.createResponse() exists — build real encrypted test responses with deterministic keys, no need to mock encryption
  4. runTest auto-advances virtual time — timeout tests resolve instantly without real delays
  5. Codebase prefers anonymous objects over mockk for complex interfaces (see RelayConnectionManagerTest)

Overview

Add comprehensive test coverage for the desktop wallet column and enhanced zapping UX. Quartz already has 160+ NIP-47 protocol tests (request/response serialization, URI parsing, Alby interop). This plan covers the desktop-specific layer: NwcPaymentHandler RPC methods, wallet column state management, and zap dialog logic.

Existing Coverage (Already Done)

Layer Module Tests Status
NIP-47 URI parsing quartz Nip47WalletConnectTest.kt Complete
NWC request serialization quartz RequestTest.kt (50+ cases) Complete
NWC response parsing quartz ResponseTest.kt (50+ cases) Complete
NWC event encryption quartz LnZapPaymentRequestEventTest.kt Complete
Alby interop quartz AlbyInteropTest.kt (60+ cases) Complete
Account management desktopApp AccountManager*Test.kt (5 files) Complete
Cache pipeline desktopApp DesktopCachePipelineTest.kt Complete

New Coverage Needed

Component Location Test Type Priority
NwcPaymentHandler desktopApp Unit (mockk) P0
Wallet column state desktopApp State management P1
Zap dialog logic desktopApp Unit P1
NWC RPC round-trip desktopApp Integration P2

Test Framework & Patterns

From codebase research:

// Framework: kotlin.test + mockk + kotlinx-coroutines-test
import kotlin.test.*
import io.mockk.*
import kotlinx.coroutines.test.runTest

// Pattern: relaxed mocks for infrastructure
val relayManager = mockk<DesktopRelayConnectionManager>(relaxed = true)

// Pattern: deterministic keys for crypto
val keyPair = KeyPair()
val signer = NostrSignerInternal(keyPair)

// Pattern: StateFlow assertions via .value
assertEquals(expected, stateFlow.value)

// Pattern: BeforeTest/AfterTest lifecycle
@BeforeTest fun setup() { ... }
@AfterTest fun teardown() { ... }

Phase 1: NwcPaymentHandler Unit Tests (P0)

File: desktopApp/src/jvmTest/kotlin/.../nwc/NwcPaymentHandlerTest.kt

Tests the 3 NWC RPC methods + error/timeout handling. Uses mockk to mock DesktopRelayConnectionManager and DesktopLocalCache, then simulates wallet responses via the onEvent callback.

Test Cases

payInvoice

# Test Setup Expected
1 payInvoice returns Success on valid response Mock relay, simulate PayInvoiceSuccessResponse PaymentResult.Success(preimage)
2 payInvoice returns Error on wallet error Simulate PayInvoiceErrorResponse PaymentResult.Error(message)
3 payInvoice returns Timeout when no response No simulated response, short timeout PaymentResult.Timeout
4 payInvoice returns Error when no secret nwcConnection with null secret PaymentResult.Error("no secret")
5 payInvoice publishes to correct relay Capture publishToRelay args Correct relay URI + valid event
6 payInvoice subscribes with correct filter Capture subscribeOnRelay args Filter has kind 23195, author = wallet pubkey
7 payInvoice tracks payment in zapped note Provide zappedNote mock zappedNote.addZapPayment() called
8 payInvoice cleans up subscription on cancel Cancel coroutine mid-flight closeSubscription() called

getBalance

# Test Setup Expected
9 getBalance returns Success with msats Simulate GetBalanceSuccessResponse(balance=50000) BalanceResult.Success(50000)
10 getBalance returns Error on wallet error Simulate NwcErrorResponse BalanceResult.Error(message)
11 getBalance returns Timeout No response, short timeout BalanceResult.Timeout
12 getBalance returns Error when no secret null secret BalanceResult.Error
13 getBalance uses Nip47Client.fromNip47URI Verify request event is kind 23194 Correct NWC request structure

makeInvoice

# Test Setup Expected
14 makeInvoice returns invoice on success Simulate MakeInvoiceSuccessResponse InvoiceResult.Success(invoice, hash)
15 makeInvoice returns Error when no invoice in response Simulate success with null invoice InvoiceResult.Error("no invoice")
16 makeInvoice returns Error on wallet error Simulate NwcErrorResponse InvoiceResult.Error(message)
17 makeInvoice returns Timeout No response InvoiceResult.Timeout
18 makeInvoice sends correct amount in msats Capture request, verify amount field Amount matches input

Implementation Pattern

class NwcPaymentHandlerTest {
    private lateinit var relayManager: DesktopRelayConnectionManager
    private lateinit var localCache: DesktopLocalCache
    private lateinit var handler: NwcPaymentHandler
    private lateinit var nwcConnection: Nip47WalletConnect.Nip47URINorm

    // Capture the onEvent callback from subscribeOnRelay so we can
    // feed simulated wallet responses back into the handler.
    private var capturedOnEvent: ((Event, NormalizedRelayUrl) -> Unit)? = null

    @BeforeTest
    fun setup() {
        relayManager = mockk(relaxed = true)
        localCache = mockk(relaxed = true)
        handler = NwcPaymentHandler(relayManager, localCache)

        // Build a deterministic NWC connection
        val walletKeyPair = KeyPair()
        val clientKeyPair = KeyPair()
        nwcConnection = Nip47WalletConnect.Nip47URINorm(
            pubKeyHex = walletKeyPair.pubKey.toHexKey(),
            relayUri = NormalizedRelayUrl("wss://relay.test/"),
            secret = clientKeyPair.privKey!!.toHexKey(),
        )

        // Capture the onEvent callback
        every {
            relayManager.subscribeOnRelay(
                relay = any(),
                subId = any(),
                filters = any(),
                onEvent = capture(capturedOnEvent),
            )
        } answers { /* store callback */ }

        // Cache verification always passes
        coEvery { localCache.justVerify(any()) } returns true
    }

    @Test
    fun `payInvoice returns Success on valid response`() = runTest {
        // Simulate wallet response in a launched coroutine
        // after handler subscribes
        launch {
            delay(50) // Let handler subscribe first
            val responseEvent = buildPayInvoiceResponse(
                requestId = /* captured from subscribe filter */,
                preimage = "abc123",
                walletSigner = walletSigner,
                clientPubKey = clientKeyPair.pubKey.toHexKey(),
            )
            capturedOnEvent?.invoke(responseEvent, nwcConnection.relayUri)
        }

        val result = handler.payInvoice(
            bolt11 = "lnbc50n1...",
            nwcConnection = nwcConnection,
        )

        assertIs<NwcPaymentHandler.PaymentResult.Success>(result)
        assertEquals("abc123", result.preimage)
    }
}

Challenge: The subscribeOnRelay callback runs asynchronously. Tests must capture the onEvent lambda and invoke it with a simulated LnZapPaymentResponseEvent. This requires building encrypted response events using the wallet's signer — similar to quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt.

Alternative (simpler): If capturing the relay callback is too complex, extract the response processing logic into a testable pure function and test that directly, then integration-test the full flow separately.


Phase 2: Wallet Column State Tests (P1)

File: desktopApp/src/jvmTest/kotlin/.../ui/wallet/WalletColumnStateTest.kt

Tests the state management logic of WalletColumnScreen without Compose UI. Extract state logic into a testable class if needed.

Test Cases

# Test Expected
19 initial state is HOME with no balance currentScreen=HOME, balanceSats=null
20 navigating to CONNECT and back preserves state Screen transitions correctly
21 connecting with valid URI transitions to HOME After setNwcConnection, screen=HOME
22 connecting with invalid URI shows error connectionError is set
23 disconnect clears connection nwcConnection becomes null
24 balance updates after refresh balanceSats reflects RPC result
25 send success clears invoice field sendInvoice="" after success
26 send error shows error message sendResult contains error
27 receive generates invoice generatedInvoice is set
28 screen navigation: HOME->SEND->HOME Back button returns to HOME

Implementation Approach

Currently the wallet column state is all var inside the composable. To test without Compose, either:

Option A: Extract state holder class

class WalletColumnState {
    var currentScreen by mutableStateOf(WalletScreen.HOME)
    var balanceSats by mutableStateOf<Long?>(null)
    var isLoadingBalance by mutableStateOf(false)
    // ... etc

    fun navigateToConnect() { currentScreen = WalletScreen.CONNECT }
    fun navigateBack() { currentScreen = WalletScreen.HOME }
}

Option B: Test via AccountManager integration

Test that AccountManager.setNwcConnection() and clearNwcConnection() work correctly (these are already partially covered by existing AccountManager tests — verify and extend).

Recommendation: Option A (extract state holder) gives the cleanest test surface. Minimal refactor — move state vars into a class, test the class directly.


Phase 3: Zap Dialog Logic Tests (P1)

File: desktopApp/src/jvmTest/kotlin/.../ui/ZapDialogLogicTest.kt

Tests the zap amount/type selection logic without Compose rendering.

Test Cases

# Test Expected
29 default amount is first preset selectedAmount = 21
30 selecting preset updates amount selectedAmount = 500 after click
31 custom amount mode enables text input useCustom = true
32 custom amount parses to long effectiveAmount = 1337
33 invalid custom amount results in 0 effectiveAmount = 0 for "abc"
34 zap type defaults to PUBLIC selectedType = ZapType.PUBLIC
35 zap type changes to PRIVATE selectedType = ZapType.PRIVATE
36 zap type changes to ANONYMOUS selectedType = ZapType.ANONYMOUS
37 empty message is valid message = "" is accepted
38 confirm disabled when amount is 0 enabled = false when custom=""
39 DEFAULT_ZAP_AMOUNTS has expected values [21, 100, 500, 1000, 5000, 10000]
40 formatSats formats thousands formatSats(5000) = "5k"
41 formatSats keeps small numbers formatSats(100) = "100"

Implementation

class ZapDialogLogicTest {
    @Test
    fun `formatSats formats thousands with k suffix`() {
        assertEquals("5k", formatSats(5000))
        assertEquals("10k", formatSats(10000))
        assertEquals("1k", formatSats(1000))
    }

    @Test
    fun `formatSats preserves small amounts`() {
        assertEquals("21", formatSats(21))
        assertEquals("100", formatSats(100))
        assertEquals("500", formatSats(500))
    }

    @Test
    fun `DEFAULT_ZAP_AMOUNTS contains expected presets`() {
        assertEquals(listOf(21L, 100L, 500L, 1000L, 5000L, 10000L), DEFAULT_ZAP_AMOUNTS)
    }

    @Test
    fun `ZapType has correct labels`() {
        assertEquals("Public", ZapType.PUBLIC.label)
        assertEquals("Private", ZapType.PRIVATE.label)
        assertEquals("Anonymous", ZapType.ANONYMOUS.label)
    }
}

Note: formatSats and DEFAULT_ZAP_AMOUNTS are currently private in NoteActions.kt. Either make them internal (for test access) or extract to a utility object.


Phase 4: NWC RPC Integration Tests (P2)

File: desktopApp/src/jvmTest/kotlin/.../nwc/NwcRpcIntegrationTest.kt

Full round-trip test: create NWC request event -> encrypt -> decrypt -> verify request content -> build response -> encrypt -> decrypt -> verify. Uses real crypto (NostrSignerInternal) with no mocking.

Test Cases

# Test Description
42 pay_invoice round-trip Build request, verify BOLT11 in decrypted content
43 get_balance round-trip Build request, create balance response, verify amount
44 make_invoice round-trip Build request with amount, verify in decrypted content
45 error response round-trip Build error response, verify error code and message
46 NWC URI -> Nip47Client -> request event Full client pipeline test

Implementation Pattern

class NwcRpcIntegrationTest {
    private val clientKeyPair = KeyPair()
    private val walletKeyPair = KeyPair()
    private val clientSigner = NostrSignerInternal(clientKeyPair)
    private val walletSigner = NostrSignerInternal(walletKeyPair)

    @Test
    fun `get_balance full round-trip`() = runTest {
        // 1. Client builds request
        val client = Nip47Client(
            walletPubKeyHex = walletKeyPair.pubKey.toHexKey(),
            relayUrl = NormalizedRelayUrl("wss://relay.test/"),
            signer = clientSigner,
        )
        val requestEvent = client.getBalance()

        // 2. Verify request is properly encrypted
        assertEquals(LnZapPaymentRequestEvent.KIND, requestEvent.kind)
        assertTrue(requestEvent.content.isNotBlank())

        // 3. Wallet decrypts and verifies method
        val decryptedRequest = requestEvent.decryptRequest(walletSigner)
        assertIs<GetBalanceMethod>(decryptedRequest)

        // 4. Wallet builds encrypted response
        val responseEvent = LnZapPaymentResponseEvent.create(
            request = requestEvent,
            response = GetBalanceSuccessResponse(
                result = GetBalanceSuccessResponse.GetBalanceResult(balance = 125000),
            ),
            signer = walletSigner,
        )

        // 5. Client decrypts response
        val decryptedResponse = client.parseResponse(responseEvent)
        assertIs<GetBalanceSuccessResponse>(decryptedResponse)
        assertEquals(125000, decryptedResponse.result?.balance)
    }
}

File Summary

New Test Files (4)

desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/
  nwc/
    NwcPaymentHandlerTest.kt          (~300 lines, 18 test cases)
    NwcRpcIntegrationTest.kt          (~150 lines, 5 test cases)
  ui/
    wallet/WalletColumnStateTest.kt   (~200 lines, 10 test cases)
    ZapDialogLogicTest.kt             (~100 lines, 13 test cases)

Modified Files (1-2)

desktopApp/.../ui/NoteActions.kt      (make formatSats + DEFAULT_ZAP_AMOUNTS internal)
desktopApp/.../ui/wallet/WalletColumnScreen.kt  (optionally extract state holder)

Total: ~46 test cases across 4 files

Implementation Order

Phase 1: NwcPaymentHandlerTest (P0)     -- 18 tests, ~3h
Phase 3: ZapDialogLogicTest (P1)        -- 13 tests, ~1h (quick, no deps)
Phase 2: WalletColumnStateTest (P1)     -- 10 tests, ~2h (may need refactor)
Phase 4: NwcRpcIntegrationTest (P2)     -- 5 tests, ~2h
                                        ──────────
                                        ~8h total, 46 tests

Phase 3 before Phase 2 because it's simpler (pure functions, no state extraction needed).

Acceptance Criteria

  • All 46 tests pass: ./gradlew :desktopApp:test
  • NwcPaymentHandler: all 3 RPC methods tested (success + error + timeout)
  • Zap dialog: amount selection, custom amounts, type selection, formatSats tested
  • Wallet state: navigation, connect/disconnect, balance refresh tested
  • NWC round-trip: request->encrypt->decrypt->response cycle tested
  • No flaky tests (no real network, no timing-dependent assertions)
  • spotlessCheck passes

Risks

Risk Severity Mitigation
Mocking relay callback is complex Medium Extract response processing into pure function, test that directly
Compose state testing without Compose Medium Extract state holder class (Option A)
formatSats is private Low Change to internal visibility
Encrypted response building may need quartz test utils Low Use same pattern as LnZapPaymentRequestEventTest.kt

Sources

  • Existing desktop tests: desktopApp/src/jvmTest/kotlin/.../ (25+ files)
  • NIP-47 protocol tests: quartz/src/commonTest/.../nip47WalletConnect/ (13 files)
  • Test framework: kotlin.test + mockk 1.14.9 + kotlinx-coroutines-test 1.10.2
  • Mock pattern: AccountManagerBunkerLoginTest.kt (relaxed mockk + coEvery)
  • Crypto test pattern: LnZapPaymentRequestEventTest.kt (deterministic KeyPair)