[asterisk-commits] Add local attended transfer tests with direct media, hold, a... (testsuite[master])

SVN commits to the Asterisk project asterisk-commits at lists.digium.com
Fri May 22 12:56:48 CDT 2015


Joshua Colp has submitted this change and it was merged.

Change subject: Add local attended transfer tests with direct media, hold, and REFER/Replaces.
......................................................................


Add local attended transfer tests with direct media, hold, and REFER/Replaces.

This adds two pjsip tests for caller and callee initiated local attended
transfers with direct media, hold, and REFER/Replaces. Both tests use the
packet_sniffer.py module to check AMI UserEvents that it generates.

* packet_sniffer.py: This sniffs SIP packets to track calls. It generates an
AMI UserEvent when a remote bridge is setup on two call legs, when a remote
bridge is torn down on a call leg, and when a REFER, Notify sipfrag, and 491
Request Pending is found.
* lib/python/asterisk/pcap.py: Fix the finding of SIP packets and attribute
references.
* lib/python/asterisk/phones.py: When a pjsua operation (transfer/hold) is
performed and before any callbacks are called or state changes occur, there is
no way to know that an operation is in progress. This adds tracking of calls
with operations (transfer & hold) that are in progress. When an operation is in
progress for a call, this prevents the same operation from being performed on
the call. This also adds additional call & media state checking and updates
some of the exception handling.

ASTERISK-23644 #close
Reported by: Matt Jordan

Change-Id: Ibd1f9a3ea8bf6b80fe95efe3fcd880a55d5a7cae
---
M lib/python/asterisk/pcap.py
M lib/python/asterisk/phones.py
A tests/channels/pjsip/transfers/attended_transfer/nominal/callee_local_direct_media/configs/ast1/extensions.conf
A tests/channels/pjsip/transfers/attended_transfer/nominal/callee_local_direct_media/configs/ast1/pjsip.conf
A tests/channels/pjsip/transfers/attended_transfer/nominal/callee_local_direct_media/test-config.yaml
A tests/channels/pjsip/transfers/attended_transfer/nominal/caller_local_direct_media/configs/ast1/extensions.conf
A tests/channels/pjsip/transfers/attended_transfer/nominal/caller_local_direct_media/configs/ast1/pjsip.conf
A tests/channels/pjsip/transfers/attended_transfer/nominal/caller_local_direct_media/test-config.yaml
A tests/channels/pjsip/transfers/attended_transfer/nominal/packet_sniffer.py
M tests/channels/pjsip/transfers/attended_transfer/nominal/tests.yaml
10 files changed, 1,208 insertions(+), 16 deletions(-)

Approvals:
  Samuel Galarneau: Looks good to me, but someone else must approve
  Ashley Sanders: Looks good to me, but someone else must approve
  Joshua Colp: Looks good to me, approved; Verified



diff --git a/lib/python/asterisk/pcap.py b/lib/python/asterisk/pcap.py
index e35a328..e675699 100644
--- a/lib/python/asterisk/pcap.py
+++ b/lib/python/asterisk/pcap.py
@@ -333,7 +333,7 @@
         self.request_line = ''
         self.ascii_packet = ascii_packet
 
-        ascii_packet = ascii_packet.strip()
+        ascii_packet = ascii_packet.lstrip()
         last_pos = ascii_packet.find('\r\n',
                                      ascii_packet.find('Content-Length'))
         header_count = 0
@@ -390,12 +390,12 @@
         # interpret packets correctly
         if ret_packet and ret_packet.body and \
                 ret_packet.body.packet_type == 'SDP' and \
-                ret_packet.sdp_packet.rtp_port != 0 and \
-                ret_packet.sdp_packet.rtcp_port != 0:
+                ret_packet.body.rtp_port != 0 and \
+                ret_packet.body.rtcp_port != 0:
             self._factory_manager.add_global_data(
                 ret_packet.ip_layer.header.source,
-                {'rtp': ret_packet.sdp_packet.rtp_port,
-                 'rtcp': ret_packet.sdp_packet.rtcp_port})
+                {'rtp': ret_packet.body.rtp_port,
+                 'rtcp': ret_packet.body.rtcp_port})
         return ret_packet
 
 
diff --git a/lib/python/asterisk/phones.py b/lib/python/asterisk/phones.py
index 78f1200..5ad1c36 100755
--- a/lib/python/asterisk/phones.py
+++ b/lib/python/asterisk/phones.py
@@ -10,7 +10,7 @@
 
 import sys
 import logging
-from twisted.internet import reactor
+from twisted.internet import reactor, task
 import pjsua as pj
 
 import pjsua_mod
@@ -102,6 +102,10 @@
         self.pj_lib = controller.pj_accounts[self.name].pj_lib
         # List of Call objects
         self.calls = []
+        # Track calls with operations that are in progress. The tracking is to
+        # prevent the same operation from being attempted on a call when it's
+        # already in progress.
+        self.__op_calls = {'hold': [], 'transfer': []}
 
     def make_call(self, uri):
         """Place a call.
@@ -120,50 +124,97 @@
         except pj.Error as err:
             raise Exception("Exception occurred while making call: '%s'" %
                             str(err))
+        except:
+            raise
 
         return call
 
     def blind_transfer(self, transfer_uri):
         """Do a blind transfer.
 
+        Attempt to transfer the call to the specified URI.
+
         Keyword Arguments:
         transfer_uri SIP URI of transfer target.
         """
         LOGGER.info("'%s' is transfering (blind) '%s' to '%s'." %
                     (self.name, self.calls[0].info().remote_uri, transfer_uri))
+        self.__op_calls['transfer'].append(self.calls[0])
+
         try:
             self.calls[0].transfer(transfer_uri)
         except pj.Error as err:
             raise Exception("Exception occurred while transferring: '%s'" %
                             str(err))
+        except:
+            self.remove_call_op_tracking(self.calls[0], operation='transfer')
+            raise
 
     def attended_transfer(self):
         """Do an attended transfer.
 
-        The first call will be transfered to the second call.
+        Attempt to transfer the first call to the second call.
         """
         LOGGER.info("'%s' is transfering (attended) '%s' to '%s'." %
                     (self.name, self.calls[0].info().remote_uri,
                      self.calls[1].info().remote_uri))
+        self.__op_calls['transfer'].append(self.calls[0])
+
         try:
             self.calls[0].transfer_to_call(self.calls[1], hdr_list=None,
                                            options=0)
         except pj.Error as err:
             raise Exception("Exception occurred while transferring: '%s'" %
                             str(err))
+        except:
+            self.remove_call_op_tracking(self.calls[0], operation='transfer')
+            raise
 
     def hold_call(self):
         """Place call on hold.
 
-        The first call will be placed on hold.
+        Attempt to place the first call on hold.
         """
         LOGGER.info("'%s' is putting '%s' on hold." %
                     (self.name, self.calls[0].info().remote_uri))
+        self.__op_calls['hold'].append(self.calls[0])
+
         try:
             self.calls[0].hold()
         except pj.Error as err:
-            msg = ("Exception occurred while putting call on hold: '%s'" % str(err))
-            raise Exception(msg)
+            msg = ("Exception occurred while putting call on hold: '%s'" %
+                    str(err))
+            LOGGER.debug(msg)
+            raise
+        except:
+            self.remove_call_op_tracking(self.calls[0], operation='hold')
+            raise
+
+    def is_call_op_tracked(self, call, operation=None):
+        """Check if a call is being tracked for a specific operation
+
+        Keyword Arguments:
+        call Object of call.
+        operation String of call operation.
+
+        Returns True if found. False otherwise.
+        """
+        if operation is None:
+            raise Exception("No operation specified.")
+        return call in self.__op_calls[operation]
+
+    def remove_call_op_tracking(self, call, operation=None):
+        """Remove channel from operation tracking
+
+        Keyword Arguments:
+        call Object of call.
+        operation String of call operation.
+        """
+        if operation is None:
+            raise Exception("No operation specified.")
+        self.__op_calls[operation] = \
+                [x for x in self.__op_calls[operation] if x != call]
+        LOGGER.debug("Removed call from %s operation tracking." % operation)
 
 
 class AccCallback(pj.AccountCallback):
@@ -221,6 +272,13 @@
             except ValueError:
                 pass
 
+    def on_media_state(self):
+        """Callback for call media state changes."""
+        if self.call.info().media_state == pj.MediaState.LOCAL_HOLD:
+            self.phone.remove_call_op_tracking(self.call, operation='hold')
+        LOGGER.debug(fmt_call_info(self.call.info()))
+        LOGGER.debug("Call media state: %s" % self.call.info().media_state)
+
     def on_transfer_status(self, code, reason, final, cont):
         """Callback for the status of a previous call transfer request."""
         LOGGER.debug(fmt_call_info(self.call.info()))
@@ -238,6 +296,8 @@
             LOGGER.info("Transfer target answered the call.")
         else:
             LOGGER.warn("Transfer failed!")
+
+        self.phone.remove_call_op_tracking(self.call, operation='transfer')
 
         try:
             LOGGER.info("Hanging up '%s'" % self.call)
@@ -299,20 +359,73 @@
 
 def hold(test_object, triggered_by, ari, event, args):
     """Pluggable action module callback to place a call on hold"""
+    def __handle_error(reason):
+        """Callback handler for twisted deferred errors.
+
+        Handle twisted deferred errors. If it's due to a PJsua invalid
+        operation then retry the hold. Otherwise stop the reactor and raise the
+        error.
+
+        Keyword Arguments:
+        reason Instance of Failure for the reason of the error
+        """
+        if reason.check(pj.Error):
+            if "PJ_EINVALIDOP" in reason.value.err_msg():
+                __retry()
+            else:
+                test_object.stop_reactor()
+                raise Exception("Exception: '%s'" % str(reason))
+        else:
+            test_object.stop_reactor()
+            raise Exception("Exception: '%s'" % str(reason))
+
+    def __retry():
+        """Retry placing the call on hold.
+
+        Create a deferred to handle errors and schedule retry.
+        """
+        task.deferLater(reactor, .25,
+                        phone.hold_call).addErrback(__handle_error)
+
     controller = PjsuaPhoneController.get_instance()
     phone = controller.get_phone_obj(name=args['pjsua_account'])
     if len(phone.calls) < 1:
         msg = "'%s' must have 1 active call to put on hold!" % phone.name
         test_object.stop_reactor()
         raise Exception(msg)
+
+    if phone.calls[0].info().media_state == pj.MediaState.LOCAL_HOLD:
+        LOGGER.debug("Call is already on local hold. Ignoring...")
+        return
+
+    if phone.is_call_op_tracked(phone.calls[0], operation='hold'):
+        LOGGER.debug("Call hold operation is already in progress. Ignoring...")
+        return
+
     if phone.calls[0].info().state != pj.CallState.CONFIRMED:
         LOGGER.debug("Call is not fully established. Retrying hold shortly.")
         reactor.callLater(.25, hold, test_object, triggered_by, ari, event,
                           args)
         return
 
+    if phone.calls[0].info().media_state != pj.MediaState.ACTIVE:
+        LOGGER.debug("Call media is not active. Ignoring...")
+        return
+
+    # Try putting the call on hold. If a pj.Error exception is caught due to an
+    # invalid operation then schedule another attempt to put the call on hold.
+    # Even when the CallState is CONFIRMED and the MediaState is ACTIVE, the
+    # hold may fail due to a reinvite that is in progress.
     try:
         phone.hold_call()
+    except pj.Error as err:
+        if "PJ_EINVALIDOP" in err.err_msg():
+            # Create a deferred to handle errors and schedule another attempt.
+            task.deferLater(reactor, .25,
+                            phone.hold_call).addErrback(__handle_error)
+        else:
+            test_object.stop_reactor()
+            raise Exception("Exception: '%s'" % str(err))
     except:
         test_object.stop_reactor()
         raise Exception("Exception: '%s'" % str(sys.exc_info()))
@@ -327,26 +440,42 @@
     res = False
     msg = None
 
+    if phone.calls:
+        if phone.is_call_op_tracked(phone.calls[0], operation='transfer'):
+            LOGGER.debug("Call transfer operation is already in progress.")
+            return
+
     if transfer_type == "attended":
-        if len(phone.calls) == 2:
+        if len(phone.calls) != 2:
+            msg = "'%s' must have 2 active calls to transfer" % phone.name
+        elif (phone.calls[0].info().state != pj.CallState.CONFIRMED or
+              phone.calls[1].info().state != pj.CallState.CONFIRMED):
+            LOGGER.debug("Call is not fully established. Retrying transfer.")
+            reactor.callLater(.25, transfer, test_object, triggered_by, ari,
+                              event, args)
+            return
+        else:
             try:
                 phone.attended_transfer()
                 res = True
             except:
                 msg = "Exception: '%s'" % str(sys.exc_info())
-        else:
-            msg = "'%s' must have 2 active calls to transfer" % phone.name
     elif transfer_type == "blind":
         if transfer_uri is None:
             msg = "Transfer URI not found!"
-        elif len(phone.calls) == 1:
+        elif len(phone.calls) != 1:
+            msg = "'%s' must have 1 active call to transfer" % phone.name
+        elif (phone.calls[0].info().state != pj.CallState.CONFIRMED):
+            LOGGER.debug("Call is not fully established. Retrying transfer.")
+            reactor.callLater(.25, transfer, test_object, triggered_by, ari,
+                              event, args)
+            return
+        else:
             try:
                 phone.blind_transfer(transfer_uri)
                 res = True
             except:
                 msg = "Exception: '%s'" % str(sys.exc_info())
-        else:
-            msg = "'%s' must have 1 active call to transfer" % phone.name
     else:
         msg = "Unknown transfer type"
 
diff --git a/tests/channels/pjsip/transfers/attended_transfer/nominal/callee_local_direct_media/configs/ast1/extensions.conf b/tests/channels/pjsip/transfers/attended_transfer/nominal/callee_local_direct_media/configs/ast1/extensions.conf
new file mode 100644
index 0000000..066cc4a
--- /dev/null
+++ b/tests/channels/pjsip/transfers/attended_transfer/nominal/callee_local_direct_media/configs/ast1/extensions.conf
@@ -0,0 +1,5 @@
+[default]
+
+exten => 101,1,Dial(PJSIP/bob)
+exten => 102,1,Dial(PJSIP/charlie)
+
diff --git a/tests/channels/pjsip/transfers/attended_transfer/nominal/callee_local_direct_media/configs/ast1/pjsip.conf b/tests/channels/pjsip/transfers/attended_transfer/nominal/callee_local_direct_media/configs/ast1/pjsip.conf
new file mode 100644
index 0000000..01caf51
--- /dev/null
+++ b/tests/channels/pjsip/transfers/attended_transfer/nominal/callee_local_direct_media/configs/ast1/pjsip.conf
@@ -0,0 +1,47 @@
+[global]
+type=global
+debug=yes
+
+[system]
+type=system
+
+[local]
+type=transport
+protocol=udp
+bind=127.0.0.1:5060
+
+[alice]
+type=endpoint
+context=default
+disallow=all
+allow=ulaw
+direct_media=yes
+aors=alice
+
+[alice]
+type=aor
+max_contacts=1
+
+[bob]
+type=endpoint
+context=default
+disallow=all
+allow=ulaw
+direct_media=yes
+aors=bob
+
+[bob]
+type=aor
+max_contacts=1
+
+[charlie]
+type=endpoint
+context=default
+disallow=all
+allow=ulaw
+direct_media=yes
+aors=charlie
+
+[charlie]
+type=aor
+max_contacts=1
diff --git a/tests/channels/pjsip/transfers/attended_transfer/nominal/callee_local_direct_media/test-config.yaml b/tests/channels/pjsip/transfers/attended_transfer/nominal/callee_local_direct_media/test-config.yaml
new file mode 100644
index 0000000..898660f
--- /dev/null
+++ b/tests/channels/pjsip/transfers/attended_transfer/nominal/callee_local_direct_media/test-config.yaml
@@ -0,0 +1,251 @@
+testinfo:
+    summary: "Callee initiated attended transfer w/Replaces, direct media, hold"
+    description: |
+        "This verifies a callee initiated local attended transfer with
+         REFER/Replaces, hold, and direct media. This uses a specialized packet
+         sniffer module that generates AMI UserEvents that are then checked to
+         determine the result of this test.
+
+         Alice calls bob via extension '101' and bob answers. Upon alice and
+         bob being remotely bridged, bob places alice on hold. Upon alices's
+         remote bridge being torn down, bob places a second call to charlie.
+         Charlie answers and once bob's second call and charlie are remotely
+         bridged, bob transfers alice to charlie via an attended transfer.
+
+         This then checks some data from the REFER and NOTIFY sipfrag. It
+         also ensures MOH is stopped on alice and that alice and charlie are
+         remotely bridged. If a race condition occurs between pjsua putting a
+         call on hold and Asterisk setting up direct media, the resulting 491
+         Request Pending message is examined and hold is attempted again."
+
+test-modules:
+    add-relative-to-search-path: ['..']
+    test-object:
+        config-section: test-object-config
+        typename: 'test_case.TestCaseModule'
+    modules:
+        -
+            config-section: packet-listener
+            typename: 'packet_sniffer.Sniffer'
+        -
+            config-section: pluggable-config
+            typename: 'pluggable_modules.EventActionModule'
+        -
+            config-section: pjsua-config
+            typename: 'phones.PjsuaPhoneController'
+
+test-object-config:
+    connect-ami: True
+
+packet-listener:
+    register-observer: True
+    device: 'lo'
+    bpf-filter: 'udp and port 5060'
+    debug-packets: False
+
+pjsua-config:
+    transports:
+        -
+            name: 'local-ipv4-1'
+            bind: '127.0.0.1'
+            bindport: '5061'
+        -
+            name: 'local-ipv4-2'
+            bind: '127.0.0.1'
+            bindport: '5062'
+        -
+            name: 'local-ipv4-3'
+            bind: '127.0.0.1'
+            bindport: '5063'
+    accounts:
+        -
+            name: 'alice'
+            username: 'alice'
+            domain: '127.0.0.1'
+            transport: 'local-ipv4-1'
+        -
+            name: 'bob'
+            username: 'bob'
+            domain: '127.0.0.1'
+            transport: 'local-ipv4-2'
+        -
+            name: 'charlie'
+            username: 'charlie'
+            domain: '127.0.0.1'
+            transport: 'local-ipv4-3'
+
+pluggable-config:
+    # Ensure our pjsua phones are ready. Then alice calls bob via exten 101.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'PJsuaPhonesReady'
+            count: 1
+        pjsua_phone:
+            action: 'call'
+            pjsua_account: 'alice'
+            call_uri: 'sip:101 at 127.0.0.1'
+    # Ensure alice and bob are remotely bridged. Then bob places alice on hold.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'RemoteRTPBridgeStarted'
+                    Endpoint1: '(bob|alice)'
+                    Endpoint2: '(bob|alice)'
+            count: 1
+        pjsua_phone:
+            action: 'hold'
+            pjsua_account: 'bob'
+    # If this is received then there was a race condition with our hold and a
+    # reinvite. Therefore try hold again but use override method.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: '491RequestPending'
+            count: <3
+        callback:
+            module: packet_sniffer
+            method: hold
+    # Ensure MOH starts on alice.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'MusicOnHoldStart'
+                    Channel: 'PJSIP/alice-.*'
+            count: 1
+    # Ensure remote bridge is torn down for alice since she's on hold. Then bob
+    # makes a call to charlie.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'RemoteRTPBridgeStopped'
+                    Endpoint1: 'alice'
+            count: 1
+        pjsua_phone:
+            action: 'call'
+            pjsua_account: 'bob'
+            call_uri: 'sip:102 at 127.0.0.1'
+    # Ensure bob and charlie are remotely bridged. Then bob transfers alice
+    # to charlie via an attended transfer.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'RemoteRTPBridgeStarted'
+                    Endpoint1: '(charlie|bob)'
+                    Endpoint2: '(charlie|bob)'
+            count: 1
+        pjsua_phone:
+            action: 'transfer'
+            pjsua_account: 'bob'
+            transfer_type: 'attended'
+    # Ensure the REFER contains correct info
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'ReferInfo'
+                    ReferTo: '.*102 at 127.0.0.1.*Replaces=.*'
+                    ReferredBy: '.*sip:bob at 127.0.0.1.*'
+            count: 1
+    # Ensure MOH stops on alice's channel.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'MusicOnHoldStop'
+                    Channel: 'PJSIP/alice-.*'
+            count: 1
+    # Ensure the transfer is successful.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'AttendedTransfer'
+                    OrigTransfererChannel: 'PJSIP/bob-.*'
+                    SecondTransfererChannel: 'PJSIP/bob-.*'
+                    TransfereeChannel: 'PJSIP/alice-.*'
+                    TransferTargetChannel: 'PJSIP/charlie-.*'
+                    Result: 'Success'
+            count: 1
+    # Ensure a 200 OK sipfrag NOTIFY occurred.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'NotifySIPFrag'
+                    NotifyBody: 'SIP/2.0 200 OK'
+            count: 1
+    # Ensure each bob channel leaves the bridge it was in.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'BridgeLeave'
+                    Channel: 'PJSIP/bob-.*'
+            count: 2
+    # Ensure bob's second call to charlie is hung up.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'Hangup'
+                    Channel: 'PJSIP/bob-.*'
+                    Exten: '102'
+            count: 1
+    # Ensure bob's first call to alice is hung up.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'Hangup'
+                    Channel: 'PJSIP/bob-.*'
+                nomatch:
+                    Exten: '102'
+            count: 1
+    # The remote bridge for charlie's call may or may not be torn down due to
+    # how fast things occur.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'RemoteRTPBridgeStopped'
+                    Endpoint1: 'charlie'
+            count: <1
+    # The remote bridge may be setup twice depending on how fast things occur.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'RemoteRTPBridgeStarted'
+                    Endpoint1: '(charlie|alice)'
+                    Endpoint2: '(charlie|alice)'
+            count: <2
+        stop_test:
+
+properties:
+    minversion: '13.0.0'
+    dependencies:
+        - python : twisted
+        - python : starpy
+        - python : yappcap
+        - python : pyxb
+        - python : pjsua
+        - asterisk : res_pjsip
+    tags:
+        - pjsip
+
diff --git a/tests/channels/pjsip/transfers/attended_transfer/nominal/caller_local_direct_media/configs/ast1/extensions.conf b/tests/channels/pjsip/transfers/attended_transfer/nominal/caller_local_direct_media/configs/ast1/extensions.conf
new file mode 100644
index 0000000..066cc4a
--- /dev/null
+++ b/tests/channels/pjsip/transfers/attended_transfer/nominal/caller_local_direct_media/configs/ast1/extensions.conf
@@ -0,0 +1,5 @@
+[default]
+
+exten => 101,1,Dial(PJSIP/bob)
+exten => 102,1,Dial(PJSIP/charlie)
+
diff --git a/tests/channels/pjsip/transfers/attended_transfer/nominal/caller_local_direct_media/configs/ast1/pjsip.conf b/tests/channels/pjsip/transfers/attended_transfer/nominal/caller_local_direct_media/configs/ast1/pjsip.conf
new file mode 100644
index 0000000..01caf51
--- /dev/null
+++ b/tests/channels/pjsip/transfers/attended_transfer/nominal/caller_local_direct_media/configs/ast1/pjsip.conf
@@ -0,0 +1,47 @@
+[global]
+type=global
+debug=yes
+
+[system]
+type=system
+
+[local]
+type=transport
+protocol=udp
+bind=127.0.0.1:5060
+
+[alice]
+type=endpoint
+context=default
+disallow=all
+allow=ulaw
+direct_media=yes
+aors=alice
+
+[alice]
+type=aor
+max_contacts=1
+
+[bob]
+type=endpoint
+context=default
+disallow=all
+allow=ulaw
+direct_media=yes
+aors=bob
+
+[bob]
+type=aor
+max_contacts=1
+
+[charlie]
+type=endpoint
+context=default
+disallow=all
+allow=ulaw
+direct_media=yes
+aors=charlie
+
+[charlie]
+type=aor
+max_contacts=1
diff --git a/tests/channels/pjsip/transfers/attended_transfer/nominal/caller_local_direct_media/test-config.yaml b/tests/channels/pjsip/transfers/attended_transfer/nominal/caller_local_direct_media/test-config.yaml
new file mode 100644
index 0000000..ccc971b
--- /dev/null
+++ b/tests/channels/pjsip/transfers/attended_transfer/nominal/caller_local_direct_media/test-config.yaml
@@ -0,0 +1,250 @@
+testinfo:
+    summary: "Caller initiated attended transfer w/Replaces, direct media, hold"
+    description: |
+        "This verifies a caller initiated local attended transfer with
+         REFER/Replaces, hold, and direct media. This uses a specialized packet
+         sniffer module that generates AMI UserEvents that are then checked to
+         determine the result of this test.
+
+         Alice calls bob via extension '101' and bob answers. Upon alice and
+         bob being remotely bridged, alice places bob on hold. Upon bob's
+         remote bridge being torn down, alice places a second call to charlie.
+         Charlie answers and once alice's second call and charlie are remotely
+         bridged, alice transfers bob to charlie via an attended transfer.
+
+         This then checks some data from the REFER and NOTIFY sipfrag. It
+         also ensures MOH is stopped on bob and that bob and charlie are
+         remotely bridged. If a race condition occurs between pjsua putting a
+         call on hold and Asterisk setting up direct media, the resulting 491
+         Request Pending message is examined and hold is attempted again."
+
+test-modules:
+    add-relative-to-search-path: ['..']
+    test-object:
+        config-section: test-object-config
+        typename: 'test_case.TestCaseModule'
+    modules:
+        -
+            config-section: packet-listener
+            typename: 'packet_sniffer.Sniffer'
+        -
+            config-section: pluggable-config
+            typename: 'pluggable_modules.EventActionModule'
+        -
+            config-section: pjsua-config
+            typename: 'phones.PjsuaPhoneController'
+
+test-object-config:
+    connect-ami: True
+
+packet-listener:
+    register-observer: True
+    device: 'lo'
+    bpf-filter: 'udp and port 5060'
+    debug-packets: False
+
+pjsua-config:
+    transports:
+        -
+            name: 'local-ipv4-1'
+            bind: '127.0.0.1'
+            bindport: '5061'
+        -
+            name: 'local-ipv4-2'
+            bind: '127.0.0.1'
+            bindport: '5062'
+        -
+            name: 'local-ipv4-3'
+            bind: '127.0.0.1'
+            bindport: '5063'
+    accounts:
+        -
+            name: 'alice'
+            username: 'alice'
+            domain: '127.0.0.1'
+            transport: 'local-ipv4-1'
+        -
+            name: 'bob'
+            username: 'bob'
+            domain: '127.0.0.1'
+            transport: 'local-ipv4-2'
+        -
+            name: 'charlie'
+            username: 'charlie'
+            domain: '127.0.0.1'
+            transport: 'local-ipv4-3'
+
+pluggable-config:
+    # Ensure our pjsua phones are ready. Then alice calls bob via exten 101.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'PJsuaPhonesReady'
+            count: 1
+        pjsua_phone:
+            action: 'call'
+            pjsua_account: 'alice'
+            call_uri: 'sip:101 at 127.0.0.1'
+    # Ensure alice and bob are remotely bridged. Then alice places bob on hold.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'RemoteRTPBridgeStarted'
+                    Endpoint1: '(bob|alice)'
+                    Endpoint2: '(bob|alice)'
+            count: 1
+        pjsua_phone:
+            action: 'hold'
+            pjsua_account: 'alice'
+    # If this is received then there was a race condition with our hold and a
+    # reinvite. Therefore try hold again but use override method.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: '491RequestPending'
+            count: <3
+        callback:
+            module: packet_sniffer
+            method: hold
+    # Ensure MOH starts on bob.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'MusicOnHoldStart'
+                    Channel: 'PJSIP/bob-.*'
+            count: 1
+    # Ensure remote bridge is torn down for bob since he's on hold. Then alice
+    # makes a call to charlie.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'RemoteRTPBridgeStopped'
+                    Endpoint1: 'bob'
+            count: 1
+        pjsua_phone:
+            action: 'call'
+            pjsua_account: 'alice'
+            call_uri: 'sip:102 at 127.0.0.1'
+    # Ensure alice and charlie are remotely bridged. Then alice transfers bob
+    # to charlie via an attended transfer.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'RemoteRTPBridgeStarted'
+                    Endpoint1: '(charlie|alice)'
+                    Endpoint2: '(charlie|alice)'
+            count: 1
+        pjsua_phone:
+            action: 'transfer'
+            pjsua_account: 'alice'
+            transfer_type: 'attended'
+    # Ensure the REFER contains correct info
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'ReferInfo'
+                    ReferTo: '.*102 at 127.0.0.1.*Replaces=.*'
+                    ReferredBy: '.*sip:alice at 127.0.0.1.*'
+            count: 1
+    # Ensure MOH stops on bob's channel.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'MusicOnHoldStop'
+                    Channel: 'PJSIP/bob-.*'
+            count: 1
+    # Ensure the transfer is successful.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'AttendedTransfer'
+                    OrigTransfererChannel: 'PJSIP/alice-.*'
+                    SecondTransfererChannel: 'PJSIP/alice-.*'
+                    TransfereeChannel: 'PJSIP/bob-.*'
+                    TransferTargetChannel: 'PJSIP/charlie-.*'
+                    Result: 'Success'
+            count: 1
+    # Ensure a 200 OK sipfrag NOTIFY occurred.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'NotifySIPFrag'
+                    NotifyBody: 'SIP/2.0 200 OK'
+            count: 1
+    # Ensure each alice channel leaves the bridge it was in.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'BridgeLeave'
+                    Channel: 'PJSIP/alice-.*'
+            count: 2
+    # Ensure alice's second call to charlie is hung up.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'Hangup'
+                    Channel: 'PJSIP/alice-.*'
+                    Exten: '102'
+            count: 1
+    # Ensure alice's first call to bob is hung up.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'Hangup'
+                    Channel: 'PJSIP/alice-.*'
+                    Exten: '101'
+            count: 1
+    # The remote bridge for charlie's call may or may not be torn down due to
+    # how fast things occur.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'RemoteRTPBridgeStopped'
+                    Endpoint1: 'charlie'
+            count: <1
+    # The remote bridge may be setup twice depending on how fast things occur.
+    -
+        ami-events:
+            conditions:
+                match:
+                    Event: 'UserEvent'
+                    UserEvent: 'RemoteRTPBridgeStarted'
+                    Endpoint1: '(charlie|bob)'
+                    Endpoint2: '(charlie|bob)'
+            count: <2
+        stop_test:
+
+properties:
+    minversion: '13.0.0'
+    dependencies:
+        - python : twisted
+        - python : starpy
+        - python : yappcap
+        - python : pyxb
+        - python : pjsua
+        - asterisk : res_pjsip
+    tags:
+        - pjsip
+
diff --git a/tests/channels/pjsip/transfers/attended_transfer/nominal/packet_sniffer.py b/tests/channels/pjsip/transfers/attended_transfer/nominal/packet_sniffer.py
new file mode 100755
index 0000000..a96a661
--- /dev/null
+++ b/tests/channels/pjsip/transfers/attended_transfer/nominal/packet_sniffer.py
@@ -0,0 +1,456 @@
+#!/usr/bin/env python
+"""Pluggable module that generates AMI UserEvents based on packets sniffed.
+
+This will generate AMI UserEvents based on specific SIP packets that have been
+sniffed. Some SIP messages are used to determine if a call leg gets remotely
+bridged or if a remote bridge is torn down. It determines this by examining
+packets containing INVITE and 200 OK messages, using call states, and SIP/RTP
+ports for a specific scenario.
+
+An AMI UserEvent is generated when:
+* it's been determined that a remote bridge is setup on two call legs
+* it's been determined that a remote bridge is torn down on a call leg
+* a sipfrag NOTIFY is sniffed
+* a REFER is sniffed
+* a 491 Request Pending is sniffed
+
+This is for a specific scenario of nominal local attended transfers where
+direct media, hold, and REFER w/Replaces is used. It may also be specific to
+only work when using pjsua clients and chan_pjsip.
+
+This does not set a pass/fail result. The UserEvents are used by other modules
+to set the test's result.
+
+Copyright (C) 2015, Digium, Inc.
+John Bigelow <jbigelow at digium.com>
+
+This program is free software, distributed under the terms of
+the GNU General Public License Version 2.
+"""
+
+import sys
+import logging
+from twisted.internet import reactor, task
+import pjsua as pj
+
+sys.path.append('lib/python')
+
+from phones import PjsuaPhoneController
+from pcap import VOIPListener
+
+LOGGER = logging.getLogger(__name__)
+
+
+class Sniffer(VOIPListener):
+    """Pluggable module class derived from pcap.VOIPListener.
+
+    This examines SIP packets for INVITE, 200 OK, REFER, NOTIFY, and 491
+    Request Pending messages. It determines if a remote RTP bridge has been
+    setup or torn down for call leg(s). When a determination is made it will
+    generate an AMI UserEvent indicating what was determined. It also generates
+    an AMI UserEvent when a REFER, NOTIFY sipfrag, and a 491 Request
+    Pending are found.
+    """
+    def __init__(self, module_config, test_object):
+        """Create listener, add callback, and add AMI observer.
+
+        Keyword Arguments:
+        module_config Dict of module configuration
+        test_object Test object
+        """
+        self.set_pcap_defaults(module_config)
+        VOIPListener.__init__(self, module_config, test_object)
+
+        self.test_object = test_object
+        self.ami = None
+        self.test_object.register_ami_observer(self.ami_connect)
+        self.add_callback('SIP', self.__check_sip_packet)
+        self.calls = {'alice': [], 'bob': [], 'charlie': []}
+
+    def set_pcap_defaults(self, module_config):
+        """Set default PcapListener config that isn't explicitly overridden.
+
+        Keyword Arguments:
+        module_config Dict of module configuration
+        """
+        pcap_defaults = {'device': 'lo', 'snaplen': 2000,
+                         'bpf-filter': 'udp port 5060', 'debug-packets': True,
+                         'buffer-size': 4194304, 'register-observer': True}
+        for name, value in pcap_defaults.items():
+            module_config[name] = module_config.get(name, value)
+
+    def ami_connect(self, ami):
+        """Callback when AMI connects. Sets AMI instance.
+
+        Keyword Arguments:
+        ami AMI instance
+        """
+        self.ami = ami
+
+    def __check_sip_packet(self, packet):
+        """Callback function for when we have a SIP packet
+
+        Check if SIP packet is an INVITE or 200 OK message.
+
+        Keyword Arguments:
+        packet A SIP packet
+        """
+        if (('INVITE' in packet.request_line or
+             '200 OK' in packet.request_line or
+             'NOTIFY' in packet.request_line) and not packet.body):
+            return
+
+        if 'INVITE' in packet.request_line:
+            self.check_invite(packet)
+        elif '200 OK' in packet.request_line:
+            self.check_200(packet)
+        elif 'REFER' in packet.request_line:
+            self.check_refer(packet)
+        elif 'NOTIFY' in packet.request_line:
+            self.check_notify(packet)
+        elif '491 Request Pending' in packet.request_line:
+            self.hold_reinvite_race(packet)
+
+    def get_rtp_data(self, packet):
+        """Get the RTP port associated with this packet
+
+        Keyword Arguments:
+        packet A SIP packet
+
+        Returns:
+        Int of RTP port if found. Otherwise None.
+        """
+        src_ip = packet.ip_layer.header.source
+        sdp_ports = self.packet_factory.get_global_data(src_ip)
+        return sdp_ports.get('rtp')
+
+    def add_call(self, packet):
+        """Track a new call and set the initial state
+
+        Must ensure call is not already being tracked before calling this and
+        should only be called when an INVITE is found.
+
+        Keyword Arguments:
+        packet A SIP packet
+        """
+        callid = packet.headers['Call-ID']
+        src_port = str(packet.transport_layer.header.source)
+        dst_port = str(packet.transport_layer.header.destination)
+        # We know the ports our pjsua instances are using.
+        if '5061' in [src_port, dst_port]:
+            self.calls['alice'].append({callid: "CONNECTING"})
+        elif '5062' in [src_port, dst_port]:
+            self.calls['bob'].append({callid: "CONNECTING"})
+        elif '5063' in [src_port, dst_port]:
+            self.calls['charlie'].append({callid: "CONNECTING"})
+        else:
+            LOGGER.warn("Unexpected SIP port.")
+            return
+
+        LOGGER.debug("{0}: {1}".format(callid, self.get_call_state(callid)))
+
+    def update_call_state(self, callid, newstate):
+        """Update the state of a call
+
+        Keyword Arguments:
+        callid String of the call-id to update
+        newstate String of the state to set for the call
+        """
+        location = self.find_call(callid)
+        if location is None:
+            LOGGER.warn("Call not found. Unable to update call state.")
+            return
+        self.calls[location[0]][location[1]][callid] = newstate
+        LOGGER.debug("{0}: {1}".format(callid, newstate))
+
+    def find_call(self, callid):
+        """Find the endpoint and index location of the dict for the callid
+
+        This searches self.calls for the callid.
+
+        Keyword Arguments:
+        callid String of the call-id to search for
+
+        Returns:
+        Tuple containing the key name of self.calls and the index location of
+        list where the callid is located. Otherwise None.
+        """
+        for endpoint, endpoint_calls in self.calls.items():
+            gen = (idx for (idx, call) in enumerate(endpoint_calls)
+                   if callid in call)
+            call_index = next(gen, None)
+            if call_index is not None:
+                return (endpoint, call_index)
+        return None
+
+    def get_call_state(self, callid):
+        """Get the state of a call
+
+        Keyword Arguments:
+        callid String of the call-id to search for
+
+        Returns:
+        String of call state if found. Otherwise None.
+        """
+        location = self.find_call(callid)
+        if location is not None:
+            return self.calls[location[0]][location[1]][callid]
+        return None
+
+    def check_invite(self, packet):
+        """Check INVITE to determine and set the state of the call.
+
+        Keyword Arguments:
+        packet A SIP packet
+        """
+        callid = packet.headers['Call-ID']
+
+        # If call is not known about, start tracking it.
+        if self.find_call(callid) is None:
+            self.add_call(packet)
+            return
+
+        # Call is already known
+        if self.is_hold(packet):
+            return
+
+        if packet.transport_layer.header.source != 5060:
+            # This shouldn't be hit since an INVITE from an endpoint is either
+            # a new call or for hold.
+            LOGGER.warn("Unexpected INVITE found: {0}".format(callid))
+            return
+
+        # We have an INVITE of an already known call from Asterisk (we know
+        # it's using port 5060).
+        if self.get_rtp_data(packet) > 10000:
+            # The SDP has an RTP port within Asterisk's range (we know it uses
+            # 10000-20000).
+            if self.get_call_state(callid) == "RTP_RB_STARTED":
+                # If the call is already in a remote RTP bridge, then this
+                # INVITE means the remote RTP bridge is being torn down.
+                self.update_call_state(callid, "RTP_RB_STOPPING")
+            elif self.get_call_state(callid) == "HOLD_STARTED":
+                # If the call is already holding (the one that had sent the
+                # re-INVITE restricting media) then this INVITE is due to the
+                # remote RTP bridge being torn down.
+                self.update_call_state(callid, "RTP_RB_STOPPING")
+        else:
+            # The SDP has an RTP port not within Asterisk's range so Asterisk
+            # must be initiating a remote RTP bridge.
+            self.update_call_state(callid, "RTP_RB_STARTING")
+
+    def check_200(self, packet):
+        """Check 200 OK to determine and set the state of the call.
+
+        This will then send an AMI UserEvent if two call legs are remotely
+        bridged of a call leg is no longer remotely bridged.
+
+        Keyword Arguments:
+        packet A SIP packet
+        """
+        callid = packet.headers['Call-ID']
+
+        if self.find_call(callid) is None:
+            self.add_call(packet)
+            return
+
+        if packet.transport_layer.header.source == 5060:
+            # We have a 200 OK of an already known call from Asterisk.
+            if (not self.is_hold(packet) and
+                    self.get_call_state(callid) == "CONNECTING"):
+                # It's not due to a hold and the call is connecting. So this
+                # 200 OK means it's being answered.
+                self.update_call_state(callid, "CONNECTED")
+            return
+
+        # We have a 200 OK of an already known call from a pjsua endpoint.
+        if self.get_call_state(callid) == "CONNECTING":
+            # If the call is connecting then this 200 OK means it's being
+            # answered.
+            self.update_call_state(callid, "CONNECTED")
+        elif self.get_call_state(callid) == "RTP_RB_STARTING":
+            # If the call is already in the process of being remotely
+            # bridged, then this 200 OK means the call now is.
+            self.update_call_state(callid, "RTP_RB_STARTED")
+
+            # Find all endpoints that have at least 1 call that is remotely
+            # bridged.
+            reinvited_endpoints = []
+            for endpoint, endpoint_calls in self.calls.items():
+                lst = [c for c in endpoint_calls
+                       if 'RTP_RB_STARTED' in c.values()]
+                if lst:
+                    reinvited_endpoints.append(endpoint)
+            LOGGER.debug("Endpoints in remote RTP bridge: {0}"
+                         .format(reinvited_endpoints))
+
+            # Only send event if two are in a remote RTP bridge. Even though
+            # it's not known if these are up with each other, we assume they
+            # are since it's a specific scenario.
+            if len(reinvited_endpoints) == 2:
+                event_data = {}
+                # Build dict for the UserEvent.
+                for num, endpoint in enumerate(reinvited_endpoints):
+                    k = "Endpoint{0}".format(num + 1)
+                    event_data[k] = endpoint
+                self.send_user_event('RemoteRTPBridgeStarted', event_data)
+        elif self.get_call_state(callid) == "RTP_RB_STOPPING":
+            # If the call is already in the process of having a remote RTP
+            # bridge being torn down, then this 200 OK means it now is.
+            self.update_call_state(callid, "RTP_RB_STOPPED")
+            event_data = {}
+            event_data['Endpoint1'] = self.find_call(callid)[0]
+            # Send event for each.
+            self.send_user_event('RemoteRTPBridgeStopped', event_data)
+
+    def check_refer(self, packet):
+        """Get header values from REFER and send an AMI UserEvent.
+
+        This will send an AMI UserEvent with the values of the Refer-To &
+        Referred-By headers.
+
+        Keyword Arguments:
+        packet A SIP packet
+        """
+        event_data = {}
+        event_data['ReferTo'] = packet.headers.get('Refer-To')
+        event_data['ReferredBy'] = packet.headers.get('Referred-By')
+        self.send_user_event('ReferInfo', event_data)
+
+    def check_notify(self, packet):
+        """Check NOTIFY for sipfrag refer event.
+
+        This will then parse a sipfrag NOTIFY and send an AMI UserEvent
+        containing the body of the message.
+
+        Keyword Arguments:
+        packet A SIP packet
+        """
+        event_data = {}
+        callid = packet.headers['Call-ID']
+
+        if self.find_call(callid) is None:
+            return
+        if packet.body.packet_type != "message/sipfrag":
+            return
+        if "refer" not in packet.headers['Event']:
+            return
+        if packet.transport_layer.header.source != 5060:
+            return
+
+        ascii_pkt = packet.ascii_packet
+        last_pos = ascii_pkt.find('\r\n', ascii_pkt.find('Content-Length'))
+        event_data['NotifyBody'] = packet.ascii_packet[last_pos:].strip()
+        self.send_user_event('NotifySIPFrag', event_data)
+
+    def is_hold(self, packet):
+        """If INVITE or 200 OK message then check if it corresponds to a hold
+
+        Keyword Arguments:
+        packet A SIP packet
+
+        Returns:
+        True if it corresponds to a hold. False otherwise.
+        """
+        callid = packet.headers['Call-ID']
+        for line in packet.body.sdp_lines:
+            if 'INVITE' in packet.request_line:
+                if 'a=sendonly' in line:
+                    self.update_call_state(callid, "HOLD_STARTING")
+                    return True
+            elif '200 OK' in packet.request_line:
+                if 'a=recvonly' in line:
+                    self.update_call_state(callid, "HOLD_STARTED")
+                    return True
+        return False
+
+    def hold_reinvite_race(self, packet):
+        """Get endpoint from From header and generate UserEvent.
+
+        The previous hold attempt was not actually successfull as a 491 SIP
+        message was found. Generate a UserEvent with info.
+
+        Keyword Arguments:
+        packet A SIP packet
+        """
+        LOGGER.debug("Race between hold and reinvite found.")
+        for endpoint in self.calls.keys():
+            if endpoint in packet.headers['From']:
+                break
+        self.send_user_event('491RequestPending', {'PjsuaAccount': endpoint})
+
+    def send_user_event(self, user_event, event_data):
+        """Send AMI UserEvent
+
+        Keyword Arguments:
+        user_event String of user event name
+        event_data Dict containing event headers and values
+        """
+        LOGGER.debug("Sending UserEvent '{0}'".format(user_event))
+        self.ami.userEvent(user_event, **event_data)
+
+
+def hold(test_object, triggered_by, source, event):
+    """Override of phones.hold()
+
+    The phones.hold() method checks if a hold is already in progress or not.
+    When a 491 occurs the hold will remain in progress as pjsua doesn't raise
+    an exception or change any state. This allows bypassing the checks so a
+    hold can be attempted again.
+
+    Keyword Arguments:
+    test_object Test object
+    triggered_by Object that triggered this callback
+    source AMI instance
+    event Dictionary of AMI event causing the trigger
+
+    Returns:
+    True
+    """
+    def __handle_error(reason):
+        """Callback handler for twisted deferred errors.
+
+        Handle twisted deferred errors. If it's due to a PJsua invalid
+        operation then retry the hold. Otherwise stop the reactor and raise the
+        error.
+
+        Keyword Arguments:
+        reason Instance of Failure for the reason of the error
+        """
+        if reason.check(pj.Error):
+            if "PJ_EINVALIDOP" in reason.value.err_msg():
+                __retry()
+            else:
+                test_object.stop_reactor()
+                raise Exception("Exception: '{0}'".format(str(reason)))
+        else:
+            test_object.stop_reactor()
+            raise Exception("Exception: '{0}'".format(str(reason)))
+
+    def __retry():
+        """Retry placing the call on hold.
+
+        Create a deferred to handle errors and schedule retry.
+        """
+        task.deferLater(reactor, .25,
+                        phone.hold_call).addErrback(__handle_error)
+
+    controller = PjsuaPhoneController.get_instance()
+    phone = controller.get_phone_obj(name=event['pjsuaaccount'])
+
+    try:
+        phone.hold_call()
+    except pj.Error as err:
+        if "PJ_EINVALIDOP" in err.err_msg():
+            # Create a deferred to handle errors and schedule to try placing
+            # the call on hold again.
+            task.deferLater(reactor, .25,
+                            phone.hold_call).addErrback(__handle_error)
+        else:
+            test_object.stop_reactor()
+            raise Exception("Exception: '{0}'".format(str(err)))
+    except:
+        test_object.stop_reactor()
+        raise Exception("Exception: '{0}'".format(str(sys.exc_info())))
+
+    return True
diff --git a/tests/channels/pjsip/transfers/attended_transfer/nominal/tests.yaml b/tests/channels/pjsip/transfers/attended_transfer/nominal/tests.yaml
index 0fed3be..d46199b 100644
--- a/tests/channels/pjsip/transfers/attended_transfer/nominal/tests.yaml
+++ b/tests/channels/pjsip/transfers/attended_transfer/nominal/tests.yaml
@@ -5,3 +5,5 @@
     - test: 'callee_remote'
     - test: 'caller_local_app'
     - test: 'callee_local_app'
+    - test: 'caller_local_direct_media'
+    - test: 'callee_local_direct_media'

-- 
To view, visit https://gerrit.asterisk.org/483
To unsubscribe, visit https://gerrit.asterisk.org/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: Ibd1f9a3ea8bf6b80fe95efe3fcd880a55d5a7cae
Gerrit-PatchSet: 4
Gerrit-Project: testsuite
Gerrit-Branch: master
Gerrit-Owner: John Bigelow <jbigelow at digium.com>
Gerrit-Reviewer: Ashley Sanders <asanders at digium.com>
Gerrit-Reviewer: John Bigelow <jbigelow at digium.com>
Gerrit-Reviewer: Joshua Colp <jcolp at digium.com>
Gerrit-Reviewer: Samuel Galarneau <sgalarneau at digium.com>



More information about the asterisk-commits mailing list