diff --git a/aioxmpp/presence/service.py b/aioxmpp/presence/service.py index eba30443..aa4d4beb 100644 --- a/aioxmpp/presence/service.py +++ b/aioxmpp/presence/service.py @@ -236,6 +236,183 @@ def handle_presence(self, st): self.on_changed(st.from_, st) +class DirectedPresenceHandle: + """ + Represent a directed presence relationship with a peer. + + Directed Presence is specified in :rfc:`6121` section 4.6. Since the users + server is not responsible for distributing presence updates to peers to + which the client has sent directed presence, special handling is needed. + (The only presence automatically sent by a client’s server to a peer which + has received directed presence is the + :attr:`~aioxmpp.PresenceType.UNAVAILABLE` presence which is created when + the client disconnects.) + + .. note:: + + Directed presence relationships get + :meth:`unsubscribed ` immediately when the stream is + destroyed. This is because the peer has received + :attr:`~aioxmpp.PresenceType.UNAVAILABLE` presence from the client’s + server. + + .. autoattribute:: address + + .. autoattribute:: muted + + .. autoattribute:: presence_filter + + .. automethod:: set_muted + + .. automethod:: unsubscribe + + .. automethod:: send_presence + """ + + def __init__(self, service: "PresenceServer", peer: aioxmpp.JID, + muted: bool = False): + super().__init__() + self._address = peer + self._service = service + self._muted = muted + self._presence_filter = None + self._unsubscribed = False + + def _require_subscribed(self): + if self._unsubscribed: + raise RuntimeError("directed presence relationship is unsubscribed") + + @property + def address(self) -> aioxmpp.JID: + """ + The address of the peer. This attribute is read-only. + + To change the address of a peer, + :meth:`aioxmpp.PresenceServer.rebind_directed_presence` can be used. + """ + return self._address + + @property + def muted(self) -> bool: + """ + Flag to indicate whether the directed presence relationship is *muted*. + + If the relationship is **not** muted, presence updates made through the + :class:`PresenceServer` will be unicast to the peer entity of the + relationship. + + For a muted relationships, presence updates will *not* be automatically + sent to the peer. + + The *muted* behaviour is useful if presence updates need to be managed + by a service for some reason. + + This attribute is read-only. It must be modified through + :meth:`set_muted`. + """ + return self._muted + + @property + def presence_filter(self): + """ + Optional callback which is invoked on the presence stanza before it is + sent. + + This is called whenever a presence stanza is sent for this relationship + by the :class:`PresenceServer`. This is not invoked for presence stanzas + sent to the peer by other means (e.g. :meth:`aioxmpp.Client.send`). + + If the :attr:`presence_filter` is not :data:`None`, it is called with + the presence stanza as its only argument. It must either return the + presence stanza, or :data:`None`. If it returns :data:`None`, the + presence stanza is not sent. + + The callback operates on a copy of the presence stanza to prevent + modifications from leaking into other presence relationships; making a + copy inside the callback is not required or recommended. + """ + return self._presence_filter + + @presence_filter.setter + def presence_filter(self, new_callback): + self._presence_filter = new_callback + + def set_muted(self, muted: bool, *, send_update_now: bool = True): + """ + Change the :attr:`muted` state of the relationship. + + (This is not a setter to the property due to the additional options + which are available when changing the :attr:`muted` state.) + + :param muted: The new muted state. + :type muted: :class:`bool` + :param send_update_now: Whether to send a presence update to the peer + immediately. + :type send_update_now: :class:`bool` + :raises RuntimeError: if the presence relationship has been destroyed + with :meth:`unsubscribe` + + If `muted` is equal to :attr:`muted`, this method does nothing. + + If `muted` is :data:`True`, the presence relationship will be muted. + `send_update_now` is ignored. + + If `muted` is :data:`False`, the presence relationship will be unmuted. + If `send_update_now` is :data:`True`, the current presence is sent to + the peer immediately. + """ + self._require_subscribed() + + muted = bool(muted) + if muted == self._muted: + return + + self._muted = muted + self._service._emit_presence_directed(self) + + def unsubscribe(self): + """ + Destroy the directed presence relationship. + + The presence relationship becomes useless afterwards. Any additional + calls to the methods will result in :class:`RuntimeError`. No additional + updates wil be sent to the peer automatically (except, of course, if + a new relationship is created). + + If the presence relationship is still active when the method is called, + :attr:`~aioxmpp.PresenceType.UNAVAILABLE` presence is sent to the peer + immediately. Otherwise, no stanza is sent. The stanza is passed through + the :attr:`presence_filter`. + + .. note:: + + Directed presence relationships get unsubscribed immediately when + the stream is destroyed. This is because the peer has received + :attr:`~aioxmpp.PresenceType.UNAVAILABLE` presence from the client’s + server. + + This operation is idempotent. + """ + if self._unsubscribed: + return + + self._service._unsubscribe_peer_directed(self) + self._unsubscribed = True + + def resend_presence(self): + """ + Resend the current presence to the directed peer. + + :raises RuntimeError: if the presence relationship has been destroyed + with :meth:`unsubscribe` + + This forces a presence send, even if the relationship is muted. The + :attr:`presence_filter` is invoked as normal. + """ + self._require_subscribed() + self._service._emit_presence_directed(self) + + class PresenceServer(aioxmpp.service.Service): """ Manage the presence broadcast by the client. @@ -293,6 +470,7 @@ def __init__(self, client, **kwargs): self._state = aioxmpp.PresenceState(False) self._status = {} self._priority = 0 + self._directed_sessions = {} client.before_stream_established.connect( self._before_stream_established @@ -422,6 +600,15 @@ def set_presence(self, state, status={}, priority=0): self.on_presence_changed() return self.resend_presence() + def _emit_presence_directed(self, session): + st = self.make_stanza() + st.to = session.address + if session.presence_filter is not None: + st = session.presence_filter(st) + if st is None: + return + self.client.enqueue(st) + def resend_presence(self): """ Re-send the currently configured presence. @@ -430,11 +617,164 @@ def resend_presence(self): stream is not established. :rtype: :class:`~.stream.StanzaToken` + This will also emit all non-muted directed presences. + .. note:: :meth:`set_presence` automatically broadcasts the new presence if any of the parameters changed. """ - if self.client.established: - return self.client.enqueue(self.make_stanza()) + if not self.client.established: + return + + main_token = self.client.enqueue(self.make_stanza()) + + for sessions in self._directed_sessions.values(): + for session in sessions.values(): + if session.muted: + continue + self._emit_presence_directed(session) + + return main_token + + def subscribe_peer_directed(self, + peer: aioxmpp.JID, + muted: bool = False): + """ + Create a directed presence relationship with a peer. + + :param peer: The address of the peer. This can be a full or bare JID. + :type peer: :class:`aioxmpp.JID` + :param muted: Flag to create the relationship in muted state. + :type muted: :class:`bool` + :rtype: :class:`DirectedPresenceHandle` + :return: The new directed presence handle. + + `peer` is the address of the peer which is going to receive directed + presence. For each bare JID, there can only exist either a single bare + JID directed presence relationship, or zero or more full JID directed + presence relationships. It is not possible to have a bare JID directed + presence relationship and a full JID directed presence relationship for + the same bare JID. + + If `muted` is :data:`False` (the default), the current presence is + unicast to `peer` when the relationship is created and the relationship + is created with :attr:`~.DirectedPresenceHandle.muted` set to + :data:`False`. + + If `muted` is :data:`True`, no presence is sent when the relationship is + created, and it is created with :attr:`~.DirectedPresenceHandle.muted` + set to :data:`True`. + + If the user of this method needs to set a + :attr:`~.DirectedPresenceHandle.presence_filter` on the relationship, + creating it with `muted` set to true is the only way to achieve this + before the initial directed presence to the peer is sent. + + The newly created handle is returned. + """ + bare_peer = peer.bare() + try: + sessions = self._directed_sessions[bare_peer] + except KeyError: + sessions = self._directed_sessions[bare_peer] = {} + else: + if ( + # bare JID registration with any existing registration is + # always a conflict + (peer.resource is None and sessions) or + # full JID registration with None or the resource in the + # sessions is a conflict, too + (peer.resource in sessions or None in sessions)): + raise ValueError( + "cannot create multiple directed presence sessions for the " + "same peer") + + result = sessions[peer.resource] = DirectedPresenceHandle( + self, + peer, + muted=muted, + ) + + if not muted: + self._emit_presence_directed(result) + + return result + + def _unsubscribe_peer_directed(self, handle: DirectedPresenceHandle): + bare_peer = handle.address.bare() + resource = handle.address.resource + sessions = self._directed_sessions[bare_peer] + assert sessions[resource] is handle + del sessions[resource] + if not sessions: + del self._directed_sessions[bare_peer] + + def rebind_directed_presence(self, + relationship: DirectedPresenceHandle, + new_peer: aioxmpp.JID): + """ + Modify the peer of an existing directed presence relationship. + + :param relationship: The relationship to operate on. + :type relationship: :class:`DirectedPresenceHandle` + :param new_peer: The new destination address of the relationship. + :type new_peer: :class:`aioxmpp.JID` + :raises RuntimeError: if the `relationship` has been destroyed with + :meth:`~DirectedPresenceHandle.unsubscribe` already. + :raises ValueError: if another relationship for `new_peer` exists and + is active + + This changes the peer :attr:`~.DirectedPresenceHandle.address` of the + `relationship` to `new_peer`. The conditions for peer addresses of + directed presence relationships as described in + :meth:`subscribe_peer_directed` are enforced in this operation. If they + are violated, :class:`ValueError` is raised. + + If the `relationship` has already been closed/destroyed using + :meth:`~DirectedPresenceHandle.unsubscribe`, :class:`RuntimeError` is + raised. + """ + if new_peer == relationship.address: + return + + previous_bare = relationship.address.bare() + + prev_sessions = self._directed_sessions[previous_bare] + + bare_peer = new_peer.bare() + try: + new_sessions = self._directed_sessions[bare_peer] + except KeyError: + new_sessions = self._directed_sessions[bare_peer] = {} + else: + # bare_peer already has sessions, we need to do checks + check_sessions = new_sessions + if check_sessions is prev_sessions: + # we take a copy and remove the current resource from it, + # because it doesn’t play a role in the check (it will be + # removed anyways) + check_sessions = dict(check_sessions) + del check_sessions[relationship.address.resource] + + if ( + # bare JID registration with any existing registration is + # always a conflict + (new_peer.resource is None and check_sessions) or + # full JID registration with None or the resource in the + # sessions is a conflict, too + (new_peer.resource in new_sessions or + None in check_sessions) + ): + raise ValueError( + "cannot create multiple directed presence sessions for the " + "same peer") + + if prev_sessions is new_sessions: + del prev_sessions[relationship.address.resource] + else: + self._unsubscribe_peer_directed(relationship) + + new_sessions[new_peer.resource] = relationship + relationship._address = new_peer diff --git a/tests/presence/test_service.py b/tests/presence/test_service.py index 8a4a3fa0..829d9619 100644 --- a/tests/presence/test_service.py +++ b/tests/presence/test_service.py @@ -19,6 +19,8 @@ # . # ######################################################################## +import contextlib +import itertools import types import unittest @@ -449,6 +451,67 @@ def tearDown(self): del self.cc +class TestDirectedPresenceHandle(unittest.TestCase): + def setUp(self): + self.svc = unittest.mock.Mock(spec=presence_service.PresenceServer) + self.h = presence_service.DirectedPresenceHandle( + self.svc, + TEST_PEER_JID1, + ) + + def tearDown(self): + del self.svc + del self.h + + def test_init(self): + self.assertEqual(self.h.address, TEST_PEER_JID1) + self.assertIsNone(self.h.presence_filter) + self.assertIs(self.h.muted, False) + + def test_address_cannot_be_assigned_directly(self): + with self.assertRaises(AttributeError): + self.h.address = self.h.address + + def test_muted_cannot_be_assigned_directly(self): + with self.assertRaises(AttributeError): + self.h.muted = self.h.muted + + def test_presence_filter_can_be_assigned_to(self): + self.h.presence_filter = unittest.mock.sentinel.foo + self.assertEqual(self.h.presence_filter, + unittest.mock.sentinel.foo) + + def test_set_muted_changes_muted(self): + self.h.set_muted(True) + + self.assertTrue(self.h.muted) + + self.h.set_muted(False) + + self.assertFalse(self.h.muted) + + def test_set_muted_raises_if_unsubscribed(self): + self.h.unsubscribe() + + with self.assertRaisesRegexp( + RuntimeError, + r"directed presence relationship is unsubscribed"): + self.h.set_muted(True) + + with self.assertRaisesRegexp( + RuntimeError, + r"directed presence relationship is unsubscribed"): + self.h.set_muted(False) + + def test_resend_presence_raises_if_unsubscribed(self): + self.h.unsubscribe() + + with self.assertRaisesRegexp( + RuntimeError, + r"directed presence relationship is unsubscribed"): + self.h.resend_presence() + + class TestPresenceServer(unittest.TestCase): def setUp(self): self.cc = make_connected_client() @@ -757,3 +820,696 @@ def test_resend_presence_broadcasts_if_established(self): result, self.cc.enqueue(), ) + + def test_subscribe_peer_directed_creates_handle(self): + h = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + self.assertIsInstance(h, presence_service.DirectedPresenceHandle) + self.assertEqual(h.address, TEST_PEER_JID1) + self.assertFalse(h.muted) + self.assertIsNone(h.presence_filter) + + def test_subscribe_peer_directed_different_handles_for_different_peers(self): # NOQA + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + h2 = self.s.subscribe_peer_directed( + TEST_PEER_JID2 + ) + + self.assertEqual(h1.address, TEST_PEER_JID1) + self.assertEqual(h2.address, TEST_PEER_JID2) + + def test_subscribe_peer_directed_rejects_duplicate_peer(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + with self.assertRaisesRegex( + ValueError, + r"cannot create multiple directed presence sessions for " + r"the same peer"): + self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + def test_subscribe_peer_directed_allows_multiple_sessions_for_distinct_resources(self): # NOQA + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + h2 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="y") + ) + + self.assertIsNot(h1, h2) + self.assertEqual(h1.address, TEST_PEER_JID1.replace(resource="x")) + self.assertEqual(h2.address, TEST_PEER_JID1.replace(resource="y")) + + def test_subscribe_peer_directed_does_not_allow_resource_and_bare(self): # NOQA + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + h2 = self.s.subscribe_peer_directed( + TEST_PEER_JID2.replace(resource="y") + ) + + with self.assertRaisesRegex( + ValueError, + r"cannot create multiple directed presence sessions for the " + r"same peer"): + self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="y") + ) + + with self.assertRaisesRegex( + ValueError, + r"cannot create multiple directed presence sessions for the " + r"same peer"): + self.s.subscribe_peer_directed( + TEST_PEER_JID2 + ) + + def test_unsubscribing_DirectedPresenceHandle_frees_the_slot(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + h1.unsubscribe() + + h2 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + self.assertIsNot(h1, h2) + self.assertEqual(h1.address, TEST_PEER_JID1) + self.assertEqual(h2.address, TEST_PEER_JID1) + + def test_unsubscribing_bare_DirectedPresenceHandle_frees_the_slot_for_full(self): # NOQA + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + h1.unsubscribe() + + h2 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + self.assertIsNot(h1, h2) + self.assertEqual(h1.address, TEST_PEER_JID1) + self.assertEqual(h2.address, TEST_PEER_JID1.replace(resource="x")) + + def test_unsubscribing_full_DirectedPresenceHandle_frees_the_slot_for_bare(self): # NOQA + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + h1.unsubscribe() + + h2 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + self.assertIsNot(h1, h2) + self.assertEqual(h1.address, TEST_PEER_JID1.replace(resource="x")) + self.assertEqual(h2.address, TEST_PEER_JID1) + + def test_unsubscribing_single_full_DirectedPresenceHandle_does_not_free_the_slot_for_bare(self): # NOQA + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + h2 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="y") + ) + + h1.unsubscribe() + + with self.assertRaises(ValueError): + h3 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + h2.unsubscribe() + + h3 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + self.assertIsNot(h1, h3) + self.assertIsNot(h2, h3) + self.assertEqual(h1.address, TEST_PEER_JID1.replace(resource="x")) + self.assertEqual(h2.address, TEST_PEER_JID1.replace(resource="y")) + self.assertEqual(h3.address, TEST_PEER_JID1) + + def test_unsubscribe_is_idempotent(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + h1.unsubscribe() + + h1.unsubscribe() + + h2 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + def test_unsubscribe_is_idempotent_even_after_resubscription(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + h1.unsubscribe() + + h2 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + h1.unsubscribe() + + with self.assertRaises(ValueError): + self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + def test_rebind_directed_presence_to_other_jid(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + self.s.rebind_directed_presence( + h1, + TEST_PEER_JID2 + ) + + self.assertEqual(h1.address, TEST_PEER_JID2) + + def test_rebind_directed_presence_to_other_jid_releases_old_slot(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + self.s.rebind_directed_presence( + h1, + TEST_PEER_JID2 + ) + + h2 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + self.assertIsNot(h1, h2) + self.assertEqual(h1.address, TEST_PEER_JID2) + self.assertEqual(h2.address, TEST_PEER_JID1) + + def test_rebind_directed_presence_to_other_jid_holds_new_slot(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + self.s.rebind_directed_presence( + h1, + TEST_PEER_JID2 + ) + + with self.assertRaisesRegex( + ValueError, + r"cannot create multiple directed presence sessions for the " + r"same peer"): + self.s.subscribe_peer_directed( + TEST_PEER_JID2 + ) + + def test_rebind_directed_presence_to_existing_jid_fails(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + h2 = self.s.subscribe_peer_directed( + TEST_PEER_JID2 + ) + + with self.assertRaisesRegex( + ValueError, + r"cannot create multiple directed presence sessions for the " + r"same peer"): + self.s.rebind_directed_presence( + h1, + TEST_PEER_JID2 + ) + + def test_rebind_directed_presence_noop(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + self.s.rebind_directed_presence( + h1, + TEST_PEER_JID1 + ) + + self.assertEqual(h1.address, TEST_PEER_JID1) + + def test_rebind_directed_presence_bare_to_full(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + self.s.rebind_directed_presence( + h1, + TEST_PEER_JID1.replace(resource="x") + ) + + self.assertEqual(h1.address, TEST_PEER_JID1.replace(resource="x")) + + def test_rebind_directed_presence_bare_to_full_holds_new_slot(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + self.s.rebind_directed_presence( + h1, + TEST_PEER_JID1.replace(resource="x") + ) + + with self.assertRaisesRegex( + ValueError, + r"cannot create multiple directed presence sessions for the " + r"same peer"): + self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + def test_rebind_directed_presence_bare_to_full_blocks_bare_alloc(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + self.s.rebind_directed_presence( + h1, + TEST_PEER_JID1.replace(resource="x") + ) + + with self.assertRaisesRegex( + ValueError, + r"cannot create multiple directed presence sessions for the " + r"same peer"): + self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + def test_rebind_directed_presence_full_to_bare(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + self.s.rebind_directed_presence( + h1, + TEST_PEER_JID1 + ) + + self.assertEqual(h1.address, TEST_PEER_JID1) + + def test_rebind_directed_presence_full_to_bare_holds_new_slot(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + self.s.rebind_directed_presence( + h1, + TEST_PEER_JID1 + ) + + with self.assertRaisesRegex( + ValueError, + r"cannot create multiple directed presence sessions for the " + r"same peer"): + self.s.subscribe_peer_directed( + TEST_PEER_JID1 + ) + + def test_rebind_directed_presence_full_to_bare_blocks_full_alloc(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + self.s.rebind_directed_presence( + h1, + TEST_PEER_JID1 + ) + + with self.assertRaisesRegex( + ValueError, + r"cannot create multiple directed presence sessions for the " + r"same peer"): + self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + def test_rebind_directed_presence_change_resource(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + self.s.rebind_directed_presence( + h1, + TEST_PEER_JID1.replace(resource="y") + ) + + self.assertEqual(h1.address, TEST_PEER_JID1.replace(resource="y")) + + def test_rebind_directed_presence_change_resource_releases_old_slot(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + self.s.rebind_directed_presence( + h1, + TEST_PEER_JID1.replace(resource="y") + ) + + self.assertEqual(h1.address, TEST_PEER_JID1.replace(resource="y")) + + h2 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + self.assertIsNot(h1, h2) + self.assertEqual(h1.address, TEST_PEER_JID1.replace(resource="y")) + self.assertEqual(h2.address, TEST_PEER_JID1.replace(resource="x")) + + def test_rebind_directed_presence_change_resource_holds_new_slot(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + self.s.rebind_directed_presence( + h1, + TEST_PEER_JID1.replace(resource="y") + ) + + self.assertEqual(h1.address, TEST_PEER_JID1.replace(resource="y")) + + with self.assertRaisesRegex( + ValueError, + r"cannot create multiple directed presence sessions for the " + r"same peer"): + self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="y") + ) + + def test_resend_presence_emits_stanza_for_directed_session(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x") + ) + + self.cc.enqueue.reset_mock() + + stanzas = [] + + def stanza_generator(): + while True: + st = aioxmpp.stanza.Presence() + st.type_ = aioxmpp.structs.PresenceType.AVAILABLE + stanzas.append(st) + yield st + + def token_generator(): + for i in itertools.count(): + yield getattr(unittest.mock.sentinel, "token{}".format(i)) + + with contextlib.ExitStack() as stack: + stack.enter_context(unittest.mock.patch.object( + self.s, "make_stanza", + side_effect=stanza_generator(), + )) + + self.cc.enqueue.side_effect = token_generator() + + result = self.s.resend_presence() + + self.cc.established = True + + (_, (p1, ), _), (_, (p2, ), _) = self.cc.enqueue.mock_calls + + self.assertIsNot(p1, p2) + self.assertEqual(p1, stanzas[0]) + self.assertEqual(p2, stanzas[1]) + + self.assertEqual(p2.to, TEST_PEER_JID1.replace(resource="x")) + + self.assertEqual(result, unittest.mock.sentinel.token0) + + def test_direct_presence_stanza_passes_through_filter(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x"), + ) + + self.cc.enqueue.reset_mock() + + filter_func = unittest.mock.Mock() + + h1.presence_filter = filter_func + + stanzas = [] + + def stanza_generator(): + while True: + st = aioxmpp.stanza.Presence() + st.type_ = aioxmpp.structs.PresenceType.AVAILABLE + stanzas.append(st) + yield st + + with contextlib.ExitStack() as stack: + stack.enter_context(unittest.mock.patch.object( + self.s, "make_stanza", + side_effect=stanza_generator(), + )) + + self.s.resend_presence() + + self.cc.established = True + + _, (_, (p2, ), _) = self.cc.enqueue.mock_calls + + filter_func.assert_called_once_with(stanzas[1]) + self.assertEqual(p2, filter_func()) + + def test_emission_of_directed_presence_is_skipped_if_filter_returns_None( + self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x"), + ) + + self.cc.enqueue.reset_mock() + + filter_func = unittest.mock.Mock() + filter_func.return_value = None + + h1.presence_filter = filter_func + + stanzas = [] + + def stanza_generator(): + while True: + st = aioxmpp.stanza.Presence() + st.type_ = aioxmpp.structs.PresenceType.AVAILABLE + stanzas.append(st) + yield st + + with contextlib.ExitStack() as stack: + stack.enter_context(unittest.mock.patch.object( + self.s, "make_stanza", + side_effect=stanza_generator(), + )) + + self.s.resend_presence() + + self.cc.established = True + + self.assertEqual(len(self.cc.enqueue.mock_calls), 1) + + filter_func.assert_called_once_with(stanzas[1]) + + def test_emission_of_directed_presence_is_skipped_for_muted(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x"), + muted=True + ) + + self.cc.enqueue.reset_mock() + + filter_func = unittest.mock.Mock() + h1.presence_filter = filter_func + + stanzas = [] + + def stanza_generator(): + while True: + st = aioxmpp.stanza.Presence() + st.type_ = aioxmpp.structs.PresenceType.AVAILABLE + stanzas.append(st) + yield st + + with contextlib.ExitStack() as stack: + stack.enter_context(unittest.mock.patch.object( + self.s, "make_stanza", + side_effect=stanza_generator(), + )) + + self.s.resend_presence() + + self.cc.established = True + + self.assertEqual(len(self.cc.enqueue.mock_calls), 1) + + filter_func.assert_not_called() + + def test_creating_unmuted_directed_presence_relation_emits_presence(self): + st = unittest.mock.Mock(spec=aioxmpp.stanza.Presence) + + with unittest.mock.patch.object(self.s, "make_stanza", + return_value=st) as make_stanza: + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x"), + ) + + self.assertFalse(h1.muted) + + make_stanza.assert_called_once_with() + + (_, (p, ), _), = self.cc.enqueue.mock_calls + + self.assertIs(p, st) + self.assertEqual(p.to, TEST_PEER_JID1.replace(resource="x")) + + def test_creating_muted_directed_presence_relation_does_not_emit_presence(self): # NOQA + st = unittest.mock.Mock(spec=aioxmpp.stanza.Presence) + + with unittest.mock.patch.object(self.s, "make_stanza", + return_value=st) as make_stanza: + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x"), + muted=True, + ) + + self.assertTrue(h1.muted) + + make_stanza.assert_not_called() + self.cc.enqueue.assert_not_called() + + def test_unmuting_relation_emits_presence_by_default(self): + st = unittest.mock.Mock(spec=aioxmpp.stanza.Presence) + + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x"), + muted=True, + ) + + self.assertTrue(h1.muted) + + with unittest.mock.patch.object(self.s, "make_stanza", + return_value=st) as make_stanza: + h1.set_muted(False) + + make_stanza.assert_called_once_with() + + (_, (p, ), _), = self.cc.enqueue.mock_calls + + self.assertIs(p, st) + self.assertEqual(p.to, TEST_PEER_JID1.replace(resource="x")) + + def test_unmuting_relation_twice_does_not_reemit_presence(self): + st = unittest.mock.Mock(spec=aioxmpp.stanza.Presence) + + with unittest.mock.patch.object(self.s, "make_stanza", + return_value=st) as make_stanza: + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x"), + muted=True, + ) + + self.assertTrue(h1.muted) + + h1.set_muted(False) + + self.cc.enqueue.reset_mock() + + h1.set_muted(False) + + self.cc.enqueue.assert_not_called() + + def test_muted_relationship_does_not_emit_presence_when_resending(self): + st = unittest.mock.Mock(spec=aioxmpp.stanza.Presence) + + with unittest.mock.patch.object(self.s, "make_stanza", + return_value=st) as make_stanza: + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x"), + muted=True, + ) + + self.assertTrue(h1.muted) + + make_stanza.assert_not_called() + self.cc.enqueue.assert_not_called() + + def test_resend_presence_on_muted_handle_emits_presence(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x"), + muted=True, + ) + + self.cc.enqueue.assert_not_called() + + st = unittest.mock.Mock(spec=aioxmpp.stanza.Presence) + + with unittest.mock.patch.object(self.s, "make_stanza", + return_value=st) as make_stanza: + h1.resend_presence() + + self.cc.enqueue.assert_called_once_with(st) + self.assertEqual(st.to, h1.address) + + def test_resend_presence_on_unmuted_handle_emits_presence(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x"), + ) + + self.cc.enqueue.reset_mock() + + st = unittest.mock.Mock(spec=aioxmpp.stanza.Presence) + + with unittest.mock.patch.object(self.s, "make_stanza", + return_value=st) as make_stanza: + h1.resend_presence() + + self.cc.enqueue.assert_called_once_with(st) + self.assertEqual(st.to, h1.address) + + def test_resend_presence_calls_filter(self): + h1 = self.s.subscribe_peer_directed( + TEST_PEER_JID1.replace(resource="x"), + muted=True + ) + + self.cc.enqueue.assert_not_called() + + filter_func = unittest.mock.Mock() + h1.presence_filter = filter_func + + st = unittest.mock.Mock(spec=aioxmpp.stanza.Presence) + + with unittest.mock.patch.object(self.s, "make_stanza", + return_value=st) as make_stanza: + h1.resend_presence() + + filter_func.assert_called_once_with(st) + self.cc.enqueue.assert_called_once_with(filter_func()) + self.assertEqual(st.to, h1.address)