refactor(chat): message dedup

This commit is contained in:
Anatoliy
2026-09-15 23:06:12 +03:00
parent c4f5cae36d
commit 077cfb3a67
3 changed files with 131 additions and 2 deletions

View File

@@ -31,6 +31,11 @@ abstract class ChatMessageModel with _$ChatMessageModel {
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
// @deprecated Backend BC-поле. В broadcast (ChatMessageUpdated) оно
// recipient-relative и для второго устройства отправителя всегда false,
// поэтому решать «моё/не моё» по нему нельзя. Источник истины — authorId:
// сравнивайте его со своим user_id (wsManager.userId). Поле оставлено
// только для обратной совместимости парсинга.
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
@JsonKey(name: 'room_id') required String? roomId,
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,

View File

@@ -144,6 +144,9 @@ class ChatsCubit extends Cubit<ChatsState> {
final json = jsonDecode(data) as Map<String, dynamic>;
final message = ChatMessageModel.fromJson(json);
final currentUserId = wsManager.userId;
final isOwn = currentUserId != null && message.authorId == currentUserId;
final currentChatRooms = [...state.chatRooms];
bool wasChanged = false;
@@ -151,10 +154,18 @@ class ChatsCubit extends Cubit<ChatsState> {
final updatedChatRooms = currentChatRooms.map((chatRoom) {
if (chatRoom.id == message.roomId) {
wasChanged = true;
if (chatRoom.unreadMessagesCount == 0) {
// Идемпотентность: повторная доставка того же message.id (реконнект,
// доставка по нескольким каналам) не начисляет непрочитанное дважды.
final isDuplicate = message.id != 0 && chatRoom.lastMessage?.id == message.id;
// Собственное сообщение (второе устройство автора) обновляет
// lastMessage, но не создаёт непрочитанное (S3).
if (!isOwn && !isDuplicate && chatRoom.unreadMessagesCount == 0) {
willIncrementUnreadChats = true;
}
return chatRoom.copyWith(lastMessage: message, unreadMessagesCount: chatRoom.unreadMessagesCount + 1);
return chatRoom.copyWith(
lastMessage: message,
unreadMessagesCount: isOwn || isDuplicate ? chatRoom.unreadMessagesCount : chatRoom.unreadMessagesCount + 1,
);
}
return chatRoom;
}).toList();

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:eatvio_app/core/enums/base_state.dart';
import 'package:eatvio_app/core/models/pagination_model.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
@@ -43,6 +44,39 @@ void main() {
}
});
ChatRoom buildChatRoom(String chatId, {int unread = 0}) {
return ChatRoom(
id: chatId,
lastMessage: null,
users: const Users(
fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''),
toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''),
),
unreadMessagesCount: unread,
chosenPlan: null,
);
}
ChannelReadEvent socketEvent({required int id, required int authorId, required String roomId}) {
return _FakeChannelReadEvent(jsonEncode(<String, dynamic>{
'id': id,
'data': 'socket message',
'media_data': null,
'author_id': authorId,
'updated_at': DateTime.now().toIso8601String(),
'read_by_to_user': false,
'is_author_message_auth_user': false,
'room_id': roomId,
}));
}
Future<StreamController<ChannelReadEvent>> connectToSocketStream() async {
final controller = StreamController<ChannelReadEvent>.broadcast();
when(mockWsManager.newMessagesStream).thenAnswer((_) => controller.stream);
await chatsCubit.connectToWebSocket();
return controller;
}
test('начальное состояние - initial', () {
expect(chatsCubit.state, isA<ChatsState>());
expect(chatsCubit.state.state, BaseState.initial);
@@ -180,6 +214,76 @@ void main() {
verify(mockWsManager.newMessagesStream).called(1);
});
test('_handleNewMessageViaSocket - собственное сообщение обновляет lastMessage без непрочитанного', () async {
const chatId = 'chat-id';
chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: [buildChatRoom(chatId)]));
when(mockWsManager.userId).thenReturn(42);
final controller = await connectToSocketStream();
controller.add(socketEvent(id: 100, authorId: 42, roomId: chatId));
await Future.delayed(const Duration(milliseconds: 100));
final room = chatsCubit.state.chatRooms.firstWhere((r) => r.id == chatId);
expect(room.lastMessage?.id, 100);
expect(room.unreadMessagesCount, 0);
expect(chatsCubit.state.chatsWithUnreadMessagesCount, 0);
await controller.close();
});
test('_handleNewMessageViaSocket - чужое сообщение увеличивает непрочитанное', () async {
const chatId = 'chat-id';
chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: [buildChatRoom(chatId)]));
when(mockWsManager.userId).thenReturn(42);
final controller = await connectToSocketStream();
controller.add(socketEvent(id: 200, authorId: 7, roomId: chatId));
await Future.delayed(const Duration(milliseconds: 100));
final room = chatsCubit.state.chatRooms.firstWhere((r) => r.id == chatId);
expect(room.lastMessage?.id, 200);
expect(room.unreadMessagesCount, 1);
expect(chatsCubit.state.chatsWithUnreadMessagesCount, 1);
await controller.close();
});
test('_handleNewMessageViaSocket - повторная доставка того же id не двоит непрочитанное', () async {
const chatId = 'chat-id';
chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: [buildChatRoom(chatId)]));
when(mockWsManager.userId).thenReturn(42);
final controller = await connectToSocketStream();
controller.add(socketEvent(id: 300, authorId: 7, roomId: chatId));
await Future.delayed(const Duration(milliseconds: 100));
controller.add(socketEvent(id: 300, authorId: 7, roomId: chatId));
await Future.delayed(const Duration(milliseconds: 100));
final room = chatsCubit.state.chatRooms.firstWhere((r) => r.id == chatId);
expect(room.unreadMessagesCount, 1);
expect(chatsCubit.state.chatsWithUnreadMessagesCount, 1);
await controller.close();
});
test('_handleNewMessageViaSocket - чужое сообщение в непрочитанном чате не поднимает счётчик чатов', () async {
const chatId = 'chat-id';
chatsCubit.emit(
ChatsState(state: BaseState.success, chatRooms: [buildChatRoom(chatId, unread: 2)], chatsWithUnreadMessagesCount: 1),
);
when(mockWsManager.userId).thenReturn(42);
final controller = await connectToSocketStream();
controller.add(socketEvent(id: 400, authorId: 7, roomId: chatId));
await Future.delayed(const Duration(milliseconds: 100));
final room = chatsCubit.state.chatRooms.firstWhere((r) => r.id == chatId);
expect(room.unreadMessagesCount, 3);
expect(chatsCubit.state.chatsWithUnreadMessagesCount, 1);
await controller.close();
});
test('close - закрывает cubit и отписывается от подписок', () async {
final mockSubscription1 = MockStreamSubscription();
@@ -476,3 +580,12 @@ void main() {
});
});
}
/// Облегчённый фейк события канала: ChatsCubit использует только event.data,
/// поэтому реальный ChannelReadEvent (требует Channel) не нужен.
class _FakeChannelReadEvent extends Fake implements ChannelReadEvent {
_FakeChannelReadEvent(this.data);
@override
final dynamic data;
}