hardened API decoding against server fields the models did not expect
The Talk room list crashed for a new account because the server omits
lastMessage for rooms without a visible message and the model required it.
Beyond that single field, a review of the Talk models against the OpenAPI
spec of the installed version (23.0.8) found the same crash class in several
places: closed enums over open server values (inCall bitmask, rich-object
types, actor and message types), a deleted parent sent as {id, deleted},
and the autocomplete status arriving as an object.
- json_serializable `checked: true`: decode errors now name class and field
- lists (rooms, messages, participants, autocomplete) decode per entry;
a malformed entry is skipped and reported via ClientErrorReporter
- unknownEnumValue fallbacks for all response enums of Talk and breakers
- lastMessage nullable; inCall as bitmask; deleted parent placeholder;
autocomplete status object/string; fixed shareWithDisplayNameUnique key
- GetRoomParams no longer sends null query parameters
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrepDTL4qDe35TuS29sLW3
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/json_lenient.dart';
|
||||
import 'package:marianum_mobile/api/marianumcloud/autocomplete/autocomplete_response.dart';
|
||||
|
||||
void main() {
|
||||
test('accepts the status as object, string or empty', () {
|
||||
final response = AutocompleteResponse.fromJson({
|
||||
'data': [
|
||||
{
|
||||
'id': 'a',
|
||||
'label': 'A',
|
||||
'status': {'status': 'online', 'message': null},
|
||||
},
|
||||
{'id': 'b', 'label': 'B', 'status': ''},
|
||||
{'id': 'c', 'label': 'C', 'status': 'dnd'},
|
||||
],
|
||||
});
|
||||
expect(response.data.map((e) => e.status), ['online', null, 'dnd']);
|
||||
expect(drainDecodeWarnings(), isEmpty);
|
||||
});
|
||||
|
||||
test('skips an entry without id instead of failing the search', () {
|
||||
final response = AutocompleteResponse.fromJson({
|
||||
'data': [
|
||||
{'label': 'no id'},
|
||||
{'id': 'b', 'label': 'B'},
|
||||
],
|
||||
});
|
||||
expect(response.data.single.id, 'b');
|
||||
expect(drainDecodeWarnings().single, contains('"id"'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/marianumcloud/talk/get_participants/get_participants_response.dart';
|
||||
|
||||
void main() {
|
||||
test('reads inCall as a bitmask', () {
|
||||
final participant = GetParticipantsResponseObject.fromJson({
|
||||
'attendeeId': 1,
|
||||
'actorType': 'users',
|
||||
'actorId': 'u',
|
||||
'displayName': 'U',
|
||||
'participantType': 3,
|
||||
'lastPing': 0,
|
||||
'inCall': 7,
|
||||
'permissions': 0,
|
||||
'attendeePermissions': 0,
|
||||
'sessionIds': <String>[],
|
||||
});
|
||||
expect(participant.isInCall, isTrue);
|
||||
expect(participant.inCallWithAudio, isTrue);
|
||||
expect(participant.inCallWithVideo, isTrue);
|
||||
expect(participant.inCallViaPhone, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/json_lenient.dart';
|
||||
import 'package:marianum_mobile/api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import 'package:marianum_mobile/api/marianumcloud/talk/room/get_room_response.dart';
|
||||
|
||||
Map<String, dynamic> _room({bool withLastMessage = true}) => {
|
||||
'id': 1,
|
||||
'token': 'abc',
|
||||
'type': 2,
|
||||
'name': 'room',
|
||||
'displayName': 'Room',
|
||||
'description': '',
|
||||
'participantType': 3,
|
||||
'participantFlags': 0,
|
||||
'readOnly': 0,
|
||||
'listable': 0,
|
||||
'lastPing': 0,
|
||||
'sessionId': '0',
|
||||
'hasPassword': false,
|
||||
'hasCall': false,
|
||||
'callFlag': 0,
|
||||
'canStartCall': true,
|
||||
'canDeleteConversation': false,
|
||||
'canLeaveConversation': true,
|
||||
'lastActivity': 1700000000,
|
||||
'isFavorite': false,
|
||||
'notificationLevel': 0,
|
||||
'unreadMessages': 0,
|
||||
'unreadMention': false,
|
||||
'unreadMentionDirect': false,
|
||||
'lastReadMessage': 0,
|
||||
'lastCommonReadMessage': 0,
|
||||
if (withLastMessage)
|
||||
'lastMessage': {
|
||||
'id': 7,
|
||||
'token': 'abc',
|
||||
'actorType': 'users',
|
||||
'actorId': 'u',
|
||||
'actorDisplayName': 'U',
|
||||
'timestamp': 1700000000,
|
||||
'systemMessage': '',
|
||||
'messageType': 'comment',
|
||||
'isReplyable': true,
|
||||
'referenceId': '',
|
||||
'message': 'hi',
|
||||
},
|
||||
};
|
||||
|
||||
void main() {
|
||||
setUp(drainDecodeWarnings);
|
||||
|
||||
test('skips a room that does not parse and records class and key', () {
|
||||
final broken = _room()..remove('displayName');
|
||||
final response = GetRoomResponse.fromJson({
|
||||
'data': [broken, _room()],
|
||||
});
|
||||
expect(response.data.length, 1);
|
||||
final warnings = drainDecodeWarnings();
|
||||
expect(warnings, hasLength(1));
|
||||
expect(warnings.single, contains('GetRoomResponseObject'));
|
||||
expect(warnings.single, contains('"displayName"'));
|
||||
});
|
||||
|
||||
test('parses a room whose lastMessage the server omitted', () {
|
||||
final response = GetRoomResponse.fromJson({
|
||||
'data': [_room(withLastMessage: false), _room()],
|
||||
});
|
||||
final rooms = response.data.toList();
|
||||
expect(rooms[0].lastMessage, isNull);
|
||||
expect(rooms[1].lastMessage?.id, 7);
|
||||
expect(drainDecodeWarnings(), isEmpty);
|
||||
});
|
||||
|
||||
test('maps unknown enum values to their fallbacks', () {
|
||||
final room = _room()
|
||||
..['type'] = 99
|
||||
..['notificationLevel'] = 42;
|
||||
(room['lastMessage'] as Map<String, dynamic>)
|
||||
..['actorType'] = 'emails'
|
||||
..['messageType'] = 'record-video'
|
||||
..['messageParameters'] = {
|
||||
'actor': {'type': 'deck-card', 'id': '1', 'name': 'Card'},
|
||||
};
|
||||
final parsed = GetRoomResponse.fromJson({
|
||||
'data': [room],
|
||||
}).data.single;
|
||||
expect(parsed.type, GetRoomResponseObjectConversationType.unknown);
|
||||
expect(
|
||||
parsed.notificationLevel,
|
||||
GetRoomResponseObjectParticipantNotificationLevel.defaultLevel,
|
||||
);
|
||||
expect(
|
||||
parsed.lastMessage?.actorType,
|
||||
GetRoomResponseObjectMessageActorType.email,
|
||||
);
|
||||
expect(
|
||||
parsed.lastMessage?.messageType,
|
||||
GetRoomResponseObjectMessageType.unknown,
|
||||
);
|
||||
expect(
|
||||
parsed.lastMessage?.messageParameters?['actor']?.type,
|
||||
RichObjectStringObjectType.unknown,
|
||||
);
|
||||
expect(drainDecodeWarnings(), isEmpty);
|
||||
});
|
||||
|
||||
test('turns a deleted parent into a placeholder', () {
|
||||
final room = _room();
|
||||
(room['lastMessage'] as Map<String, dynamic>)['parent'] = {
|
||||
'id': 3,
|
||||
'deleted': true,
|
||||
};
|
||||
final parent = GetRoomResponse.fromJson({
|
||||
'data': [room],
|
||||
}).data.single.lastMessage?.parent;
|
||||
expect(parent?.id, 3);
|
||||
expect(
|
||||
parent?.messageType,
|
||||
GetRoomResponseObjectMessageType.deletedComment,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user