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:
2026-09-29 16:35:41 +02:00
co-authored by Claude Fable 5.1
parent a79b376bb3
commit 279b973e89
92 changed files with 2150 additions and 963 deletions
@@ -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);
});
}
+122
View File
@@ -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,
);
});
}