Skip to content

Commit b9eafee

Browse files
committed
message: Preface message move handling with more comprehensive null-checks
This throws `AssertionError`s on debug builds when the event data is malformed, and ignores it for release builds, ensuring that none of the move-related fields are `null` by the time we look at their values. Because of this simplification, it is easier to make sense of the constraints between the fields when there is a move. This is similar to zulip-mobile code for parsing move data. The main difference is that we now also check the value of `propagate_mode`, which is documented to be present on message moves. See Greg's comment on this: zulip#1311 (comment) Signed-off-by: Zixuan James Li <[email protected]>
1 parent a220016 commit b9eafee

File tree

3 files changed

+98
-26
lines changed

3 files changed

+98
-26
lines changed

lib/model/message.dart

+21-26
Original file line numberDiff line numberDiff line change
@@ -173,44 +173,39 @@ class MessageStoreImpl with MessageStore {
173173
// For reference, see: https://zulip.com/api/get-events#update_message
174174

175175
final origStreamId = event.origStreamId;
176-
final newStreamId = event.newStreamId; // null if topic-only move
176+
final newStreamId = event.newStreamId ?? origStreamId;
177177
final origTopic = event.origTopic;
178-
final newTopic = event.newTopic;
178+
final newTopic = event.newTopic ?? origTopic;
179179
final propagateMode = event.propagateMode;
180180

181-
if (origTopic == null) {
181+
if (origTopic == newTopic && origStreamId == newStreamId) {
182+
assert(propagateMode == null,
183+
'Malformed UpdateMessageEvent: incoherent message-move fields; '
184+
'propagate_mode present but no new channel or topic'); // TODO(log)
182185
// There was no move.
183-
assert(() {
184-
if (newStreamId != null && origStreamId != null
185-
&& newStreamId != origStreamId) {
186-
// This should be impossible; `orig_subject` (aka origTopic) is
187-
// documented to be present when either the stream or topic changed.
188-
debugLog('Malformed UpdateMessageEvent: stream move but no origTopic'); // TODO(log)
189-
}
190-
return true;
191-
}());
192186
return;
193187
}
194188

195-
if (newStreamId == null && newTopic == null) {
196-
// If neither the channel nor topic name changed, nothing moved.
197-
// In that case `orig_subject` (aka origTopic) should have been null.
198-
assert(debugLog('Malformed UpdateMessageEvent: move but no newStreamId or newTopic')); // TODO(log)
189+
if (origStreamId == null || newStreamId == null) {
190+
// The `stream_id` field (aka origStreamId) is documented to be present on moves;
191+
// newStreamId should not be null either because it falls back to origStreamId.
192+
assert(false, 'Malformed UpdateMessageEvent: move but no origStreamId'); // TODO(log)
199193
return;
200194
}
201-
if (origStreamId == null) {
202-
// The `stream_id` field (aka origStreamId) is documented to be present on moves.
203-
assert(debugLog('Malformed UpdateMessageEvent: move but no origStreamId')); // TODO(log)
195+
if (origTopic == null || newTopic == null) {
196+
// The `orig_subject` field (aka origTopic) is documented to be present on moves;
197+
// newTopic should not be null either because it falls back to origTopic.
198+
assert(false, 'Malformed UpdateMessageEvent: move but no origTopic'); // TODO(log)
204199
return;
205200
}
206201
if (propagateMode == null) {
207202
// The `propagate_mode` field (aka propagateMode) is documented to be present on moves.
208-
assert(debugLog('Malformed UpdateMessageEvent: move but no propagateMode')); // TODO(log)
203+
assert(false, 'Malformed UpdateMessageEvent: move but no propagateMode'); // TODO(log)
209204
return;
210205
}
211206

212-
final wasResolveOrUnresolve = (newStreamId == null
213-
&& MessageEditState.topicMoveWasResolveOrUnresolve(origTopic, newTopic!));
207+
final wasResolveOrUnresolve = origStreamId == newStreamId
208+
&& MessageEditState.topicMoveWasResolveOrUnresolve(origTopic, newTopic);
214209

215210
for (final messageId in event.messageIds) {
216211
final message = messages[messageId];
@@ -221,14 +216,14 @@ class MessageStoreImpl with MessageStore {
221216
continue;
222217
}
223218

224-
if (newStreamId != null) {
219+
if (origStreamId != newStreamId) {
225220
message.streamId = newStreamId;
226221
// See [StreamMessage.displayRecipient] on why the invalidation is
227222
// needed.
228223
message.displayRecipient = null;
229224
}
230225

231-
if (newTopic != null) {
226+
if (origTopic != newTopic) {
232227
message.topic = newTopic;
233228
}
234229

@@ -241,9 +236,9 @@ class MessageStoreImpl with MessageStore {
241236
for (final view in _messageListViews) {
242237
view.messagesMoved(
243238
origStreamId: origStreamId,
244-
newStreamId: newStreamId ?? origStreamId,
239+
newStreamId: newStreamId,
245240
origTopic: origTopic,
246-
newTopic: newTopic ?? origTopic,
241+
newTopic: newTopic,
247242
messageIds: event.messageIds,
248243
propagateMode: propagateMode,
249244
);

test/api/model/model_checks.dart

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ extension TopicNameChecks on Subject<TopicName> {
5353

5454
extension StreamMessageChecks on Subject<StreamMessage> {
5555
Subject<String?> get displayRecipient => has((e) => e.displayRecipient, 'displayRecipient');
56+
Subject<int> get streamId => has((e) => e.streamId, 'streamId');
5657
Subject<TopicName> get topic => has((e) => e.topic, 'topic');
5758
}
5859

test/model/message_test.dart

+76
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,82 @@ void main() {
377377
});
378378
});
379379

380+
group('message move', () {
381+
final message = eg.streamMessage();
382+
final baseJson = {
383+
'id': 1,
384+
'type': 'update_message',
385+
'user_id': eg.selfUser.userId,
386+
'rendering_only': false,
387+
'message_id': message.id,
388+
'message_ids': [message.id],
389+
'flags': <String>[],
390+
'edit_timestamp': 1718741351,
391+
'stream_id': eg.stream().streamId,
392+
};
393+
final baseMoveJson = { ...baseJson,
394+
'orig_subject': 'foo',
395+
'propagate_mode': 'change_all',
396+
};
397+
398+
Future<void> setupAndHandleEvent(Map<String, Object?> json) async {
399+
await prepare();
400+
await prepareMessages([message]);
401+
await store.handleEvent(Event.fromJson(json) as UpdateMessageEvent);
402+
}
403+
404+
test('smoke', () async {
405+
await setupAndHandleEvent({ ...baseMoveJson,
406+
'stream_id': 1,
407+
'new_stream_id': 2,
408+
});
409+
checkNotified(count: 2);
410+
check(store).messages[message.id].isA<StreamMessage>()
411+
..topic.equals(message.topic)
412+
..streamId.equals(2);
413+
});
414+
415+
test('no message move', () async {
416+
await setupAndHandleEvent({ ...baseJson,
417+
'orig_content': 'foo',
418+
'orig_rendered_content': 'foo',
419+
'content': 'bar',
420+
'rendered_content': 'bar',
421+
});
422+
checkNotifiedOnce();
423+
});
424+
425+
test('stream move but no orig_subject', () async {
426+
await check(setupAndHandleEvent({ ...baseMoveJson,
427+
'stream_id': 1,
428+
'new_stream_id': 2,
429+
'orig_subject': null,
430+
})).throws<AssertionError>();
431+
});
432+
433+
test('move but no subject or new_stream_id', () async {
434+
await check(setupAndHandleEvent({ ...baseMoveJson,
435+
'new_stream_id': null,
436+
'subject': null,
437+
})).throws<AssertionError>();
438+
});
439+
440+
test('move but no orig_stream_id', () async {
441+
await check(setupAndHandleEvent({ ...baseMoveJson,
442+
'stream_id': null,
443+
'new_stream_id': 2,
444+
})).throws<AssertionError>();
445+
});
446+
447+
test('move but no propagate_mode', () async {
448+
await check(setupAndHandleEvent({ ...baseMoveJson,
449+
'orig_subject': 'foo',
450+
'subject': 'bar',
451+
'propagate_mode': null,
452+
})).throws<AssertionError>();
453+
});
454+
});
455+
380456
group('handleDeleteMessageEvent', () {
381457
test('delete an unknown message', () async {
382458
final message1 = eg.streamMessage();

0 commit comments

Comments
 (0)