diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 72d905ec8..8154c2431 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -83,6 +83,7 @@ kotlin { commonTest { dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt new file mode 100644 index 000000000..3b4b9831c --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt @@ -0,0 +1,1223 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.call + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * CallManager State Machine Tests + * + * Tests the NIP-AC call state machine implementation against all specified + * transitions from the spec: + * + * ``` + * Idle ──> Offering ──> Connecting ──> Connected ──> Ended ──> Idle + * Idle ──> IncomingCall ──> Connecting ──> Connected ──> Ended ──> Idle + * Idle ──> IncomingCall ──> Ended (reject) + * Idle ──> Offering ──> Ended (rejected / timeout) + * ``` + * + * These tests construct events directly (without signing) to verify + * state machine logic independently of cryptographic operations. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CallManagerTest { + // Real crypto identities — all tests use actual KeyPairs so that + // signer.pubKey matches the pubkey used in constructed events. + private val aliceSigner = NostrSignerInternal(KeyPair()) + private val bobSigner = NostrSignerInternal(KeyPair()) + private val carolSigner = NostrSignerInternal(KeyPair()) + + private val alice = aliceSigner.pubKey + private val bob = bobSigner.pubKey + private val carol = carolSigner.pubKey + + private val callId = "550e8400-e29b-41d4-a716-446655440000" + private val callId2 = "660e8400-e29b-41d4-a716-446655440001" + private val sdpOffer = "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n..." + private val sdpAnswer = "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n..." + + private val signers = mapOf(alice to aliceSigner, bob to bobSigner, carol to carolSigner) + + /** + * Creates a CallManager backed by a real NostrSignerInternal. Tests the full + * pipeline: CallManager → WebRtcCallFactory → sign → gift wrap → publish. + */ + private fun TestScope.createManager( + localPubKey: HexKey = bob, + followedKeys: Set = setOf(alice, carol), + ): Pair> { + val published = mutableListOf() + val signer = signers[localPubKey] ?: error("Unknown test identity: $localPubKey") + val manager = + CallManager( + signer = signer, + scope = this, + isFollowing = { it in followedKeys }, + publishEvent = { published.add(it) }, + ) + return manager to published + } + + // ---- Event construction helpers ---- + + private var eventCounter = 0 + + private fun makeOffer( + from: HexKey, + to: HexKey, + callId: String = this.callId, + callType: CallType = CallType.VOICE, + sdp: String = sdpOffer, + createdAt: Long = TimeUtils.now(), + ): CallOfferEvent { + val tags = + arrayOf( + arrayOf("p", to), + arrayOf("call-id", callId), + arrayOf("call-type", callType.value), + arrayOf("alt", "WebRTC call offer"), + ) + return CallOfferEvent("offer${eventCounter++}", from, createdAt, tags, sdp, "sig") + } + + private fun makeGroupOffer( + from: HexKey, + members: Set, + callId: String = this.callId, + callType: CallType = CallType.VOICE, + sdp: String = sdpOffer, + createdAt: Long = TimeUtils.now(), + ): CallOfferEvent { + val pTags = members.map { arrayOf("p", it) }.toTypedArray() + val tags = + pTags + + arrayOf( + arrayOf("call-id", callId), + arrayOf("call-type", callType.value), + arrayOf("alt", "WebRTC call offer"), + ) + return CallOfferEvent("offer${eventCounter++}", from, createdAt, tags, sdp, "sig") + } + + private fun makeAnswer( + from: HexKey, + to: HexKey, + callId: String = this.callId, + sdp: String = sdpAnswer, + createdAt: Long = TimeUtils.now(), + ): CallAnswerEvent { + val tags = + arrayOf( + arrayOf("p", to), + arrayOf("call-id", callId), + arrayOf("alt", "WebRTC call answer"), + ) + return CallAnswerEvent("answer${eventCounter++}", from, createdAt, tags, sdp, "sig") + } + + private fun makeGroupAnswer( + from: HexKey, + members: Set, + callId: String = this.callId, + sdp: String = sdpAnswer, + createdAt: Long = TimeUtils.now(), + ): CallAnswerEvent { + val pTags = members.map { arrayOf("p", it) }.toTypedArray() + val tags = + pTags + + arrayOf( + arrayOf("call-id", callId), + arrayOf("alt", "WebRTC call answer"), + ) + return CallAnswerEvent("answer${eventCounter++}", from, createdAt, tags, sdp, "sig") + } + + private fun makeHangup( + from: HexKey, + to: HexKey, + callId: String = this.callId, + reason: String = "", + createdAt: Long = TimeUtils.now(), + ): CallHangupEvent { + val tags = + arrayOf( + arrayOf("p", to), + arrayOf("call-id", callId), + arrayOf("alt", "WebRTC call hangup"), + ) + return CallHangupEvent("hangup${eventCounter++}", from, createdAt, tags, reason, "sig") + } + + private fun makeReject( + from: HexKey, + to: HexKey, + callId: String = this.callId, + reason: String = "", + createdAt: Long = TimeUtils.now(), + ): CallRejectEvent { + val tags = + arrayOf( + arrayOf("p", to), + arrayOf("call-id", callId), + arrayOf("alt", "WebRTC call rejection"), + ) + return CallRejectEvent("reject${eventCounter++}", from, createdAt, tags, reason, "sig") + } + + private fun makeIceCandidate( + from: HexKey, + to: HexKey, + callId: String = this.callId, + createdAt: Long = TimeUtils.now(), + ): CallIceCandidateEvent { + val json = """{"candidate":"candidate:1","sdpMid":"0","sdpMLineIndex":0}""" + val tags = + arrayOf( + arrayOf("p", to), + arrayOf("call-id", callId), + arrayOf("alt", "WebRTC ICE candidate"), + ) + return CallIceCandidateEvent("ice${eventCounter++}", from, createdAt, tags, json, "sig") + } + + private fun makeRenegotiate( + from: HexKey, + to: HexKey, + callId: String = this.callId, + sdp: String = sdpOffer, + createdAt: Long = TimeUtils.now(), + ): CallRenegotiateEvent { + val tags = + arrayOf( + arrayOf("p", to), + arrayOf("call-id", callId), + arrayOf("alt", "WebRTC call renegotiation"), + ) + return CallRenegotiateEvent("renego${eventCounter++}", from, createdAt, tags, sdp, "sig") + } + + // ======================================================================== + // 1. P2P Call: Idle → IncomingCall → Connecting → Connected → Ended + // ======================================================================== + + @Test + fun incomingCallFromFollowedUser_transitionsToIncomingCall() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + val offer = makeOffer(from = alice, to = bob) + manager.onSignalingEvent(offer) + + val state = manager.state.value + assertIs(state) + assertEquals(callId, state.callId) + assertEquals(alice, state.callerPubKey) + assertEquals(CallType.VOICE, state.callType) + assertEquals(sdpOffer, state.sdpOffer) + } + + @Test + fun incomingCallFromNonFollowed_isIgnored() = + runTest { + val (manager, _) = createManager(localPubKey = bob, followedKeys = emptySet()) + + val offer = makeOffer(from = alice, to = bob) + manager.onSignalingEvent(offer) + + assertIs(manager.state.value) + } + + @Test + fun acceptingCall_transitionsToConnecting() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + val offer = makeOffer(from = alice, to = bob) + manager.onSignalingEvent(offer) + assertIs(manager.state.value) + + manager.acceptCall(sdpAnswer) + + val state = manager.state.value + assertIs(state) + assertEquals(callId, state.callId) + } + + @Test + fun peerConnected_transitionsConnectingToConnected() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + assertIs(manager.state.value) + + manager.onPeerConnected() + + val state = manager.state.value + assertIs(state) + assertEquals(callId, state.callId) + } + + @Test + fun peerHangup_endsConnectedCall() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + assertIs(manager.state.value) + + val hangup = makeHangup(from = alice, to = bob) + manager.onSignalingEvent(hangup) + + val state = manager.state.value + assertIs(state) + assertEquals(EndReason.PEER_HANGUP, state.reason) + } + + @Test + fun endedState_autoResetsToIdle() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + manager.onSignalingEvent(makeHangup(from = alice, to = bob)) + + assertIs(manager.state.value) + + // Advance past the ENDED_DISPLAY_MS (2 seconds) + advanceUntilIdle() + + assertIs(manager.state.value) + } + + // ======================================================================== + // 2. Caller Side: Idle → Offering → Connecting → Connected + // ======================================================================== + + @Test + fun initiateCall_transitionsToOffering() = + runTest { + val (manager, _) = createManager(localPubKey = alice) + + manager.initiateCall(bob, CallType.VIDEO, callId, sdpOffer) + + val state = manager.state.value + assertIs(state) + assertEquals(callId, state.callId) + assertEquals(setOf(bob), state.peerPubKeys) + assertEquals(CallType.VIDEO, state.callType) + } + + @Test + fun receivingAnswer_transitionsOfferingToConnecting() = + runTest { + val (manager, _) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer) + assertIs(manager.state.value) + + var answerReceived = false + manager.onAnswerReceived = { answerReceived = true } + + val answer = makeAnswer(from = bob, to = alice) + manager.onSignalingEvent(answer) + + val state = manager.state.value + assertIs(state) + assertTrue(answerReceived, "onAnswerReceived callback should fire") + } + + // ======================================================================== + // 3. Call Rejection + // ======================================================================== + + @Test + fun rejectingIncomingCall_transitionsToEnded() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + assertIs(manager.state.value) + + manager.rejectCall() + + val state = manager.state.value + assertIs(state) + assertEquals(EndReason.REJECTED, state.reason) + } + + @Test + fun receivingReject_endsOfferingCall() = + runTest { + val (manager, _) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer) + assertIs(manager.state.value) + + val reject = makeReject(from = bob, to = alice) + manager.onSignalingEvent(reject) + + val state = manager.state.value + assertIs(state) + assertEquals(EndReason.PEER_REJECTED, state.reason) + } + + // ======================================================================== + // 4. Busy Auto-Reject + // ======================================================================== + + @Test + fun incomingCallWhileInActiveCall_autoRejectsBusy() = + runTest { + val (manager, published) = createManager(localPubKey = bob) + + // First call: accepted and connected + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + assertIs(manager.state.value) + published.clear() + + // Second call arrives while connected + val secondOffer = makeOffer(from = carol, to = bob, callId = callId2) + manager.onSignalingEvent(secondOffer) + advanceUntilIdle() + + // Should still be in the original call + assertIs(manager.state.value) + + // Should have published a reject (busy) + assertTrue(published.isNotEmpty(), "Should publish a busy reject") + } + + // ======================================================================== + // 5. Staleness: Old Events Discarded + // ======================================================================== + + @Test + fun staleEvents_areDiscarded() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + // Event from 30 seconds ago (beyond 20s threshold) + val staleOffer = makeOffer(from = alice, to = bob, createdAt = TimeUtils.now() - 30) + manager.onSignalingEvent(staleOffer) + + assertIs(manager.state.value, "Stale events (>20s old) MUST be discarded") + } + + @Test + fun freshEvents_areProcessed() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + // Event from 5 seconds ago (within 20s threshold) + val freshOffer = makeOffer(from = alice, to = bob, createdAt = TimeUtils.now() - 5) + manager.onSignalingEvent(freshOffer) + + assertIs(manager.state.value, "Fresh events (<20s old) should be processed") + } + + // ======================================================================== + // 6. Deduplication + // ======================================================================== + + @Test + fun duplicateEvents_areIgnored() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + val offer = makeOffer(from = alice, to = bob) + manager.onSignalingEvent(offer) + assertIs(manager.state.value) + + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + assertIs(manager.state.value) + + // Re-deliver the same offer (same event ID) + manager.onSignalingEvent(offer) + + // Should still be Connected, not re-processing the offer + assertIs(manager.state.value) + } + + // ======================================================================== + // 7. Self-Event Filtering + // ======================================================================== + + @Test + fun selfIceCandidates_areAlwaysIgnored() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + + var iceCalled = false + manager.onIceCandidateReceived = { iceCalled = true } + + // ICE candidate from self (echoed back by relay) + val selfIce = makeIceCandidate(from = bob, to = alice) + manager.onSignalingEvent(selfIce) + + assertTrue(!iceCalled, "Self ICE candidates MUST be ignored") + } + + @Test + fun selfHangup_isAlwaysIgnored() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + assertIs(manager.state.value) + + // Self hangup echo from relay + val selfHangup = makeHangup(from = bob, to = alice) + manager.onSignalingEvent(selfHangup) + + // Should still be Connected (self hangup is handled locally) + assertIs(manager.state.value) + } + + @Test + fun selfAnswer_inIncomingCall_meansAnsweredElsewhere() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + assertIs(manager.state.value) + + // Self-answer from another device + val selfAnswer = makeAnswer(from = bob, to = alice) + manager.onSignalingEvent(selfAnswer) + + val state = manager.state.value + assertIs(state) + assertEquals(EndReason.ANSWERED_ELSEWHERE, state.reason) + } + + @Test + fun selfAnswer_inOfferingState_isIgnored() = + runTest { + val (manager, _) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer) + assertIs(manager.state.value) + + // Self-answer echo — should be ignored in Offering state + val selfAnswer = makeAnswer(from = alice, to = bob) + manager.onSignalingEvent(selfAnswer) + + assertIs(manager.state.value, "Self-answer in Offering should be ignored") + } + + // ======================================================================== + // 8. ICE Candidate Forwarding + // ======================================================================== + + @Test + fun iceCandidates_areForwardedViaCallback() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + + var receivedIce: CallIceCandidateEvent? = null + manager.onIceCandidateReceived = { receivedIce = it } + + val ice = makeIceCandidate(from = alice, to = bob) + manager.onSignalingEvent(ice) + + assertNotNull(receivedIce, "ICE candidate should be forwarded via callback") + } + + // ======================================================================== + // 9. Mid-Call Renegotiation (Voice → Video Switch) + // ======================================================================== + + @Test + fun renegotiation_inConnectedState_isForwarded() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + assertIs(manager.state.value) + + var receivedRenego: CallRenegotiateEvent? = null + manager.onRenegotiationOfferReceived = { receivedRenego = it } + + val renego = makeRenegotiate(from = alice, to = bob) + manager.onSignalingEvent(renego) + + assertNotNull(receivedRenego, "Renegotiation should be forwarded in Connected state") + } + + @Test + fun renegotiation_inConnectingState_isForwarded() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + assertIs(manager.state.value) + + var receivedRenego: CallRenegotiateEvent? = null + manager.onRenegotiationOfferReceived = { receivedRenego = it } + + val renego = makeRenegotiate(from = alice, to = bob) + manager.onSignalingEvent(renego) + + assertNotNull(receivedRenego) + } + + @Test + fun renegotiation_inIdleState_isIgnored() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + var receivedRenego: CallRenegotiateEvent? = null + manager.onRenegotiationOfferReceived = { receivedRenego = it } + + val renego = makeRenegotiate(from = alice, to = bob) + manager.onSignalingEvent(renego) + + assertIs(manager.state.value) + assertEquals(null, receivedRenego, "Renegotiation in Idle state should be ignored") + } + + @Test + fun renegotiation_wrongCallId_isIgnored() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + + var receivedRenego: CallRenegotiateEvent? = null + manager.onRenegotiationOfferReceived = { receivedRenego = it } + + val renego = makeRenegotiate(from = alice, to = bob, callId = "wrong-call-id") + manager.onSignalingEvent(renego) + + assertEquals(null, receivedRenego, "Renegotiation for wrong call-id should be ignored") + } + + // ======================================================================== + // 10. Hangup from Any Active State + // ======================================================================== + + @Test + fun hangup_fromOffering_transitionsToEnded() = + runTest { + val (manager, _) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer) + assertIs(manager.state.value) + + manager.hangup() + + val state = manager.state.value + assertIs(state) + assertEquals(EndReason.HANGUP, state.reason) + } + + @Test + fun hangup_fromConnecting_transitionsToEnded() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + assertIs(manager.state.value) + + manager.hangup() + + assertIs(manager.state.value) + } + + @Test + fun hangup_fromConnected_transitionsToEnded() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + + manager.hangup() + + val state = manager.state.value + assertIs(state) + assertEquals(EndReason.HANGUP, state.reason) + } + + @Test + fun hangup_fromIdle_isNoop() = + runTest { + val (manager, published) = createManager(localPubKey = bob) + + manager.hangup() + + assertIs(manager.state.value) + assertTrue(published.isEmpty()) + } + + // ======================================================================== + // 11. Group Call: Multiple Peers + // ======================================================================== + + @Test + fun groupCallOffer_detectsMultipleMembers() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + val offer = makeGroupOffer(from = alice, members = setOf(bob, carol)) + manager.onSignalingEvent(offer) + + val state = manager.state.value + assertIs(state) + assertTrue(state.groupMembers.containsAll(setOf(alice, bob, carol))) + } + + @Test + fun groupCall_peerReject_removesFromGroup() = + runTest { + val (manager, _) = createManager(localPubKey = alice, followedKeys = setOf(bob, carol)) + + manager.beginOffering(callId, setOf(bob, carol), CallType.VOICE) + assertIs(manager.state.value) + + // Bob rejects + val reject = makeReject(from = bob, to = alice) + manager.onSignalingEvent(reject) + + val state = manager.state.value + assertIs(state) + assertTrue(bob !in state.peerPubKeys, "Rejected peer should be removed") + assertTrue(carol in state.peerPubKeys, "Remaining peers should stay") + } + + @Test + fun groupCall_allPeersReject_endsCall() = + runTest { + val (manager, _) = createManager(localPubKey = alice, followedKeys = setOf(bob, carol)) + + manager.beginOffering(callId, setOf(bob, carol), CallType.VOICE) + + manager.onSignalingEvent(makeReject(from = bob, to = alice)) + manager.onSignalingEvent(makeReject(from = carol, to = alice)) + + assertIs(manager.state.value) + } + + @Test + fun groupCall_partialDisconnect_continuesWithRemainingPeers() = + runTest { + val (manager, _) = createManager(localPubKey = alice, followedKeys = setOf(bob, carol)) + + manager.beginOffering(callId, setOf(bob, carol), CallType.VOICE) + + // Bob answers first + val bobAnswer = makeAnswer(from = bob, to = alice) + manager.onSignalingEvent(bobAnswer) + assertIs(manager.state.value) + + manager.onPeerConnected() + assertIs(manager.state.value) + + // Carol answers + val carolAnswer = makeAnswer(from = carol, to = alice) + manager.onSignalingEvent(carolAnswer) + + // Bob hangs up + manager.onSignalingEvent(makeHangup(from = bob, to = alice)) + + // Call should continue with carol + val state = manager.state.value + assertIs(state) + assertTrue(bob !in state.peerPubKeys, "Bob should be removed") + } + + @Test + fun groupCall_lastPeerLeaves_endsCall() = + runTest { + val (manager, _) = createManager(localPubKey = alice, followedKeys = setOf(bob, carol)) + + manager.beginOffering(callId, setOf(bob, carol), CallType.VOICE) + + // Both answer + manager.onSignalingEvent(makeAnswer(from = bob, to = alice)) + manager.onPeerConnected() + + val carolAnswer = makeAnswer(from = carol, to = alice) + manager.onSignalingEvent(carolAnswer) + + // Both leave + manager.onSignalingEvent(makeHangup(from = bob, to = alice)) + manager.onSignalingEvent(makeHangup(from = carol, to = alice)) + + assertIs(manager.state.value) + } + + // ======================================================================== + // 12. Group Call: Callee-to-Callee Mesh Discovery + // ======================================================================== + + @Test + fun groupCall_discoversPeerWhileRinging() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + // Alice calls bob and carol + val offer = makeGroupOffer(from = alice, members = setOf(bob, carol)) + manager.onSignalingEvent(offer) + assertIs(manager.state.value) + + // Carol answers (bob sees this while ringing) + val carolAnswer = makeGroupAnswer(from = carol, members = setOf(alice, bob, carol)) + manager.onSignalingEvent(carolAnswer) + + // Bob still ringing + assertIs(manager.state.value) + + // Track mesh setup callback + val newPeers = mutableListOf() + manager.onNewPeerInGroupCall = { newPeers.add(it) } + + // Bob accepts + manager.acceptCall(sdpAnswer) + + // Should trigger callee-to-callee mesh setup with carol + assertTrue(carol in newPeers, "Should discover carol for mesh setup after accepting") + } + + // ======================================================================== + // 13. Mid-Call Offer (Callee-to-Callee) + // ======================================================================== + + @Test + fun midCallOffer_sameCallId_isForwardedAsMidCallOffer() = + runTest { + val (manager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice, carol)) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + assertIs(manager.state.value) + + var midCallPeer: HexKey? = null + var midCallSdp: String? = null + manager.onMidCallOfferReceived = { peer, sdp -> + midCallPeer = peer + midCallSdp = sdp + } + + // Carol sends a mid-call offer (callee-to-callee mesh) + val carolOffer = makeOffer(from = carol, to = bob, callId = callId, sdp = "carol-sdp") + manager.onSignalingEvent(carolOffer) + + assertEquals(carol, midCallPeer) + assertEquals("carol-sdp", midCallSdp) + } + + // ======================================================================== + // 14. Call-ID Mismatch Ignored + // ======================================================================== + + @Test + fun answer_wrongCallId_isIgnored() = + runTest { + val (manager, _) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer) + assertIs(manager.state.value) + + val wrongAnswer = makeAnswer(from = bob, to = alice, callId = "wrong-id") + manager.onSignalingEvent(wrongAnswer) + + assertIs(manager.state.value, "Answer with wrong call-id should be ignored") + } + + @Test + fun hangup_wrongCallId_isIgnored() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + assertIs(manager.state.value) + + val wrongHangup = makeHangup(from = alice, to = bob, callId = "wrong-id") + manager.onSignalingEvent(wrongHangup) + + assertIs(manager.state.value, "Hangup with wrong call-id should be ignored") + } + + // ======================================================================== + // 15. Peer Left Callback + // ======================================================================== + + @Test + fun peerLeft_callback_firesOnHangup() = + runTest { + val (manager, _) = createManager(localPubKey = alice, followedKeys = setOf(bob, carol)) + + manager.beginOffering(callId, setOf(bob, carol), CallType.VOICE) + manager.onSignalingEvent(makeAnswer(from = bob, to = alice)) + manager.onPeerConnected() + manager.onSignalingEvent(makeAnswer(from = carol, to = alice)) + + val leftPeers = mutableListOf() + manager.onPeerLeft = { leftPeers.add(it) } + + manager.onSignalingEvent(makeHangup(from = bob, to = alice)) + + assertTrue(bob in leftPeers, "onPeerLeft should fire when a peer hangs up") + } + + // ======================================================================== + // 16. Reset + // ======================================================================== + + @Test + fun reset_returnsToIdle() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + assertIs(manager.state.value) + + manager.reset() + + assertIs(manager.state.value) + } + + // ======================================================================== + // 17. Video Call Type Preserved + // ======================================================================== + + @Test + fun videoCallType_preservedThroughStates() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + val videoOffer = makeOffer(from = alice, to = bob, callType = CallType.VIDEO) + manager.onSignalingEvent(videoOffer) + + val incoming = manager.state.value + assertIs(incoming) + assertEquals(CallType.VIDEO, incoming.callType) + + manager.acceptCall(sdpAnswer) + val connecting = manager.state.value + assertIs(connecting) + assertEquals(CallType.VIDEO, connecting.callType) + + manager.onPeerConnected() + val connected = manager.state.value + assertIs(connected) + assertEquals(CallType.VIDEO, connected.callType) + } + + // ======================================================================== + // 18. Caller Cancels (Hangup While Ringing) + // ======================================================================== + + @Test + fun callerHangup_whileRinging_endsIncomingCall() = + runTest { + val (manager, _) = createManager(localPubKey = bob) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + assertIs(manager.state.value) + + // Caller cancels + manager.onSignalingEvent(makeHangup(from = alice, to = bob)) + + val state = manager.state.value + assertIs(state) + assertEquals(EndReason.PEER_HANGUP, state.reason) + } + // ======================================================================== + // INTERFACE-LEVEL TESTS (Real Signers, Full Pipeline) + // ======================================================================== + // These tests use real NostrSignerInternal with actual crypto keys to verify + // the full pipeline: CallManager → WebRtcCallFactory → sign → gift wrap → publish + + @Test + fun interface_initiateCall_publishesGiftWrappedOffer() = + runTest { + val (manager, published) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + manager.initiateCall(bob, CallType.VIDEO, callId, sdpOffer) + + assertIs(manager.state.value) + assertEquals(1, published.size, "Should publish exactly one gift-wrapped offer") + assertEquals(EphemeralGiftWrapEvent.KIND, published[0].kind, "Wrap must be kind 21059") + } + + @Test + fun interface_acceptCall_publishesGiftWrappedAnswer() = + runTest { + val (manager, published) = createManager(localPubKey = bob, followedKeys = setOf(alice)) + + // Simulate incoming offer from alice + val offer = makeOffer(from = alice, to = bob) + manager.onSignalingEvent(offer) + assertIs(manager.state.value) + published.clear() + + manager.acceptCall(sdpAnswer) + + assertIs(manager.state.value) + // Should publish answer wrapped for all recipients (alice + self for multi-device) + assertTrue(published.isNotEmpty(), "Should publish gift-wrapped answer(s)") + published.forEach { wrap -> + assertEquals(EphemeralGiftWrapEvent.KIND, wrap.kind) + } + } + + @Test + fun interface_rejectCall_publishesGiftWrappedReject() = + runTest { + val (manager, published) = createManager(localPubKey = bob, followedKeys = setOf(alice)) + + val offer = makeOffer(from = alice, to = bob) + manager.onSignalingEvent(offer) + assertIs(manager.state.value) + published.clear() + + manager.rejectCall() + + assertIs(manager.state.value) + assertTrue(published.isNotEmpty(), "Should publish gift-wrapped reject(s)") + } + + @Test + fun interface_hangup_publishesGiftWrappedHangup() = + runTest { + val (manager, published) = createManager(localPubKey = bob, followedKeys = setOf(alice)) + + val offer = makeOffer(from = alice, to = bob) + manager.onSignalingEvent(offer) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + assertIs(manager.state.value) + published.clear() + + manager.hangup() + + assertIs(manager.state.value) + assertTrue(published.isNotEmpty(), "Should publish gift-wrapped hangup(s)") + } + + @Test + fun interface_sendRenegotiation_publishesGiftWrappedRenegotiate() = + runTest { + val (manager, published) = createManager(localPubKey = bob, followedKeys = setOf(alice)) + + val offer = makeOffer(from = alice, to = bob) + manager.onSignalingEvent(offer) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + assertIs(manager.state.value) + published.clear() + + val newSdp = "v=0\r\nnew-sdp-for-video" + manager.sendRenegotiation(newSdp, alice) + + assertEquals(1, published.size, "Should publish exactly one gift-wrapped renegotiate") + assertEquals(EphemeralGiftWrapEvent.KIND, published[0].kind) + } + + @Test + fun interface_sendRenegotiationAnswer_publishesGiftWrappedAnswer() = + runTest { + val (manager, published) = createManager(localPubKey = bob, followedKeys = setOf(alice)) + + val offer = makeOffer(from = alice, to = bob) + manager.onSignalingEvent(offer) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + published.clear() + + manager.sendRenegotiationAnswer("renegotiation-answer-sdp", alice) + + assertEquals(1, published.size) + assertEquals(EphemeralGiftWrapEvent.KIND, published[0].kind) + } + + @Test + fun interface_busyAutoReject_publishesRejectEvent() = + runTest { + val (manager, published) = createManager(localPubKey = bob, followedKeys = setOf(alice, carol)) + + // Accept first call + val offer = makeOffer(from = alice, to = bob) + manager.onSignalingEvent(offer) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + published.clear() + + // Second call from carol while in active call + val secondOffer = makeOffer(from = carol, to = bob, callId = callId2) + manager.onSignalingEvent(secondOffer) + advanceUntilIdle() + + // Should remain in original call + assertIs(manager.state.value) + // Should have published auto-reject + assertTrue(published.isNotEmpty(), "Should publish busy auto-reject") + } + + @Test + fun interface_groupCall_publishesPerPeerOffers() = + runTest { + val (manager, published) = createManager(localPubKey = alice, followedKeys = setOf(bob, carol)) + + manager.beginOffering(callId, setOf(bob, carol), CallType.VOICE) + + // Publish per-peer offers + manager.publishOfferToPeer(bob, setOf(bob, carol), CallType.VOICE, callId, sdpOffer) + manager.publishOfferToPeer(carol, setOf(bob, carol), CallType.VOICE, callId, "carol-sdp") + + assertEquals(2, published.size, "Should publish one gift-wrapped offer per peer") + } + + @Test + fun interface_invitePeer_publishesOfferToNewPeer() = + runTest { + val (manager, published) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer) + val answer = makeAnswer(from = bob, to = alice) + manager.onSignalingEvent(answer) + manager.onPeerConnected() + assertIs(manager.state.value) + published.clear() + + manager.invitePeer(carol, "invite-sdp") + + assertEquals(1, published.size, "Should publish one gift-wrapped invite offer") + + val state = manager.state.value + assertIs(state) + assertTrue(carol in state.pendingPeerPubKeys, "Invited peer should be in pending set") + } + + @Test + fun interface_fullP2PCallFlow_withRealSigners() = + runTest { + // Full end-to-end P2P call: Alice calls Bob + val (aliceManager, alicePublished) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + val (bobManager, bobPublished) = createManager(localPubKey = bob, followedKeys = setOf(alice)) + + // Step 1: Alice initiates call + aliceManager.initiateCall(bob, CallType.VIDEO, callId, sdpOffer) + assertIs(aliceManager.state.value) + assertEquals(1, alicePublished.size) + + // Step 2: Bob receives offer (simulated, since we can't decrypt the gift wrap) + val bobOffer = makeOffer(from = alice, to = bob, callType = CallType.VIDEO) + bobManager.onSignalingEvent(bobOffer) + assertIs(bobManager.state.value) + + // Step 3: Bob accepts + bobManager.acceptCall(sdpAnswer) + assertIs(bobManager.state.value) + assertTrue(bobPublished.isNotEmpty()) + + // Step 4: Alice receives answer + val aliceAnswer = makeAnswer(from = bob, to = alice) + aliceManager.onSignalingEvent(aliceAnswer) + assertIs(aliceManager.state.value) + + // Step 5: Both sides report peer connected + aliceManager.onPeerConnected() + bobManager.onPeerConnected() + assertIs(aliceManager.state.value) + assertIs(bobManager.state.value) + + // Step 6: Alice sends renegotiation (add video) + alicePublished.clear() + aliceManager.sendRenegotiation("new-video-sdp", bob) + assertEquals(1, alicePublished.size) + + // Step 7: Bob receives renegotiate and responds + var renegoReceived = false + bobManager.onRenegotiationOfferReceived = { renegoReceived = true } + bobManager.onSignalingEvent(makeRenegotiate(from = alice, to = bob)) + assertTrue(renegoReceived) + + bobPublished.clear() + bobManager.sendRenegotiationAnswer("renego-answer-sdp", alice) + assertEquals(1, bobPublished.size) + + // Step 8: Alice hangs up + alicePublished.clear() + aliceManager.hangup() + assertIs(aliceManager.state.value) + assertTrue(alicePublished.isNotEmpty()) + + // Step 9: Bob receives hangup + bobManager.onSignalingEvent(makeHangup(from = alice, to = bob)) + assertIs(bobManager.state.value) + + // Step 10: Both auto-reset to Idle + advanceUntilIdle() + assertIs(aliceManager.state.value) + assertIs(bobManager.state.value) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md index fb9dab021..e438df5dc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md @@ -478,6 +478,91 @@ These self-notification events use the same `call-id` as the original call and f - If SDP offer/answer creation fails, the client SHOULD surface the error instead of hanging silently. - Clients SHOULD handle `ICE_CONNECTION_FAILED` state by ending the call and notifying the user of a connection failure. +## Test Vectors + +Implementations SHOULD validate their state machine and event handling against these test scenarios. +The reference test suite is available in this repository under `quartz/src/commonTest/` and +`commons/src/commonTest/`. + +### Event Structure Tests + +| # | Test | Verification | +|---|------|-------------| +| E1 | Build Call Offer (kind 25050) | Contains `p`, `call-id`, `call-type`, `alt` tags; content = SDP offer | +| E2 | Build Call Answer (kind 25051) | Contains `p`, `call-id`, `alt` tags; content = SDP answer; NO `call-type` tag | +| E3 | Build ICE Candidate (kind 25052) | Contains `p`, `call-id`, `alt` tags; content = JSON with `candidate`, `sdpMid`, `sdpMLineIndex` | +| E4 | Build Call Hangup (kind 25053) | Contains `p`, `call-id`, `alt` tags; content MAY be empty or contain reason | +| E5 | Build Call Reject (kind 25054) | Contains `p`, `call-id`, `alt` tags; content MAY be `"busy"` for auto-reject | +| E6 | Build Call Renegotiate (kind 25055) | Contains `p`, `call-id`, `alt` tags; content = new SDP offer | +| E7 | Group offer with N callees | N `p` tags present, one per callee | +| E8 | ICE candidate in group call | Still addressed to single peer (1 `p` tag) | +| E9 | All events for same call share `call-id` | Offer, answer, ICE, hangup, reject, renegotiate — same UUID | + +### State Machine Tests + +| # | Scenario | Initial State | Event/Action | Expected State | +|---|----------|---------------|-------------|----------------| +| S1 | Receive offer from followed user | Idle | CallOffer | IncomingCall | +| S2 | Receive offer from non-followed user | Idle | CallOffer | Idle (ignored) | +| S3 | Accept incoming call | IncomingCall | acceptCall() | Connecting | +| S4 | ICE peer connected | Connecting | onPeerConnected() | Connected | +| S5 | Peer hangs up (P2P) | Connected | CallHangup | Ended(PEER_HANGUP) | +| S6 | Ended auto-resets | Ended | ~2s timeout | Idle | +| S7 | Initiate outbound call | Idle | initiateCall() | Offering | +| S8 | Receive answer to offer | Offering | CallAnswer | Connecting | +| S9 | Reject incoming call | IncomingCall | rejectCall() | Ended(REJECTED) | +| S10 | Peer rejects our offer (P2P) | Offering | CallReject | Ended(PEER_REJECTED) | +| S11 | Busy auto-reject | Connected | CallOffer (different call-id) | Connected + publish CallReject("busy") | +| S12 | Stale event (>20s old) | Any | Any signaling event | No state change | +| S13 | Duplicate event (same ID) | Any | Re-delivered event | No state change | +| S14 | Self ICE candidate echo | Any active | CallIceCandidate from self | Ignored | +| S15 | Self hangup echo | Connected | CallHangup from self | Ignored (stay Connected) | +| S16 | Self answer in IncomingCall | IncomingCall | CallAnswer from self | Ended(ANSWERED_ELSEWHERE) | +| S17 | Self answer in Offering | Offering | CallAnswer from self | Ignored (stay Offering) | +| S18 | Hangup from Offering | Offering | hangup() | Ended(HANGUP) | +| S19 | Hangup from Connecting | Connecting | hangup() | Ended(HANGUP) | +| S20 | Hangup from Connected | Connected | hangup() | Ended(HANGUP) | +| S21 | Hangup from Idle | Idle | hangup() | Idle (no-op) | + +### Renegotiation Tests + +| # | Scenario | Verification | +|---|----------|-------------| +| R1 | Renegotiate in Connected state | Forwarded to callback | +| R2 | Renegotiate in Connecting state | Forwarded to callback | +| R3 | Renegotiate in Idle state | Ignored | +| R4 | Renegotiate with wrong call-id | Ignored | +| R5 | Renegotiation response | MUST be CallAnswer (kind 25051), NOT CallRenegotiate | +| R6 | Glare tiebreaker | Higher pubkey wins; lower pubkey rolls back | + +### Group Call Tests + +| # | Scenario | Verification | +|---|----------|-------------| +| G1 | Group offer detected | Multiple `p` tags → group call | +| G2 | Peer rejects in group | Removed from group; call continues with remaining | +| G3 | All peers reject | Call ends | +| G4 | Partial disconnect | Close only that peer's connection; continue with remaining | +| G5 | Last peer leaves | Call ends | +| G6 | Discover peer while ringing | Buffer peer; trigger mesh setup after accepting | +| G7 | Mid-call offer (callee-to-callee) | Forwarded via onMidCallOfferReceived callback | +| G8 | Invite new peer | Offer with all existing members + new invitee in `p` tags | +| G9 | Callee-to-callee glare | Lower pubkey initiates; higher waits | + +### Interface-Level Tests (Full Pipeline) + +| # | Scenario | Verification | +|---|----------|-------------| +| I1 | Initiate call | Publishes 1 EphemeralGiftWrap (kind 21059) | +| I2 | Accept call | Publishes EphemeralGiftWrap answer(s) | +| I3 | Reject call | Publishes EphemeralGiftWrap reject(s) | +| I4 | Hangup | Publishes EphemeralGiftWrap hangup(s) | +| I5 | Send renegotiation | Publishes 1 EphemeralGiftWrap renegotiate | +| I6 | Busy auto-reject | Publishes EphemeralGiftWrap reject while staying in current call | +| I7 | Group per-peer offers | Publishes 1 EphemeralGiftWrap per peer | +| I8 | Invite peer | Publishes 1 EphemeralGiftWrap; invitee added to pending set | +| I9 | Full P2P flow | Offer → Answer → Renegotiate → Hangup, all via gift wraps | + ## References - [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) — Event structure diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NipACStateMachineTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NipACStateMachineTest.kt new file mode 100644 index 000000000..0384cfa4e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NipACStateMachineTest.kt @@ -0,0 +1,476 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipACWebRtcCalls + +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * NIP-AC Protocol Compliance Test Vectors + * + * These tests verify that event structures conform to the NIP-AC specification + * for WebRTC signaling over Nostr. They serve as reference test vectors that + * any NIP-AC implementation can use to validate compliance. + * + * Test categories: + * 1. Event structure validation (kinds, required tags, content) + * 2. P2P call flow (offer → answer → ICE → hangup) + * 3. Call rejection flow + * 4. Mid-call renegotiation (voice ↔ video switch) + * 5. Group call flows (multi-party mesh) + * 6. Self-event filtering rules + * 7. Staleness / spam prevention + */ +class NipACStateMachineTest { + // ---- Test identities ---- + private val alice = "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111" + private val bob = "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222" + private val carol = "cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333" + private val dave = "dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444dddd4444" + private val callId = "550e8400-e29b-41d4-a716-446655440000" + private val sdpOffer = "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n..." + private val sdpAnswer = "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n..." + + // ======================================================================== + // 1. Event Structure Validation + // ======================================================================== + + @Test + fun callOfferMustIncludeAllRequiredTags() { + val template = CallOfferEvent.build(sdpOffer, bob, callId, CallType.VIDEO) + assertEquals(CallOfferEvent.KIND, template.kind) + assertEquals(sdpOffer, template.content) + + val pTag = template.tags.firstOrNull { it[0] == "p" } + assertNotNull(pTag, "Call offer MUST include p tag") + assertEquals(bob, pTag[1]) + + val callIdTag = template.tags.firstOrNull { it[0] == "call-id" } + assertNotNull(callIdTag, "Call offer MUST include call-id tag") + assertEquals(callId, callIdTag[1]) + + val callTypeTag = template.tags.firstOrNull { it[0] == "call-type" } + assertNotNull(callTypeTag, "Call offer MUST include call-type tag") + assertEquals("video", callTypeTag[1]) + + val altTag = template.tags.firstOrNull { it[0] == "alt" } + assertNotNull(altTag, "Call offer MUST include alt tag (NIP-31)") + } + + @Test + fun callAnswerMustIncludeRequiredTags() { + val template = CallAnswerEvent.build(sdpAnswer, alice, callId) + assertEquals(CallAnswerEvent.KIND, template.kind) + assertEquals(sdpAnswer, template.content) + + assertNotNull(template.tags.firstOrNull { it[0] == "p" }, "Call answer MUST include p tag") + assertNotNull(template.tags.firstOrNull { it[0] == "call-id" }, "Call answer MUST include call-id tag") + assertNotNull(template.tags.firstOrNull { it[0] == "alt" }, "Call answer MUST include alt tag") + } + + @Test + fun callAnswerMustNotIncludeCallTypeTag() { + val template = CallAnswerEvent.build(sdpAnswer, alice, callId) + val callTypeTag = template.tags.firstOrNull { it[0] == "call-type" } + assertNull(callTypeTag, "Call answer MUST NOT include call-type tag (only offers have it)") + } + + @Test + fun iceCandidateMustIncludeRequiredTags() { + val candidateJson = """{"candidate":"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx","sdpMid":"0","sdpMLineIndex":0}""" + val template = CallIceCandidateEvent.build(candidateJson, bob, callId) + assertEquals(CallIceCandidateEvent.KIND, template.kind) + assertEquals(candidateJson, template.content) + + assertNotNull(template.tags.firstOrNull { it[0] == "p" }) + assertNotNull(template.tags.firstOrNull { it[0] == "call-id" }) + assertNotNull(template.tags.firstOrNull { it[0] == "alt" }) + } + + @Test + fun hangupMayHaveEmptyContent() { + val template = CallHangupEvent.build(bob, callId) + assertEquals("", template.content, "Hangup content MAY be empty") + } + + @Test + fun hangupMayHaveReasonContent() { + val template = CallHangupEvent.build(bob, callId, reason = "user ended call") + assertEquals("user ended call", template.content) + } + + @Test + fun rejectMayHaveBusyReason() { + val template = CallRejectEvent.build(alice, callId, reason = "busy") + assertEquals("busy", template.content, "Reject with 'busy' content for auto-reject while in call") + } + + @Test + fun renegotiateMustContainNewSdpOffer() { + val newSdp = "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n..." + val template = CallRenegotiateEvent.build(newSdp, bob, callId) + assertEquals(CallRenegotiateEvent.KIND, template.kind) + assertEquals(newSdp, template.content, "Renegotiate content MUST be new SDP offer") + } + + // ======================================================================== + // 2. P2P Call Flow: Offer → Answer → ICE → Connected → Hangup + // ======================================================================== + + @Test + fun p2pCallFlowProducesCorrectEventSequence() { + // Step 1: Alice offers to Bob (voice call) + val offer = CallOfferEvent.build(sdpOffer, bob, callId, CallType.VOICE) + assertEquals(25050, offer.kind) + assertEquals(bob, offer.tags.first { it[0] == "p" }[1]) + assertEquals("voice", offer.tags.first { it[0] == "call-type" }[1]) + + // Step 2: Bob answers + val answer = CallAnswerEvent.build(sdpAnswer, alice, callId) + assertEquals(25051, answer.kind) + assertEquals(alice, answer.tags.first { it[0] == "p" }[1]) + assertEquals(callId, answer.tags.first { it[0] == "call-id" }[1]) + + // Step 3: ICE candidates exchange + val iceJson = + CallIceCandidateEvent.serializeCandidate( + "candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx", + "0", + 0, + ) + val ice = CallIceCandidateEvent.build(iceJson, bob, callId) + assertEquals(25052, ice.kind) + + // Step 4: Hangup + val hangup = CallHangupEvent.build(bob, callId) + assertEquals(25053, hangup.kind) + } + + // ======================================================================== + // 3. Call Rejection Flow + // ======================================================================== + + @Test + fun rejectEventHasCorrectStructure() { + val reject = CallRejectEvent.build(alice, callId) + assertEquals(25054, reject.kind) + assertEquals(alice, reject.tags.first { it[0] == "p" }[1]) + assertEquals(callId, reject.tags.first { it[0] == "call-id" }[1]) + } + + @Test + fun busyRejectHasContentBusy() { + val reject = CallRejectEvent.build(alice, callId, reason = "busy") + assertEquals("busy", reject.content) + } + + // ======================================================================== + // 4. Mid-Call Renegotiation (Voice ↔ Video Switch) + // ======================================================================== + + @Test + fun renegotiationFlowProducesOfferThenAnswer() { + // Party A sends renegotiate with new SDP + val newSdp = "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n..." + val renegotiate = CallRenegotiateEvent.build(newSdp, bob, callId) + assertEquals(25055, renegotiate.kind) + assertEquals(newSdp, renegotiate.content) + + // Party B responds with a CallAnswer (kind 25051), NOT a renegotiate + val renegAnswer = CallAnswerEvent.build("answer-for-renego", alice, callId) + assertEquals(25051, renegAnswer.kind, "Renegotiation response MUST be CallAnswer (25051)") + } + + @Test + fun renegotiationPreservesCallId() { + val renegotiate = CallRenegotiateEvent.build(sdpOffer, bob, callId) + assertEquals(callId, renegotiate.tags.first { it[0] == "call-id" }[1]) + } + + // ======================================================================== + // 5. Group Call Flows + // ======================================================================== + + @Test + fun groupCallOfferIncludesPTagForEveryMember() { + val callees = setOf(bob, carol, dave) + val template = CallOfferEvent.build(sdpOffer, callees, callId, CallType.VIDEO) + + val pTagValues = + template.tags + .filter { it[0] == "p" } + .map { it[1] } + .toSet() + assertEquals(callees, pTagValues, "Group offer MUST include p tag for every callee") + } + + @Test + fun groupCallOfferIsDetectedByMultiplePTags() { + // Single callee → not a group call + val singleOffer = CallOfferEvent.build(sdpOffer, bob, callId, CallType.VOICE) + assertEquals(1, singleOffer.tags.count { it[0] == "p" }) + + // Multiple callees → group call + val groupOffer = CallOfferEvent.build(sdpOffer, setOf(bob, carol), callId, CallType.VOICE) + assertEquals(2, groupOffer.tags.count { it[0] == "p" }) + } + + @Test + fun groupCallAnswerIncludesAllMemberPTags() { + val allMembers = setOf(alice, bob, carol) + val template = CallAnswerEvent.build(sdpAnswer, allMembers, callId) + + val pTagValues = + template.tags + .filter { it[0] == "p" } + .map { it[1] } + .toSet() + assertEquals(allMembers, pTagValues, "Group answer MUST include p tag for every member") + } + + @Test + fun groupCallHangupIncludesAllMemberPTags() { + val allMembers = setOf(alice, bob, carol) + val template = CallHangupEvent.build(allMembers, callId) + + val pTagValues = + template.tags + .filter { it[0] == "p" } + .map { it[1] } + .toSet() + assertEquals(allMembers, pTagValues) + } + + @Test + fun groupCallRejectIncludesAllMemberPTags() { + val allMembers = setOf(alice, bob, carol) + val template = CallRejectEvent.build(allMembers, callId) + + val pTagValues = + template.tags + .filter { it[0] == "p" } + .map { it[1] } + .toSet() + assertEquals(allMembers, pTagValues) + } + + @Test + fun groupCallRenegotiateIncludesAllMemberPTags() { + val allMembers = setOf(alice, bob, carol) + val template = CallRenegotiateEvent.build(sdpOffer, allMembers, callId) + + val pTagValues = + template.tags + .filter { it[0] == "p" } + .map { it[1] } + .toSet() + assertEquals(allMembers, pTagValues) + } + + @Test + fun groupMembersIncludesEventAuthorPlusPTags() { + // Build a signed-style offer event to test groupMembers() + val template = CallOfferEvent.build(sdpOffer, setOf(bob, carol), callId, CallType.VIDEO) + // Template doesn't have pubKey set, so test via the tag helper + val pTags = + template.tags + .filter { it[0] == "p" } + .map { it[1] } + .toSet() + assertEquals(setOf(bob, carol), pTags) + } + + @Test + fun iceCandidateInGroupCallIsAddressedToSinglePeer() { + // Per spec: ICE candidates remain addressed to a single peer + val ice = + CallIceCandidateEvent.build( + """{"candidate":"c","sdpMid":"0","sdpMLineIndex":0}""", + bob, + callId, + ) + val pTags = ice.tags.filter { it[0] == "p" } + assertEquals(1, pTags.size, "ICE candidate MUST be addressed to single peer, even in group call") + } + + // ======================================================================== + // 6. ICE Candidate Serialization + // ======================================================================== + + @Test + fun iceCandidateSerializationRoundTrips() { + val sdp = "candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0" + val mid = "0" + val index = 0 + + val json = CallIceCandidateEvent.serializeCandidate(sdp, mid, index) + val template = CallIceCandidateEvent.build(json, bob, callId) + + // Construct event to test parsing + val event = CallIceCandidateEvent("fakeid", alice, TimeUtils.now(), template.tags, json, "fakesig") + assertEquals(sdp, event.candidateSdp()) + assertEquals(mid, event.sdpMid()) + assertEquals(index, event.sdpMLineIndex()) + } + + @Test + fun iceCandidateSerializationEscapesQuotes() { + // Verify that quotes are properly escaped in the JSON output + val sdp = """candidate:1 1 udp 2122260223 192.168.1.1 44323 typ host""" + val json = CallIceCandidateEvent.serializeCandidate(sdp, "audio", 0) + assertTrue(json.startsWith("{"), "Must be valid JSON object") + assertTrue(json.endsWith("}"), "Must be valid JSON object") + assertTrue(json.contains(""""candidate":"""), "Must contain candidate key") + assertTrue(json.contains(""""sdpMid":"audio""""), "Must contain sdpMid") + assertTrue(json.contains(""""sdpMLineIndex":0"""), "Must contain sdpMLineIndex") + // Roundtrip through event parsing + val event = CallIceCandidateEvent("fakeid", alice, TimeUtils.now(), arrayOf(), json, "fakesig") + assertEquals(sdp, event.candidateSdp()) + assertEquals("audio", event.sdpMid()) + assertEquals(0, event.sdpMLineIndex()) + } + + // ======================================================================== + // 7. Staleness Check + // ======================================================================== + + @Test + fun eventsOlderThan20SecondsMustBeDiscarded() { + // Per spec: "Clients MUST discard signaling events older than 20 seconds" + val now = TimeUtils.now() + val freshCreatedAt = now - 5 // 5 seconds ago — fresh + val staleCreatedAt = now - 25 // 25 seconds ago — stale + + assertTrue(now - freshCreatedAt <= 20, "Event 5s old should be fresh") + assertTrue(now - staleCreatedAt > 20, "Event 25s old should be stale") + } + + // ======================================================================== + // 8. Call-ID Consistency + // ======================================================================== + + @Test + fun allSignalingEventsForSameCallShareCallId() { + val offer = CallOfferEvent.build(sdpOffer, bob, callId, CallType.VOICE) + val answer = CallAnswerEvent.build(sdpAnswer, alice, callId) + val ice = CallIceCandidateEvent.build("{}", bob, callId) + val hangup = CallHangupEvent.build(bob, callId) + val reject = CallRejectEvent.build(alice, callId) + val renego = CallRenegotiateEvent.build(sdpOffer, bob, callId) + + val events = listOf(offer, answer, ice, hangup, reject, renego) + for (event in events) { + val parsedCallId = event.tags.first { it[0] == "call-id" }[1] + assertEquals(callId, parsedCallId, "All events for same call MUST share call-id") + } + } + + // ======================================================================== + // 9. Renegotiation Glare: Pubkey Comparison Tiebreaker + // ======================================================================== + + @Test + fun renegotiationGlareTiebreaker_higherPubkeyWins() { + // Per spec: "the peer with the higher pubkey wins" + // alice < bob (lexicographically: 'a' < 'b') + assertTrue(alice < bob, "Precondition: alice pubkey < bob pubkey") + // So bob's offer takes priority, alice must rollback + } + + @Test + fun calleeToCalleeMeshGlare_lowerPubkeyInitiates() { + // Per spec: "the peer with the lexicographically lower pubkey initiates the offer" + assertTrue(alice < bob) + // Alice (lower) should initiate, Bob (higher) waits + } + + // ======================================================================== + // 10. Event Kind Constants + // ======================================================================== + + @Test + fun eventKindsMustMatchSpec() { + assertEquals(25050, CallOfferEvent.KIND, "Call Offer kind") + assertEquals(25051, CallAnswerEvent.KIND, "Call Answer kind") + assertEquals(25052, CallIceCandidateEvent.KIND, "ICE Candidate kind") + assertEquals(25053, CallHangupEvent.KIND, "Call Hangup kind") + assertEquals(25054, CallRejectEvent.KIND, "Call Reject kind") + assertEquals(25055, CallRenegotiateEvent.KIND, "Call Renegotiate kind") + } + + // ======================================================================== + // 11. Invite New Peer to Active Group Call + // ======================================================================== + + @Test + fun inviteNewPeerOfferIncludesAllExistingMembersPlusInvitee() { + // When inviting dave into an active call with alice, bob, carol: + val allMembers = setOf(alice, bob, carol, dave) + val inviteOffer = CallOfferEvent.build(sdpOffer, allMembers, callId, CallType.VIDEO) + + val pTags = + inviteOffer.tags + .filter { it[0] == "p" } + .map { it[1] } + .toSet() + assertEquals(allMembers, pTags, "Invite offer MUST include all existing members plus new invitee") + } + + // ======================================================================== + // 12. Multi-Device Support: Self-Addressed Events + // ======================================================================== + + @Test + fun selfAddressedAnswerForMultiDeviceSupport() { + // Per spec: callee should publish answer addressed to own pubkey + // for "answered elsewhere" notification to other devices + val selfAnswer = CallAnswerEvent.build(sdpAnswer, setOf(alice, bob), callId) + val pTags = + selfAnswer.tags + .filter { it[0] == "p" } + .map { it[1] } + .toSet() + // The callee (bob) includes self in p-tags for group broadcast + assertTrue(bob in pTags || alice in pTags, "Self-addressed answer must include own pubkey in p-tags") + } + + @Test + fun selfAddressedRejectForMultiDeviceSupport() { + val selfReject = CallRejectEvent.build(setOf(alice, bob), callId) + val pTags = + selfReject.tags + .filter { it[0] == "p" } + .map { it[1] } + .toSet() + assertTrue(pTags.size >= 2, "Self-reject in group should address all members including self") + } +}