Skip to content

Commit 6c1eee2

Browse files
authored
chore(llc): add type safety in extraData getters (#2274)
1 parent e833cf7 commit 6c1eee2

File tree

5 files changed

+240
-14
lines changed

5 files changed

+240
-14
lines changed

packages/stream_chat/lib/src/core/models/channel_model.dart

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:json_annotation/json_annotation.dart';
22
import 'package:stream_chat/src/core/models/channel_config.dart';
33
import 'package:stream_chat/src/core/models/member.dart';
44
import 'package:stream_chat/src/core/models/user.dart';
5+
import 'package:stream_chat/src/core/util/extension.dart';
56
import 'package:stream_chat/src/core/util/serializer.dart';
67

78
part 'channel_model.g.dart';
@@ -94,7 +95,7 @@ class ChannelModel {
9495

9596
/// The date at which the channel was last updated.
9697
@JsonKey(includeToJson: false, includeFromJson: false)
97-
DateTime? get lastUpdatedAt => lastMessageAt ?? createdAt;
98+
DateTime get lastUpdatedAt => lastMessageAt ?? createdAt;
9899

99100
/// The date of the last channel update
100101
@JsonKey(includeToJson: false)
@@ -118,18 +119,21 @@ class ChannelModel {
118119

119120
/// True if the channel is disabled
120121
@JsonKey(includeToJson: false, includeFromJson: false)
121-
bool? get disabled => extraData['disabled'] as bool?;
122+
bool? get disabled => extraData['disabled'].safeCast<bool>();
122123

123124
/// True if the channel is hidden
124125
@JsonKey(includeToJson: false, includeFromJson: false)
125-
bool? get hidden => extraData['hidden'] as bool?;
126+
bool? get hidden => extraData['hidden'].safeCast<bool>();
126127

127128
/// The date of the last time channel got truncated
128129
@JsonKey(includeToJson: false, includeFromJson: false)
129130
DateTime? get truncatedAt {
130-
final truncatedAt = extraData['truncated_at'] as String?;
131-
if (truncatedAt == null) return null;
132-
return DateTime.parse(truncatedAt);
131+
final truncatedAt = extraData['truncated_at'].safeCast<String>();
132+
if (truncatedAt != null && truncatedAt.isNotEmpty) {
133+
return DateTime.parse(truncatedAt);
134+
}
135+
136+
return null;
133137
}
134138

135139
/// Map of custom channel extraData
@@ -139,6 +143,14 @@ class ChannelModel {
139143
@JsonKey(includeToJson: false)
140144
final String? team;
141145

146+
/// Shortcut for channel name
147+
String? get name {
148+
final name = extraData['name'].safeCast<String>();
149+
if (name != null && name.isNotEmpty) return name;
150+
151+
return null;
152+
}
153+
142154
/// Known top level fields.
143155
/// Useful for [Serializer] methods.
144156
static const topLevelFields = [
@@ -159,9 +171,6 @@ class ChannelModel {
159171
'cooldown',
160172
];
161173

162-
/// Shortcut for channel name
163-
String? get name => extraData['name'] as String?;
164-
165174
/// Serialize to json
166175
Map<String, dynamic> toJson() => Serializer.moveFromExtraDataToRoot(
167176
_$ChannelModelToJson(this),

packages/stream_chat/lib/src/core/models/user.dart

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:equatable/equatable.dart';
22
import 'package:json_annotation/json_annotation.dart';
33
import 'package:stream_chat/src/core/models/comparable_field.dart';
4+
import 'package:stream_chat/src/core/util/extension.dart';
45
import 'package:stream_chat/src/core/util/serializer.dart';
56

67
part 'user.g.dart';
@@ -83,18 +84,22 @@ class User extends Equatable implements ComparableFieldProvider {
8384
/// {@macro name}
8485
@JsonKey(includeToJson: false, includeFromJson: false)
8586
String get name {
86-
if (extraData.containsKey('name') && extraData['name'] != null) {
87-
final name = extraData['name']! as String;
88-
if (name.isNotEmpty) return name;
89-
}
87+
final name = extraData['name'].safeCast<String>();
88+
if (name != null && name.isNotEmpty) return name;
89+
9090
return id;
9191
}
9292

9393
/// Shortcut for user image.
9494
///
9595
/// {@macro image}
9696
@JsonKey(includeToJson: false, includeFromJson: false)
97-
String? get image => extraData['image'] as String?;
97+
String? get image {
98+
final image = extraData['image'].safeCast<String>();
99+
if (image != null && image.isNotEmpty) return image;
100+
101+
return null;
102+
}
98103

99104
/// User role.
100105
@JsonKey(includeToJson: false)

packages/stream_chat/lib/src/core/util/extension.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,14 @@ extension IterableMergeExtension<T extends Object?> on Iterable<T> {
9292
return itemMap.values;
9393
}
9494
}
95+
96+
/// Extension on [Object] providing safe casting functionality.
97+
extension SafeCastExtension on Object? {
98+
/// Safely casts the object to a specific type.
99+
///
100+
/// Returns null if the object is null or cannot be cast to the type.
101+
T? safeCast<T>() {
102+
if (this is T) return this as T;
103+
return null;
104+
}
105+
}

packages/stream_chat/test/src/core/models/channel_test.dart

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// ignore_for_file: avoid_redundant_argument_values
2+
13
import 'package:stream_chat/src/core/models/channel_model.dart';
24
import 'package:test/test.dart';
35

@@ -188,4 +190,131 @@ void main() {
188190
expect(newChannel.extraData['truncated_at'], dateThree.toIso8601String());
189191
expect(newChannel.truncatedAt, dateThree);
190192
});
193+
194+
test('.name should fetch from extraData if available', () {
195+
final channel = ChannelModel(
196+
cid: 'test:channel',
197+
extraData: const {'name': 'Test Channel'},
198+
);
199+
200+
expect(channel.name, 'Test Channel');
201+
});
202+
203+
test('.name should return null if not available in extraData', () {
204+
final channel = ChannelModel(
205+
cid: 'test:channel',
206+
extraData: const {},
207+
);
208+
209+
expect(channel.name, isNull);
210+
});
211+
212+
test('.name should return null if extraData value is not String', () {
213+
final channel = ChannelModel(
214+
cid: 'test:channel',
215+
extraData: const {'name': true},
216+
);
217+
218+
expect(channel.name, isNull);
219+
});
220+
221+
test('.name should return null if extraData value is empty', () {
222+
final channel = ChannelModel(
223+
cid: 'test:channel',
224+
extraData: const {'name': ''},
225+
);
226+
227+
expect(channel.name, isNull);
228+
});
229+
230+
test('.disabled should fetch from extraData if available', () {
231+
final channel = ChannelModel(
232+
cid: 'test:channel',
233+
extraData: const {'disabled': true},
234+
);
235+
236+
expect(channel.disabled, true);
237+
});
238+
239+
test('.disabled should return null if not available in extraData', () {
240+
final channel = ChannelModel(
241+
cid: 'test:channel',
242+
extraData: const {},
243+
);
244+
245+
expect(channel.disabled, isNull);
246+
});
247+
248+
test('.disabled should return null if extraData value is not bool', () {
249+
final channel = ChannelModel(
250+
cid: 'test:channel',
251+
extraData: const {'disabled': 'true'},
252+
);
253+
254+
expect(channel.disabled, isNull);
255+
});
256+
257+
test('.hidden should fetch from extraData if available', () {
258+
final channel = ChannelModel(
259+
cid: 'test:channel',
260+
extraData: const {'hidden': true},
261+
);
262+
263+
expect(channel.hidden, true);
264+
});
265+
266+
test('.hidden should return null if not available in extraData', () {
267+
final channel = ChannelModel(
268+
cid: 'test:channel',
269+
extraData: const {},
270+
);
271+
272+
expect(channel.hidden, isNull);
273+
});
274+
275+
test('.hidden should return null if extraData value is not bool', () {
276+
final channel = ChannelModel(
277+
cid: 'test:channel',
278+
extraData: const {'hidden': 'false'},
279+
);
280+
281+
expect(channel.hidden, isNull);
282+
});
283+
284+
test('.truncatedAt should fetch from extraData if available', () {
285+
final testDate = DateTime.now();
286+
final channel = ChannelModel(
287+
cid: 'test:channel',
288+
extraData: {'truncated_at': testDate.toIso8601String()},
289+
);
290+
291+
expect(channel.truncatedAt, testDate);
292+
});
293+
294+
test('.truncatedAt should return null if not available in extraData', () {
295+
final channel = ChannelModel(
296+
cid: 'test:channel',
297+
extraData: const {},
298+
);
299+
300+
expect(channel.truncatedAt, isNull);
301+
});
302+
303+
test('.truncatedAt should return null if extraData value is not String', () {
304+
final channel = ChannelModel(
305+
cid: 'test:channel',
306+
extraData: const {'truncated_at': true},
307+
);
308+
309+
expect(channel.truncatedAt, isNull);
310+
});
311+
312+
test('.truncatedAt should return null if extraData value is empty', () {
313+
final channel = ChannelModel(
314+
cid: 'test:channel',
315+
extraData: const {'truncated_at': ''},
316+
);
317+
318+
expect(channel.truncatedAt, isNull);
319+
});
191320
}

packages/stream_chat/test/src/core/models/user_test.dart

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,78 @@ void main() {
366366
expect(field!.value, equals('without-name')); // Fallback to user id
367367
});
368368
});
369+
370+
test('.name should fetch from extraData if available', () {
371+
final user = User(
372+
id: 'test-user',
373+
extraData: const {'name': 'Test User'},
374+
);
375+
376+
expect(user.name, 'Test User');
377+
});
378+
379+
test('.name should return id if extraData value is empty', () {
380+
final user = User(
381+
id: 'test-user',
382+
extraData: const {'name': ''},
383+
);
384+
385+
expect(user.name, 'test-user');
386+
});
387+
388+
test('.name should return id if not available in extraData', () {
389+
final user = User(
390+
id: 'test-user',
391+
extraData: const {},
392+
);
393+
394+
expect(user.name, 'test-user');
395+
});
396+
397+
test('.name should return id if extraData value is not String', () {
398+
final user = User(
399+
id: 'test-user',
400+
extraData: const {'name': true},
401+
);
402+
403+
expect(user.name, 'test-user');
404+
});
405+
406+
test('.image should fetch from extraData if available', () {
407+
final user = User(
408+
id: 'test-user',
409+
extraData: const {'image': 'https://example.com/image.png'},
410+
);
411+
412+
expect(user.image, 'https://example.com/image.png');
413+
});
414+
415+
test('.image should return null if not available in extraData', () {
416+
final user = User(
417+
id: 'test-user',
418+
extraData: const {},
419+
);
420+
421+
expect(user.image, isNull);
422+
});
423+
424+
test('.image should return null if extraData value is not String', () {
425+
final user = User(
426+
id: 'test-user',
427+
extraData: const {'image': true},
428+
);
429+
430+
expect(user.image, isNull);
431+
});
432+
433+
test('.image should return null if extraData value is empty', () {
434+
final user = User(
435+
id: 'test-user',
436+
extraData: const {'image': ''},
437+
);
438+
439+
expect(user.image, isNull);
440+
});
369441
});
370442
}
371443

0 commit comments

Comments
 (0)