|
| 1 | +""" |
| 2 | +Unit tests for delta (vcdiff) message decoding. |
| 3 | +
|
| 4 | +A protocol message can carry several messages, each one a delta from the message before it, so the |
| 5 | +decoding context is chained per message and a batch that fails part way through leaves it untouched. |
| 6 | +""" |
| 7 | + |
| 8 | +import base64 |
| 9 | + |
| 10 | +import pytest |
| 11 | + |
| 12 | +from ably import AblyRealtime |
| 13 | +from ably.types.channelstate import ChannelState |
| 14 | +from ably.types.message import Message |
| 15 | +from ably.types.mixins import DecodingContext |
| 16 | +from ably.types.options import VCDiffDecoder |
| 17 | +from ably.util.exceptions import AblyException |
| 18 | +from test.ably.utils import BaseAsyncTestCase |
| 19 | + |
| 20 | + |
| 21 | +class AppendingDecoder(VCDiffDecoder): |
| 22 | + """A decoder whose output depends on the base, so a wrong base is visible in the result""" |
| 23 | + |
| 24 | + def __init__(self): |
| 25 | + self.number_of_calls = 0 |
| 26 | + |
| 27 | + def decode(self, delta: bytes, base: bytes) -> bytes: |
| 28 | + self.number_of_calls += 1 |
| 29 | + return base + delta |
| 30 | + |
| 31 | + |
| 32 | +class FailingDecoder(VCDiffDecoder): |
| 33 | + |
| 34 | + def decode(self, delta: bytes, base: bytes) -> bytes: |
| 35 | + raise Exception("Failed to decode delta.") |
| 36 | + |
| 37 | + |
| 38 | +def full_message(id, data): |
| 39 | + return {'id': id, 'name': id, 'data': data, 'encoding': 'utf-8'} |
| 40 | + |
| 41 | + |
| 42 | +def delta_message(id, delta, from_id): |
| 43 | + """A message carrying `delta` as a vcdiff delta from the message `from_id`""" |
| 44 | + return { |
| 45 | + 'id': id, |
| 46 | + 'name': id, |
| 47 | + 'data': base64.b64encode(delta.encode()).decode(), |
| 48 | + 'encoding': 'utf-8/vcdiff/base64', |
| 49 | + 'extras': {'delta': {'format': 'vcdiff', 'from': from_id}}, |
| 50 | + } |
| 51 | + |
| 52 | + |
| 53 | +class TestDeltaBatchDecoding(BaseAsyncTestCase): |
| 54 | + """Message.from_encoded_array over a protocol message's messages""" |
| 55 | + |
| 56 | + def test_batch_of_chained_deltas_decodes(self): |
| 57 | + decoder = AppendingDecoder() |
| 58 | + context = DecodingContext(vcdiff_decoder=decoder) |
| 59 | + |
| 60 | + messages = Message.from_encoded_array([ |
| 61 | + full_message('m:0', 'a'), |
| 62 | + delta_message('m:1', 'b', from_id='m:0'), |
| 63 | + delta_message('m:2', 'c', from_id='m:1'), |
| 64 | + ], context=context) |
| 65 | + |
| 66 | + assert [message.data for message in messages] == ['a', 'ab', 'abc'] |
| 67 | + assert decoder.number_of_calls == 2 |
| 68 | + assert context.last_message_id == 'm:2' |
| 69 | + |
| 70 | + def test_delta_chains_from_the_previous_batch(self): |
| 71 | + context = DecodingContext(vcdiff_decoder=AppendingDecoder()) |
| 72 | + |
| 73 | + Message.from_encoded_array([full_message('m:0', 'a')], context=context) |
| 74 | + messages = Message.from_encoded_array([delta_message('m:1', 'b', from_id='m:0')], |
| 75 | + context=context) |
| 76 | + |
| 77 | + assert [message.data for message in messages] == ['ab'] |
| 78 | + |
| 79 | + def test_delta_from_an_unknown_message_is_rejected(self): |
| 80 | + context = DecodingContext(vcdiff_decoder=AppendingDecoder()) |
| 81 | + Message.from_encoded_array([full_message('m:0', 'a')], context=context) |
| 82 | + |
| 83 | + with pytest.raises(AblyException) as excinfo: |
| 84 | + Message.from_encoded_array([delta_message('m:9', 'b', from_id='m:8')], context=context) |
| 85 | + |
| 86 | + assert excinfo.value.code == 40018 |
| 87 | + |
| 88 | + def test_a_batch_that_fails_leaves_the_context_unchanged(self): |
| 89 | + # RTL18: the batch is discarded and replayed by the server from the last channel serial, |
| 90 | + # so the base payload has to still be the one the replayed batch decodes against |
| 91 | + context = DecodingContext(vcdiff_decoder=AppendingDecoder()) |
| 92 | + Message.from_encoded_array([full_message('m:0', 'a')], context=context) |
| 93 | + base_payload, last_message_id = context.base_payload, context.last_message_id |
| 94 | + |
| 95 | + with pytest.raises(AblyException): |
| 96 | + Message.from_encoded_array([ |
| 97 | + delta_message('m:1', 'b', from_id='m:0'), |
| 98 | + delta_message('m:2', 'c', from_id='out-of-order'), |
| 99 | + ], context=context) |
| 100 | + |
| 101 | + assert context.base_payload == base_payload |
| 102 | + assert context.last_message_id == last_message_id |
| 103 | + |
| 104 | + def test_a_batch_whose_decoder_fails_leaves_the_context_unchanged(self): |
| 105 | + context = DecodingContext(vcdiff_decoder=FailingDecoder()) |
| 106 | + Message.from_encoded_array([full_message('m:0', 'a')], context=context) |
| 107 | + base_payload, last_message_id = context.base_payload, context.last_message_id |
| 108 | + |
| 109 | + with pytest.raises(AblyException) as excinfo: |
| 110 | + Message.from_encoded_array([delta_message('m:1', 'b', from_id='m:0')], context=context) |
| 111 | + |
| 112 | + assert excinfo.value.code == 40018 |
| 113 | + assert context.base_payload == base_payload |
| 114 | + assert context.last_message_id == last_message_id |
| 115 | + |
| 116 | + def test_messages_decode_without_a_decoding_context(self): |
| 117 | + # the REST paths (history, for one) decode without a context |
| 118 | + messages = Message.from_encoded_array([full_message('m:0', 'a')]) |
| 119 | + |
| 120 | + assert [message.data for message in messages] == ['a'] |
| 121 | + |
| 122 | + def test_a_delta_without_a_decoding_context_reports_the_missing_decoder(self): |
| 123 | + # and not a failure to look up the previous message id in a context that isn't there |
| 124 | + with pytest.raises(AblyException) as excinfo: |
| 125 | + Message.from_encoded_array([delta_message('m:1', 'b', from_id='m:0')]) |
| 126 | + |
| 127 | + assert excinfo.value.code == 40019 |
| 128 | + |
| 129 | + |
| 130 | +class TestChannelDeltaBatch(BaseAsyncTestCase): |
| 131 | + """RealtimeChannel handling of a protocol message carrying several deltas""" |
| 132 | + |
| 133 | + def setup_channel(self, decoder): |
| 134 | + ably = AblyRealtime(key='not_a.real:key', auto_connect=False, vcdiff_decoder=decoder) |
| 135 | + channel = ably.channels.get('delta') |
| 136 | + received = [] |
| 137 | + # subscribe() would also wait for the channel to attach, which this client cannot do |
| 138 | + channel._RealtimeChannel__message_emitter.on(lambda message: received.append(message)) |
| 139 | + return ably, channel, received |
| 140 | + |
| 141 | + async def test_every_message_of_a_delta_batch_is_emitted(self): |
| 142 | + ably, channel, received = self.setup_channel(AppendingDecoder()) |
| 143 | + |
| 144 | + channel._on_message({ |
| 145 | + 'action': 15, |
| 146 | + 'channel': 'delta', |
| 147 | + 'channelSerial': 'serial-1', |
| 148 | + 'messages': [ |
| 149 | + full_message('m:0', 'a'), |
| 150 | + delta_message('m:1', 'b', from_id='m:0'), |
| 151 | + delta_message('m:2', 'c', from_id='m:1'), |
| 152 | + ], |
| 153 | + }) |
| 154 | + |
| 155 | + assert [message.data for message in received] == ['a', 'ab', 'abc'] |
| 156 | + assert channel._RealtimeChannel__channel_serial == 'serial-1' |
| 157 | + await ably.close() |
| 158 | + |
| 159 | + async def test_a_batch_that_fails_to_decode_starts_recovery(self): |
| 160 | + ably, channel, received = self.setup_channel(FailingDecoder()) |
| 161 | + channel._on_message({ |
| 162 | + 'action': 15, 'channel': 'delta', 'channelSerial': 'serial-1', |
| 163 | + 'messages': [full_message('m:0', 'a')], |
| 164 | + }) |
| 165 | + |
| 166 | + channel._on_message({ |
| 167 | + 'action': 15, |
| 168 | + 'channel': 'delta', |
| 169 | + 'channelSerial': 'serial-2', |
| 170 | + 'messages': [delta_message('m:1', 'b', from_id='m:0')], |
| 171 | + }) |
| 172 | + |
| 173 | + # RTL18b/RTL18c: the batch is dropped and the channel reattaches from the serial of the |
| 174 | + # last batch it decoded |
| 175 | + assert [message.data for message in received] == ['a'] |
| 176 | + assert channel._RealtimeChannel__channel_serial == 'serial-1' |
| 177 | + assert channel.state == ChannelState.ATTACHING |
| 178 | + assert channel.error_reason.code == 40018 |
| 179 | + await ably.close() |
0 commit comments