diff --git a/tests/bdd-test-scenarios.feature b/tests/bdd-test-scenarios.feature
new file mode 100644
index 0000000..9897bac
--- /dev/null
+++ b/tests/bdd-test-scenarios.feature
@@ -0,0 +1,1206 @@
+# =============================================================================
+# BDD TEST SCENARIOS FOR NOSTR-JS-SDK — MISSING COVERAGE
+# =============================================================================
+# Techniques applied per scenario are annotated with:
+# [EP] = Equivalence Partitioning
+# [BVA] = Boundary Value Analysis
+# [DT] = Decision Table Testing
+# [ST] = State Transition Testing
+# [UC] = Use Case Testing
+# [EG] = Error Guessing
+# [PW] = Pairwise/Combinatorial Testing
+# [SC] = Statement/Branch/Condition Coverage
+# [LC] = Loop Testing
+# [RB] = Risk-Based Testing
+# =============================================================================
+
+
+# =============================================================================
+# FEATURE 1: NostrClient — Relay Connection Lifecycle
+# =============================================================================
+
+Feature: NostrClient relay connection lifecycle
+ As a Nostr application developer
+ I want the client to manage WebSocket connections to relays
+ So that events can be published and received reliably
+
+ Background:
+ Given a NostrKeyManager with a generated key pair
+ And a NostrClient created with that key manager
+
+ # --- State Transition Testing [ST] ---
+ # States: Created → Connecting → Connected → Disconnected → (terminal)
+ # Created → Connecting → Failed
+ # Connected → Reconnecting → Connected
+
+ Scenario: Client starts in disconnected state
+ Then the client should report isConnected as false
+ And getConnectedRelays should return an empty set
+
+ Scenario: Successful connection to a single relay
+ Given a mock WebSocket that opens successfully
+ When I connect to "wss://relay1.example.com"
+ Then the client should report isConnected as true
+ And getConnectedRelays should contain "wss://relay1.example.com"
+
+ Scenario: Successful connection to multiple relays
+ Given mock WebSockets that open successfully
+ When I connect to "wss://relay1.example.com" and "wss://relay2.example.com"
+ Then getConnectedRelays should contain both relay URLs
+ And the client should report isConnected as true
+
+ Scenario: Connection to already-connected relay is idempotent
+ Given I am connected to "wss://relay1.example.com"
+ When I connect to "wss://relay1.example.com" again
+ Then the connection should succeed without creating a second socket
+ And getConnectedRelays should contain exactly 1 entry
+
+ Scenario: Connection timeout after 30 seconds
+ Given a mock WebSocket that never fires onopen
+ When I connect to "wss://slow-relay.example.com"
+ And 30 seconds elapse without onopen
+ Then the connect promise should reject with "timed out"
+
+ # [BVA] — Connection timeout boundary
+ Scenario: Connection succeeds just before timeout
+ Given a mock WebSocket that opens at 29999ms
+ When I connect to "wss://relay.example.com"
+ Then the connect promise should resolve successfully
+
+ Scenario: WebSocket creation fails
+ Given createWebSocket rejects with "ECONNREFUSED"
+ When I connect to "wss://down-relay.example.com"
+ Then the connect promise should reject with an error
+
+ Scenario: WebSocket fires onerror before onopen
+ Given a mock WebSocket that fires onerror immediately
+ When I connect to "wss://bad-relay.example.com"
+ Then the connect promise should reject with "Failed to connect"
+
+ # [ST] — Closed state is terminal
+ Scenario: All operations reject after disconnect
+ Given I am connected to "wss://relay.example.com"
+ When I call disconnect
+ Then calling connect should reject with "disconnected"
+ And calling publishEvent should reject with "disconnected"
+ And calling sendPrivateMessage should reject with "disconnected"
+ And calling sendTokenTransfer should reject with "disconnected"
+ And calling sendPaymentRequest should reject with "disconnected"
+
+ # [EG] — Double disconnect
+ Scenario: Calling disconnect multiple times does not throw
+ When I call disconnect
+ And I call disconnect again
+ Then no error should be thrown
+
+
+# =============================================================================
+# FEATURE 2: NostrClient — Relay Message Handling
+# =============================================================================
+
+Feature: NostrClient relay message handling
+ As a Nostr application developer
+ I want the client to correctly parse and dispatch relay messages
+ So that my subscriptions receive the right events
+
+ Background:
+ Given a connected NostrClient with a mock relay
+
+ # --- Decision Table Testing [DT] ---
+ # Message Type | Array Length | Subscription Exists | Expected Behavior
+ # EVENT | >= 3 | yes | Parse event, call onEvent
+ # EVENT | >= 3 | no | Ignore silently
+ # EVENT | < 3 | any | Ignore silently
+ # OK | >= 4 | pending exists | Resolve/reject pending promise
+ # OK | < 4 | any | Ignore silently
+ # EOSE | >= 2 | yes with callback | Call onEndOfStoredEvents
+ # EOSE | >= 2 | no callback | Ignore silently
+ # NOTICE | >= 2 | any | Log warning
+ # CLOSED | >= 3 | yes with onError | Call onError
+ # AUTH | >= 2 | any | Send AUTH event, resubscribe
+ # (unknown) | any | any | Ignore silently
+ # (malformed) | any | any | Ignore silently
+
+ Scenario: Receiving EVENT message dispatches to correct subscription listener
+ Given I have an active subscription "sub_1" with a listener
+ When the relay sends '["EVENT", "sub_1", {"id":"abc","pubkey":"...","kind":1,"content":"hello","tags":[],"created_at":1000,"sig":"..."}]'
+ Then the listener's onEvent should be called with the parsed Event
+ And the event content should be "hello"
+
+ Scenario: EVENT for unknown subscription is silently ignored
+ When the relay sends '["EVENT", "unknown_sub", {"id":"abc","pubkey":"...","kind":1,"content":"test","tags":[],"created_at":1000,"sig":"..."}]'
+ Then no error should be thrown
+ And no listener should be called
+
+ # [BVA] — Minimum valid array length for EVENT
+ Scenario: EVENT message with fewer than 3 elements is ignored
+ When the relay sends '["EVENT", "sub_1"]'
+ Then no listener should be called
+
+ Scenario: OK message resolves pending publish (accepted)
+ Given I published an event with id "event123" that is awaiting OK
+ When the relay sends '["OK", "event123", true, ""]'
+ Then the publish promise should resolve with "event123"
+
+ Scenario: OK message rejects pending publish (rejected)
+ Given I published an event with id "event123" that is awaiting OK
+ When the relay sends '["OK", "event123", false, "blocked: rate limit exceeded"]'
+ Then the publish promise should reject with "Event rejected: blocked: rate limit exceeded"
+
+ # [BVA] — OK message with fewer than 4 elements
+ Scenario: OK message with insufficient elements is ignored
+ Given I published an event with id "event123" that is awaiting OK
+ When the relay sends '["OK", "event123", true]'
+ Then the pending promise should remain unresolved
+
+ Scenario: EOSE triggers onEndOfStoredEvents callback
+ Given I have an active subscription "sub_1" with an onEndOfStoredEvents callback
+ When the relay sends '["EOSE", "sub_1"]'
+ Then onEndOfStoredEvents should be called with "sub_1"
+
+ Scenario: EOSE for subscription without onEndOfStoredEvents is handled gracefully
+ Given I have an active subscription "sub_1" with only onEvent
+ When the relay sends '["EOSE", "sub_1"]'
+ Then no error should be thrown
+
+ Scenario: CLOSED message triggers onError callback
+ Given I have an active subscription "sub_1" with an onError callback
+ When the relay sends '["CLOSED", "sub_1", "auth-required: must authenticate"]'
+ Then onError should be called with "sub_1" and message containing "auth-required"
+
+ Scenario: NOTICE message is logged as warning
+ When the relay sends '["NOTICE", "rate-limited: slow down"]'
+ Then a console warning should be emitted with "rate-limited: slow down"
+
+ Scenario: AUTH message triggers NIP-42 authentication flow
+ When the relay sends '["AUTH", "challenge-string-abc"]'
+ Then the client should send an AUTH event with kind 22242
+ And the AUTH event should contain a "relay" tag with the relay URL
+ And the AUTH event should contain a "challenge" tag with "challenge-string-abc"
+ And all subscriptions should be resubscribed after a 100ms delay
+
+ # [EG] — Malformed and garbage messages
+ Scenario: Malformed JSON message is silently ignored
+ When the relay sends "this is not JSON"
+ Then no error should be thrown
+
+ Scenario: Non-array JSON message is silently ignored
+ When the relay sends '{"type": "EVENT"}'
+ Then no error should be thrown
+
+ Scenario: Empty array message is silently ignored
+ When the relay sends '[]'
+ Then no error should be thrown
+
+ Scenario: Single-element array message is silently ignored
+ When the relay sends '["EVENT"]'
+ Then no error should be thrown
+
+ Scenario: Unknown message type is silently ignored
+ When the relay sends '["UNKNOWN_TYPE", "data"]'
+ Then no error should be thrown
+
+ # [EG] — Invalid event data in EVENT message
+ Scenario: EVENT message with invalid event JSON is silently ignored
+ Given I have an active subscription "sub_1" with a listener
+ When the relay sends '["EVENT", "sub_1", {"invalid": "event"}]'
+ Then the listener's onEvent should NOT be called
+
+ # [SC] — Branch: lastPongTime update on any message
+ Scenario: Any relay message updates the lastPongTime
+ Given the relay last-pong time was 60 seconds ago
+ When the relay sends '["NOTICE", "hello"]'
+ Then the relay last-pong time should be updated to now
+
+
+# =============================================================================
+# FEATURE 3: NostrClient — Event Publishing
+# =============================================================================
+
+Feature: NostrClient event publishing
+ As a Nostr application developer
+ I want to publish events to connected relays
+ So that other users can receive them
+
+ Background:
+ Given a NostrKeyManager with a generated key pair
+ And a NostrClient created with that key manager
+
+ # --- Equivalence Partitioning [EP] ---
+ # Valid: connected to 1+ relays, event is well-formed
+ # Invalid: not connected, client disconnected
+
+ Scenario: Publishing event broadcasts to all connected relays
+ Given I am connected to "wss://relay1.example.com" and "wss://relay2.example.com"
+ When I publish a text note event with content "Hello Nostr"
+ Then the event should be sent to both relays as '["EVENT", ...]'
+ And the publish promise should eventually resolve with the event ID
+
+ Scenario: Publishing event broadcasts only to connected relays (skips disconnected)
+ Given I am connected to "wss://relay1.example.com"
+ And "wss://relay2.example.com" has a disconnected socket
+ When I publish a text note event
+ Then the event should be sent only to relay1
+
+ # [ST] — Offline queuing
+ Scenario: Publishing when not connected queues the event
+ Given I am not connected to any relay
+ When I publish a text note event
+ Then the event should be queued
+ And the publish promise should remain pending
+
+ Scenario: Queued events are flushed upon connection
+ Given I published 3 events while offline (queued)
+ When I connect to "wss://relay.example.com"
+ Then all 3 queued events should be broadcast to the relay
+ And all 3 publish promises should resolve
+
+ Scenario: Queued events are rejected on disconnect
+ Given I published 2 events while offline (queued)
+ When I call disconnect
+ Then both publish promises should reject with "Client disconnected"
+
+ # [BVA] — OK timeout behavior (5 second timeout)
+ Scenario: Publish resolves after 5 seconds even without OK response
+ Given I am connected to "wss://relay.example.com"
+ When I publish a text note event
+ And the relay never sends an OK response
+ And 5 seconds elapse
+ Then the publish promise should resolve with the event ID (optimistic)
+
+ Scenario: OK response clears the pending timeout
+ Given I am connected to "wss://relay.example.com"
+ When I publish a text note event
+ And the relay sends OK accepted immediately
+ Then the publish promise should resolve with the event ID
+ And the pending OK timer should be cleared
+
+ # [EP] — Publishing specific event types through convenience methods
+ Scenario: Publishing an encrypted direct message (NIP-04)
+ Given I am connected to "wss://relay.example.com"
+ And a recipient key pair
+ When I call publishEncryptedMessage with recipient pubkey and "secret"
+ Then the published event kind should be 4
+ And the event should have a "p" tag with the recipient pubkey
+ And the content should be NIP-04 encrypted
+
+ Scenario: Publishing a nametag binding
+ Given I am connected to "wss://relay.example.com"
+ When I call publishNametagBinding with "alice" and an address
+ Then the published event should be a kind 30078 event
+ And publishNametagBinding should return true
+
+ Scenario: publishNametagBinding returns false on publish failure
+ Given I am connected to "wss://relay.example.com"
+ And the relay rejects all events
+ When I call publishNametagBinding with "alice" and an address
+ Then publishNametagBinding should return false
+
+ Scenario: createAndPublishEvent creates and publishes a signed event
+ Given I am connected to "wss://relay.example.com"
+ When I call createAndPublishEvent with kind 1 and content "test"
+ Then the published event should be signed by my key
+ And the publish promise should resolve with the event ID
+
+
+# =============================================================================
+# FEATURE 4: NostrClient — Subscriptions
+# =============================================================================
+
+Feature: NostrClient subscription management
+ As a Nostr application developer
+ I want to subscribe to events matching filters
+ So that I can receive real-time updates from relays
+
+ Background:
+ Given a connected NostrClient with a mock relay
+
+ # --- Equivalence Partitioning [EP] ---
+ # Valid: auto-generated ID, custom ID
+ # Invalid: unsubscribe non-existent ID
+
+ Scenario: Subscribe with auto-generated ID
+ When I subscribe with a filter for kind 1 events
+ Then the subscription ID should match pattern "sub_\d+"
+ And a REQ message should be sent to the relay
+
+ Scenario: Subscribe with custom ID
+ When I subscribe with ID "my-custom-sub" and a filter for kind 1 events
+ Then the subscription ID should be "my-custom-sub"
+ And a REQ message with ID "my-custom-sub" should be sent to the relay
+
+ Scenario: Auto-generated IDs are sequential
+ When I create 3 subscriptions
+ Then the IDs should be "sub_1", "sub_2", "sub_3"
+
+ Scenario: Subscribe sends REQ to all connected relays
+ Given I am connected to 3 relays
+ When I subscribe with a filter
+ Then each relay should receive a REQ message
+
+ Scenario: Subscribe while not connected stores subscription locally
+ Given I am not connected to any relay
+ When I subscribe with a filter for kind 1 events
+ Then the subscription should be stored
+ And no REQ message should be sent (no connected relays)
+
+ # [ST] — Stored subscriptions re-established on connect
+ Scenario: Subscriptions are re-established after reconnection
+ Given I have subscriptions "sub_1" and "sub_2" before reconnection
+ When the relay reconnects
+ Then both subscriptions should be sent as REQ messages to the relay
+
+ Scenario: Unsubscribe sends CLOSE to all connected relays
+ Given I have an active subscription "sub_1"
+ When I unsubscribe from "sub_1"
+ Then a CLOSE message for "sub_1" should be sent to all relays
+ And the subscription should be removed from internal tracking
+
+ Scenario: Unsubscribe with unknown ID does nothing
+ When I unsubscribe from "non_existent_sub"
+ Then no CLOSE message should be sent
+ And no error should be thrown
+
+ # [LC] — Loop: multiple subscriptions concurrently
+ Scenario: Managing 100 concurrent subscriptions
+ When I create 100 subscriptions with different filters
+ Then all 100 should be stored internally
+ And all 100 REQ messages should be sent to the relay
+ When I unsubscribe from all 100
+ Then all 100 CLOSE messages should be sent
+ And no subscriptions should remain
+
+
+# =============================================================================
+# FEATURE 5: NostrClient — Reconnection & Health
+# =============================================================================
+
+Feature: NostrClient automatic reconnection
+ As a Nostr application developer
+ I want the client to automatically reconnect on connection loss
+ So that the application remains resilient to network issues
+
+ Background:
+ Given a NostrKeyManager with a generated key pair
+
+ # --- State Transition Testing [ST] ---
+ # Connected → socket.onclose → scheduleReconnect → Reconnecting → connectToRelay → Connected
+ # Connected → socket.onclose → (autoReconnect=false) → stays Disconnected
+
+ Scenario: Auto-reconnect after connection loss
+ Given a NostrClient with autoReconnect enabled (default)
+ And I am connected to "wss://relay.example.com"
+ When the WebSocket fires onclose
+ Then the client should emit a "disconnect" event
+ And the client should schedule a reconnection attempt
+
+ Scenario: No auto-reconnect when disabled
+ Given a NostrClient with autoReconnect set to false
+ And I am connected to "wss://relay.example.com"
+ When the WebSocket fires onclose
+ Then the client should emit a "disconnect" event
+ And the client should NOT schedule a reconnection attempt
+
+ # [BVA] — Exponential backoff boundaries
+ Scenario Outline: Exponential backoff delay calculation
+ Given reconnectIntervalMs is and maxReconnectIntervalMs is
+ When reconnect attempt is scheduled
+ Then the delay should be ms
+
+ Examples:
+ | base | max | attempt | expected_delay |
+ | 1000 | 30000 | 1 | 1000 |
+ | 1000 | 30000 | 2 | 2000 |
+ | 1000 | 30000 | 3 | 4000 |
+ | 1000 | 30000 | 4 | 8000 |
+ | 1000 | 30000 | 5 | 16000 |
+ | 1000 | 30000 | 6 | 30000 |
+ | 1000 | 30000 | 100 | 30000 |
+ | 500 | 10000 | 1 | 500 |
+ | 500 | 10000 | 5 | 8000 |
+ | 500 | 10000 | 6 | 10000 |
+
+ Scenario: Reconnection resets attempt counter on success
+ Given I am reconnecting after 5 failed attempts
+ When the reconnection succeeds
+ Then the reconnect attempt counter should reset to 0
+
+ Scenario: Reconnection emits "reconnected" event (not "connect")
+ Given I was previously connected to "wss://relay.example.com"
+ When the client successfully reconnects
+ Then the "reconnected" event should be emitted (not "connect")
+
+ Scenario: First connection emits "connect" event
+ When I connect to "wss://relay.example.com" for the first time
+ Then the "connect" event should be emitted
+
+ # --- Connection Event Listeners [DT] ---
+ # Listener has | Event Type | Expected callback
+ # onConnect | connect | onConnect called
+ # onConnect | disconnect | nothing (no onDisconnect)
+ # all methods | reconnecting | onReconnecting called with attempt number
+ # all methods | reconnected | onReconnected called
+
+ Scenario: Connection listener errors are swallowed
+ Given a connection listener whose onConnect throws an exception
+ When the client connects to a relay
+ Then the connection should succeed
+ And the error from the listener should be swallowed
+
+ # --- Ping/Health Check [ST][BVA] ---
+ Scenario: Ping sends subscription request as health check
+ Given I am connected with pingIntervalMs set to 30000
+ When 30 seconds elapse
+ Then the client should send a CLOSE "ping" then REQ "ping" with limit:1
+
+ Scenario: Stale connection is force-closed after 2x ping interval
+ Given I am connected with pingIntervalMs set to 30000
+ And the relay has not sent any message for 61 seconds
+ When the next ping check runs
+ Then the socket should be force-closed
+ And reconnection should be triggered
+
+ # [BVA] — Exactly at the stale threshold
+ Scenario: Connection at exactly 2x ping interval is not considered stale
+ Given I am connected with pingIntervalMs set to 30000
+ And the relay last message was exactly 60000ms ago
+ When the next ping check runs
+ Then the connection should NOT be force-closed
+
+ Scenario: Ping disabled when pingIntervalMs is 0
+ Given a NostrClient with pingIntervalMs set to 0
+ When I connect to a relay
+ Then no ping timer should be started
+
+ Scenario: Ping timer is stopped on disconnect
+ Given I am connected with active ping timer
+ When the WebSocket fires onclose
+ Then the ping timer should be cleared
+
+ Scenario: Ping send failure triggers reconnect
+ Given I am connected to a relay
+ And the socket.send throws an error
+ When the ping check runs
+ Then the socket should be force-closed
+
+
+# =============================================================================
+# FEATURE 6: NostrClient — NIP-17 Private Messaging
+# =============================================================================
+
+Feature: NostrClient NIP-17 private message integration
+ As a Nostr application user
+ I want to send and receive private messages via the client
+ So that my communication is end-to-end encrypted with sender anonymity
+
+ Background:
+ Given a connected NostrClient "Alice" with a mock relay
+ And a separate NostrKeyManager "Bob"
+
+ # --- Use Case Testing [UC] ---
+
+ Scenario: Send a private message via gift-wrapping
+ When Alice sends a private message "Hello Bob" to Bob's pubkey
+ Then a kind 1059 (gift wrap) event should be published
+ And the gift wrap's "p" tag should contain Bob's pubkey
+ And the gift wrap should be signed by an ephemeral key (not Alice's)
+
+ Scenario: Send a private message to a nametag (resolved via relay)
+ Given Bob has a nametag binding "bob123" published on the relay
+ When Alice sends a private message to nametag "bob123"
+ Then the client should first query the relay for "bob123"
+ And then send a gift-wrapped message to Bob's resolved pubkey
+
+ Scenario: Send private message to unknown nametag fails
+ Given no nametag binding exists for "unknown-user"
+ When Alice sends a private message to nametag "unknown-user"
+ Then the promise should reject with "Nametag not found: unknown-user"
+
+ Scenario: Send a read receipt
+ When Alice sends a read receipt to Bob for message "event-id-123"
+ Then a kind 1059 (gift wrap) event should be published
+ And the inner rumor should be kind 15 (read receipt)
+
+ Scenario: Unwrap a received gift-wrapped message
+ Given a gift-wrapped message from Bob to Alice
+ When Alice calls unwrapPrivateMessage
+ Then the result should contain Bob's pubkey as sender
+ And the result should contain the decrypted message content
+
+ # [EG] — Reply message
+ Scenario: Send a reply to a previous message
+ When Alice sends a private message "reply text" to Bob with replyToEventId "prev-event-id"
+ Then the inner rumor should have an "e" tag with "prev-event-id"
+
+
+# =============================================================================
+# FEATURE 7: NostrClient — Token Transfer & Payment via Client
+# =============================================================================
+
+Feature: NostrClient token transfer and payment request delegation
+ As a Nostr application developer
+ I want convenience methods on the client for transfers and payments
+ So that I don't have to construct protocol events manually
+
+ Background:
+ Given a connected NostrClient with a mock relay
+
+ # --- Use Case Testing [UC] ---
+
+ Scenario: Send token transfer
+ When I call sendTokenTransfer with recipient pubkey and token JSON '{"id":"tok1"}'
+ Then a kind 31113 event should be published
+ And the content should be NIP-04 encrypted
+
+ Scenario: Send token transfer with amount and symbol
+ When I call sendTokenTransfer with options amount=100 and symbol="ALPHA"
+ Then the published event should have an "amount" tag with "100"
+ And the published event should have a "symbol" tag with "ALPHA"
+
+ Scenario: Send token transfer as reply to payment request
+ When I call sendTokenTransfer with replyToEventId "req-event-id"
+ Then the published event should have an "e" tag with "req-event-id"
+
+ Scenario: Send payment request
+ When I call sendPaymentRequest with target pubkey and amount 1000000000n and coinId "0x01"
+ Then a kind 31115 event should be published
+
+ Scenario: Send payment request decline
+ When I call sendPaymentRequestDecline with original sender pubkey, eventId, and requestId
+ Then a kind 31116 response event should be published
+ And the response status should be "DECLINED"
+
+ Scenario: Send payment request response with EXPIRED status
+ When I call sendPaymentRequestResponse with status "EXPIRED"
+ Then the response status should be "EXPIRED"
+
+
+# =============================================================================
+# FEATURE 8: NostrClient — Nametag Query
+# =============================================================================
+
+Feature: NostrClient nametag query
+ As a Nostr application developer
+ I want to look up public keys by nametag
+ So that I can address users by human-readable identifiers
+
+ Background:
+ Given a connected NostrClient with a mock relay
+
+ # --- State Transition Testing [ST] ---
+ # Query states: Pending → (receive events) → EOSE → Resolved
+ # Query states: Pending → (timeout) → Resolved(null)
+
+ Scenario: Query resolves with pubkey on EOSE
+ Given a nametag binding event for "alice" with pubkey "abc123" exists on the relay
+ When I call queryPubkeyByNametag("alice")
+ And the relay sends the binding event followed by EOSE
+ Then the promise should resolve with "abc123"
+
+ Scenario: Query resolves with null when no binding exists
+ When I call queryPubkeyByNametag("nobody")
+ And the relay sends EOSE with no events
+ Then the promise should resolve with null
+
+ Scenario: Query returns most recent binding when multiple exist
+ Given two nametag binding events for "alice":
+ | pubkey | created_at |
+ | old123 | 1000 |
+ | new456 | 2000 |
+ When I call queryPubkeyByNametag("alice")
+ And the relay sends both events followed by EOSE
+ Then the promise should resolve with "new456"
+
+ # [BVA] — Query timeout
+ Scenario: Query times out and resolves with null
+ Given queryTimeoutMs is 5000
+ When I call queryPubkeyByNametag("slow-lookup")
+ And 5 seconds elapse without EOSE
+ Then the promise should resolve with null
+ And the subscription should be cleaned up
+
+ Scenario: Query timeout respects custom timeout value
+ Given queryTimeoutMs is set to 1000
+ When I call queryPubkeyByNametag("test")
+ And 1 second elapses without EOSE
+ Then the promise should resolve with null
+
+
+# =============================================================================
+# FEATURE 9: NostrClient — Disconnect Cleanup
+# =============================================================================
+
+Feature: NostrClient disconnect cleanup
+ As a Nostr application developer
+ I want disconnect to clean up all resources
+ So that there are no resource leaks or dangling promises
+
+ Background:
+ Given a NostrClient connected to 2 relays with active subscriptions
+
+ # --- Risk-Based Testing [RB] ---
+
+ Scenario: Disconnect clears all pending OK promises
+ Given I have 3 pending OK acknowledgments
+ When I call disconnect
+ Then all 3 pending promises should reject with "Client disconnected"
+ And the pendingOks map should be empty
+
+ Scenario: Disconnect rejects all queued events
+ Given I have 2 events queued for offline delivery
+ When I call disconnect
+ Then both queued event promises should reject with "Client disconnected"
+ And the event queue should be empty
+
+ Scenario: Disconnect closes all WebSocket connections
+ When I call disconnect
+ Then socket.close(1000, "Client disconnected") should be called for each relay
+ And each relay should receive a "disconnect" event emission
+
+ Scenario: Disconnect clears all timers
+ Given relay1 has an active ping timer
+ And relay2 has an active reconnect timer
+ When I call disconnect
+ Then both timers should be cleared
+
+ Scenario: Disconnect clears all subscriptions
+ Given I have 5 active subscriptions
+ When I call disconnect
+ Then the subscriptions map should be empty
+
+ Scenario: Disconnect clears relay map
+ When I call disconnect
+ Then the relays map should be empty
+
+
+# =============================================================================
+# FEATURE 10: WebSocketAdapter — Message Extraction
+# =============================================================================
+
+Feature: WebSocket message data extraction
+ As the NostrClient internals
+ I want to extract string data from various WebSocket message formats
+ So that relay messages can be parsed regardless of platform
+
+ # --- Equivalence Partitioning [EP] ---
+ # Valid partitions: string data, ArrayBuffer data, Node.js Buffer data
+ # Invalid partitions: Blob data, unknown type
+
+ Scenario: Extract string data from string message
+ Given a WebSocket message event with data "hello"
+ When I call extractMessageData
+ Then the result should be "hello"
+
+ Scenario: Extract string data from ArrayBuffer message
+ Given a WebSocket message event with data as ArrayBuffer of "hello"
+ When I call extractMessageData
+ Then the result should be "hello"
+
+ Scenario: Extract string data from Node.js Buffer message
+ Given a WebSocket message event with data as Buffer of "hello"
+ When I call extractMessageData
+ Then the result should be "hello"
+
+ Scenario: Blob message throws an error
+ Given a WebSocket message event with Blob data
+ When I call extractMessageData
+ Then it should throw "Blob messages are not supported"
+
+ # [EG] — Unusual data types
+ Scenario: Numeric data is converted to string
+ Given a WebSocket message event with data as number 42
+ When I call extractMessageData
+ Then the result should be "42"
+
+ # [BVA] — Empty string
+ Scenario: Empty string data returns empty string
+ Given a WebSocket message event with data ""
+ When I call extractMessageData
+ Then the result should be ""
+
+ # [BVA] — Large message
+ Scenario: Very large ArrayBuffer message is extracted correctly
+ Given a WebSocket message event with a 1MB ArrayBuffer of repeated text
+ When I call extractMessageData
+ Then the result should be the full decoded text
+
+ # [PW] — Platform combinations
+ Scenario: createWebSocket uses native WebSocket in browser environment
+ Given the global WebSocket constructor is available
+ When I call createWebSocket("wss://relay.example.com")
+ Then it should return a native WebSocket instance
+
+ Scenario: createWebSocket uses ws package in Node.js environment
+ Given the global WebSocket constructor is NOT available
+ And the "ws" package is importable
+ When I call createWebSocket("wss://relay.example.com")
+ Then it should return a ws WebSocket instance
+
+ Scenario: createWebSocket throws when no WebSocket is available
+ Given the global WebSocket constructor is NOT available
+ And the "ws" package import fails
+ When I call createWebSocket("wss://relay.example.com")
+ Then it should throw 'WebSocket not available. In Node.js, install the "ws" package'
+
+
+# =============================================================================
+# FEATURE 11: NostrKeyManager — NIP-44 Encryption Methods
+# =============================================================================
+
+Feature: NostrKeyManager NIP-44 encryption
+ As a Nostr application developer
+ I want to use NIP-44 encryption through the KeyManager
+ So that I can use modern XChaCha20-Poly1305 encryption without raw key handling
+
+ Background:
+ Given a NostrKeyManager "Alice" with a generated key pair
+ And a NostrKeyManager "Bob" with a generated key pair
+
+ # --- Equivalence Partitioning [EP] ---
+ # Valid: normal message, unicode, long message
+ # Invalid: cleared key manager, empty message
+
+ Scenario: Encrypt and decrypt a message with NIP-44 (bytes keys)
+ When Alice encrypts "Hello Bob" with NIP-44 using Bob's public key bytes
+ And Bob decrypts the result with NIP-44 using Alice's public key bytes
+ Then the decrypted message should be "Hello Bob"
+
+ Scenario: Encrypt and decrypt a message with NIP-44 (hex keys)
+ When Alice encrypts "Hello Bob" with encryptNip44Hex using Bob's public key hex
+ And Bob decrypts the result with decryptNip44Hex using Alice's public key hex
+ Then the decrypted message should be "Hello Bob"
+
+ Scenario: NIP-44 encryption produces different ciphertext each time
+ When Alice encrypts "same message" with NIP-44 twice
+ Then the two ciphertexts should be different (random nonce)
+
+ Scenario: NIP-44 encryption with unicode content
+ When Alice encrypts "Привіт 🌍 مرحبا" with NIP-44 using Bob's public key
+ And Bob decrypts the result
+ Then the decrypted message should be "Привіт 🌍 مرحبا"
+
+ # [BVA] — Message length boundaries
+ Scenario: NIP-44 encryption of 1-byte message
+ When Alice encrypts "x" with NIP-44 using Bob's public key
+ And Bob decrypts the result
+ Then the decrypted message should be "x"
+
+ Scenario: NIP-44 encryption of maximum-length message (65535 bytes)
+ Given a message of exactly 65535 bytes
+ When Alice encrypts it with NIP-44 using Bob's public key
+ And Bob decrypts the result
+ Then the decrypted message should match the original
+
+ Scenario: NIP-44 encryption of message exceeding max length rejects
+ Given a message of 65536 bytes
+ When Alice tries to encrypt it with NIP-44
+ Then it should throw "Message too long"
+
+ # [EP] — Wrong key decryption
+ Scenario: Decrypting with wrong key fails
+ Given a third NostrKeyManager "Eve"
+ When Alice encrypts "secret" with NIP-44 for Bob
+ And Eve tries to decrypt the result using Alice's public key
+ Then it should throw an error (authentication failure)
+
+ # [ST] — Cleared key manager
+ Scenario: NIP-44 encryption fails after key manager is cleared
+ When Alice's key manager is cleared
+ Then calling encryptNip44 should throw "KeyManager has been cleared"
+ And calling decryptNip44 should throw "KeyManager has been cleared"
+ And calling encryptNip44Hex should throw "KeyManager has been cleared"
+ And calling decryptNip44Hex should throw "KeyManager has been cleared"
+ And calling deriveConversationKey should throw "KeyManager has been cleared"
+
+ # --- Conversation Key Derivation ---
+
+ Scenario: Derive conversation key produces consistent result
+ When Alice derives a conversation key with Bob's public key
+ And Alice derives the conversation key again with Bob's public key
+ Then both keys should be identical
+
+ Scenario: Conversation key is symmetric (A→B equals B→A)
+ When Alice derives a conversation key with Bob's public key
+ And Bob derives a conversation key with Alice's public key
+ Then both conversation keys should be identical
+
+ Scenario: Different key pairs produce different conversation keys
+ Given a third NostrKeyManager "Charlie"
+ When Alice derives conversation key with Bob
+ And Alice derives conversation key with Charlie
+ Then the two conversation keys should differ
+
+
+# =============================================================================
+# FEATURE 12: CallbackEventListener
+# =============================================================================
+
+Feature: CallbackEventListener
+ As a developer
+ I want a convenience class for creating event listeners from callbacks
+ So that I can use inline functions instead of implementing the full interface
+
+ # --- Equivalence Partitioning [EP] ---
+
+ Scenario: onEvent callback is invoked
+ Given a CallbackEventListener with an onEvent callback
+ When onEvent is called with an Event
+ Then the callback should receive the event
+
+ Scenario: onEndOfStoredEvents callback is invoked when provided
+ Given a CallbackEventListener with onEvent and onEndOfStoredEvents callbacks
+ When onEndOfStoredEvents is called with "sub_1"
+ Then the EOSE callback should receive "sub_1"
+
+ Scenario: onEndOfStoredEvents does not throw when not provided
+ Given a CallbackEventListener with only an onEvent callback
+ When onEndOfStoredEvents is called with "sub_1"
+ Then no error should be thrown
+
+ Scenario: onError callback is invoked when provided
+ Given a CallbackEventListener with all three callbacks
+ When onError is called with "sub_1" and "connection lost"
+ Then the error callback should receive "sub_1" and "connection lost"
+
+ Scenario: onError does not throw when not provided
+ Given a CallbackEventListener with only an onEvent callback
+ When onError is called with "sub_1" and "error"
+ Then no error should be thrown
+
+
+# =============================================================================
+# FEATURE 13: Edge Cases — NIP-04 Corrupted Data
+# =============================================================================
+
+Feature: NIP-04 corrupted and malformed data handling
+ As the encryption layer
+ I want to handle corrupted inputs gracefully
+ So that the application doesn't crash on bad data
+
+ # --- Error Guessing [EG] ---
+
+ Scenario: Decrypt with corrupted base64 ciphertext
+ Given an encrypted NIP-04 message
+ When I corrupt the base64 ciphertext portion
+ And I attempt to decrypt
+ Then it should throw an error (decryption failure)
+
+ Scenario: Decrypt with corrupted IV
+ Given an encrypted NIP-04 message
+ When I corrupt the IV portion
+ And I attempt to decrypt
+ Then it should throw an error
+
+ Scenario: Decrypt with missing IV separator
+ When I attempt to decrypt "justbase64withoutiv"
+ Then it should throw an error about invalid format
+
+ Scenario: Decrypt with empty ciphertext
+ When I attempt to decrypt "?iv=aGVsbG8="
+ Then it should throw an error
+
+ Scenario: Decrypt compressed message with corrupted GZIP data
+ When I attempt to decrypt "gz:invalidbase64data?iv=aGVsbG8="
+ Then it should throw an error
+
+ Scenario: Decrypt message with truncated ciphertext
+ Given an encrypted NIP-04 message
+ When I truncate the ciphertext to half its length
+ And I attempt to decrypt
+ Then it should throw an error
+
+
+# =============================================================================
+# FEATURE 14: Edge Cases — NIP-44 Corrupted Data
+# =============================================================================
+
+Feature: NIP-44 corrupted and malformed data handling
+ As the encryption layer
+ I want to handle corrupted NIP-44 inputs gracefully
+ So that authentication failures are caught
+
+ # --- Error Guessing [EG] ---
+
+ Scenario: Decrypt with corrupted base64 payload
+ Given a valid NIP-44 encrypted message
+ When I corrupt a byte in the ciphertext portion
+ And I attempt to decrypt
+ Then it should throw an error (authentication failure / Poly1305 MAC mismatch)
+
+ Scenario: Decrypt with wrong version byte
+ Given a NIP-44 payload with version byte 0x01 instead of 0x02
+ When I attempt to decrypt
+ Then it should throw "Unsupported NIP-44 version: 1"
+
+ Scenario: Decrypt with version byte 0x00
+ Given a NIP-44 payload with version byte 0x00
+ When I attempt to decrypt
+ Then it should throw "Unsupported NIP-44 version: 0"
+
+ # [BVA] — Payload too short
+ Scenario: Decrypt with payload shorter than minimum
+ Given a base64 payload of only 10 bytes
+ When I attempt to decrypt with NIP-44
+ Then it should throw "Payload too short"
+
+ Scenario: Decrypt with exactly minimum valid payload length
+ Given a base64 payload of exactly 1 + 24 + 32 + 16 = 73 bytes (but invalid crypto)
+ When I attempt to decrypt with NIP-44
+ Then it should throw an error (crypto failure, not "too short")
+
+ Scenario: Decrypt with truncated nonce
+ Given a NIP-44 payload with correct version but only 10 bytes of nonce
+ When I attempt to decrypt
+ Then it should throw "Payload too short"
+
+
+# =============================================================================
+# FEATURE 15: Edge Cases — NIP-44 Padding
+# =============================================================================
+
+Feature: NIP-44 padding edge cases
+ As the encryption layer
+ I want padding to work correctly at all boundary values
+ So that message lengths are properly hidden
+
+ # --- Boundary Value Analysis [BVA] ---
+
+ Scenario Outline: calcPaddedLen returns correct padded length
+ When I calculate padded length for
+ Then the result should be
+
+ Examples:
+ | input_len | expected |
+ | 1 | 32 |
+ | 31 | 32 |
+ | 32 | 32 |
+ | 33 | 64 |
+ | 37 | 64 |
+ | 63 | 64 |
+ | 64 | 64 |
+ | 65 | 96 |
+ | 100 | 128 |
+ | 255 | 256 |
+ | 256 | 256 |
+ | 257 | 320 |
+ | 1000 | 1024 |
+ | 65535 | 65536 |
+
+ # [BVA] — Invalid inputs
+ Scenario: calcPaddedLen rejects zero length
+ When I call calcPaddedLen(0)
+ Then it should throw "Message too short"
+
+ Scenario: calcPaddedLen rejects negative length
+ When I call calcPaddedLen(-1)
+ Then it should throw "Message too short"
+
+ Scenario: calcPaddedLen rejects length exceeding maximum
+ When I call calcPaddedLen(65536)
+ Then it should throw "Message too long"
+
+ # --- Pad/Unpad roundtrip [EP] ---
+ Scenario: pad and unpad roundtrip preserves message
+ Given a message of 50 bytes
+ When I pad the message
+ And I unpad the result
+ Then the output should equal the original message
+
+ Scenario: Unpad rejects payload with wrong padding length
+ Given a padded message with deliberately incorrect padding size
+ When I call unpad
+ Then it should throw "Invalid padding"
+
+ Scenario: Unpad rejects payload with zero length prefix
+ Given a padded message where the 2-byte length prefix is 0
+ When I call unpad
+ Then it should throw "Invalid message length: 0"
+
+
+# =============================================================================
+# FEATURE 16: Edge Cases — Event Handling
+# =============================================================================
+
+Feature: Event edge cases
+ As the Nostr protocol layer
+ I want events to handle unusual inputs correctly
+
+ # --- Error Guessing [EG] ---
+
+ Scenario: Event with empty tags array
+ When I create an event with kind 1, empty tags, and content "test"
+ Then the event should be created successfully
+ And getTagValue for any tag should return undefined
+
+ Scenario: Event with many tags (1000+)
+ When I create an event with 1000 tags
+ Then the event should be created and signed successfully
+ And the event ID should be valid
+
+ Scenario: Event with empty content string
+ When I create an event with content ""
+ Then the event should be created successfully
+
+ # [BVA] — Tag access methods
+ Scenario: getTagEntryValues returns all entries for a tag name
+ Given an event with tags [["p","pk1"],["p","pk2"],["p","pk3"]]
+ When I call getTagEntryValues("p")
+ Then the result should be [["p","pk1"],["p","pk2"],["p","pk3"]]
+
+ Scenario: getTagValues returns values at index 1 for a tag name
+ Given an event with tags [["p","pk1","relay1"],["p","pk2","relay2"]]
+ When I call getTagValues("p")
+ Then the result should be ["pk1","pk2"]
+
+ Scenario: hasTag returns false for non-existent tag
+ Given an event with tags [["p","pk1"]]
+ When I call hasTag("e")
+ Then the result should be false
+
+
+# =============================================================================
+# FEATURE 17: Pairwise — Client Configuration Combinations
+# =============================================================================
+
+Feature: NostrClient configuration combinations
+ As a Nostr application developer
+ I want all combinations of client options to work correctly
+
+ # --- Pairwise Testing [PW] ---
+
+ Scenario Outline: Client works with various option combinations
+ Given a NostrClient with options:
+ | autoReconnect | |
+ | queryTimeoutMs | |
+ | pingIntervalMs | |
+ Then the client should be created successfully
+ And getQueryTimeout should return
+
+ Examples:
+ | autoReconnect | queryTimeoutMs | pingIntervalMs |
+ | true | 5000 | 30000 |
+ | true | 1000 | 0 |
+ | false | 5000 | 0 |
+ | false | 10000 | 30000 |
+ | true | 30000 | 60000 |
+ | false | 100 | 10000 |
+
+
+# =============================================================================
+# FEATURE 18: Risk-Based — Security Critical Paths
+# =============================================================================
+
+Feature: Security-critical operations
+ As a security-conscious developer
+ I want to verify that sensitive operations behave correctly
+ So that no cryptographic keys or messages leak
+
+ # --- Risk-Based Testing [RB] ---
+
+ Scenario: Private key is not accessible after clear()
+ Given a NostrKeyManager with a key pair
+ When I call clear()
+ Then getPrivateKey should throw "KeyManager has been cleared"
+ And getPrivateKeyHex should throw "KeyManager has been cleared"
+ And getNsec should throw "KeyManager has been cleared"
+ And sign should throw "KeyManager has been cleared"
+ And encrypt should throw "KeyManager has been cleared"
+
+ Scenario: Private key memory is zeroed on clear
+ Given a NostrKeyManager with a key pair
+ When I call clear()
+ Then the internal private key buffer should contain all zeros
+
+ Scenario: getPrivateKey returns a copy, not a reference
+ Given a NostrKeyManager with a key pair
+ When I get the private key
+ And I modify the returned array
+ Then getting the private key again should return the original unmodified key
+
+ Scenario: Gift wrap does not leak sender identity
+ Given a gift-wrapped message from Alice to Bob
+ Then the gift wrap event's pubkey should NOT be Alice's pubkey
+ And the gift wrap event's signature should be from an ephemeral key
+
+ Scenario: NIP-44 conversation key is symmetric
+ When Alice derives conversation key with Bob's public key
+ And Bob derives conversation key with Alice's public key
+ Then both keys should be byte-identical
+
+ Scenario: NIP-17 timestamps are randomized for privacy
+ When Alice creates 20 gift wraps
+ Then the created_at timestamps should vary (not all the same)
+ And all timestamps should be within ±2 days of current time
+
+ Scenario: AUTH event contains correct challenge-response
+ Given a relay sends AUTH challenge "test-challenge-123"
+ When the client responds with an AUTH event
+ Then the AUTH event kind should be 22242
+ And the AUTH event should have tag ["relay", ""]
+ And the AUTH event should have tag ["challenge", "test-challenge-123"]
+ And the AUTH event should be signed by the client's key manager
+
+
+# =============================================================================
+# FEATURE 19: Loop Testing — Subscription Re-establishment
+# =============================================================================
+
+Feature: Subscription re-establishment on reconnect
+ As a Nostr application developer
+ I want all subscriptions to be re-established after reconnect
+ So that no events are missed during brief disconnections
+
+ # --- Loop Testing [LC] ---
+
+ Scenario: Zero subscriptions — nothing to re-establish
+ Given I have no active subscriptions
+ When the relay reconnects
+ Then no REQ messages should be sent
+
+ Scenario: One subscription is re-established
+ Given I have 1 active subscription
+ When the relay reconnects
+ Then exactly 1 REQ message should be sent
+
+ Scenario: Many subscriptions are all re-established
+ Given I have 50 active subscriptions
+ When the relay reconnects
+ Then exactly 50 REQ messages should be sent
+ And each REQ should contain the original filter
+
+ Scenario: Unsubscribed subscriptions are NOT re-established
+ Given I had 3 subscriptions and unsubscribed from 1
+ When the relay reconnects
+ Then exactly 2 REQ messages should be sent
+
+
+# =============================================================================
+# FEATURE 20: Exploratory — Concurrent Operations
+# =============================================================================
+
+Feature: Concurrent operations safety
+ As a Nostr application developer
+ I want concurrent operations to not corrupt state
+
+ # --- Exploratory / Error Guessing [EG] ---
+
+ Scenario: Publishing 100 events concurrently
+ Given I am connected to a relay
+ When I publish 100 events concurrently (Promise.all)
+ Then all 100 should be sent without errors
+ And the pending OKs map should have 100 entries
+
+ Scenario: Subscribing and unsubscribing rapidly
+ When I subscribe and immediately unsubscribe 50 times
+ Then no subscriptions should remain
+ And no errors should be thrown
+
+ Scenario: Disconnect while publish is pending
+ Given I am connected and have published an event awaiting OK
+ When I call disconnect before OK arrives
+ Then the pending publish should reject with "Client disconnected"
+
+ Scenario: Connect to same relay URL concurrently
+ When I call connect("wss://relay.example.com") twice concurrently
+ Then only one WebSocket connection should be established
+ Or both connect calls should resolve without error
diff --git a/tests/unit/callback-listener.test.ts b/tests/unit/callback-listener.test.ts
new file mode 100644
index 0000000..c10157c
--- /dev/null
+++ b/tests/unit/callback-listener.test.ts
@@ -0,0 +1,90 @@
+/**
+ * Unit tests for CallbackEventListener
+ * Feature 12: CallbackEventListener
+ * Techniques: [EP] Equivalence Partitioning
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { CallbackEventListener } from '../../src/client/NostrEventListener.js';
+import { Event } from '../../src/protocol/Event.js';
+import { NostrKeyManager } from '../../src/NostrKeyManager.js';
+import * as EventKinds from '../../src/protocol/EventKinds.js';
+
+describe('CallbackEventListener', () => {
+ const keyManager = NostrKeyManager.generate();
+
+ function createTestEvent(): Event {
+ return Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [],
+ content: 'test',
+ });
+ }
+
+ // [EP] Valid: onEvent callback invoked
+ it('should invoke onEvent callback with the event', () => {
+ const onEvent = vi.fn();
+ const listener = new CallbackEventListener(onEvent);
+ const event = createTestEvent();
+
+ listener.onEvent(event);
+
+ expect(onEvent).toHaveBeenCalledOnce();
+ expect(onEvent).toHaveBeenCalledWith(event);
+ });
+
+ // [EP] Valid: onEndOfStoredEvents callback invoked when provided
+ it('should invoke onEndOfStoredEvents callback when provided', () => {
+ const onEvent = vi.fn();
+ const onEose = vi.fn();
+ const listener = new CallbackEventListener(onEvent, onEose);
+
+ listener.onEndOfStoredEvents('sub_1');
+
+ expect(onEose).toHaveBeenCalledOnce();
+ expect(onEose).toHaveBeenCalledWith('sub_1');
+ });
+
+ // [EP] Valid: onEndOfStoredEvents does not throw when not provided
+ it('should not throw when onEndOfStoredEvents is called without callback', () => {
+ const onEvent = vi.fn();
+ const listener = new CallbackEventListener(onEvent);
+
+ expect(() => listener.onEndOfStoredEvents('sub_1')).not.toThrow();
+ });
+
+ // [EP] Valid: onError callback invoked when provided
+ it('should invoke onError callback when provided', () => {
+ const onEvent = vi.fn();
+ const onEose = vi.fn();
+ const onError = vi.fn();
+ const listener = new CallbackEventListener(onEvent, onEose, onError);
+
+ listener.onError('sub_1', 'connection lost');
+
+ expect(onError).toHaveBeenCalledOnce();
+ expect(onError).toHaveBeenCalledWith('sub_1', 'connection lost');
+ });
+
+ // [EP] Valid: onError does not throw when not provided
+ it('should not throw when onError is called without callback', () => {
+ const onEvent = vi.fn();
+ const listener = new CallbackEventListener(onEvent);
+
+ expect(() => listener.onError('sub_1', 'error')).not.toThrow();
+ });
+
+ // Multiple events
+ it('should handle multiple events', () => {
+ const onEvent = vi.fn();
+ const listener = new CallbackEventListener(onEvent);
+
+ const event1 = createTestEvent();
+ const event2 = createTestEvent();
+
+ listener.onEvent(event1);
+ listener.onEvent(event2);
+
+ expect(onEvent).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/tests/unit/event-edge-cases.test.ts b/tests/unit/event-edge-cases.test.ts
new file mode 100644
index 0000000..b2f8218
--- /dev/null
+++ b/tests/unit/event-edge-cases.test.ts
@@ -0,0 +1,220 @@
+/**
+ * Unit tests for Event edge cases
+ * Feature 16: Event Edge Cases
+ * Techniques: [EG] Error Guessing, [BVA] Boundary Value Analysis
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { Event } from '../../src/protocol/Event.js';
+import { NostrKeyManager } from '../../src/NostrKeyManager.js';
+import * as EventKinds from '../../src/protocol/EventKinds.js';
+
+describe('Event Edge Cases', () => {
+ let keyManager: NostrKeyManager;
+
+ beforeEach(() => {
+ keyManager = NostrKeyManager.generate();
+ });
+
+ // [EG] Empty tags array
+ it('should create event with empty tags array', () => {
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [],
+ content: 'test',
+ });
+
+ expect(event.tags).toEqual([]);
+ expect(event.getTagValue('p')).toBeUndefined();
+ expect(event.getTagValue('e')).toBeUndefined();
+ expect(event.getTagValues('p')).toEqual([]);
+ expect(event.hasTag('p')).toBe(false);
+ expect(event.verify()).toBe(true);
+ });
+
+ // [EG] Event with many tags (1000+)
+ it('should create and sign event with 1000 tags', () => {
+ const tags: [string, string][] = [];
+ for (let i = 0; i < 1000; i++) {
+ tags.push(['p', `pubkey_${i.toString().padStart(4, '0')}`]);
+ }
+
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags,
+ content: 'event with many tags',
+ });
+
+ expect(event.tags).toHaveLength(1000);
+ expect(event.id).toHaveLength(64);
+ expect(event.verify()).toBe(true);
+ });
+
+ // [EG] Empty content string
+ it('should create event with empty content', () => {
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [],
+ content: '',
+ });
+
+ expect(event.content).toBe('');
+ expect(event.verify()).toBe(true);
+ });
+
+ // [EG] Very long content
+ it('should create event with very long content', () => {
+ const content = 'A'.repeat(100000);
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [],
+ content,
+ });
+
+ expect(event.content).toBe(content);
+ expect(event.verify()).toBe(true);
+ });
+
+ // [EG] Unicode content
+ it('should handle unicode content correctly', () => {
+ const content = '\ud83d\ude00\ud83c\udf89 \u0425\u0435\u043b\u043b\u043e \u4e16\u754c \u0645\u0631\u062d\u0628\u0627';
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [],
+ content,
+ });
+
+ expect(event.content).toBe(content);
+ expect(event.verify()).toBe(true);
+ });
+
+ // [BVA] getTagEntryValues returns all entries for a tag name
+ it('getTagEntryValues should return all entries for tag name', () => {
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [
+ ['p', 'pk1', 'relay1'],
+ ['p', 'pk2', 'relay2'],
+ ['p', 'pk3'],
+ ['e', 'evt1'],
+ ],
+ content: 'test',
+ });
+
+ // getTagEntryValues returns values from first matching tag (beyond tag name)
+ const pValues = event.getTagEntryValues('p');
+ expect(pValues).toEqual(['pk1', 'relay1']);
+ });
+
+ // [BVA] getTagValues returns all first-values for a tag name
+ it('getTagValues should return values at index 1 for all matching tags', () => {
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [
+ ['p', 'pk1', 'relay1'],
+ ['p', 'pk2', 'relay2'],
+ ['e', 'evt1'],
+ ],
+ content: 'test',
+ });
+
+ expect(event.getTagValues('p')).toEqual(['pk1', 'pk2']);
+ expect(event.getTagValues('e')).toEqual(['evt1']);
+ });
+
+ // [BVA] hasTag returns false for non-existent tag
+ it('hasTag should return false for non-existent tag', () => {
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [['p', 'pk1']],
+ content: 'test',
+ });
+
+ expect(event.hasTag('p')).toBe(true);
+ expect(event.hasTag('e')).toBe(false);
+ expect(event.hasTag('t')).toBe(false);
+ expect(event.hasTag('')).toBe(false);
+ });
+
+ // [EG] Tags with empty values
+ it('should handle tags with empty string values', () => {
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [['d', '']],
+ content: 'test',
+ });
+
+ expect(event.getTagValue('d')).toBe('');
+ expect(event.hasTag('d')).toBe(true);
+ expect(event.verify()).toBe(true);
+ });
+
+ // [EG] Deterministic ID calculation
+ it('should produce same ID for same inputs', () => {
+ const id1 = Event.calculateId('abc', 1000, 1, [['p', 'pk1']], 'content');
+ const id2 = Event.calculateId('abc', 1000, 1, [['p', 'pk1']], 'content');
+ expect(id1).toBe(id2);
+ });
+
+ // [EG] Different content produces different ID
+ it('should produce different IDs for different content', () => {
+ const id1 = Event.calculateId('abc', 1000, 1, [], 'content1');
+ const id2 = Event.calculateId('abc', 1000, 1, [], 'content2');
+ expect(id1).not.toBe(id2);
+ });
+
+ // [EG] Different kind produces different ID
+ it('should produce different IDs for different kinds', () => {
+ const id1 = Event.calculateId('abc', 1000, 1, [], 'content');
+ const id2 = Event.calculateId('abc', 1000, 4, [], 'content');
+ expect(id1).not.toBe(id2);
+ });
+
+ // [BVA] Event with kind 0 (metadata)
+ it('should create event with kind 0', () => {
+ const event = Event.create(keyManager, {
+ kind: 0,
+ tags: [],
+ content: '{"name":"test"}',
+ });
+
+ expect(event.kind).toBe(0);
+ expect(event.verify()).toBe(true);
+ });
+
+ // [EG] JSON roundtrip preserves all fields
+ it('should preserve all fields through JSON roundtrip', () => {
+ const original = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [['p', 'pk1', 'extra'], ['e', 'eid']],
+ content: 'hello world',
+ created_at: 1700000000,
+ });
+
+ const json = original.toJSON();
+ const parsed = Event.fromJSON(json);
+
+ expect(parsed.id).toBe(original.id);
+ expect(parsed.pubkey).toBe(original.pubkey);
+ expect(parsed.created_at).toBe(original.created_at);
+ expect(parsed.kind).toBe(original.kind);
+ expect(parsed.tags).toEqual(original.tags);
+ expect(parsed.content).toBe(original.content);
+ expect(parsed.sig).toBe(original.sig);
+ });
+
+ // [EG] JSON string parsing
+ it('should parse from JSON string', () => {
+ const original = Event.create(keyManager, {
+ kind: 1,
+ tags: [],
+ content: 'test',
+ });
+
+ const jsonString = JSON.stringify(original.toJSON());
+ const parsed = Event.fromJSON(jsonString);
+
+ expect(parsed.id).toBe(original.id);
+ expect(parsed.verify()).toBe(true);
+ });
+});
diff --git a/tests/unit/event-kinds-comprehensive.test.ts b/tests/unit/event-kinds-comprehensive.test.ts
new file mode 100644
index 0000000..2536c5f
--- /dev/null
+++ b/tests/unit/event-kinds-comprehensive.test.ts
@@ -0,0 +1,268 @@
+/**
+ * Comprehensive tests for EventKinds helper functions
+ * Covers isReplaceable, isEphemeral, isParameterizedReplaceable, getName
+ * Techniques: [BVA] Boundary Value Analysis, [EP] Equivalence Partitioning
+ */
+
+import { describe, it, expect } from 'vitest';
+import * as EventKinds from '../../src/protocol/EventKinds.js';
+
+describe('EventKinds Helper Functions', () => {
+ // ==========================================================
+ // isReplaceable
+ // ==========================================================
+ describe('isReplaceable', () => {
+ it('should return true for kind 0 (Profile)', () => {
+ expect(EventKinds.isReplaceable(0)).toBe(true);
+ });
+
+ it('should return false for kind 1 (Text Note)', () => {
+ expect(EventKinds.isReplaceable(1)).toBe(false);
+ });
+
+ it('should return false for kind 2', () => {
+ expect(EventKinds.isReplaceable(2)).toBe(false);
+ });
+
+ it('should return true for kind 3 (Contacts)', () => {
+ expect(EventKinds.isReplaceable(3)).toBe(true);
+ });
+
+ it('should return false for kind 4', () => {
+ expect(EventKinds.isReplaceable(4)).toBe(false);
+ });
+
+ // Boundary: 9999 (just below range)
+ it('should return false for kind 9999', () => {
+ expect(EventKinds.isReplaceable(9999)).toBe(false);
+ });
+
+ // Boundary: 10000 (start of range)
+ it('should return true for kind 10000', () => {
+ expect(EventKinds.isReplaceable(10000)).toBe(true);
+ });
+
+ // Inside range
+ it('should return true for kind 15000', () => {
+ expect(EventKinds.isReplaceable(15000)).toBe(true);
+ });
+
+ // Boundary: 19999 (end of range)
+ it('should return true for kind 19999', () => {
+ expect(EventKinds.isReplaceable(19999)).toBe(true);
+ });
+
+ // Boundary: 20000 (just above range)
+ it('should return false for kind 20000', () => {
+ expect(EventKinds.isReplaceable(20000)).toBe(false);
+ });
+ });
+
+ // ==========================================================
+ // isEphemeral
+ // ==========================================================
+ describe('isEphemeral', () => {
+ it('should return false for kind 0', () => {
+ expect(EventKinds.isEphemeral(0)).toBe(false);
+ });
+
+ it('should return false for kind 1', () => {
+ expect(EventKinds.isEphemeral(1)).toBe(false);
+ });
+
+ // Boundary: 19999 (just below range)
+ it('should return false for kind 19999', () => {
+ expect(EventKinds.isEphemeral(19999)).toBe(false);
+ });
+
+ // Boundary: 20000 (start of range)
+ it('should return true for kind 20000', () => {
+ expect(EventKinds.isEphemeral(20000)).toBe(true);
+ });
+
+ // AUTH is in ephemeral range
+ it('should return true for AUTH kind (22242)', () => {
+ expect(EventKinds.isEphemeral(EventKinds.AUTH)).toBe(true);
+ });
+
+ // Inside range
+ it('should return true for kind 25000', () => {
+ expect(EventKinds.isEphemeral(25000)).toBe(true);
+ });
+
+ // Boundary: 29999 (end of range)
+ it('should return true for kind 29999', () => {
+ expect(EventKinds.isEphemeral(29999)).toBe(true);
+ });
+
+ // Boundary: 30000 (just above range)
+ it('should return false for kind 30000', () => {
+ expect(EventKinds.isEphemeral(30000)).toBe(false);
+ });
+ });
+
+ // ==========================================================
+ // isParameterizedReplaceable
+ // ==========================================================
+ describe('isParameterizedReplaceable', () => {
+ it('should return false for kind 0', () => {
+ expect(EventKinds.isParameterizedReplaceable(0)).toBe(false);
+ });
+
+ it('should return false for kind 1', () => {
+ expect(EventKinds.isParameterizedReplaceable(1)).toBe(false);
+ });
+
+ // Boundary: 29999 (just below range)
+ it('should return false for kind 29999', () => {
+ expect(EventKinds.isParameterizedReplaceable(29999)).toBe(false);
+ });
+
+ // Boundary: 30000 (start of range)
+ it('should return true for kind 30000', () => {
+ expect(EventKinds.isParameterizedReplaceable(30000)).toBe(true);
+ });
+
+ // APP_DATA is parameterized replaceable
+ it('should return true for APP_DATA kind (30078)', () => {
+ expect(EventKinds.isParameterizedReplaceable(EventKinds.APP_DATA)).toBe(true);
+ });
+
+ // TOKEN_TRANSFER is parameterized replaceable
+ it('should return true for TOKEN_TRANSFER kind (31113)', () => {
+ expect(EventKinds.isParameterizedReplaceable(EventKinds.TOKEN_TRANSFER)).toBe(true);
+ });
+
+ // Inside range
+ it('should return true for kind 35000', () => {
+ expect(EventKinds.isParameterizedReplaceable(35000)).toBe(true);
+ });
+
+ // Boundary: 39999 (end of range)
+ it('should return true for kind 39999', () => {
+ expect(EventKinds.isParameterizedReplaceable(39999)).toBe(true);
+ });
+
+ // Boundary: 40000 (just above range)
+ it('should return false for kind 40000', () => {
+ expect(EventKinds.isParameterizedReplaceable(40000)).toBe(false);
+ });
+ });
+
+ // ==========================================================
+ // getName
+ // ==========================================================
+ describe('getName', () => {
+ it('should return "Profile" for kind 0', () => {
+ expect(EventKinds.getName(0)).toBe('Profile');
+ });
+
+ it('should return "Text Note" for kind 1', () => {
+ expect(EventKinds.getName(1)).toBe('Text Note');
+ });
+
+ it('should return "Recommend Relay" for kind 2', () => {
+ expect(EventKinds.getName(2)).toBe('Recommend Relay');
+ });
+
+ it('should return "Contacts" for kind 3', () => {
+ expect(EventKinds.getName(3)).toBe('Contacts');
+ });
+
+ it('should return "Encrypted DM" for kind 4', () => {
+ expect(EventKinds.getName(4)).toBe('Encrypted DM');
+ });
+
+ it('should return "Deletion" for kind 5', () => {
+ expect(EventKinds.getName(5)).toBe('Deletion');
+ });
+
+ it('should return "Reaction" for kind 7', () => {
+ expect(EventKinds.getName(7)).toBe('Reaction');
+ });
+
+ it('should return "Seal" for kind 13', () => {
+ expect(EventKinds.getName(13)).toBe('Seal');
+ });
+
+ it('should return "Chat Message" for kind 14', () => {
+ expect(EventKinds.getName(14)).toBe('Chat Message');
+ });
+
+ it('should return "Read Receipt" for kind 15', () => {
+ expect(EventKinds.getName(15)).toBe('Read Receipt');
+ });
+
+ it('should return "Gift Wrap" for kind 1059', () => {
+ expect(EventKinds.getName(1059)).toBe('Gift Wrap');
+ });
+
+ it('should return "Relay List" for kind 10002', () => {
+ expect(EventKinds.getName(10002)).toBe('Relay List');
+ });
+
+ it('should return "App Data" for kind 30078', () => {
+ expect(EventKinds.getName(30078)).toBe('App Data');
+ });
+
+ it('should return "Token Transfer" for kind 31113', () => {
+ expect(EventKinds.getName(31113)).toBe('Token Transfer');
+ });
+
+ it('should return "Payment Request" for kind 31115', () => {
+ expect(EventKinds.getName(31115)).toBe('Payment Request');
+ });
+
+ it('should return "Payment Request Response" for kind 31116', () => {
+ expect(EventKinds.getName(31116)).toBe('Payment Request Response');
+ });
+
+ // Unknown kinds should include classification
+ it('should return "Replaceable (X)" for unknown replaceable kinds', () => {
+ expect(EventKinds.getName(10500)).toBe('Replaceable (10500)');
+ });
+
+ it('should return "Ephemeral (X)" for unknown ephemeral kinds', () => {
+ expect(EventKinds.getName(25000)).toBe('Ephemeral (25000)');
+ });
+
+ it('should return "Parameterized Replaceable (X)" for unknown parameterized kinds', () => {
+ expect(EventKinds.getName(35000)).toBe('Parameterized Replaceable (35000)');
+ });
+
+ it('should return "Unknown (X)" for completely unknown kinds', () => {
+ expect(EventKinds.getName(999)).toBe('Unknown (999)');
+ });
+ });
+
+ // ==========================================================
+ // Constants existence
+ // ==========================================================
+ describe('constants', () => {
+ it('should have all standard NIP kinds defined', () => {
+ expect(EventKinds.PROFILE).toBe(0);
+ expect(EventKinds.TEXT_NOTE).toBe(1);
+ expect(EventKinds.RECOMMEND_RELAY).toBe(2);
+ expect(EventKinds.CONTACTS).toBe(3);
+ expect(EventKinds.ENCRYPTED_DM).toBe(4);
+ expect(EventKinds.DELETION).toBe(5);
+ expect(EventKinds.REACTION).toBe(7);
+ expect(EventKinds.SEAL).toBe(13);
+ expect(EventKinds.CHAT_MESSAGE).toBe(14);
+ expect(EventKinds.READ_RECEIPT).toBe(15);
+ expect(EventKinds.GIFT_WRAP).toBe(1059);
+ expect(EventKinds.RELAY_LIST).toBe(10002);
+ expect(EventKinds.AUTH).toBe(22242);
+ expect(EventKinds.APP_DATA).toBe(30078);
+ });
+
+ it('should have all Unicity custom kinds defined', () => {
+ expect(EventKinds.AGENT_PROFILE).toBe(31111);
+ expect(EventKinds.AGENT_LOCATION).toBe(31112);
+ expect(EventKinds.TOKEN_TRANSFER).toBe(31113);
+ expect(EventKinds.FILE_METADATA).toBe(31114);
+ expect(EventKinds.PAYMENT_REQUEST).toBe(31115);
+ expect(EventKinds.PAYMENT_REQUEST_RESPONSE).toBe(31116);
+ });
+ });
+});
diff --git a/tests/unit/event-mutability.test.ts b/tests/unit/event-mutability.test.ts
new file mode 100644
index 0000000..49e42c9
--- /dev/null
+++ b/tests/unit/event-mutability.test.ts
@@ -0,0 +1,304 @@
+/**
+ * Unit tests for Event mutability and equality edge cases
+ * Covers tags mutability issue found in Java SDK
+ * Techniques: [EG] Error Guessing, [RB] Risk-Based Testing
+ */
+
+import { describe, it, expect } from 'vitest';
+import { Event } from '../../src/protocol/Event.js';
+import { NostrKeyManager } from '../../src/NostrKeyManager.js';
+import * as EventKinds from '../../src/protocol/EventKinds.js';
+
+describe('Event Mutability Edge Cases', () => {
+ let keyManager: NostrKeyManager;
+
+ beforeEach(() => {
+ keyManager = NostrKeyManager.generate();
+ });
+
+ // ==========================================================
+ // Tags mutability (Java finding #8)
+ // ==========================================================
+ describe('tags mutability', () => {
+ it('tags array is directly exposed (known issue)', () => {
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [['p', 'pubkey1']],
+ content: 'test',
+ });
+
+ // WARNING: This is a known issue - tags is mutable
+ // Callers CAN modify internal state
+ const originalTagCount = event.tags.length;
+ event.tags.push(['e', 'eventid']);
+
+ // Tags array is mutated
+ expect(event.tags.length).toBe(originalTagCount + 1);
+
+ // This breaks the event integrity - signature no longer matches
+ expect(event.verify()).toBe(false);
+ });
+
+ it('modifying tags after creation invalidates signature', () => {
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [['t', 'topic']],
+ content: 'test',
+ });
+
+ expect(event.verify()).toBe(true);
+
+ // Mutate tags
+ event.tags[0]![1] = 'modified';
+
+ // Signature is now invalid
+ expect(event.verify()).toBe(false);
+ });
+
+ it('clearing tags array invalidates signature', () => {
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [['p', 'pk1'], ['e', 'e1']],
+ content: 'test',
+ });
+
+ expect(event.verify()).toBe(true);
+
+ // Clear tags
+ event.tags.length = 0;
+
+ expect(event.verify()).toBe(false);
+ });
+ });
+
+ // ==========================================================
+ // Content mutability
+ // ==========================================================
+ describe('content mutability', () => {
+ it('modifying content invalidates signature', () => {
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [],
+ content: 'original',
+ });
+
+ expect(event.verify()).toBe(true);
+
+ // Mutate content
+ event.content = 'modified';
+
+ expect(event.verify()).toBe(false);
+ });
+ });
+
+ // ==========================================================
+ // Event equality (Java finding #4 about null IDs)
+ // ==========================================================
+ describe('event comparison', () => {
+ it('events with same ID are logically equal', () => {
+ const event1 = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [],
+ content: 'test',
+ created_at: 1000,
+ });
+
+ // Parse same event from JSON
+ const event2 = Event.fromJSON(event1.toJSON());
+
+ expect(event1.id).toBe(event2.id);
+ });
+
+ it('events with different IDs are different', () => {
+ const event1 = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [],
+ content: 'test1',
+ });
+
+ const event2 = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [],
+ content: 'test2',
+ });
+
+ expect(event1.id).not.toBe(event2.id);
+ });
+
+ // TypeScript doesn't have the Java null-ID equality issue
+ // because Event constructor requires all fields including id
+ it('Event.fromJSON requires id field', () => {
+ expect(() => Event.fromJSON({
+ pubkey: 'abc',
+ created_at: 1000,
+ kind: 1,
+ tags: [],
+ content: '',
+ sig: 'abc',
+ // id is missing
+ })).toThrow('Invalid event data');
+ });
+ });
+
+ // ==========================================================
+ // JSON serialization consistency
+ // ==========================================================
+ describe('JSON serialization', () => {
+ it('toJSON creates a new object (not a reference)', () => {
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [['p', 'pk1']],
+ content: 'test',
+ });
+
+ const json1 = event.toJSON();
+ const json2 = event.toJSON();
+
+ // Different object references
+ expect(json1).not.toBe(json2);
+
+ // But equal values
+ expect(json1).toEqual(json2);
+ });
+
+ it('toJSON tags IS the same reference as event.tags (known issue)', () => {
+ const event = Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [['p', 'pk1']],
+ content: 'test',
+ });
+
+ const json = event.toJSON();
+
+ // WARNING: This is a known issue - toJSON returns same reference
+ // Modifying json.tags DOES affect event.tags
+ json.tags.push(['e', 'eid']);
+
+ // Both are affected because they're the same array
+ expect(event.tags.length).toBe(2);
+ expect(json.tags.length).toBe(2);
+
+ // This breaks event integrity
+ expect(event.verify()).toBe(false);
+ });
+ });
+
+ // ==========================================================
+ // Event ID calculation consistency
+ // ==========================================================
+ describe('Event ID calculation', () => {
+ it('calculateId is deterministic', () => {
+ const id1 = Event.calculateId('pubkey', 1000, 1, [['p', 'pk']], 'content');
+ const id2 = Event.calculateId('pubkey', 1000, 1, [['p', 'pk']], 'content');
+ expect(id1).toBe(id2);
+ });
+
+ it('calculateId produces 64-character hex string', () => {
+ const id = Event.calculateId('pubkey', 1000, 1, [], 'content');
+ expect(id.length).toBe(64);
+ expect(/^[0-9a-f]+$/.test(id)).toBe(true);
+ });
+
+ it('different content produces different ID', () => {
+ const id1 = Event.calculateId('pubkey', 1000, 1, [], 'content1');
+ const id2 = Event.calculateId('pubkey', 1000, 1, [], 'content2');
+ expect(id1).not.toBe(id2);
+ });
+
+ it('different kind produces different ID', () => {
+ const id1 = Event.calculateId('pubkey', 1000, 1, [], 'content');
+ const id2 = Event.calculateId('pubkey', 1000, 4, [], 'content');
+ expect(id1).not.toBe(id2);
+ });
+
+ it('different tags produce different ID', () => {
+ const id1 = Event.calculateId('pubkey', 1000, 1, [], 'content');
+ const id2 = Event.calculateId('pubkey', 1000, 1, [['p', 'pk']], 'content');
+ expect(id1).not.toBe(id2);
+ });
+
+ it('different pubkey produces different ID', () => {
+ const id1 = Event.calculateId('pubkey1', 1000, 1, [], 'content');
+ const id2 = Event.calculateId('pubkey2', 1000, 1, [], 'content');
+ expect(id1).not.toBe(id2);
+ });
+
+ it('different timestamp produces different ID', () => {
+ const id1 = Event.calculateId('pubkey', 1000, 1, [], 'content');
+ const id2 = Event.calculateId('pubkey', 2000, 1, [], 'content');
+ expect(id1).not.toBe(id2);
+ });
+ });
+
+ // ==========================================================
+ // isValidEventData
+ // ==========================================================
+ describe('isValidEventData', () => {
+ it('should reject null', () => {
+ expect(Event.isValidEventData(null)).toBe(false);
+ });
+
+ it('should reject undefined', () => {
+ expect(Event.isValidEventData(undefined)).toBe(false);
+ });
+
+ it('should reject string', () => {
+ expect(Event.isValidEventData('not an event')).toBe(false);
+ });
+
+ it('should reject number', () => {
+ expect(Event.isValidEventData(123)).toBe(false);
+ });
+
+ it('should reject empty object', () => {
+ expect(Event.isValidEventData({})).toBe(false);
+ });
+
+ it('should reject object missing id', () => {
+ expect(Event.isValidEventData({
+ pubkey: 'pk',
+ created_at: 1000,
+ kind: 1,
+ tags: [],
+ content: '',
+ sig: 'sig',
+ })).toBe(false);
+ });
+
+ it('should reject object with wrong id type', () => {
+ expect(Event.isValidEventData({
+ id: 123,
+ pubkey: 'pk',
+ created_at: 1000,
+ kind: 1,
+ tags: [],
+ content: '',
+ sig: 'sig',
+ })).toBe(false);
+ });
+
+ it('should reject object with wrong tags type', () => {
+ expect(Event.isValidEventData({
+ id: 'id',
+ pubkey: 'pk',
+ created_at: 1000,
+ kind: 1,
+ tags: 'not an array',
+ content: '',
+ sig: 'sig',
+ })).toBe(false);
+ });
+
+ it('should accept valid event data', () => {
+ expect(Event.isValidEventData({
+ id: 'id',
+ pubkey: 'pk',
+ created_at: 1000,
+ kind: 1,
+ tags: [],
+ content: '',
+ sig: 'sig',
+ })).toBe(true);
+ });
+ });
+});
diff --git a/tests/unit/keymanager-nip44.test.ts b/tests/unit/keymanager-nip44.test.ts
new file mode 100644
index 0000000..2317bc7
--- /dev/null
+++ b/tests/unit/keymanager-nip44.test.ts
@@ -0,0 +1,149 @@
+/**
+ * Unit tests for NostrKeyManager NIP-44 encryption methods
+ * Feature 11: KeyManager NIP-44 Encryption
+ * Techniques: [EP] Equivalence Partitioning, [BVA] Boundary Value Analysis, [ST] State Transition
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { bytesToHex } from '@noble/hashes/utils';
+import { NostrKeyManager } from '../../src/NostrKeyManager.js';
+
+describe('NostrKeyManager NIP-44 Encryption', () => {
+ let alice: NostrKeyManager;
+ let bob: NostrKeyManager;
+
+ beforeEach(() => {
+ alice = NostrKeyManager.generate();
+ bob = NostrKeyManager.generate();
+ });
+
+ // [EP] Valid: encrypt/decrypt with byte keys
+ describe('encryptNip44 / decryptNip44 (bytes keys)', () => {
+ it('should encrypt and decrypt a message', () => {
+ const encrypted = alice.encryptNip44('Hello Bob', bob.getPublicKey());
+ const decrypted = bob.decryptNip44(encrypted, alice.getPublicKey());
+ expect(decrypted).toBe('Hello Bob');
+ });
+
+ it('should handle unicode content', () => {
+ const message = '\u041f\u0440\u0438\u0432\u0456\u0442 \ud83c\udf0d \u0645\u0631\u062d\u0628\u0627';
+ const encrypted = alice.encryptNip44(message, bob.getPublicKey());
+ const decrypted = bob.decryptNip44(encrypted, alice.getPublicKey());
+ expect(decrypted).toBe(message);
+ });
+
+ it('should produce different ciphertext each time (random nonce)', () => {
+ const ct1 = alice.encryptNip44('same message', bob.getPublicKey());
+ const ct2 = alice.encryptNip44('same message', bob.getPublicKey());
+ expect(ct1).not.toBe(ct2);
+ });
+ });
+
+ // [EP] Valid: encrypt/decrypt with hex keys
+ describe('encryptNip44Hex / decryptNip44Hex (hex keys)', () => {
+ it('should encrypt and decrypt a message', () => {
+ const encrypted = alice.encryptNip44Hex('Hello Bob', bob.getPublicKeyHex());
+ const decrypted = bob.decryptNip44Hex(encrypted, alice.getPublicKeyHex());
+ expect(decrypted).toBe('Hello Bob');
+ });
+
+ it('should produce base64-encoded output', () => {
+ const encrypted = alice.encryptNip44Hex('test', bob.getPublicKeyHex());
+ // base64 characters are alphanumeric + /+=
+ expect(encrypted).toMatch(/^[A-Za-z0-9+/=]+$/);
+ });
+ });
+
+ // [BVA] Message length boundaries
+ describe('message length boundaries', () => {
+ it('should encrypt 1-byte message', () => {
+ const encrypted = alice.encryptNip44('x', bob.getPublicKey());
+ const decrypted = bob.decryptNip44(encrypted, alice.getPublicKey());
+ expect(decrypted).toBe('x');
+ });
+
+ it('should encrypt a long message (close to max)', () => {
+ // 60000 bytes is well under 65535 limit but large enough to stress test
+ const message = 'A'.repeat(60000);
+ const encrypted = alice.encryptNip44(message, bob.getPublicKey());
+ const decrypted = bob.decryptNip44(encrypted, alice.getPublicKey());
+ expect(decrypted).toBe(message);
+ });
+
+ it('should reject empty message', () => {
+ expect(() => alice.encryptNip44('', bob.getPublicKey())).toThrow(/too short/);
+ });
+ });
+
+ // [EP] Invalid: wrong key decryption
+ describe('wrong key decryption', () => {
+ it('should fail when decrypting with wrong key', () => {
+ const eve = NostrKeyManager.generate();
+ const encrypted = alice.encryptNip44('secret', bob.getPublicKey());
+
+ expect(() => eve.decryptNip44(encrypted, alice.getPublicKey())).toThrow();
+ });
+ });
+
+ // [ST] Cleared key manager
+ describe('cleared key manager', () => {
+ it('encryptNip44 should throw after clear', () => {
+ alice.clear();
+ expect(() => alice.encryptNip44('test', bob.getPublicKey()))
+ .toThrow(/has been cleared/);
+ });
+
+ it('decryptNip44 should throw after clear', () => {
+ const encrypted = alice.encryptNip44('test', bob.getPublicKey());
+ bob.clear();
+ expect(() => bob.decryptNip44(encrypted, alice.getPublicKey()))
+ .toThrow(/has been cleared/);
+ });
+
+ it('encryptNip44Hex should throw after clear', () => {
+ alice.clear();
+ expect(() => alice.encryptNip44Hex('test', bob.getPublicKeyHex()))
+ .toThrow(/has been cleared/);
+ });
+
+ it('decryptNip44Hex should throw after clear', () => {
+ const encrypted = alice.encryptNip44Hex('test', bob.getPublicKeyHex());
+ bob.clear();
+ expect(() => bob.decryptNip44Hex(encrypted, alice.getPublicKeyHex()))
+ .toThrow(/has been cleared/);
+ });
+
+ it('deriveConversationKey should throw after clear', () => {
+ alice.clear();
+ expect(() => alice.deriveConversationKey(bob.getPublicKey()))
+ .toThrow(/has been cleared/);
+ });
+ });
+
+ // Conversation key derivation
+ describe('deriveConversationKey', () => {
+ it('should produce consistent result', () => {
+ const key1 = alice.deriveConversationKey(bob.getPublicKey());
+ const key2 = alice.deriveConversationKey(bob.getPublicKey());
+ expect(bytesToHex(key1)).toBe(bytesToHex(key2));
+ });
+
+ it('should be symmetric (A->B equals B->A)', () => {
+ const keyAB = alice.deriveConversationKey(bob.getPublicKey());
+ const keyBA = bob.deriveConversationKey(alice.getPublicKey());
+ expect(bytesToHex(keyAB)).toBe(bytesToHex(keyBA));
+ });
+
+ it('should differ for different key pairs', () => {
+ const charlie = NostrKeyManager.generate();
+ const keyAB = alice.deriveConversationKey(bob.getPublicKey());
+ const keyAC = alice.deriveConversationKey(charlie.getPublicKey());
+ expect(bytesToHex(keyAB)).not.toBe(bytesToHex(keyAC));
+ });
+
+ it('should return 32-byte key', () => {
+ const key = alice.deriveConversationKey(bob.getPublicKey());
+ expect(key.length).toBe(32);
+ });
+ });
+});
diff --git a/tests/unit/nametag-edge-cases.test.ts b/tests/unit/nametag-edge-cases.test.ts
new file mode 100644
index 0000000..6271a5f
--- /dev/null
+++ b/tests/unit/nametag-edge-cases.test.ts
@@ -0,0 +1,268 @@
+/**
+ * Unit tests for Nametag edge cases
+ * Covers phone number heuristic, normalization quirks, display formatting
+ * Techniques: [EG] Error Guessing, [BVA] Boundary Value Analysis
+ */
+
+import { describe, it, expect } from 'vitest';
+import * as NametagUtils from '../../src/nametag/NametagUtils.js';
+
+describe('Nametag Edge Cases', () => {
+ // ==========================================================
+ // Phone number heuristic edge cases
+ // ==========================================================
+ describe('phone number heuristic', () => {
+ // These SHOULD be treated as phone numbers
+ it('should treat +1 prefix as phone number', () => {
+ const normalized = NametagUtils.normalizeNametag('+14155551234');
+ expect(normalized).toBe('+14155551234');
+ });
+
+ it('should treat formatted US phone as phone number', () => {
+ const normalized = NametagUtils.normalizeNametag('(415) 555-1234');
+ expect(normalized).toBe('+14155551234');
+ });
+
+ it('should treat phone with dashes as phone number', () => {
+ const normalized = NametagUtils.normalizeNametag('415-555-1234');
+ expect(normalized).toBe('+14155551234');
+ });
+
+ // Edge cases that might be misclassified
+ it('should NOT treat "user123" as phone (only 3 digits)', () => {
+ const normalized = NametagUtils.normalizeNametag('user123');
+ expect(normalized).toBe('user123');
+ });
+
+ it('should NOT treat "test12345" as phone (only 5 digits)', () => {
+ const normalized = NametagUtils.normalizeNametag('test12345');
+ expect(normalized).toBe('test12345');
+ });
+
+ it('should NOT treat "abc1234567" as phone (7 digits but <50% ratio)', () => {
+ // 7 digits, 10 total chars = 70% but with 'abc' prefix it's borderline
+ const normalized = NametagUtils.normalizeNametag('abc1234567');
+ // This has 7 digits out of 10 chars = 70% ratio > 50%, so it WILL be treated as phone
+ // But it's not a valid phone number, so it falls back to standard normalization
+ expect(normalized).toBe('abc1234567');
+ });
+
+ // WARNING: This is the "user1234567" case from Java findings
+ // A nametag like "user1234567" (7+ digits, >50% digits) gets treated as a phone number
+ it('should handle "user1234567" - 7 digits with text prefix', () => {
+ // 7 digits out of 11 chars = 63% ratio > 50%
+ // This WILL trigger phone detection, but then fail validation
+ const normalized = NametagUtils.normalizeNametag('user1234567');
+ // Falls back to standard normalization since it's not a valid phone
+ expect(normalized).toBe('user1234567');
+ });
+
+ it('should handle "12345678901" - just digits, looks like phone', () => {
+ const normalized = NametagUtils.normalizeNametag('12345678901');
+ // 11 digits, all digits = 100% ratio, treated as phone
+ // libphonenumber interprets leading 1 as US country code
+ expect(normalized).toBe('+12345678901');
+ });
+
+ it('should handle 6-digit code (below threshold)', () => {
+ const normalized = NametagUtils.normalizeNametag('123456');
+ // Only 6 digits, below 7 threshold, NOT treated as phone
+ expect(normalized).toBe('123456');
+ });
+
+ it('should handle 7-digit number (at threshold)', () => {
+ // Exactly 7 digits, 100% ratio, treated as phone
+ // May or may not be valid depending on country
+ const normalized = NametagUtils.normalizeNametag('1234567');
+ expect(normalized).toBeDefined();
+ });
+ });
+
+ // ==========================================================
+ // Standard normalization
+ // ==========================================================
+ describe('standard normalization', () => {
+ it('should lowercase nametag', () => {
+ expect(NametagUtils.normalizeNametag('Alice')).toBe('alice');
+ });
+
+ it('should lowercase mixed case', () => {
+ expect(NametagUtils.normalizeNametag('AlIcE')).toBe('alice');
+ });
+
+ it('should remove @unicity suffix', () => {
+ expect(NametagUtils.normalizeNametag('alice@unicity')).toBe('alice');
+ });
+
+ it('should remove @UNICITY suffix (case insensitive)', () => {
+ expect(NametagUtils.normalizeNametag('Alice@UNICITY')).toBe('alice');
+ });
+
+ it('should trim whitespace', () => {
+ expect(NametagUtils.normalizeNametag(' alice ')).toBe('alice');
+ });
+
+ it('should handle empty string', () => {
+ expect(NametagUtils.normalizeNametag('')).toBe('');
+ });
+
+ it('should handle whitespace only', () => {
+ expect(NametagUtils.normalizeNametag(' ')).toBe('');
+ });
+
+ it('should preserve non-@unicity suffixes', () => {
+ expect(NametagUtils.normalizeNametag('alice@example')).toBe('alice@example');
+ });
+ });
+
+ // ==========================================================
+ // hashNametag determinism
+ // ==========================================================
+ describe('hashNametag', () => {
+ it('should produce deterministic hash', () => {
+ const hash1 = NametagUtils.hashNametag('alice');
+ const hash2 = NametagUtils.hashNametag('alice');
+ expect(hash1).toBe(hash2);
+ });
+
+ it('should produce 64-character hex hash', () => {
+ const hash = NametagUtils.hashNametag('alice');
+ expect(hash.length).toBe(64);
+ expect(/^[0-9a-f]+$/.test(hash)).toBe(true);
+ });
+
+ it('should normalize before hashing', () => {
+ const hash1 = NametagUtils.hashNametag('Alice');
+ const hash2 = NametagUtils.hashNametag('alice');
+ expect(hash1).toBe(hash2);
+ });
+
+ it('should normalize @unicity suffix before hashing', () => {
+ const hash1 = NametagUtils.hashNametag('alice@unicity');
+ const hash2 = NametagUtils.hashNametag('alice');
+ expect(hash1).toBe(hash2);
+ });
+
+ it('should produce different hashes for different nametags', () => {
+ const hash1 = NametagUtils.hashNametag('alice');
+ const hash2 = NametagUtils.hashNametag('bob');
+ expect(hash1).not.toBe(hash2);
+ });
+
+ it('should normalize phone before hashing', () => {
+ const hash1 = NametagUtils.hashNametag('+14155551234');
+ const hash2 = NametagUtils.hashNametag('(415) 555-1234');
+ expect(hash1).toBe(hash2);
+ });
+ });
+
+ // ==========================================================
+ // areSameNametag
+ // ==========================================================
+ describe('areSameNametag', () => {
+ it('should match different case', () => {
+ expect(NametagUtils.areSameNametag('Alice', 'alice')).toBe(true);
+ });
+
+ it('should match with and without @unicity suffix', () => {
+ expect(NametagUtils.areSameNametag('alice', 'alice@unicity')).toBe(true);
+ });
+
+ it('should match phone numbers in different formats', () => {
+ expect(NametagUtils.areSameNametag('+14155551234', '(415) 555-1234')).toBe(true);
+ });
+
+ it('should NOT match different nametags', () => {
+ expect(NametagUtils.areSameNametag('alice', 'bob')).toBe(false);
+ });
+
+ it('should match with trimming', () => {
+ expect(NametagUtils.areSameNametag(' alice ', 'alice')).toBe(true);
+ });
+ });
+
+ // ==========================================================
+ // formatForDisplay
+ // ==========================================================
+ describe('formatForDisplay', () => {
+ it('should mask phone number middle digits', () => {
+ const display = NametagUtils.formatForDisplay('+14155551234');
+ expect(display).toBe('+1415***1234');
+ });
+
+ it('should mask formatted phone number', () => {
+ const display = NametagUtils.formatForDisplay('(415) 555-1234');
+ expect(display).toBe('+1415***1234');
+ });
+
+ // NOTE: Java finding #6 - formatForDisplay returns un-normalized for non-phones
+ // TypeScript returns normalizeNametag() result for non-phones (line 168)
+ it('should return normalized text nametag (not raw input)', () => {
+ const display = NametagUtils.formatForDisplay('Alice@UNICITY');
+ // TypeScript returns normalized, not raw
+ expect(display).toBe('alice');
+ });
+
+ it('should return normalized for non-phone digit strings', () => {
+ const display = NametagUtils.formatForDisplay('user123');
+ expect(display).toBe('user123');
+ });
+
+ it('should handle short phone numbers without masking', () => {
+ // Numbers with <= 6 digits after + shouldn't be masked
+ const display = NametagUtils.formatForDisplay('+123456');
+ // Falls back to standard normalization since it's not a valid phone
+ expect(display).toBeDefined();
+ });
+ });
+
+ // ==========================================================
+ // isPhoneNumber
+ // ==========================================================
+ describe('isPhoneNumber', () => {
+ it('should return true for valid US phone', () => {
+ expect(NametagUtils.isPhoneNumber('+14155551234')).toBe(true);
+ });
+
+ it('should return true for formatted US phone', () => {
+ expect(NametagUtils.isPhoneNumber('(415) 555-1234')).toBe(true);
+ });
+
+ it('should return false for text nametag', () => {
+ expect(NametagUtils.isPhoneNumber('alice')).toBe(false);
+ });
+
+ it('should return false for short digit string', () => {
+ expect(NametagUtils.isPhoneNumber('12345')).toBe(false);
+ });
+
+ it('should return true for international phone', () => {
+ expect(NametagUtils.isPhoneNumber('+442071234567')).toBe(true);
+ });
+ });
+
+ // ==========================================================
+ // Country code handling
+ // ==========================================================
+ describe('country code handling', () => {
+ it('should use US as default country', () => {
+ // 10-digit number without country code should be treated as US
+ const normalized = NametagUtils.normalizeNametag('4155551234');
+ expect(normalized).toBe('+14155551234');
+ });
+
+ it('should accept explicit country code', () => {
+ const normalized = NametagUtils.normalizeNametag('02071234567', 'GB');
+ expect(normalized).toBe('+442071234567');
+ });
+
+ it('should hash with custom country code', () => {
+ const hashUS = NametagUtils.hashNametag('4155551234', 'US');
+ const hashGB = NametagUtils.hashNametag('4155551234', 'GB');
+ // Different country codes should produce different results for ambiguous numbers
+ // (though this specific number may normalize the same)
+ expect(hashUS).toBeDefined();
+ expect(hashGB).toBeDefined();
+ });
+ });
+});
diff --git a/tests/unit/nip04-edge-cases.test.ts b/tests/unit/nip04-edge-cases.test.ts
new file mode 100644
index 0000000..8f192bd
--- /dev/null
+++ b/tests/unit/nip04-edge-cases.test.ts
@@ -0,0 +1,103 @@
+/**
+ * Unit tests for NIP-04 corrupted and malformed data handling
+ * Feature 13: NIP-04 Corrupted Data
+ * Techniques: [EG] Error Guessing
+ */
+
+import { describe, it, expect } from 'vitest';
+import * as NIP04 from '../../src/crypto/nip04.js';
+import * as Schnorr from '../../src/crypto/schnorr.js';
+
+describe('NIP-04 Corrupted Data Handling', () => {
+ const alicePrivateKey = new Uint8Array(32).fill(0x01);
+ const alicePublicKey = Schnorr.getPublicKey(alicePrivateKey);
+ const bobPrivateKey = new Uint8Array(32).fill(0x02);
+ const bobPublicKey = Schnorr.getPublicKey(bobPrivateKey);
+
+ // [EG] Corrupted base64 ciphertext
+ it('should fail when ciphertext is corrupted', async () => {
+ const encrypted = await NIP04.encrypt('test message', alicePrivateKey, bobPublicKey);
+ const parts = encrypted.split('?iv=');
+ // Corrupt the ciphertext by flipping characters
+ const corrupted = parts[0]!.slice(0, -4) + 'XXXX' + '?iv=' + parts[1];
+
+ await expect(
+ NIP04.decrypt(corrupted, bobPrivateKey, alicePublicKey)
+ ).rejects.toThrow();
+ });
+
+ // [EG] Missing IV separator
+ it('should reject message without IV separator', async () => {
+ await expect(
+ NIP04.decrypt('justbase64withoutiv', bobPrivateKey, alicePublicKey)
+ ).rejects.toThrow(/Invalid encrypted content format/);
+ });
+
+ // [EG] Empty ciphertext with valid IV format
+ it('should fail with empty ciphertext portion', async () => {
+ // Empty ciphertext but valid IV format
+ await expect(
+ NIP04.decrypt('?iv=dGVzdGl2MTIzNDU2Nzg=', bobPrivateKey, alicePublicKey)
+ ).rejects.toThrow();
+ });
+
+ // [EG] Corrupted compressed message
+ it('should fail with corrupted GZIP data', async () => {
+ // gz: prefix indicates compression but the data is invalid
+ await expect(
+ NIP04.decrypt('gz:aW52YWxpZGd6aXBkYXRh?iv=dGVzdGl2MTIzNDU2Nzg=', bobPrivateKey, alicePublicKey)
+ ).rejects.toThrow();
+ });
+
+ // [EG] Truncated ciphertext
+ it('should fail with truncated ciphertext', async () => {
+ const encrypted = await NIP04.encrypt('Hello World test message', alicePrivateKey, bobPublicKey);
+ const parts = encrypted.split('?iv=');
+ // Truncate ciphertext to half
+ const truncated = parts[0]!.slice(0, Math.floor(parts[0]!.length / 2)) + '?iv=' + parts[1];
+
+ await expect(
+ NIP04.decrypt(truncated, bobPrivateKey, alicePublicKey)
+ ).rejects.toThrow();
+ });
+
+ // [EG] Invalid IV length
+ it('should fail with invalid IV length', async () => {
+ const encrypted = await NIP04.encrypt('test', alicePrivateKey, bobPublicKey);
+ const parts = encrypted.split('?iv=');
+ // Replace IV with a short one (not 16 bytes)
+ const badIv = Buffer.from('short').toString('base64');
+ const modified = parts[0] + '?iv=' + badIv;
+
+ await expect(
+ NIP04.decrypt(modified, bobPrivateKey, alicePublicKey)
+ ).rejects.toThrow(/Invalid IV length/);
+ });
+
+ // [EG] Multiple ?iv= separators
+ it('should fail with multiple IV separators', async () => {
+ await expect(
+ NIP04.decrypt('part1?iv=part2?iv=part3', bobPrivateKey, alicePublicKey)
+ ).rejects.toThrow();
+ });
+
+ // [EG] Valid format but completely random data
+ it('should fail to decrypt random ciphertext', async () => {
+ const randomCiphertext = Buffer.from(new Uint8Array(48)).toString('base64');
+ const randomIv = Buffer.from(new Uint8Array(16)).toString('base64');
+
+ await expect(
+ NIP04.decrypt(`${randomCiphertext}?iv=${randomIv}`, bobPrivateKey, alicePublicKey)
+ ).rejects.toThrow();
+ });
+
+ // [EG] Very long ciphertext (should not crash)
+ it('should handle very long ciphertext gracefully', async () => {
+ const longCiphertext = Buffer.from(new Uint8Array(100000)).toString('base64');
+ const randomIv = Buffer.from(new Uint8Array(16)).toString('base64');
+
+ await expect(
+ NIP04.decrypt(`${longCiphertext}?iv=${randomIv}`, bobPrivateKey, alicePublicKey)
+ ).rejects.toThrow();
+ });
+});
diff --git a/tests/unit/nip44-edge-cases.test.ts b/tests/unit/nip44-edge-cases.test.ts
new file mode 100644
index 0000000..94fee97
--- /dev/null
+++ b/tests/unit/nip44-edge-cases.test.ts
@@ -0,0 +1,268 @@
+/**
+ * Unit tests for NIP-44 corrupted data and padding edge cases
+ * Features 14 & 15: NIP-44 Corrupted Data + Padding Edge Cases
+ * Techniques: [EG] Error Guessing, [BVA] Boundary Value Analysis, [EP] Equivalence Partitioning
+ */
+
+import { describe, it, expect } from 'vitest';
+import { bytesToHex } from '@noble/hashes/utils';
+import * as NIP44 from '../../src/crypto/nip44.js';
+import * as Schnorr from '../../src/crypto/schnorr.js';
+
+describe('NIP-44 Corrupted Data Handling', () => {
+ const alicePrivateKey = new Uint8Array(32).fill(0x01);
+ const alicePublicKey = Schnorr.getPublicKey(alicePrivateKey);
+ const bobPrivateKey = new Uint8Array(32).fill(0x02);
+ const bobPublicKey = Schnorr.getPublicKey(bobPrivateKey);
+
+ // [EG] Corrupted ciphertext byte
+ it('should fail to decrypt when ciphertext is corrupted (MAC mismatch)', () => {
+ const encrypted = NIP44.encrypt('test message', alicePrivateKey, bobPublicKey);
+ const decoded = Buffer.from(encrypted, 'base64');
+
+ // Corrupt a byte in the ciphertext area (after version + nonce = 25 bytes)
+ if (decoded.length > 30) {
+ decoded[30] = (decoded[30]! + 1) % 256;
+ }
+ const corrupted = decoded.toString('base64');
+
+ expect(() => NIP44.decrypt(corrupted, bobPrivateKey, alicePublicKey)).toThrow();
+ });
+
+ // [EG] Wrong version byte 0x01
+ it('should reject version byte 0x01', () => {
+ const encrypted = NIP44.encrypt('test', alicePrivateKey, bobPublicKey);
+ const decoded = Buffer.from(encrypted, 'base64');
+ decoded[0] = 0x01;
+ const modified = decoded.toString('base64');
+
+ expect(() => NIP44.decrypt(modified, bobPrivateKey, alicePublicKey))
+ .toThrow(/Unsupported NIP-44 version: 1/);
+ });
+
+ // [EG] Wrong version byte 0x00
+ it('should reject version byte 0x00', () => {
+ const encrypted = NIP44.encrypt('test', alicePrivateKey, bobPublicKey);
+ const decoded = Buffer.from(encrypted, 'base64');
+ decoded[0] = 0x00;
+ const modified = decoded.toString('base64');
+
+ expect(() => NIP44.decrypt(modified, bobPrivateKey, alicePublicKey))
+ .toThrow(/Unsupported NIP-44 version: 0/);
+ });
+
+ // [EG] Wrong version byte 0xFF
+ it('should reject version byte 0xFF', () => {
+ const encrypted = NIP44.encrypt('test', alicePrivateKey, bobPublicKey);
+ const decoded = Buffer.from(encrypted, 'base64');
+ decoded[0] = 0xFF;
+ const modified = decoded.toString('base64');
+
+ expect(() => NIP44.decrypt(modified, bobPrivateKey, alicePublicKey))
+ .toThrow(/Unsupported NIP-44 version/);
+ });
+
+ // [BVA] Payload too short — 10 bytes
+ it('should reject payload of only 10 bytes', () => {
+ const shortPayload = Buffer.from(new Uint8Array(10)).toString('base64');
+ // Set version byte
+ const buf = Buffer.from(shortPayload, 'base64');
+ buf[0] = 0x02;
+ const encoded = buf.toString('base64');
+
+ expect(() => NIP44.decrypt(encoded, bobPrivateKey, alicePublicKey))
+ .toThrow(/too short/);
+ });
+
+ // [BVA] Payload at minimum valid length (1 + 24 + 32 + 16 = 73 bytes) but invalid crypto
+ it('should reject minimum-length payload with invalid crypto data', () => {
+ const payload = new Uint8Array(73);
+ payload[0] = 0x02; // correct version
+ // rest is zeros — will fail on crypto, not on "too short"
+ const encoded = Buffer.from(payload).toString('base64');
+
+ expect(() => NIP44.decrypt(encoded, bobPrivateKey, alicePublicKey)).toThrow();
+ // Should NOT throw "too short" — it's the right length, just bad crypto
+ try {
+ NIP44.decrypt(encoded, bobPrivateKey, alicePublicKey);
+ } catch (e: unknown) {
+ expect((e as Error).message).not.toMatch(/too short/);
+ }
+ });
+
+ // [BVA] Payload of 72 bytes (1 under minimum)
+ it('should reject payload of 72 bytes as too short', () => {
+ const payload = new Uint8Array(72);
+ payload[0] = 0x02;
+ const encoded = Buffer.from(payload).toString('base64');
+
+ expect(() => NIP44.decrypt(encoded, bobPrivateKey, alicePublicKey))
+ .toThrow(/too short/);
+ });
+
+ // [EG] Truncated nonce
+ it('should reject payload with truncated nonce', () => {
+ // 1 (version) + 10 (partial nonce) = 11 bytes total
+ const payload = new Uint8Array(11);
+ payload[0] = 0x02;
+ const encoded = Buffer.from(payload).toString('base64');
+
+ expect(() => NIP44.decrypt(encoded, bobPrivateKey, alicePublicKey))
+ .toThrow(/too short/);
+ });
+
+ // [EG] Corrupted nonce but correct length
+ it('should fail when nonce is corrupted', () => {
+ const encrypted = NIP44.encrypt('test data', alicePrivateKey, bobPublicKey);
+ const decoded = Buffer.from(encrypted, 'base64');
+
+ // Corrupt nonce area (bytes 1-24)
+ for (let i = 1; i <= 24 && i < decoded.length; i++) {
+ decoded[i] = (decoded[i]! + 1) % 256;
+ }
+ const corrupted = decoded.toString('base64');
+
+ expect(() => NIP44.decrypt(corrupted, bobPrivateKey, alicePublicKey)).toThrow();
+ });
+
+ // [EG] Empty base64 string
+ it('should reject empty base64 string', () => {
+ expect(() => NIP44.decrypt('', bobPrivateKey, alicePublicKey)).toThrow();
+ });
+});
+
+describe('NIP-44 Padding Edge Cases', () => {
+ // [BVA] calcPaddedLen boundary values
+ describe('calcPaddedLen boundaries', () => {
+ it('should return 32 for lengths 1 through 32', () => {
+ expect(NIP44.calcPaddedLen(1)).toBe(32);
+ expect(NIP44.calcPaddedLen(16)).toBe(32);
+ expect(NIP44.calcPaddedLen(31)).toBe(32);
+ expect(NIP44.calcPaddedLen(32)).toBe(32);
+ });
+
+ it('should return 64 for length 33', () => {
+ expect(NIP44.calcPaddedLen(33)).toBe(64);
+ });
+
+ it('should return 64 for length 64', () => {
+ expect(NIP44.calcPaddedLen(64)).toBe(64);
+ });
+
+ it('should handle mid-range values', () => {
+ // For 65: nextPow2=128, chunk=max(32,16)=32 -> ceil(65/32)*32 = 96
+ expect(NIP44.calcPaddedLen(65)).toBe(96);
+ // For 100: nextPow2=128, chunk=max(32,16)=32 -> ceil(100/32)*32 = 128
+ expect(NIP44.calcPaddedLen(100)).toBe(128);
+ });
+
+ it('should handle values near 256', () => {
+ expect(NIP44.calcPaddedLen(255)).toBe(256);
+ expect(NIP44.calcPaddedLen(256)).toBe(256);
+ expect(NIP44.calcPaddedLen(257)).toBe(320);
+ });
+
+ it('should handle large values', () => {
+ expect(NIP44.calcPaddedLen(1000)).toBe(1024);
+ expect(NIP44.calcPaddedLen(65535)).toBe(65536);
+ });
+
+ // [BVA] Invalid: zero length
+ it('should reject zero length', () => {
+ expect(() => NIP44.calcPaddedLen(0)).toThrow(/too short/);
+ });
+
+ // [BVA] Invalid: negative length
+ it('should reject negative length', () => {
+ expect(() => NIP44.calcPaddedLen(-1)).toThrow(/too short/);
+ });
+
+ // [BVA] Invalid: exceeds maximum
+ it('should reject length 65536', () => {
+ expect(() => NIP44.calcPaddedLen(65536)).toThrow(/too long/);
+ });
+
+ // [BVA] Maximum valid length
+ it('should accept length 65535', () => {
+ expect(NIP44.calcPaddedLen(65535)).toBeGreaterThanOrEqual(65535);
+ });
+ });
+
+ // [EP] Pad/Unpad roundtrip
+ describe('pad/unpad roundtrip', () => {
+ it('should preserve message for various lengths', () => {
+ const testLengths = [1, 5, 16, 31, 32, 33, 50, 64, 65, 100, 256, 500, 1000];
+
+ for (const len of testLengths) {
+ const message = new Uint8Array(len);
+ for (let i = 0; i < len; i++) {
+ message[i] = i % 256;
+ }
+
+ const padded = NIP44.pad(message);
+ const unpadded = NIP44.unpad(padded);
+
+ expect(bytesToHex(unpadded)).toBe(bytesToHex(message));
+ }
+ });
+
+ it('should include 2-byte big-endian length prefix', () => {
+ const message = new Uint8Array(300); // 0x012C
+ const padded = NIP44.pad(message);
+
+ expect(padded[0]).toBe(0x01); // high byte
+ expect(padded[1]).toBe(0x2C); // low byte (300 & 0xFF = 44 = 0x2C)
+ });
+
+ it('should include correct length prefix for small message', () => {
+ const message = new Uint8Array(5);
+ const padded = NIP44.pad(message);
+
+ expect(padded[0]).toBe(0x00); // high byte
+ expect(padded[1]).toBe(0x05); // low byte
+ });
+ });
+
+ // Unpad error conditions
+ describe('unpad error handling', () => {
+ it('should reject padded data that is too short', () => {
+ // Less than 2 + 32 = 34 bytes
+ const shortPadded = new Uint8Array(10);
+ expect(() => NIP44.unpad(shortPadded)).toThrow(/too short/);
+ });
+
+ it('should reject padded data with zero length prefix', () => {
+ // Create a padded buffer with length prefix = 0
+ const padded = new Uint8Array(2 + 32); // 34 bytes
+ padded[0] = 0x00;
+ padded[1] = 0x00; // length = 0
+
+ expect(() => NIP44.unpad(padded)).toThrow(/Invalid message length/);
+ });
+
+ it('should reject padded data with wrong padding size', () => {
+ // Create a valid-looking padded buffer but with mismatched padding
+ const padded = new Uint8Array(2 + 64); // 66 bytes total
+ padded[0] = 0x00;
+ padded[1] = 0x05; // claims 5 bytes, but calcPaddedLen(5) = 32, not 64
+
+ expect(() => NIP44.unpad(padded)).toThrow(/Invalid padding/);
+ });
+ });
+
+ // Pad error conditions
+ describe('pad error handling', () => {
+ it('should reject empty message', () => {
+ expect(() => NIP44.pad(new Uint8Array(0))).toThrow(/too short/);
+ });
+
+ it('should reject message exceeding max length', () => {
+ expect(() => NIP44.pad(new Uint8Array(65536))).toThrow(/too long/);
+ });
+
+ it('should accept maximum-length message', () => {
+ const padded = NIP44.pad(new Uint8Array(65535));
+ expect(padded.length).toBeGreaterThanOrEqual(2 + 65535);
+ });
+ });
+});
diff --git a/tests/unit/nostr-client.test.ts b/tests/unit/nostr-client.test.ts
new file mode 100644
index 0000000..b9f62c3
--- /dev/null
+++ b/tests/unit/nostr-client.test.ts
@@ -0,0 +1,1038 @@
+/**
+ * Unit tests for NostrClient
+ * Features 1-9, 17, 19, 20: Connection lifecycle, message handling, publishing,
+ * subscriptions, reconnection, NIP-17, token/payment delegation, nametag query,
+ * disconnect cleanup, config combinations, subscription re-establishment, concurrency
+ *
+ * Techniques: [ST] State Transition, [DT] Decision Table, [EP] Equivalence Partitioning,
+ * [BVA] Boundary Value Analysis, [UC] Use Case, [EG] Error Guessing, [PW] Pairwise,
+ * [LC] Loop Testing, [RB] Risk-Based, [SC] Statement/Branch Coverage
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
+import { NostrClient, type ConnectionEventListener } from '../../src/client/NostrClient.js';
+import { NostrKeyManager } from '../../src/NostrKeyManager.js';
+import { Event } from '../../src/protocol/Event.js';
+import { Filter } from '../../src/protocol/Filter.js';
+import * as EventKinds from '../../src/protocol/EventKinds.js';
+import type { IWebSocket, WebSocketMessageEvent, WebSocketCloseEvent, WebSocketErrorEvent } from '../../src/client/WebSocketAdapter.js';
+
+// ============================================================
+// Mock WebSocket Infrastructure
+// ============================================================
+
+class MockWebSocket implements IWebSocket {
+ readyState = 0; // CONNECTING
+ onopen: ((event: unknown) => void) | null = null;
+ onmessage: ((event: WebSocketMessageEvent) => void) | null = null;
+ onclose: ((event: WebSocketCloseEvent) => void) | null = null;
+ onerror: ((event: WebSocketErrorEvent) => void) | null = null;
+ sentMessages: string[] = [];
+ closeCode?: number;
+ closeReason?: string;
+
+ send(data: string): void {
+ this.sentMessages.push(data);
+ }
+
+ close(code?: number, reason?: string): void {
+ this.closeCode = code;
+ this.closeReason = reason;
+ this.readyState = 3; // CLOSED
+ }
+
+ simulateOpen(): void {
+ this.readyState = 1; // OPEN
+ this.onopen?.({});
+ }
+
+ simulateMessage(data: string): void {
+ this.onmessage?.({ data });
+ }
+
+ simulateClose(code = 1000, reason = ''): void {
+ this.readyState = 3; // CLOSED
+ this.onclose?.({ code, reason });
+ }
+
+ simulateError(message = 'Error'): void {
+ this.onerror?.({ message });
+ }
+}
+
+let mockSockets: MockWebSocket[] = [];
+let createWebSocketMock: Mock;
+
+vi.mock('../../src/client/WebSocketAdapter.js', () => ({
+ createWebSocket: (...args: unknown[]) => createWebSocketMock(...args),
+ extractMessageData: (event: WebSocketMessageEvent) => {
+ if (typeof event.data === 'string') return event.data;
+ return String(event.data);
+ },
+ CONNECTING: 0,
+ OPEN: 1,
+ CLOSING: 2,
+ CLOSED: 3,
+}));
+
+// ============================================================
+// Helpers
+// ============================================================
+
+function createMockSocket(): MockWebSocket {
+ const socket = new MockWebSocket();
+ mockSockets.push(socket);
+ return socket;
+}
+
+/** Flush microtask queue so promise chains settle */
+async function flushMicrotasks(): Promise {
+ for (let i = 0; i < 10; i++) {
+ await Promise.resolve();
+ }
+}
+
+/** Connect client to a relay and return the mock socket */
+async function connectClient(client: NostrClient, url = 'wss://relay.example.com'): Promise {
+ const socket = createMockSocket();
+ createWebSocketMock.mockResolvedValueOnce(socket);
+ const connectPromise = client.connect(url);
+ await flushMicrotasks();
+ socket.simulateOpen();
+ await connectPromise;
+ return socket;
+}
+
+function createTestEvent(keyManager: NostrKeyManager, content = 'test'): Event {
+ return Event.create(keyManager, {
+ kind: EventKinds.TEXT_NOTE,
+ tags: [],
+ content,
+ });
+}
+
+// ============================================================
+// Tests
+// ============================================================
+
+describe('NostrClient', () => {
+ let keyManager: NostrKeyManager;
+ let client: NostrClient;
+
+ beforeEach(() => {
+ mockSockets = [];
+ createWebSocketMock = vi.fn();
+ keyManager = NostrKeyManager.generate();
+ client = new NostrClient(keyManager);
+ });
+
+ afterEach(() => {
+ client.disconnect();
+ });
+
+ // ==========================================================
+ // Feature 1: Connection Lifecycle [ST]
+ // ==========================================================
+ describe('Feature 1: Connection Lifecycle', () => {
+ it('should start in disconnected state', () => {
+ expect(client.isConnected()).toBe(false);
+ expect(client.getConnectedRelays().size).toBe(0);
+ });
+
+ it('should connect to a single relay', async () => {
+ await connectClient(client, 'wss://relay1.example.com');
+
+ expect(client.isConnected()).toBe(true);
+ expect(client.getConnectedRelays().has('wss://relay1.example.com')).toBe(true);
+ });
+
+ it('should connect to multiple relays', async () => {
+ const socket1 = createMockSocket();
+ const socket2 = createMockSocket();
+ createWebSocketMock.mockResolvedValueOnce(socket1).mockResolvedValueOnce(socket2);
+
+ const connectPromise = client.connect('wss://relay1.example.com', 'wss://relay2.example.com');
+ await flushMicrotasks();
+ socket1.simulateOpen();
+ socket2.simulateOpen();
+ await connectPromise;
+
+ expect(client.getConnectedRelays().size).toBe(2);
+ });
+
+ it('should not create duplicate connection to already-connected relay', async () => {
+ await connectClient(client, 'wss://relay.example.com');
+
+ await client.connect('wss://relay.example.com');
+
+ expect(createWebSocketMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('should timeout connection after 30 seconds', async () => {
+ vi.useFakeTimers();
+ const socket = createMockSocket();
+ createWebSocketMock.mockResolvedValueOnce(socket);
+
+ const connectPromise = client.connect('wss://slow.example.com');
+ // Attach a catch handler before advancing timers to prevent unhandled rejection
+ let timeoutError: Error | undefined;
+ connectPromise.catch(e => { timeoutError = e; });
+
+ await vi.advanceTimersByTimeAsync(30001);
+ await flushMicrotasks();
+
+ expect(timeoutError).toBeDefined();
+ expect(timeoutError!.message).toMatch(/timed out/);
+ vi.useRealTimers();
+ });
+
+ it('should reject if createWebSocket fails', async () => {
+ createWebSocketMock.mockRejectedValueOnce(new Error('ECONNREFUSED'));
+
+ await expect(client.connect('wss://down.example.com')).rejects.toThrow('ECONNREFUSED');
+ });
+
+ it('should reject if WebSocket fires onerror before onopen', async () => {
+ const socket = createMockSocket();
+ createWebSocketMock.mockResolvedValueOnce(socket);
+
+ const connectPromise = client.connect('wss://bad.example.com');
+ await flushMicrotasks();
+ socket.simulateError('Connection refused');
+
+ await expect(connectPromise).rejects.toThrow(/Failed to connect/);
+ });
+
+ it('should reject all operations after disconnect [ST terminal state]', async () => {
+ client.disconnect();
+
+ await expect(client.connect('wss://relay.example.com')).rejects.toThrow(/disconnected/);
+
+ const event = createTestEvent(keyManager);
+ await expect(client.publishEvent(event)).rejects.toThrow(/disconnected/);
+ });
+
+ it('should handle multiple disconnect calls gracefully [EG]', () => {
+ expect(() => client.disconnect()).not.toThrow();
+ expect(() => client.disconnect()).not.toThrow();
+ expect(() => client.disconnect()).not.toThrow();
+ });
+
+ it('should return key manager', () => {
+ expect(client.getKeyManager()).toBe(keyManager);
+ });
+ });
+
+ // ==========================================================
+ // Feature 2: Relay Message Handling [DT]
+ // ==========================================================
+ describe('Feature 2: Relay Message Handling', () => {
+ let socket: MockWebSocket;
+
+ beforeEach(async () => {
+ socket = await connectClient(client);
+ });
+
+ it('EVENT message dispatches to correct subscription listener', () => {
+ const onEvent = vi.fn();
+ client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent });
+
+ const event = createTestEvent(keyManager, 'hello');
+ socket.simulateMessage(JSON.stringify(['EVENT', 'sub_1', event.toJSON()]));
+
+ expect(onEvent).toHaveBeenCalledOnce();
+ expect(onEvent.mock.calls[0]![0].content).toBe('hello');
+ });
+
+ it('EVENT for unknown subscription is silently ignored', () => {
+ const onEvent = vi.fn();
+ client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent });
+
+ const event = createTestEvent(keyManager);
+ socket.simulateMessage(JSON.stringify(['EVENT', 'unknown_sub', event.toJSON()]));
+
+ expect(onEvent).not.toHaveBeenCalled();
+ });
+
+ it('EVENT message with fewer than 3 elements is ignored [BVA]', () => {
+ const onEvent = vi.fn();
+ client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent });
+
+ socket.simulateMessage(JSON.stringify(['EVENT', 'sub_1']));
+ expect(onEvent).not.toHaveBeenCalled();
+ });
+
+ it('EVENT with invalid event JSON is silently ignored [EG]', () => {
+ const onEvent = vi.fn();
+ client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent });
+
+ socket.simulateMessage(JSON.stringify(['EVENT', 'sub_1', { invalid: 'data' }]));
+ expect(onEvent).not.toHaveBeenCalled();
+ });
+
+ it('OK message resolves pending publish (accepted)', async () => {
+ const event = createTestEvent(keyManager);
+ const publishPromise = client.publishEvent(event);
+
+ socket.simulateMessage(JSON.stringify(['OK', event.id, true, '']));
+ await expect(publishPromise).resolves.toBe(event.id);
+ });
+
+ it('OK message rejects pending publish (rejected)', async () => {
+ const event = createTestEvent(keyManager);
+ const publishPromise = client.publishEvent(event);
+
+ socket.simulateMessage(JSON.stringify(['OK', event.id, false, 'blocked: rate limit']));
+ await expect(publishPromise).rejects.toThrow(/Event rejected: blocked: rate limit/);
+ });
+
+ it('OK message with insufficient elements is ignored [BVA]', async () => {
+ vi.useFakeTimers();
+ const socket2 = await connectClient(client, 'wss://relay2.example.com');
+ const event = createTestEvent(keyManager);
+ const publishPromise = client.publishEvent(event);
+
+ socket2.simulateMessage(JSON.stringify(['OK', event.id, true]));
+ // Not resolved — advance past timeout
+ await vi.advanceTimersByTimeAsync(5001);
+ // Resolves via timeout (optimistic)
+ await expect(publishPromise).resolves.toBe(event.id);
+ vi.useRealTimers();
+ });
+
+ it('EOSE triggers onEndOfStoredEvents callback', () => {
+ const onEvent = vi.fn();
+ const onEndOfStoredEvents = vi.fn();
+ client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent, onEndOfStoredEvents });
+
+ socket.simulateMessage(JSON.stringify(['EOSE', 'sub_1']));
+ expect(onEndOfStoredEvents).toHaveBeenCalledWith('sub_1');
+ });
+
+ it('EOSE handled gracefully when no onEndOfStoredEvents callback', () => {
+ const onEvent = vi.fn();
+ client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent });
+
+ expect(() => {
+ socket.simulateMessage(JSON.stringify(['EOSE', 'sub_1']));
+ }).not.toThrow();
+ });
+
+ it('CLOSED message triggers onError callback', () => {
+ const onEvent = vi.fn();
+ const onError = vi.fn();
+ client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent, onError });
+
+ socket.simulateMessage(JSON.stringify(['CLOSED', 'sub_1', 'auth-required: must authenticate']));
+ expect(onError).toHaveBeenCalledWith('sub_1', expect.stringContaining('auth-required'));
+ });
+
+ it('AUTH message triggers NIP-42 authentication', () => {
+ socket.simulateMessage(JSON.stringify(['AUTH', 'challenge-abc']));
+
+ const authMessages = socket.sentMessages.filter(m => {
+ try { return JSON.parse(m)[0] === 'AUTH'; } catch { return false; }
+ });
+
+ expect(authMessages.length).toBe(1);
+ const authResponse = JSON.parse(authMessages[0]!);
+ const authEvent = authResponse[1];
+ expect(authEvent.kind).toBe(22242);
+
+ const relayTag = authEvent.tags.find((t: string[]) => t[0] === 'relay');
+ const challengeTag = authEvent.tags.find((t: string[]) => t[0] === 'challenge');
+ expect(relayTag[1]).toBe('wss://relay.example.com');
+ expect(challengeTag[1]).toBe('challenge-abc');
+ });
+
+ it('AUTH triggers resubscription after 100ms delay', async () => {
+ vi.useFakeTimers();
+ const socket3 = await connectClient(client, 'wss://relay3.example.com');
+ const onEvent = vi.fn();
+ client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent });
+ socket3.sentMessages = [];
+
+ socket3.simulateMessage(JSON.stringify(['AUTH', 'challenge']));
+
+ // Before 100ms — only AUTH sent, no REQ yet
+ const reqsBefore = socket3.sentMessages.filter(m => {
+ try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; }
+ });
+ expect(reqsBefore.length).toBe(0);
+
+ await vi.advanceTimersByTimeAsync(100);
+
+ const reqsAfter = socket3.sentMessages.filter(m => {
+ try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; }
+ });
+ expect(reqsAfter.length).toBeGreaterThanOrEqual(1);
+ vi.useRealTimers();
+ });
+
+ it('malformed JSON is silently ignored [EG]', () => {
+ expect(() => socket.simulateMessage('not json')).not.toThrow();
+ });
+
+ it('non-array JSON is silently ignored [EG]', () => {
+ expect(() => socket.simulateMessage('{"type":"EVENT"}')).not.toThrow();
+ });
+
+ it('empty array is silently ignored [EG]', () => {
+ expect(() => socket.simulateMessage('[]')).not.toThrow();
+ });
+
+ it('single-element array is silently ignored [EG]', () => {
+ expect(() => socket.simulateMessage('["EVENT"]')).not.toThrow();
+ });
+
+ it('unknown message type is silently ignored [EG]', () => {
+ expect(() => socket.simulateMessage('["UNKNOWN_TYPE","data"]')).not.toThrow();
+ });
+ });
+
+ // ==========================================================
+ // Feature 3: Event Publishing [EP]
+ // ==========================================================
+ describe('Feature 3: Event Publishing', () => {
+ it('should broadcast event to all connected relays', async () => {
+ const socket1 = createMockSocket();
+ const socket2 = createMockSocket();
+ createWebSocketMock.mockResolvedValueOnce(socket1).mockResolvedValueOnce(socket2);
+
+ const connectPromise = client.connect('wss://relay1.example.com', 'wss://relay2.example.com');
+ await flushMicrotasks();
+ socket1.simulateOpen();
+ socket2.simulateOpen();
+ await connectPromise;
+
+ const event = createTestEvent(keyManager, 'Hello Nostr');
+ const publishPromise = client.publishEvent(event);
+
+ expect(socket1.sentMessages.some(m => m.includes('Hello Nostr'))).toBe(true);
+ expect(socket2.sentMessages.some(m => m.includes('Hello Nostr'))).toBe(true);
+
+ socket1.simulateMessage(JSON.stringify(['OK', event.id, true, '']));
+ await publishPromise;
+ });
+
+ it('should queue event when not connected [ST offline]', async () => {
+ const event = createTestEvent(keyManager);
+ const publishPromise = client.publishEvent(event);
+
+ expect(publishPromise).toBeInstanceOf(Promise);
+
+ client.disconnect();
+ await expect(publishPromise).rejects.toThrow(/disconnected/);
+ });
+
+ it('should flush queued events on connection', async () => {
+ const event1 = createTestEvent(keyManager, 'queued1');
+ const event2 = createTestEvent(keyManager, 'queued2');
+
+ const p1 = client.publishEvent(event1);
+ const p2 = client.publishEvent(event2);
+
+ const socket = createMockSocket();
+ createWebSocketMock.mockResolvedValueOnce(socket);
+ const connectPromise = client.connect('wss://relay.example.com');
+ await flushMicrotasks();
+ socket.simulateOpen();
+ await connectPromise;
+ await flushMicrotasks();
+
+ expect(socket.sentMessages.some(m => m.includes('queued1'))).toBe(true);
+ expect(socket.sentMessages.some(m => m.includes('queued2'))).toBe(true);
+
+ // Resolve via OK
+ socket.simulateMessage(JSON.stringify(['OK', event1.id, true, '']));
+ socket.simulateMessage(JSON.stringify(['OK', event2.id, true, '']));
+ await p1;
+ await p2;
+ });
+
+ it('should resolve after 5s timeout even without OK [BVA]', async () => {
+ vi.useFakeTimers();
+ await connectClient(client);
+
+ const event = createTestEvent(keyManager);
+ const publishPromise = client.publishEvent(event);
+
+ await vi.advanceTimersByTimeAsync(5001);
+
+ await expect(publishPromise).resolves.toBe(event.id);
+ vi.useRealTimers();
+ });
+
+ it('OK response should clear the pending timeout', async () => {
+ const socket = await connectClient(client);
+
+ const event = createTestEvent(keyManager);
+ const publishPromise = client.publishEvent(event);
+
+ socket.simulateMessage(JSON.stringify(['OK', event.id, true, '']));
+ await expect(publishPromise).resolves.toBe(event.id);
+ });
+ });
+
+ // ==========================================================
+ // Feature 4: Subscriptions [EP]
+ // ==========================================================
+ describe('Feature 4: Subscriptions', () => {
+ it('should auto-generate sequential subscription IDs', async () => {
+ await connectClient(client);
+ const filter = Filter.builder().kinds(1).build();
+ const onEvent = vi.fn();
+
+ const id1 = client.subscribe(filter, { onEvent });
+ const id2 = client.subscribe(filter, { onEvent });
+ const id3 = client.subscribe(filter, { onEvent });
+
+ expect(id1).toBe('sub_1');
+ expect(id2).toBe('sub_2');
+ expect(id3).toBe('sub_3');
+ });
+
+ it('should accept custom subscription ID', async () => {
+ await connectClient(client);
+ const filter = Filter.builder().kinds(1).build();
+
+ const id = client.subscribe('my-custom-id', filter, { onEvent: vi.fn() });
+ expect(id).toBe('my-custom-id');
+ });
+
+ it('should send REQ to all connected relays', async () => {
+ const socket1 = createMockSocket();
+ const socket2 = createMockSocket();
+ createWebSocketMock.mockResolvedValueOnce(socket1).mockResolvedValueOnce(socket2);
+
+ const connectPromise = client.connect('wss://relay1.example.com', 'wss://relay2.example.com');
+ await flushMicrotasks();
+ socket1.simulateOpen();
+ socket2.simulateOpen();
+ await connectPromise;
+
+ const filter = Filter.builder().kinds(1).build();
+ client.subscribe(filter, { onEvent: vi.fn() });
+
+ const req1 = socket1.sentMessages.find(m => { try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; } });
+ const req2 = socket2.sentMessages.find(m => { try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; } });
+ expect(req1).toBeDefined();
+ expect(req2).toBeDefined();
+ });
+
+ it('should store subscription when not connected (no REQ sent)', () => {
+ const filter = Filter.builder().kinds(1).build();
+ const id = client.subscribe(filter, { onEvent: vi.fn() });
+
+ expect(id).toMatch(/^sub_\d+$/);
+ });
+
+ it('should send CLOSE on unsubscribe', async () => {
+ const socket = await connectClient(client);
+ const filter = Filter.builder().kinds(1).build();
+
+ const subId = client.subscribe(filter, { onEvent: vi.fn() });
+ client.unsubscribe(subId);
+
+ const closeMsg = socket.sentMessages.find(m => {
+ try { const p = JSON.parse(m); return p[0] === 'CLOSE' && p[1] === subId; }
+ catch { return false; }
+ });
+ expect(closeMsg).toBeDefined();
+ });
+
+ it('should handle unsubscribe with unknown ID gracefully', async () => {
+ await connectClient(client);
+ expect(() => client.unsubscribe('non_existent')).not.toThrow();
+ });
+
+ it('should manage many concurrent subscriptions [LC]', async () => {
+ const socket = await connectClient(client);
+
+ const ids: string[] = [];
+ for (let i = 0; i < 100; i++) {
+ ids.push(client.subscribe(Filter.builder().kinds(1).build(), { onEvent: vi.fn() }));
+ }
+
+ expect(ids.length).toBe(100);
+
+ const reqMessages = socket.sentMessages.filter(m => {
+ try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; }
+ });
+ expect(reqMessages.length).toBe(100);
+
+ for (const id of ids) {
+ client.unsubscribe(id);
+ }
+
+ const closeMessages = socket.sentMessages.filter(m => {
+ try { return JSON.parse(m)[0] === 'CLOSE'; } catch { return false; }
+ });
+ expect(closeMessages.length).toBe(100);
+ });
+ });
+
+ // ==========================================================
+ // Feature 5: Reconnection & Health [ST]
+ // ==========================================================
+ describe('Feature 5: Reconnection', () => {
+ it('should schedule reconnect after connection loss (autoReconnect=true)', async () => {
+ vi.useFakeTimers();
+ const socket = await connectClient(client, 'wss://relay.example.com');
+ const onDisconnect = vi.fn();
+ const onReconnecting = vi.fn();
+ client.addConnectionListener({ onDisconnect, onReconnecting });
+
+ socket.simulateClose(1006, 'network error');
+
+ expect(onDisconnect).toHaveBeenCalledWith('wss://relay.example.com', 'network error');
+
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(onReconnecting).toHaveBeenCalled();
+ vi.useRealTimers();
+ });
+
+ it('should NOT reconnect when autoReconnect is false', async () => {
+ vi.useFakeTimers();
+ client.disconnect();
+ client = new NostrClient(keyManager, { autoReconnect: false });
+
+ const socket = await connectClient(client, 'wss://relay.example.com');
+ const onReconnecting = vi.fn();
+ client.addConnectionListener({ onReconnecting });
+
+ socket.simulateClose(1006, 'network error');
+ await vi.advanceTimersByTimeAsync(60000);
+
+ expect(onReconnecting).not.toHaveBeenCalled();
+ vi.useRealTimers();
+ });
+
+ it('should emit "connect" on first connection', async () => {
+ const onConnect = vi.fn();
+ client.addConnectionListener({ onConnect });
+
+ await connectClient(client, 'wss://relay.example.com');
+
+ expect(onConnect).toHaveBeenCalledWith('wss://relay.example.com');
+ });
+
+ it('should swallow listener errors [EG]', async () => {
+ client.addConnectionListener({
+ onConnect: () => { throw new Error('listener crash'); },
+ });
+
+ await connectClient(client, 'wss://relay.example.com');
+ expect(client.isConnected()).toBe(true);
+ });
+
+ it('should remove connection listener', async () => {
+ const onConnect = vi.fn();
+ const listener: ConnectionEventListener = { onConnect };
+ client.addConnectionListener(listener);
+ client.removeConnectionListener(listener);
+
+ await connectClient(client, 'wss://relay.example.com');
+ expect(onConnect).not.toHaveBeenCalled();
+ });
+
+ it('should handle removing non-existent listener gracefully', () => {
+ expect(() => client.removeConnectionListener({ onConnect: vi.fn() })).not.toThrow();
+ });
+ });
+
+ // ==========================================================
+ // Feature 8: Nametag Query [ST]
+ // ==========================================================
+ describe('Feature 8: Nametag Query', () => {
+ it('should resolve with null on timeout', async () => {
+ client.disconnect();
+ client = new NostrClient(keyManager, { queryTimeoutMs: 50, pingIntervalMs: 0 });
+ await connectClient(client);
+
+ const result = await client.queryPubkeyByNametag('nobody');
+ expect(result).toBeNull();
+ }, 10000);
+
+ it('should respect custom query timeout', async () => {
+ client.disconnect();
+ client = new NostrClient(keyManager, { queryTimeoutMs: 50, pingIntervalMs: 0 });
+ await connectClient(client);
+
+ const result = await client.queryPubkeyByNametag('test');
+ expect(result).toBeNull();
+ }, 10000);
+
+ it('should return pubkey on EOSE', async () => {
+ const socket = await connectClient(client);
+
+ const queryPromise = client.queryPubkeyByNametag('alice');
+ await flushMicrotasks();
+
+ // Find the subscription
+ const reqMsg = socket.sentMessages.find(m => {
+ try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; }
+ });
+ expect(reqMsg).toBeDefined();
+ const subId = JSON.parse(reqMsg!)[1] as string;
+
+ // Simulate a binding event
+ const bindingEvent = Event.create(keyManager, {
+ kind: EventKinds.APP_DATA,
+ tags: [['d', 'nametag:hash']],
+ content: '{}',
+ created_at: 2000,
+ });
+ socket.simulateMessage(JSON.stringify(['EVENT', subId, bindingEvent.toJSON()]));
+ socket.simulateMessage(JSON.stringify(['EOSE', subId]));
+
+ const result = await queryPromise;
+ expect(result).toBe(keyManager.getPublicKeyHex());
+ });
+
+ it('should use setQueryTimeout value', () => {
+ client.setQueryTimeout(15000);
+ expect(client.getQueryTimeout()).toBe(15000);
+ });
+ });
+
+ // ==========================================================
+ // Feature 9: Disconnect Cleanup [RB]
+ // ==========================================================
+ describe('Feature 9: Disconnect Cleanup', () => {
+ it('should reject all pending OK promises on disconnect', async () => {
+ await connectClient(client);
+
+ const event1 = createTestEvent(keyManager, 'ev1');
+ const event2 = createTestEvent(keyManager, 'ev2');
+ const p1 = client.publishEvent(event1);
+ const p2 = client.publishEvent(event2);
+
+ client.disconnect();
+
+ await expect(p1).rejects.toThrow(/disconnected/);
+ await expect(p2).rejects.toThrow(/disconnected/);
+ });
+
+ it('should reject all queued events on disconnect', async () => {
+ const p1 = client.publishEvent(createTestEvent(keyManager, 'q1'));
+ const p2 = client.publishEvent(createTestEvent(keyManager, 'q2'));
+
+ client.disconnect();
+
+ await expect(p1).rejects.toThrow(/disconnected/);
+ await expect(p2).rejects.toThrow(/disconnected/);
+ });
+
+ it('should close all WebSocket connections', async () => {
+ const socket = await connectClient(client);
+ client.disconnect();
+
+ expect(socket.closeCode).toBe(1000);
+ expect(socket.closeReason).toBe('Client disconnected');
+ });
+
+ it('should emit disconnect event for each relay', async () => {
+ await connectClient(client, 'wss://relay.example.com');
+ const onDisconnect = vi.fn();
+ client.addConnectionListener({ onDisconnect });
+
+ client.disconnect();
+
+ expect(onDisconnect).toHaveBeenCalledWith('wss://relay.example.com', 'Client disconnected');
+ });
+
+ it('should clear all internal state', async () => {
+ await connectClient(client);
+ client.subscribe(Filter.builder().kinds(1).build(), { onEvent: vi.fn() });
+
+ client.disconnect();
+
+ expect(client.isConnected()).toBe(false);
+ expect(client.getConnectedRelays().size).toBe(0);
+ });
+ });
+
+ // ==========================================================
+ // Feature 17: Configuration Combinations [PW]
+ // ==========================================================
+ describe('Feature 17: Configuration Combinations', () => {
+ const configs = [
+ { autoReconnect: true, queryTimeoutMs: 5000, pingIntervalMs: 30000 },
+ { autoReconnect: true, queryTimeoutMs: 1000, pingIntervalMs: 0 },
+ { autoReconnect: false, queryTimeoutMs: 5000, pingIntervalMs: 0 },
+ { autoReconnect: false, queryTimeoutMs: 10000, pingIntervalMs: 30000 },
+ { autoReconnect: true, queryTimeoutMs: 30000, pingIntervalMs: 60000 },
+ { autoReconnect: false, queryTimeoutMs: 100, pingIntervalMs: 10000 },
+ ];
+
+ for (const config of configs) {
+ it(`should work with config: autoReconnect=${config.autoReconnect}, queryTimeout=${config.queryTimeoutMs}, ping=${config.pingIntervalMs}`, () => {
+ const c = new NostrClient(keyManager, config);
+ expect(c.getQueryTimeout()).toBe(config.queryTimeoutMs);
+ c.disconnect();
+ });
+ }
+ });
+
+ // ==========================================================
+ // Feature 19: Subscription Re-establishment [LC]
+ // ==========================================================
+ describe('Feature 19: Subscription Re-establishment', () => {
+ it('zero subscriptions — nothing to re-establish', async () => {
+ vi.useFakeTimers();
+ const socket = await connectClient(client);
+ socket.simulateClose();
+
+ const newSocket = createMockSocket();
+ createWebSocketMock.mockResolvedValueOnce(newSocket);
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushMicrotasks();
+ newSocket.simulateOpen();
+ await flushMicrotasks();
+
+ const reqs = newSocket.sentMessages.filter(m => {
+ try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; }
+ });
+ expect(reqs.length).toBe(0);
+ vi.useRealTimers();
+ });
+
+ it('subscriptions should be re-established after reconnect', async () => {
+ vi.useFakeTimers();
+ const socket = await connectClient(client);
+
+ client.subscribe(Filter.builder().kinds(1).build(), { onEvent: vi.fn() });
+ client.subscribe(Filter.builder().kinds(4).build(), { onEvent: vi.fn() });
+
+ socket.simulateClose();
+ const newSocket = createMockSocket();
+ createWebSocketMock.mockResolvedValueOnce(newSocket);
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushMicrotasks();
+ newSocket.simulateOpen();
+ await flushMicrotasks();
+
+ const reqs = newSocket.sentMessages.filter(m => {
+ try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; }
+ });
+ expect(reqs.length).toBe(2);
+ vi.useRealTimers();
+ });
+
+ it('unsubscribed subs should NOT be re-established', async () => {
+ vi.useFakeTimers();
+ const socket = await connectClient(client);
+
+ const sub1 = client.subscribe(Filter.builder().kinds(1).build(), { onEvent: vi.fn() });
+ client.subscribe(Filter.builder().kinds(4).build(), { onEvent: vi.fn() });
+ client.subscribe(Filter.builder().kinds(14).build(), { onEvent: vi.fn() });
+ client.unsubscribe(sub1);
+
+ socket.simulateClose();
+ const newSocket = createMockSocket();
+ createWebSocketMock.mockResolvedValueOnce(newSocket);
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushMicrotasks();
+ newSocket.simulateOpen();
+ await flushMicrotasks();
+
+ const reqs = newSocket.sentMessages.filter(m => {
+ try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; }
+ });
+ expect(reqs.length).toBe(2);
+ vi.useRealTimers();
+ });
+ });
+
+ // ==========================================================
+ // Feature 20: Concurrent Operations [EG]
+ // ==========================================================
+ describe('Feature 20: Concurrent Operations', () => {
+ it('should handle many concurrent publishes', async () => {
+ const socket = await connectClient(client);
+
+ const promises: Promise[] = [];
+ for (let i = 0; i < 50; i++) {
+ promises.push(client.publishEvent(createTestEvent(keyManager, `msg${i}`)));
+ }
+
+ const eventMessages = socket.sentMessages.filter(m => {
+ try { return JSON.parse(m)[0] === 'EVENT'; } catch { return false; }
+ });
+ expect(eventMessages.length).toBe(50);
+
+ // Resolve all via OK
+ for (let i = 0; i < 50; i++) {
+ const sentEvent = JSON.parse(eventMessages[i]!)[1];
+ socket.simulateMessage(JSON.stringify(['OK', sentEvent.id, true, '']));
+ }
+ const results = await Promise.all(promises);
+ expect(results.length).toBe(50);
+ });
+
+ it('should handle rapid subscribe/unsubscribe', async () => {
+ const socket = await connectClient(client);
+
+ for (let i = 0; i < 50; i++) {
+ const subId = client.subscribe(
+ Filter.builder().kinds(1).build(),
+ { onEvent: vi.fn() }
+ );
+ client.unsubscribe(subId);
+ }
+
+ // Verify all CLOSE messages were sent for each subscription
+ const closeMessages = socket.sentMessages.filter(m => {
+ try { return JSON.parse(m)[0] === 'CLOSE'; } catch { return false; }
+ });
+ expect(closeMessages.length).toBe(50);
+ });
+
+ it('disconnect while publish is pending rejects the pending promise', async () => {
+ await connectClient(client);
+
+ const event = createTestEvent(keyManager);
+ const publishPromise = client.publishEvent(event);
+
+ client.disconnect();
+
+ await expect(publishPromise).rejects.toThrow(/disconnected/);
+ });
+ });
+
+ // ==========================================================
+ // Feature 6: NIP-17 via Client [UC]
+ // ==========================================================
+ describe('Feature 6: NIP-17 via Client', () => {
+ it('should send private message via gift wrapping', async () => {
+ const socket = await connectClient(client);
+ const bob = NostrKeyManager.generate();
+
+ const publishPromise = client.sendPrivateMessage(
+ bob.getPublicKeyHex(),
+ 'Hello Bob'
+ );
+
+ const eventMsgs = socket.sentMessages.filter(m => {
+ try { return JSON.parse(m)[0] === 'EVENT'; } catch { return false; }
+ });
+ expect(eventMsgs.length).toBe(1);
+
+ const sentEvent = JSON.parse(eventMsgs[0]!)[1];
+ expect(sentEvent.kind).toBe(EventKinds.GIFT_WRAP);
+ expect(sentEvent.tags.find((t: string[]) => t[0] === 'p')[1]).toBe(bob.getPublicKeyHex());
+ expect(sentEvent.pubkey).not.toBe(keyManager.getPublicKeyHex());
+
+ socket.simulateMessage(JSON.stringify(['OK', sentEvent.id, true, '']));
+ await publishPromise;
+ });
+
+ it('should send read receipt', async () => {
+ const socket = await connectClient(client);
+ const bob = NostrKeyManager.generate();
+
+ const publishPromise = client.sendReadReceipt(
+ bob.getPublicKeyHex(),
+ 'event-id-123'
+ );
+
+ const eventMsgs = socket.sentMessages.filter(m => {
+ try { return JSON.parse(m)[0] === 'EVENT'; } catch { return false; }
+ });
+ expect(eventMsgs.length).toBe(1);
+
+ const sentEvent = JSON.parse(eventMsgs[0]!)[1];
+ expect(sentEvent.kind).toBe(EventKinds.GIFT_WRAP);
+
+ socket.simulateMessage(JSON.stringify(['OK', sentEvent.id, true, '']));
+ await publishPromise;
+ });
+
+ it('should unwrap a received private message', async () => {
+ const bob = NostrKeyManager.generate();
+
+ const { createGiftWrap } = await import('../../src/messaging/nip17.js');
+ const giftWrap = createGiftWrap(bob, keyManager.getPublicKeyHex(), 'secret from bob');
+
+ const message = client.unwrapPrivateMessage(giftWrap);
+
+ expect(message.senderPubkey).toBe(bob.getPublicKeyHex());
+ expect(message.content).toBe('secret from bob');
+ });
+
+ it('should reject sending to unknown nametag', async () => {
+ vi.useFakeTimers();
+ await connectClient(client);
+
+ const sendPromise = client.sendPrivateMessageToNametag('unknown-user', 'hi');
+ // Attach catch handler before advancing timers to prevent unhandled rejection
+ let nametagError: Error | undefined;
+ sendPromise.catch(e => { nametagError = e; });
+
+ await vi.advanceTimersByTimeAsync(5001);
+ await flushMicrotasks();
+
+ expect(nametagError).toBeDefined();
+ expect(nametagError!.message).toMatch(/Nametag not found/);
+ vi.useRealTimers();
+ });
+ });
+
+ // ==========================================================
+ // Feature 7: Token/Payment via Client [UC]
+ // ==========================================================
+ describe('Feature 7: Token/Payment via Client', () => {
+ it('should create and publish event via createAndPublishEvent', async () => {
+ const socket = await connectClient(client);
+
+ const publishPromise = client.createAndPublishEvent({
+ kind: EventKinds.TEXT_NOTE,
+ tags: [['t', 'test']],
+ content: 'published via helper',
+ });
+
+ const eventMsgs = socket.sentMessages.filter(m => {
+ try { return JSON.parse(m)[0] === 'EVENT'; } catch { return false; }
+ });
+ expect(eventMsgs.length).toBe(1);
+
+ const sentEvent = JSON.parse(eventMsgs[0]!)[1];
+ expect(sentEvent.content).toBe('published via helper');
+ expect(sentEvent.pubkey).toBe(keyManager.getPublicKeyHex());
+
+ socket.simulateMessage(JSON.stringify(['OK', sentEvent.id, true, '']));
+ await publishPromise;
+ });
+
+ it('should publish encrypted DM via publishEncryptedMessage', async () => {
+ const socket = await connectClient(client);
+ const bob = NostrKeyManager.generate();
+
+ const publishPromise = client.publishEncryptedMessage(
+ bob.getPublicKeyHex(),
+ 'secret message'
+ );
+
+ // Poll until the async encryption completes and the event is sent
+ let eventMsgs: string[] = [];
+ for (let i = 0; i < 50; i++) {
+ eventMsgs = socket.sentMessages.filter(m => {
+ try { return JSON.parse(m)[0] === 'EVENT'; } catch { return false; }
+ });
+ if (eventMsgs.length > 0) break;
+ await new Promise(r => setTimeout(r, 20));
+ }
+ expect(eventMsgs.length).toBe(1);
+
+ const sentEvent = JSON.parse(eventMsgs[0]!)[1];
+ expect(sentEvent.kind).toBe(EventKinds.ENCRYPTED_DM);
+ expect(sentEvent.tags.find((t: string[]) => t[0] === 'p')[1]).toBe(bob.getPublicKeyHex());
+ expect(sentEvent.content).not.toBe('secret message');
+ expect(sentEvent.content).toContain('?iv=');
+
+ socket.simulateMessage(JSON.stringify(['OK', sentEvent.id, true, '']));
+ await publishPromise;
+ });
+ });
+});
diff --git a/tests/unit/schnorr-edge-cases.test.ts b/tests/unit/schnorr-edge-cases.test.ts
new file mode 100644
index 0000000..c32af97
--- /dev/null
+++ b/tests/unit/schnorr-edge-cases.test.ts
@@ -0,0 +1,217 @@
+/**
+ * Unit tests for Schnorr signing edge cases
+ * Covers input validation for wrong key/message lengths
+ * Techniques: [BVA] Boundary Value Analysis, [EG] Error Guessing
+ */
+
+import { describe, it, expect } from 'vitest';
+import * as Schnorr from '../../src/crypto/schnorr.js';
+
+describe('Schnorr Edge Cases', () => {
+ const validPrivateKey = new Uint8Array(32).fill(0x42);
+ const validMessage = new Uint8Array(32).fill(0xAB);
+ const validPublicKey = Schnorr.getPublicKey(validPrivateKey);
+ const validSignature = Schnorr.sign(validMessage, validPrivateKey);
+
+ // ==========================================================
+ // getPublicKey input validation
+ // ==========================================================
+ describe('getPublicKey input validation', () => {
+ it('should reject empty private key', () => {
+ expect(() => Schnorr.getPublicKey(new Uint8Array(0))).toThrow('Private key must be 32 bytes');
+ });
+
+ it('should reject 31-byte private key', () => {
+ expect(() => Schnorr.getPublicKey(new Uint8Array(31))).toThrow('Private key must be 32 bytes');
+ });
+
+ it('should reject 33-byte private key', () => {
+ expect(() => Schnorr.getPublicKey(new Uint8Array(33))).toThrow('Private key must be 32 bytes');
+ });
+
+ it('should reject 64-byte private key', () => {
+ expect(() => Schnorr.getPublicKey(new Uint8Array(64))).toThrow('Private key must be 32 bytes');
+ });
+
+ it('should accept 32-byte private key', () => {
+ const pubkey = Schnorr.getPublicKey(validPrivateKey);
+ expect(pubkey.length).toBe(32);
+ });
+
+ it('should handle private key with leading zeros', () => {
+ const keyWithLeadingZeros = new Uint8Array(32);
+ keyWithLeadingZeros[31] = 0x01; // Only last byte is non-zero
+ const pubkey = Schnorr.getPublicKey(keyWithLeadingZeros);
+ expect(pubkey.length).toBe(32);
+ });
+ });
+
+ // ==========================================================
+ // sign input validation
+ // ==========================================================
+ describe('sign input validation', () => {
+ it('should reject empty message', () => {
+ expect(() => Schnorr.sign(new Uint8Array(0), validPrivateKey)).toThrow('Message must be 32 bytes');
+ });
+
+ it('should reject 31-byte message', () => {
+ expect(() => Schnorr.sign(new Uint8Array(31), validPrivateKey)).toThrow('Message must be 32 bytes');
+ });
+
+ it('should reject 33-byte message', () => {
+ expect(() => Schnorr.sign(new Uint8Array(33), validPrivateKey)).toThrow('Message must be 32 bytes');
+ });
+
+ it('should reject empty private key in sign', () => {
+ expect(() => Schnorr.sign(validMessage, new Uint8Array(0))).toThrow('Private key must be 32 bytes');
+ });
+
+ it('should reject 31-byte private key in sign', () => {
+ expect(() => Schnorr.sign(validMessage, new Uint8Array(31))).toThrow('Private key must be 32 bytes');
+ });
+
+ it('should reject 33-byte private key in sign', () => {
+ expect(() => Schnorr.sign(validMessage, new Uint8Array(33))).toThrow('Private key must be 32 bytes');
+ });
+
+ it('should produce 64-byte signature', () => {
+ const sig = Schnorr.sign(validMessage, validPrivateKey);
+ expect(sig.length).toBe(64);
+ });
+
+ it('should produce valid signatures (BIP-340 uses randomness)', () => {
+ // Note: BIP-340 Schnorr signatures use auxiliary randomness
+ // So two signatures of the same message are NOT identical
+ const sig1 = Schnorr.sign(validMessage, validPrivateKey);
+ const sig2 = Schnorr.sign(validMessage, validPrivateKey);
+
+ // Both should be valid, even if different
+ expect(Schnorr.verify(sig1, validMessage, validPublicKey)).toBe(true);
+ expect(Schnorr.verify(sig2, validMessage, validPublicKey)).toBe(true);
+ });
+
+ it('should produce different signatures for different messages', () => {
+ const msg1 = new Uint8Array(32).fill(0x01);
+ const msg2 = new Uint8Array(32).fill(0x02);
+ const sig1 = Schnorr.sign(msg1, validPrivateKey);
+ const sig2 = Schnorr.sign(msg2, validPrivateKey);
+ expect(sig1).not.toEqual(sig2);
+ });
+ });
+
+ // ==========================================================
+ // verify input validation
+ // ==========================================================
+ describe('verify input validation', () => {
+ it('should return false for empty signature', () => {
+ expect(Schnorr.verify(new Uint8Array(0), validMessage, validPublicKey)).toBe(false);
+ });
+
+ it('should return false for 63-byte signature', () => {
+ expect(Schnorr.verify(new Uint8Array(63), validMessage, validPublicKey)).toBe(false);
+ });
+
+ it('should return false for 65-byte signature', () => {
+ expect(Schnorr.verify(new Uint8Array(65), validMessage, validPublicKey)).toBe(false);
+ });
+
+ it('should return false for empty message', () => {
+ expect(Schnorr.verify(validSignature, new Uint8Array(0), validPublicKey)).toBe(false);
+ });
+
+ it('should return false for 31-byte message', () => {
+ expect(Schnorr.verify(validSignature, new Uint8Array(31), validPublicKey)).toBe(false);
+ });
+
+ it('should return false for 33-byte message', () => {
+ expect(Schnorr.verify(validSignature, new Uint8Array(33), validPublicKey)).toBe(false);
+ });
+
+ it('should return false for empty public key', () => {
+ expect(Schnorr.verify(validSignature, validMessage, new Uint8Array(0))).toBe(false);
+ });
+
+ it('should return false for 31-byte public key', () => {
+ expect(Schnorr.verify(validSignature, validMessage, new Uint8Array(31))).toBe(false);
+ });
+
+ it('should return false for 33-byte public key', () => {
+ expect(Schnorr.verify(validSignature, validMessage, new Uint8Array(33))).toBe(false);
+ });
+
+ it('should return false for all-zero inputs', () => {
+ expect(Schnorr.verify(
+ new Uint8Array(64),
+ new Uint8Array(32),
+ new Uint8Array(32)
+ )).toBe(false);
+ });
+
+ it('should return false for wrong public key', () => {
+ const otherKey = Schnorr.getPublicKey(new Uint8Array(32).fill(0x99));
+ expect(Schnorr.verify(validSignature, validMessage, otherKey)).toBe(false);
+ });
+
+ it('should return false for tampered message', () => {
+ const tamperedMessage = new Uint8Array(validMessage);
+ tamperedMessage[0] ^= 0xFF;
+ expect(Schnorr.verify(validSignature, tamperedMessage, validPublicKey)).toBe(false);
+ });
+
+ it('should return false for tampered signature', () => {
+ const tamperedSig = new Uint8Array(validSignature);
+ tamperedSig[0] ^= 0xFF;
+ expect(Schnorr.verify(tamperedSig, validMessage, validPublicKey)).toBe(false);
+ });
+
+ it('should return true for valid signature', () => {
+ expect(Schnorr.verify(validSignature, validMessage, validPublicKey)).toBe(true);
+ });
+ });
+
+ // ==========================================================
+ // verifyHex edge cases
+ // ==========================================================
+ describe('verifyHex edge cases', () => {
+ it('should return false for invalid hex in signature', () => {
+ expect(Schnorr.verifyHex('ZZZZ', validMessage, '00'.repeat(32))).toBe(false);
+ });
+
+ it('should return false for odd-length hex signature', () => {
+ expect(Schnorr.verifyHex('abc', validMessage, '00'.repeat(32))).toBe(false);
+ });
+
+ it('should return false for invalid hex in public key', () => {
+ expect(Schnorr.verifyHex('00'.repeat(64), validMessage, 'ZZZZ')).toBe(false);
+ });
+ });
+
+ // ==========================================================
+ // Multiple keypairs
+ // ==========================================================
+ describe('multiple keypairs', () => {
+ it('should correctly sign and verify with different keypairs', () => {
+ // Note: 0xFF fill is invalid for secp256k1 (exceeds curve order)
+ const keypairs = [
+ new Uint8Array(32).fill(0x01),
+ new Uint8Array(32).fill(0x42),
+ new Uint8Array(32).fill(0x7F), // Valid key (not 0xFF which exceeds N)
+ ];
+
+ for (const privateKey of keypairs) {
+ const publicKey = Schnorr.getPublicKey(privateKey);
+ const signature = Schnorr.sign(validMessage, privateKey);
+
+ expect(Schnorr.verify(signature, validMessage, publicKey)).toBe(true);
+
+ // Cross-verify should fail
+ for (const otherKey of keypairs) {
+ if (otherKey !== privateKey) {
+ const otherPubkey = Schnorr.getPublicKey(otherKey);
+ expect(Schnorr.verify(signature, validMessage, otherPubkey)).toBe(false);
+ }
+ }
+ }
+ });
+ });
+});
diff --git a/tests/unit/security-critical.test.ts b/tests/unit/security-critical.test.ts
new file mode 100644
index 0000000..72a59d4
--- /dev/null
+++ b/tests/unit/security-critical.test.ts
@@ -0,0 +1,226 @@
+/**
+ * Unit tests for security-critical operations
+ * Feature 18: Security Critical Paths
+ * Techniques: [RB] Risk-Based Testing
+ */
+
+import { describe, it, expect } from 'vitest';
+import { bytesToHex } from '@noble/hashes/utils';
+import { NostrKeyManager } from '../../src/NostrKeyManager.js';
+import { Event } from '../../src/protocol/Event.js';
+import * as NIP17 from '../../src/messaging/nip17.js';
+import * as EventKinds from '../../src/protocol/EventKinds.js';
+
+describe('Security Critical Paths', () => {
+ // [RB] Private key not accessible after clear
+ describe('key clearing', () => {
+ it('should prevent all private key access after clear', () => {
+ const km = NostrKeyManager.generate();
+ km.clear();
+
+ expect(() => km.getPrivateKey()).toThrow(/has been cleared/);
+ expect(() => km.getPrivateKeyHex()).toThrow(/has been cleared/);
+ expect(() => km.getNsec()).toThrow(/has been cleared/);
+ expect(() => km.getPublicKey()).toThrow(/has been cleared/);
+ expect(() => km.getPublicKeyHex()).toThrow(/has been cleared/);
+ expect(() => km.getNpub()).toThrow(/has been cleared/);
+ });
+
+ it('should prevent signing after clear', () => {
+ const km = NostrKeyManager.generate();
+ km.clear();
+
+ expect(() => km.sign(new Uint8Array(32))).toThrow(/has been cleared/);
+ expect(() => km.signHex(new Uint8Array(32))).toThrow(/has been cleared/);
+ });
+
+ it('should prevent NIP-04 encryption after clear', async () => {
+ const km = NostrKeyManager.generate();
+ const other = NostrKeyManager.generate();
+ km.clear();
+
+ await expect(km.encrypt('test', other.getPublicKey())).rejects.toThrow(/has been cleared/);
+ await expect(km.encryptHex('test', other.getPublicKeyHex())).rejects.toThrow(/has been cleared/);
+ await expect(km.decrypt('data', other.getPublicKey())).rejects.toThrow(/has been cleared/);
+ await expect(km.decryptHex('data', other.getPublicKeyHex())).rejects.toThrow(/has been cleared/);
+ });
+
+ it('should prevent NIP-44 encryption after clear', () => {
+ const km = NostrKeyManager.generate();
+ const other = NostrKeyManager.generate();
+ km.clear();
+
+ expect(() => km.encryptNip44('test', other.getPublicKey())).toThrow(/has been cleared/);
+ expect(() => km.encryptNip44Hex('test', other.getPublicKeyHex())).toThrow(/has been cleared/);
+ expect(() => km.decryptNip44('data', other.getPublicKey())).toThrow(/has been cleared/);
+ expect(() => km.decryptNip44Hex('data', other.getPublicKeyHex())).toThrow(/has been cleared/);
+ });
+
+ it('should prevent shared secret derivation after clear', () => {
+ const km = NostrKeyManager.generate();
+ const other = NostrKeyManager.generate();
+ km.clear();
+
+ expect(() => km.deriveSharedSecret(other.getPublicKey())).toThrow(/has been cleared/);
+ expect(() => km.deriveConversationKey(other.getPublicKey())).toThrow(/has been cleared/);
+ });
+
+ // [RB] Input copy semantics — fromPrivateKey copies, doesn't alias
+ it('should not mutate the original input array on clear', () => {
+ const privateKeyBytes = new Uint8Array(32).fill(0x42);
+ const km = NostrKeyManager.fromPrivateKey(privateKeyBytes);
+
+ // Verify key works before clear
+ const sig = km.sign(new Uint8Array(32));
+ expect(sig.length).toBe(64);
+
+ km.clear();
+
+ // Original input should be unchanged (was copied, not aliased)
+ expect(privateKeyBytes[0]).toBe(0x42);
+ expect(privateKeyBytes.every(b => b === 0x42)).toBe(true);
+ });
+ });
+
+ // [RB] Private key copy semantics
+ describe('key copy semantics', () => {
+ it('getPrivateKey returns a copy, not a reference', () => {
+ const km = NostrKeyManager.generate();
+ const key1 = km.getPrivateKey();
+ const original = bytesToHex(key1);
+
+ // Modify the returned array
+ key1[0] = 0xFF;
+ key1[1] = 0xFF;
+
+ // Get key again — should be unmodified
+ const key2 = km.getPrivateKey();
+ expect(bytesToHex(key2)).toBe(original);
+ });
+
+ it('getPublicKey returns a copy, not a reference', () => {
+ const km = NostrKeyManager.generate();
+ const key1 = km.getPublicKey();
+ const original = bytesToHex(key1);
+
+ key1[0] = 0xFF;
+
+ const key2 = km.getPublicKey();
+ expect(bytesToHex(key2)).toBe(original);
+ });
+
+ it('constructor copies input private key', () => {
+ const inputKey = new Uint8Array(32).fill(0x42);
+ const km = NostrKeyManager.fromPrivateKey(inputKey);
+
+ // Modify the original input
+ inputKey[0] = 0xFF;
+
+ // Key manager should still have the original value
+ expect(km.getPrivateKey()[0]).toBe(0x42);
+ });
+ });
+
+ // [RB] Gift wrap does not leak sender identity
+ describe('NIP-17 sender anonymity', () => {
+ it('gift wrap event pubkey should NOT be the sender pubkey', () => {
+ const alice = NostrKeyManager.generate();
+ const bob = NostrKeyManager.generate();
+
+ const giftWrap = NIP17.createGiftWrap(
+ alice,
+ bob.getPublicKeyHex(),
+ 'secret message'
+ );
+
+ // The gift wrap is signed by an ephemeral key, NOT Alice
+ expect(giftWrap.pubkey).not.toBe(alice.getPublicKeyHex());
+ });
+
+ it('different gift wraps use different ephemeral keys', () => {
+ const alice = NostrKeyManager.generate();
+ const bob = NostrKeyManager.generate();
+
+ const gw1 = NIP17.createGiftWrap(alice, bob.getPublicKeyHex(), 'msg1');
+ const gw2 = NIP17.createGiftWrap(alice, bob.getPublicKeyHex(), 'msg2');
+
+ // Different ephemeral keys each time
+ expect(gw1.pubkey).not.toBe(gw2.pubkey);
+ });
+
+ it('gift wrap recipient tag points to actual recipient', () => {
+ const alice = NostrKeyManager.generate();
+ const bob = NostrKeyManager.generate();
+
+ const giftWrap = NIP17.createGiftWrap(
+ alice,
+ bob.getPublicKeyHex(),
+ 'message'
+ );
+
+ expect(giftWrap.getTagValue('p')).toBe(bob.getPublicKeyHex());
+ });
+ });
+
+ // [RB] NIP-17 timestamp randomization
+ describe('NIP-17 timestamp privacy', () => {
+ it('timestamps should be randomized within +/- 2 days', () => {
+ const alice = NostrKeyManager.generate();
+ const bob = NostrKeyManager.generate();
+ const now = Math.floor(Date.now() / 1000);
+ const twoDays = 2 * 24 * 60 * 60;
+
+ const timestamps: number[] = [];
+ for (let i = 0; i < 20; i++) {
+ const gw = NIP17.createGiftWrap(alice, bob.getPublicKeyHex(), `msg${i}`);
+ timestamps.push(gw.created_at);
+ }
+
+ // All should be within +/- 2 days of now
+ for (const ts of timestamps) {
+ expect(ts).toBeGreaterThanOrEqual(now - twoDays - 10);
+ expect(ts).toBeLessThanOrEqual(now + twoDays + 10);
+ }
+
+ // Not all timestamps should be the same (randomized)
+ const unique = new Set(timestamps);
+ expect(unique.size).toBeGreaterThan(1);
+ });
+ });
+
+ // [RB] NIP-44 conversation key symmetry
+ describe('NIP-44 conversation key symmetry', () => {
+ it('conversation key should be symmetric (A->B equals B->A)', () => {
+ const alice = NostrKeyManager.generate();
+ const bob = NostrKeyManager.generate();
+
+ const keyAB = alice.deriveConversationKey(bob.getPublicKey());
+ const keyBA = bob.deriveConversationKey(alice.getPublicKey());
+
+ expect(bytesToHex(keyAB)).toBe(bytesToHex(keyBA));
+ });
+ });
+
+ // [RB] AUTH event correctness
+ describe('AUTH event structure', () => {
+ it('should create valid AUTH event with relay and challenge tags', () => {
+ const km = NostrKeyManager.generate();
+
+ const authEvent = Event.create(km, {
+ kind: EventKinds.AUTH,
+ tags: [
+ ['relay', 'wss://relay.example.com'],
+ ['challenge', 'test-challenge-123'],
+ ],
+ content: '',
+ });
+
+ expect(authEvent.kind).toBe(22242);
+ expect(authEvent.getTagValue('relay')).toBe('wss://relay.example.com');
+ expect(authEvent.getTagValue('challenge')).toBe('test-challenge-123');
+ expect(authEvent.content).toBe('');
+ expect(authEvent.pubkey).toBe(km.getPublicKeyHex());
+ expect(authEvent.verify()).toBe(true);
+ });
+ });
+});
diff --git a/tests/unit/websocket-adapter.test.ts b/tests/unit/websocket-adapter.test.ts
new file mode 100644
index 0000000..a6fa87d
--- /dev/null
+++ b/tests/unit/websocket-adapter.test.ts
@@ -0,0 +1,105 @@
+/**
+ * Unit tests for WebSocketAdapter
+ * Feature 10: WebSocket message data extraction
+ * Techniques: [EP] Equivalence Partitioning, [BVA] Boundary Value Analysis, [EG] Error Guessing
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ extractMessageData,
+ createWebSocket,
+ CONNECTING,
+ OPEN,
+ CLOSING,
+ CLOSED,
+} from '../../src/client/WebSocketAdapter.js';
+import type { WebSocketMessageEvent } from '../../src/client/WebSocketAdapter.js';
+
+describe('WebSocketAdapter', () => {
+ describe('extractMessageData', () => {
+ // [EP] Valid: string data
+ it('should extract string data from string message', () => {
+ const event: WebSocketMessageEvent = { data: 'hello' };
+ expect(extractMessageData(event)).toBe('hello');
+ });
+
+ // [EP] Valid: ArrayBuffer data
+ it('should extract string data from ArrayBuffer message', () => {
+ const encoder = new TextEncoder();
+ const buffer = encoder.encode('hello').buffer;
+ const event: WebSocketMessageEvent = { data: buffer as ArrayBuffer };
+ expect(extractMessageData(event)).toBe('hello');
+ });
+
+ // [EP] Valid: Node.js Buffer data
+ it('should extract string data from Node.js Buffer message', () => {
+ const buf = Buffer.from('hello', 'utf-8');
+ const event = { data: buf } as unknown as WebSocketMessageEvent;
+ expect(extractMessageData(event)).toBe('hello');
+ });
+
+ // [EP] Invalid: Blob data throws error
+ it('should throw for Blob messages', () => {
+ const blob = new Blob(['hello']);
+ const event = { data: blob } as unknown as WebSocketMessageEvent;
+ expect(() => extractMessageData(event)).toThrow('Blob messages are not supported');
+ });
+
+ // [BVA] Empty string
+ it('should return empty string for empty string data', () => {
+ const event: WebSocketMessageEvent = { data: '' };
+ expect(extractMessageData(event)).toBe('');
+ });
+
+ // [BVA] Empty ArrayBuffer
+ it('should return empty string for empty ArrayBuffer', () => {
+ const event: WebSocketMessageEvent = { data: new ArrayBuffer(0) };
+ expect(extractMessageData(event)).toBe('');
+ });
+
+ // [BVA] Large ArrayBuffer
+ it('should handle large ArrayBuffer message', () => {
+ const text = 'A'.repeat(100000);
+ const encoder = new TextEncoder();
+ const buffer = encoder.encode(text).buffer;
+ const event: WebSocketMessageEvent = { data: buffer as ArrayBuffer };
+ expect(extractMessageData(event)).toBe(text);
+ });
+
+ // [EP] Valid: JSON relay message
+ it('should correctly extract JSON relay message', () => {
+ const json = '["EVENT","sub_1",{"id":"abc","kind":1}]';
+ const event: WebSocketMessageEvent = { data: json };
+ expect(extractMessageData(event)).toBe(json);
+ });
+
+ // [EP] Unicode content
+ it('should handle unicode in ArrayBuffer', () => {
+ const text = 'Hello \ud83c\udf0d \u4e16\u754c';
+ const encoder = new TextEncoder();
+ const buffer = encoder.encode(text).buffer;
+ const event: WebSocketMessageEvent = { data: buffer as ArrayBuffer };
+ expect(extractMessageData(event)).toBe(text);
+ });
+ });
+
+ describe('ready state constants', () => {
+ it('should have correct WebSocket ready state values', () => {
+ expect(CONNECTING).toBe(0);
+ expect(OPEN).toBe(1);
+ expect(CLOSING).toBe(2);
+ expect(CLOSED).toBe(3);
+ });
+ });
+
+ describe('createWebSocket', () => {
+ // [EP] Node.js environment uses ws package
+ it('should return a WebSocket instance in Node.js environment', async () => {
+ const ws = await createWebSocket('wss://relay.example.com');
+ // Suppress async connection errors — no server is running
+ ws.onerror = () => {};
+ expect(ws).toBeDefined();
+ expect(ws.readyState).toBeDefined();
+ });
+ });
+});