diff --git a/lib/features/chat/models/chat_message_model.dart b/lib/features/chat/models/chat_message_model.dart index 4bffd8b7..84c238be 100644 --- a/lib/features/chat/models/chat_message_model.dart +++ b/lib/features/chat/models/chat_message_model.dart @@ -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, diff --git a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart index 10f91095..d4e421fa 100644 --- a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart +++ b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart @@ -144,6 +144,9 @@ class ChatsCubit extends Cubit { final json = jsonDecode(data) as Map; 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 { 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(); diff --git a/test/features/chat/presentation/bloc/chats_cubit_test.dart b/test/features/chat/presentation/bloc/chats_cubit_test.dart index c3af3197..a3569618 100644 --- a/test/features/chat/presentation/bloc/chats_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chats_cubit_test.dart @@ -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({ + '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> connectToSocketStream() async { + final controller = StreamController.broadcast(); + when(mockWsManager.newMessagesStream).thenAnswer((_) => controller.stream); + await chatsCubit.connectToWebSocket(); + return controller; + } + test('начальное состояние - initial', () { expect(chatsCubit.state, isA()); 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; +}