Exemplo n.º 1
0
    def test_has_pending_connection(self):
        """
        FakeSMSC knows if there's a pending connection.
        """
        fake_smsc = FakeSMSC(auto_accept=False)
        self.assertEqual(fake_smsc.has_pending_connection(), False)

        # Pending connection we reject.
        connect_d = self.connect(fake_smsc)
        self.assertEqual(fake_smsc.has_pending_connection(), True)
        fake_smsc.reject_connection()
        self.assertEqual(fake_smsc.has_pending_connection(), False)
        self.failureResultOf(connect_d)

        # Pending connection we accept.
        connected_d = self.connect(fake_smsc)
        self.assertEqual(fake_smsc.has_pending_connection(), True)
        fake_smsc.accept_connection()
        self.assertEqual(fake_smsc.has_pending_connection(), False)
        self.successResultOf(connected_d)
Exemplo n.º 2
0
    def test_has_pending_connection(self):
        """
        FakeSMSC knows if there's a pending connection.
        """
        fake_smsc = FakeSMSC(auto_accept=False)
        self.assertEqual(fake_smsc.has_pending_connection(), False)

        # Pending connection we reject.
        connect_d = self.connect(fake_smsc)
        self.assertEqual(fake_smsc.has_pending_connection(), True)
        fake_smsc.reject_connection()
        self.assertEqual(fake_smsc.has_pending_connection(), False)
        self.failureResultOf(connect_d)

        # Pending connection we accept.
        connected_d = self.connect(fake_smsc)
        self.assertEqual(fake_smsc.has_pending_connection(), True)
        fake_smsc.accept_connection()
        self.assertEqual(fake_smsc.has_pending_connection(), False)
        self.successResultOf(connected_d)
Exemplo n.º 3
0
class TestSmppService(VumiTestCase):
    @inlineCallbacks
    def setUp(self):
        self.clock = Clock()
        self.persistence_helper = self.add_helper(PersistenceHelper())
        self.redis = yield self.persistence_helper.get_redis_manager()
        self.fake_smsc = FakeSMSC(auto_accept=False)
        self.default_config = {
            'transport_name': 'sphex_transport',
            'twisted_endpoint': self.fake_smsc.endpoint,
            'system_id': 'system_id',
            'password': '******',
        }

    def get_service(self, config={}, bind_type='TRX', start=True):
        """
        Create and optionally start a new service object.
        """
        cfg = self.default_config.copy()
        cfg.update(config)
        dummy_transport = DummySmppTransport(self.clock, self.redis, cfg)
        service = SmppService(self.fake_smsc.endpoint, bind_type,
                              dummy_transport)
        service.clock = self.clock

        d = succeed(service)
        if start:
            d.addCallback(self.start_service)
        return d

    def start_service(self, service, accept_connection=True):
        """
        Start the given service.
        """
        service.startService()
        self.clock.advance(0)
        d = self.fake_smsc.await_connecting()
        if accept_connection:
            d.addCallback(lambda _: self.fake_smsc.accept_connection())
        return d.addCallback(lambda _: service)

    def lookup_message_ids(self, service, seq_nums):
        """
        Find vumi message ids associated with SMPP sequence numbers.
        """
        lookup_func = service.message_stash.get_sequence_number_message_id
        return gatherResults([lookup_func(seq_num) for seq_num in seq_nums])

    def set_sequence_number(self, service, seq_nr):
        return service.sequence_generator.redis.set(
            'smpp_last_sequence_number', seq_nr)

    @inlineCallbacks
    def test_start_sequence(self):
        """
        The service goes through several states while starting.
        """
        # New service, never started.
        service = yield self.get_service(start=False)
        self.assertEqual(service.running, False)
        self.assertEqual(service.get_bind_state(), EsmeProtocol.CLOSED_STATE)

        # Start, but don't connect.
        yield self.start_service(service, accept_connection=False)
        self.assertEqual(service.running, True)
        self.assertEqual(service.get_bind_state(), EsmeProtocol.CLOSED_STATE)

        # Connect, but don't bind.
        yield self.fake_smsc.accept_connection()
        self.assertEqual(service.running, True)
        self.assertEqual(service.get_bind_state(), EsmeProtocol.OPEN_STATE)
        bind_pdu = yield self.fake_smsc.await_pdu()
        self.assertEqual(command_id(bind_pdu), 'bind_transceiver')

        # Bind.
        yield self.fake_smsc.bind(bind_pdu)
        self.assertEqual(service.running, True)
        self.assertEqual(service.get_bind_state(),
                         EsmeProtocol.BOUND_STATE_TRX)

    @inlineCallbacks
    def test_connect_retries(self):
        """
        If we fail to connect, we retry.
        """
        service = yield self.get_service(start=False)
        self.assertEqual(self.fake_smsc.has_pending_connection(), False)

        # Start, but don't connect.
        yield self.start_service(service, accept_connection=False)
        self.assertEqual(self.fake_smsc.has_pending_connection(), True)
        self.assertEqual(service._protocol, None)
        self.assertEqual(service.retries, 1)

        # Reject the connection.
        yield self.fake_smsc.reject_connection()
        self.assertEqual(service._protocol, None)
        self.assertEqual(service.retries, 2)

        # Advance to the next connection attempt.
        self.clock.advance(service.delay)
        self.assertEqual(self.fake_smsc.has_pending_connection(), True)
        self.assertEqual(service._protocol, None)
        self.assertEqual(service.retries, 2)

        # Accept the connection.
        yield self.fake_smsc.accept_connection()
        self.assertEqual(service.running, True)
        self.assertNotEqual(service._protocol, None)

    @inlineCallbacks
    def test_submit_sm(self):
        """
        When bound, we can send a message.
        """
        service = yield self.get_service()
        yield self.fake_smsc.bind()

        seq_nums = yield service.submit_sm('abc123',
                                           'dest_addr',
                                           short_message='foo')
        submit_sm = yield self.fake_smsc.await_pdu()
        self.assertEqual(command_id(submit_sm), 'submit_sm')
        stored_ids = yield self.lookup_message_ids(service, seq_nums)
        self.assertEqual(['abc123'], stored_ids)

    @inlineCallbacks
    def test_submit_sm_unbound(self):
        """
        When unbound, we can't send a message.
        """
        service = yield self.get_service()

        self.assertRaises(EsmeProtocolError,
                          service.submit_sm,
                          'abc123',
                          'dest_addr',
                          short_message='foo')

    @inlineCallbacks
    def test_submit_sm_not_connected(self):
        """
        When not connected, we can't send a message.
        """
        service = yield self.get_service(start=False)
        yield self.start_service(service, accept_connection=False)

        self.assertRaises(EsmeProtocolError,
                          service.submit_sm,
                          'abc123',
                          'dest_addr',
                          short_message='foo')

    @skiptest("FIXME: We don't actually unbind and disconnect yet.")
    @inlineCallbacks
    def test_handle_unbind(self):
        """
        If the SMSC sends an unbind command, we respond and disconnect.
        """
        service = yield self.get_service()
        yield self.fake_smsc.bind()

        self.assertEqual(service.is_bound(), True)
        self.fake_smsc.send_pdu(Unbind(7))
        unbind_resp_pdu = yield self.fake_smsc.await_pdu()
        self.assertEqual(command_id(unbind_resp_pdu), 'unbind_resp')
        self.assertEqual(service.is_bound(), False)

    @inlineCallbacks
    def test_csm_split_message(self):
        """
        A multipart message is split into chunks such that the smallest number
        of message parts are required.
        """
        service = yield self.get_service()

        split = lambda msg: service.csm_split_message(msg.encode('utf-8'))

        # these are fine because they're in the 7-bit character set
        self.assertEqual(1, len(split(u'&' * 140)))
        self.assertEqual(1, len(split(u'&' * 160)))
        # ± is not in the 7-bit character set so it should utf-8 encode it
        # which bumps it over the 140 bytes
        self.assertEqual(2, len(split(u'±' + u'1' * 139)))

    @inlineCallbacks
    def test_submit_sm_long(self):
        """
        A long message can be sent in a single PDU using the optional
        `message_payload` PDU field.
        """
        service = yield self.get_service()
        yield self.fake_smsc.bind()

        long_message = 'This is a long message.' * 20
        seq_nums = yield service.submit_sm_long('abc123', 'dest_addr',
                                                long_message)
        submit_sm = yield self.fake_smsc.await_pdu()
        pdu_opts = unpacked_pdu_opts(submit_sm)

        self.assertEqual('submit_sm', submit_sm['header']['command_id'])
        self.assertEqual(
            None, submit_sm['body']['mandatory_parameters']['short_message'])
        self.assertEqual(''.join('%02x' % ord(c) for c in long_message),
                         pdu_opts['message_payload'])
        stored_ids = yield self.lookup_message_ids(service, seq_nums)
        self.assertEqual(['abc123'], stored_ids)

    @inlineCallbacks
    def test_submit_csm_sar(self):
        """
        A long message can be sent in multiple PDUs with SAR fields set to
        instruct the SMSC to build user data headers.
        """
        service = yield self.get_service({'send_multipart_sar': True})
        yield self.fake_smsc.bind()

        long_message = 'This is a long message.' * 20
        seq_nums = yield service.submit_csm_sar('abc123',
                                                'dest_addr',
                                                short_message=long_message)
        pdus = yield self.fake_smsc.await_pdus(4)
        # seq no 1 == bind_transceiver, 2 == enquire_link, 3 == sar_msg_ref_num
        self.assertEqual([4, 5, 6, 7], seq_nums)
        msg_parts = []
        msg_refs = []

        for i, sm in enumerate(pdus):
            pdu_opts = unpacked_pdu_opts(sm)
            mandatory_parameters = sm['body']['mandatory_parameters']

            self.assertEqual('submit_sm', sm['header']['command_id'])
            msg_parts.append(mandatory_parameters['short_message'])
            self.assertTrue(len(mandatory_parameters['short_message']) <= 130)
            msg_refs.append(pdu_opts['sar_msg_ref_num'])
            self.assertEqual(i + 1, pdu_opts['sar_segment_seqnum'])
            self.assertEqual(4, pdu_opts['sar_total_segments'])

        self.assertEqual(long_message, ''.join(msg_parts))
        self.assertEqual([3, 3, 3, 3], msg_refs)

        stored_ids = yield self.lookup_message_ids(service, seq_nums)
        self.assertEqual(['abc123'] * len(seq_nums), stored_ids)

    @inlineCallbacks
    def test_submit_csm_sar_ref_num_limit(self):
        """
        The SAR reference number is set correctly when the generated reference
        number is larger than 0xFFFF.
        """
        service = yield self.get_service({'send_multipart_sar': True})
        yield self.fake_smsc.bind()
        # forward until we go past 0xFFFF
        yield self.set_sequence_number(service, 0x10000)

        long_message = 'This is a long message.' * 20
        seq_nums = yield service.submit_csm_sar('abc123',
                                                'dest_addr',
                                                short_message=long_message)
        pdus = yield self.fake_smsc.await_pdus(4)
        msg_parts = []
        msg_refs = []

        for i, sm in enumerate(pdus):
            pdu_opts = unpacked_pdu_opts(sm)
            mandatory_parameters = sm['body']['mandatory_parameters']

            self.assertEqual('submit_sm', sm['header']['command_id'])
            msg_parts.append(mandatory_parameters['short_message'])
            self.assertTrue(len(mandatory_parameters['short_message']) <= 130)
            msg_refs.append(pdu_opts['sar_msg_ref_num'])
            self.assertEqual(i + 1, pdu_opts['sar_segment_seqnum'])
            self.assertEqual(4, pdu_opts['sar_total_segments'])

        self.assertEqual(long_message, ''.join(msg_parts))
        self.assertEqual([1, 1, 1, 1], msg_refs)

        stored_ids = yield self.lookup_message_ids(service, seq_nums)
        self.assertEqual(['abc123'] * len(seq_nums), stored_ids)

    @inlineCallbacks
    def test_submit_csm_sar_ref_num_custom_limit(self):
        """
        The SAR reference number is set correctly when the generated reference
        number is larger than the configured limit.
        """
        service = yield self.get_service({'send_multipart_sar': True})
        yield self.fake_smsc.bind()
        # forward until we go past 0xFF
        yield self.set_sequence_number(service, 0x100)

        long_message = 'This is a long message.' * 20
        seq_nums = yield service.submit_csm_sar('abc123',
                                                'dest_addr',
                                                short_message=long_message,
                                                reference_rollover=0x100)
        pdus = yield self.fake_smsc.await_pdus(4)
        msg_parts = []
        msg_refs = []

        for i, sm in enumerate(pdus):
            pdu_opts = unpacked_pdu_opts(sm)
            mandatory_parameters = sm['body']['mandatory_parameters']

            self.assertEqual('submit_sm', sm['header']['command_id'])
            msg_parts.append(mandatory_parameters['short_message'])
            self.assertTrue(len(mandatory_parameters['short_message']) <= 130)
            msg_refs.append(pdu_opts['sar_msg_ref_num'])
            self.assertEqual(i + 1, pdu_opts['sar_segment_seqnum'])
            self.assertEqual(4, pdu_opts['sar_total_segments'])

        self.assertEqual(long_message, ''.join(msg_parts))
        self.assertEqual([1, 1, 1, 1], msg_refs)

        stored_ids = yield self.lookup_message_ids(service, seq_nums)
        self.assertEqual(['abc123'] * len(seq_nums), stored_ids)

    @inlineCallbacks
    def test_submit_csm_sar_single_part(self):
        """
        If the content fits in a single message, all the multipart madness is
        avoided.
        """
        service = yield self.get_service({'send_multipart_sar': True})
        yield self.fake_smsc.bind()

        content = 'a' * 160
        seq_numbers = yield service.submit_csm_sar('abc123',
                                                   'dest_addr',
                                                   short_message=content)
        self.assertEqual(len(seq_numbers), 1)
        submit_sm_pdu = yield self.fake_smsc.await_pdu()

        self.assertEqual(command_id(submit_sm_pdu), 'submit_sm')
        self.assertEqual(short_message(submit_sm_pdu), content)
        self.assertEqual(unpacked_pdu_opts(submit_sm_pdu), {})

    @inlineCallbacks
    def test_submit_csm_udh(self):
        """
        A long message can be sent in multiple PDUs with carefully handcrafted
        user data headers.
        """
        service = yield self.get_service({'send_multipart_udh': True})
        yield self.fake_smsc.bind()

        long_message = 'This is a long message.' * 20
        seq_numbers = yield service.submit_csm_udh('abc123',
                                                   'dest_addr',
                                                   short_message=long_message)
        pdus = yield self.fake_smsc.await_pdus(4)
        self.assertEqual(len(seq_numbers), 4)

        msg_parts = []
        msg_refs = []

        for i, sm in enumerate(pdus):
            mandatory_parameters = sm['body']['mandatory_parameters']
            self.assertEqual('submit_sm', sm['header']['command_id'])
            msg = mandatory_parameters['short_message']

            udh_hlen, udh_tag, udh_len, udh_ref, udh_tot, udh_seq = [
                ord(octet) for octet in msg[:6]
            ]
            self.assertEqual(5, udh_hlen)
            self.assertEqual(0, udh_tag)
            self.assertEqual(3, udh_len)
            msg_refs.append(udh_ref)
            self.assertEqual(4, udh_tot)
            self.assertEqual(i + 1, udh_seq)
            self.assertTrue(len(msg) <= 136)
            msg_parts.append(msg[6:])
            self.assertEqual(0x40, mandatory_parameters['esm_class'])

        self.assertEqual(long_message, ''.join(msg_parts))
        self.assertEqual(1, len(set(msg_refs)))

        stored_ids = yield self.lookup_message_ids(service, seq_numbers)
        self.assertEqual(['abc123'] * len(seq_numbers), stored_ids)

    @inlineCallbacks
    def test_submit_csm_udh_ref_num_limit(self):
        """
        User data headers are crafted correctly when the generated reference
        number is larger than 0xFF.
        """
        service = yield self.get_service({'send_multipart_udh': True})
        yield self.fake_smsc.bind()
        # forward until we go past 0xFF
        yield self.set_sequence_number(service, 0x100)

        long_message = 'This is a long message.' * 20
        seq_numbers = yield service.submit_csm_udh('abc123',
                                                   'dest_addr',
                                                   short_message=long_message)
        pdus = yield self.fake_smsc.await_pdus(4)
        self.assertEqual(len(seq_numbers), 4)

        msg_parts = []
        msg_refs = []

        for i, sm in enumerate(pdus):
            mandatory_parameters = sm['body']['mandatory_parameters']
            self.assertEqual('submit_sm', sm['header']['command_id'])
            msg = mandatory_parameters['short_message']

            udh_hlen, udh_tag, udh_len, udh_ref, udh_tot, udh_seq = [
                ord(octet) for octet in msg[:6]
            ]
            self.assertEqual(5, udh_hlen)
            self.assertEqual(0, udh_tag)
            self.assertEqual(3, udh_len)
            msg_refs.append(udh_ref)
            self.assertEqual(4, udh_tot)
            self.assertEqual(i + 1, udh_seq)
            self.assertTrue(len(msg) <= 136)
            msg_parts.append(msg[6:])
            self.assertEqual(0x40, mandatory_parameters['esm_class'])

        self.assertEqual(long_message, ''.join(msg_parts))
        self.assertEqual(1, len(set(msg_refs)))

        stored_ids = yield self.lookup_message_ids(service, seq_numbers)
        self.assertEqual(['abc123'] * len(seq_numbers), stored_ids)

    @inlineCallbacks
    def test_submit_csm_udh_single_part(self):
        """
        If the content fits in a single message, all the multipart madness is
        avoided.
        """
        service = yield self.get_service({'send_multipart_udh': True})
        yield self.fake_smsc.bind()

        content = 'a' * 160
        seq_numbers = yield service.submit_csm_udh('abc123',
                                                   'dest_addr',
                                                   short_message=content)
        self.assertEqual(len(seq_numbers), 1)
        submit_sm_pdu = yield self.fake_smsc.await_pdu()

        self.assertEqual(command_id(submit_sm_pdu), 'submit_sm')
        self.assertEqual(short_message(submit_sm_pdu), content)
        self.assertEqual(
            submit_sm_pdu['body']['mandatory_parameters']['esm_class'], 0)

    @inlineCallbacks
    def test_pdu_cache_persistence(self):
        """
        A cached PDU has an appropriate TTL and can be deleted.
        """
        service = yield self.get_service()

        message_stash = service.message_stash
        config = service.get_config()

        pdu = SubmitSM(1337, short_message="foo")
        yield message_stash.cache_pdu("vumi0", pdu)

        ttl = yield message_stash.redis.ttl(pdu_key(1337))
        self.assertTrue(0 < ttl <= config.submit_sm_expiry)

        pdu_data = yield message_stash.get_cached_pdu(1337)
        self.assertEqual(pdu_data.vumi_message_id, "vumi0")
        self.assertEqual(pdu_data.pdu.get_hex(), pdu.get_hex())

        yield message_stash.delete_cached_pdu(1337)
        deleted_pdu_data = yield message_stash.get_cached_pdu(1337)
        self.assertEqual(deleted_pdu_data, None)
Exemplo n.º 4
0
class TestSmppService(VumiTestCase):

    @inlineCallbacks
    def setUp(self):
        self.clock = Clock()
        self.persistence_helper = self.add_helper(PersistenceHelper())
        self.redis = yield self.persistence_helper.get_redis_manager()
        self.fake_smsc = FakeSMSC(auto_accept=False)
        self.default_config = {
            'transport_name': 'sphex_transport',
            'twisted_endpoint': self.fake_smsc.endpoint,
            'system_id': 'system_id',
            'password': '******',
        }

    def get_service(self, config={}, bind_type='TRX', start=True):
        """
        Create and optionally start a new service object.
        """
        cfg = self.default_config.copy()
        cfg.update(config)
        dummy_transport = DummySmppTransport(self.clock, self.redis, cfg)
        service = SmppService(
            self.fake_smsc.endpoint, bind_type, dummy_transport)
        service.clock = self.clock

        d = succeed(service)
        if start:
            d.addCallback(self.start_service)
        return d

    def start_service(self, service, accept_connection=True):
        """
        Start the given service.
        """
        service.startService()
        self.clock.advance(0)
        d = self.fake_smsc.await_connecting()
        if accept_connection:
            d.addCallback(lambda _: self.fake_smsc.accept_connection())
        return d.addCallback(lambda _: service)

    def lookup_message_ids(self, service, seq_nums):
        """
        Find vumi message ids associated with SMPP sequence numbers.
        """
        lookup_func = service.message_stash.get_sequence_number_message_id
        return gatherResults([lookup_func(seq_num) for seq_num in seq_nums])

    def set_sequence_number(self, service, seq_nr):
        return service.sequence_generator.redis.set(
            'smpp_last_sequence_number', seq_nr)

    @inlineCallbacks
    def test_start_sequence(self):
        """
        The service goes through several states while starting.
        """
        # New service, never started.
        service = yield self.get_service(start=False)
        self.assertEqual(service.running, False)
        self.assertEqual(service.get_bind_state(), EsmeProtocol.CLOSED_STATE)

        # Start, but don't connect.
        yield self.start_service(service, accept_connection=False)
        self.assertEqual(service.running, True)
        self.assertEqual(service.get_bind_state(), EsmeProtocol.CLOSED_STATE)

        # Connect, but don't bind.
        yield self.fake_smsc.accept_connection()
        self.assertEqual(service.running, True)
        self.assertEqual(service.get_bind_state(), EsmeProtocol.OPEN_STATE)
        bind_pdu = yield self.fake_smsc.await_pdu()
        self.assertEqual(command_id(bind_pdu), 'bind_transceiver')

        # Bind.
        yield self.fake_smsc.bind(bind_pdu)
        self.assertEqual(service.running, True)
        self.assertEqual(
            service.get_bind_state(), EsmeProtocol.BOUND_STATE_TRX)

    @inlineCallbacks
    def test_connect_retries(self):
        """
        If we fail to connect, we retry.
        """
        service = yield self.get_service(start=False)
        self.assertEqual(self.fake_smsc.has_pending_connection(), False)

        # Start, but don't connect.
        yield self.start_service(service, accept_connection=False)
        self.assertEqual(self.fake_smsc.has_pending_connection(), True)
        self.assertEqual(service._protocol, None)
        self.assertEqual(service.retries, 1)

        # Reject the connection.
        yield self.fake_smsc.reject_connection()
        self.assertEqual(service._protocol, None)
        self.assertEqual(service.retries, 2)

        # Advance to the next connection attempt.
        self.clock.advance(service.delay)
        self.assertEqual(self.fake_smsc.has_pending_connection(), True)
        self.assertEqual(service._protocol, None)
        self.assertEqual(service.retries, 2)

        # Accept the connection.
        yield self.fake_smsc.accept_connection()
        self.assertEqual(service.running, True)
        self.assertNotEqual(service._protocol, None)

    @inlineCallbacks
    def test_submit_sm(self):
        """
        When bound, we can send a message.
        """
        service = yield self.get_service()
        yield self.fake_smsc.bind()

        seq_nums = yield service.submit_sm(
            'abc123', 'dest_addr', short_message='foo')
        submit_sm = yield self.fake_smsc.await_pdu()
        self.assertEqual(command_id(submit_sm), 'submit_sm')
        stored_ids = yield self.lookup_message_ids(service, seq_nums)
        self.assertEqual(['abc123'], stored_ids)

    @inlineCallbacks
    def test_submit_sm_unbound(self):
        """
        When unbound, we can't send a message.
        """
        service = yield self.get_service()

        self.assertRaises(
            EsmeProtocolError,
            service.submit_sm, 'abc123', 'dest_addr', short_message='foo')

    @inlineCallbacks
    def test_submit_sm_not_connected(self):
        """
        When not connected, we can't send a message.
        """
        service = yield self.get_service(start=False)
        yield self.start_service(service, accept_connection=False)

        self.assertRaises(
            EsmeProtocolError,
            service.submit_sm, 'abc123', 'dest_addr', short_message='foo')

    @skiptest("FIXME: We don't actually unbind and disconnect yet.")
    @inlineCallbacks
    def test_handle_unbind(self):
        """
        If the SMSC sends an unbind command, we respond and disconnect.
        """
        service = yield self.get_service()
        yield self.fake_smsc.bind()

        self.assertEqual(service.is_bound(), True)
        self.fake_smsc.send_pdu(Unbind(7))
        unbind_resp_pdu = yield self.fake_smsc.await_pdu()
        self.assertEqual(command_id(unbind_resp_pdu), 'unbind_resp')
        self.assertEqual(service.is_bound(), False)

    @inlineCallbacks
    def test_csm_split_message(self):
        """
        A multipart message is split into chunks such that the smallest number
        of message parts are required.
        """
        service = yield self.get_service()

        split = lambda msg: service.csm_split_message(msg.encode('utf-8'))

        # these are fine because they're in the 7-bit character set
        self.assertEqual(1, len(split(u'&' * 140)))
        self.assertEqual(1, len(split(u'&' * 160)))
        # ± is not in the 7-bit character set so it should utf-8 encode it
        # which bumps it over the 140 bytes
        self.assertEqual(2, len(split(u'±' + u'1' * 139)))

    @inlineCallbacks
    def test_submit_sm_long(self):
        """
        A long message can be sent in a single PDU using the optional
        `message_payload` PDU field.
        """
        service = yield self.get_service()
        yield self.fake_smsc.bind()

        long_message = 'This is a long message.' * 20
        seq_nums = yield service.submit_sm_long(
            'abc123', 'dest_addr', long_message)
        submit_sm = yield self.fake_smsc.await_pdu()
        pdu_opts = unpacked_pdu_opts(submit_sm)

        self.assertEqual('submit_sm', submit_sm['header']['command_id'])
        self.assertEqual(
            None, submit_sm['body']['mandatory_parameters']['short_message'])
        self.assertEqual(''.join('%02x' % ord(c) for c in long_message),
                         pdu_opts['message_payload'])
        stored_ids = yield self.lookup_message_ids(service, seq_nums)
        self.assertEqual(['abc123'], stored_ids)

    @inlineCallbacks
    def test_submit_csm_sar(self):
        """
        A long message can be sent in multiple PDUs with SAR fields set to
        instruct the SMSC to build user data headers.
        """
        service = yield self.get_service({'send_multipart_sar': True})
        yield self.fake_smsc.bind()

        long_message = 'This is a long message.' * 20
        seq_nums = yield service.submit_csm_sar(
            'abc123', 'dest_addr', short_message=long_message)
        pdus = yield self.fake_smsc.await_pdus(4)
        # seq no 1 == bind_transceiver, 2 == enquire_link, 3 == sar_msg_ref_num
        self.assertEqual([4, 5, 6, 7], seq_nums)
        msg_parts = []
        msg_refs = []

        for i, sm in enumerate(pdus):
            pdu_opts = unpacked_pdu_opts(sm)
            mandatory_parameters = sm['body']['mandatory_parameters']

            self.assertEqual('submit_sm', sm['header']['command_id'])
            msg_parts.append(mandatory_parameters['short_message'])
            self.assertTrue(len(mandatory_parameters['short_message']) <= 130)
            msg_refs.append(pdu_opts['sar_msg_ref_num'])
            self.assertEqual(i + 1, pdu_opts['sar_segment_seqnum'])
            self.assertEqual(4, pdu_opts['sar_total_segments'])

        self.assertEqual(long_message, ''.join(msg_parts))
        self.assertEqual([3, 3, 3, 3], msg_refs)

        stored_ids = yield self.lookup_message_ids(service, seq_nums)
        self.assertEqual(['abc123'] * len(seq_nums), stored_ids)

    @inlineCallbacks
    def test_submit_csm_sar_ref_num_limit(self):
        """
        The SAR reference number is set correctly when the generated reference
        number is larger than 0xFFFF.
        """
        service = yield self.get_service({'send_multipart_sar': True})
        yield self.fake_smsc.bind()
        # forward until we go past 0xFFFF
        yield self.set_sequence_number(service, 0x10000)

        long_message = 'This is a long message.' * 20
        seq_nums = yield service.submit_csm_sar(
            'abc123', 'dest_addr', short_message=long_message)
        pdus = yield self.fake_smsc.await_pdus(4)
        msg_parts = []
        msg_refs = []

        for i, sm in enumerate(pdus):
            pdu_opts = unpacked_pdu_opts(sm)
            mandatory_parameters = sm['body']['mandatory_parameters']

            self.assertEqual('submit_sm', sm['header']['command_id'])
            msg_parts.append(mandatory_parameters['short_message'])
            self.assertTrue(len(mandatory_parameters['short_message']) <= 130)
            msg_refs.append(pdu_opts['sar_msg_ref_num'])
            self.assertEqual(i + 1, pdu_opts['sar_segment_seqnum'])
            self.assertEqual(4, pdu_opts['sar_total_segments'])

        self.assertEqual(long_message, ''.join(msg_parts))
        self.assertEqual([2, 2, 2, 2], msg_refs)

        stored_ids = yield self.lookup_message_ids(service, seq_nums)
        self.assertEqual(['abc123'] * len(seq_nums), stored_ids)

    @inlineCallbacks
    def test_submit_csm_sar_single_part(self):
        """
        If the content fits in a single message, all the multipart madness is
        avoided.
        """
        service = yield self.get_service({'send_multipart_sar': True})
        yield self.fake_smsc.bind()

        content = 'a' * 160
        seq_numbers = yield service.submit_csm_sar(
            'abc123', 'dest_addr', short_message=content)
        self.assertEqual(len(seq_numbers), 1)
        submit_sm_pdu = yield self.fake_smsc.await_pdu()

        self.assertEqual(command_id(submit_sm_pdu), 'submit_sm')
        self.assertEqual(short_message(submit_sm_pdu), content)
        self.assertEqual(unpacked_pdu_opts(submit_sm_pdu), {})

    @inlineCallbacks
    def test_submit_csm_udh(self):
        """
        A long message can be sent in multiple PDUs with carefully handcrafted
        user data headers.
        """
        service = yield self.get_service({'send_multipart_udh': True})
        yield self.fake_smsc.bind()

        long_message = 'This is a long message.' * 20
        seq_numbers = yield service.submit_csm_udh(
            'abc123', 'dest_addr', short_message=long_message)
        pdus = yield self.fake_smsc.await_pdus(4)
        self.assertEqual(len(seq_numbers), 4)

        msg_parts = []
        msg_refs = []

        for i, sm in enumerate(pdus):
            mandatory_parameters = sm['body']['mandatory_parameters']
            self.assertEqual('submit_sm', sm['header']['command_id'])
            msg = mandatory_parameters['short_message']

            udh_hlen, udh_tag, udh_len, udh_ref, udh_tot, udh_seq = [
                ord(octet) for octet in msg[:6]]
            self.assertEqual(5, udh_hlen)
            self.assertEqual(0, udh_tag)
            self.assertEqual(3, udh_len)
            msg_refs.append(udh_ref)
            self.assertEqual(4, udh_tot)
            self.assertEqual(i + 1, udh_seq)
            self.assertTrue(len(msg) <= 136)
            msg_parts.append(msg[6:])
            self.assertEqual(0x40, mandatory_parameters['esm_class'])

        self.assertEqual(long_message, ''.join(msg_parts))
        self.assertEqual(1, len(set(msg_refs)))

        stored_ids = yield self.lookup_message_ids(service, seq_numbers)
        self.assertEqual(['abc123'] * len(seq_numbers), stored_ids)

    @inlineCallbacks
    def test_submit_csm_udh_ref_num_limit(self):
        """
        User data headers are crafted correctly when the generated reference
        number is larger than 0xFF.
        """
        service = yield self.get_service({'send_multipart_udh': True})
        yield self.fake_smsc.bind()
        # forward until we go past 0xFF
        yield self.set_sequence_number(service, 0x100)

        long_message = 'This is a long message.' * 20
        seq_numbers = yield service.submit_csm_udh(
            'abc123', 'dest_addr', short_message=long_message)
        pdus = yield self.fake_smsc.await_pdus(4)
        self.assertEqual(len(seq_numbers), 4)

        msg_parts = []
        msg_refs = []

        for i, sm in enumerate(pdus):
            mandatory_parameters = sm['body']['mandatory_parameters']
            self.assertEqual('submit_sm', sm['header']['command_id'])
            msg = mandatory_parameters['short_message']

            udh_hlen, udh_tag, udh_len, udh_ref, udh_tot, udh_seq = [
                ord(octet) for octet in msg[:6]]
            self.assertEqual(5, udh_hlen)
            self.assertEqual(0, udh_tag)
            self.assertEqual(3, udh_len)
            msg_refs.append(udh_ref)
            self.assertEqual(4, udh_tot)
            self.assertEqual(i + 1, udh_seq)
            self.assertTrue(len(msg) <= 136)
            msg_parts.append(msg[6:])
            self.assertEqual(0x40, mandatory_parameters['esm_class'])

        self.assertEqual(long_message, ''.join(msg_parts))
        self.assertEqual(1, len(set(msg_refs)))

        stored_ids = yield self.lookup_message_ids(service, seq_numbers)
        self.assertEqual(['abc123'] * len(seq_numbers), stored_ids)

    @inlineCallbacks
    def test_submit_csm_udh_single_part(self):
        """
        If the content fits in a single message, all the multipart madness is
        avoided.
        """
        service = yield self.get_service({'send_multipart_udh': True})
        yield self.fake_smsc.bind()

        content = 'a' * 160
        seq_numbers = yield service.submit_csm_udh(
            'abc123', 'dest_addr', short_message=content)
        self.assertEqual(len(seq_numbers), 1)
        submit_sm_pdu = yield self.fake_smsc.await_pdu()

        self.assertEqual(command_id(submit_sm_pdu), 'submit_sm')
        self.assertEqual(short_message(submit_sm_pdu), content)
        self.assertEqual(
            submit_sm_pdu['body']['mandatory_parameters']['esm_class'], 0)

    @inlineCallbacks
    def test_pdu_cache_persistence(self):
        """
        A cached PDU has an appropriate TTL and can be deleted.
        """
        service = yield self.get_service()

        message_stash = service.message_stash
        config = service.get_config()

        pdu = SubmitSM(1337, short_message="foo")
        yield message_stash.cache_pdu("vumi0", pdu)

        ttl = yield message_stash.redis.ttl(pdu_key(1337))
        self.assertTrue(0 < ttl <= config.submit_sm_expiry)

        pdu_data = yield message_stash.get_cached_pdu(1337)
        self.assertEqual(pdu_data.vumi_message_id, "vumi0")
        self.assertEqual(pdu_data.pdu.get_hex(), pdu.get_hex())

        yield message_stash.delete_cached_pdu(1337)
        deleted_pdu_data = yield message_stash.get_cached_pdu(1337)
        self.assertEqual(deleted_pdu_data, None)