From 07ca683c3ffd098f579df21533e705e2ed6c4d64 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 13 Oct 2026 16:39:34 +0200 Subject: [PATCH 1/2] fix(core): ignore other polls' votes in StreamPollVoteListController Co-Authored-By: Claude Opus 5.5 --- .../stream_chat_flutter_core/CHANGELOG.md | 1 + .../src/stream_poll_vote_list_controller.dart | 10 ++ ...stream_poll_vote_list_controller_test.dart | 132 ++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index f54adeecd2..8b6c589728 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -11,6 +11,7 @@ 🐞 Fixed - Fixed `StreamDraftListController.deleteDraft` removing the wrong draft. +- Fixed `StreamPollVoteListController` adding votes and comments from other polls in the same channel. ## 10.5.0 diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart index 258d187cc3..c931ca85be 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart @@ -215,6 +215,8 @@ class StreamPollVoteListController extends PagedValueNotifier // Returns early if the event is already handled by the listener. if (eventListener?.call(event) ?? false) return; + if (!_belongsToPoll(event)) return; + final eventType = event.type; if (eventType == EventType.pollVoteCasted || eventType == EventType.pollAnswerCasted) { _eventHandler.onPollVoteCasted(event, this); @@ -226,6 +228,14 @@ class StreamPollVoteListController extends PagedValueNotifier }); } + // Whether the vote or answer carried by [event] belongs to [pollId]. + // + // The subscription receives the events of every poll in [channel], not only the one this controller lists. + bool _belongsToPoll(Event event) { + final eventPollId = event.pollVote?.pollId ?? event.poll?.id; + return eventPollId == pollId; + } + // Unsubscribes from all poll vote list events. void _unsubscribeFromPolVoteListEvents() { if (_pollVoteListEventSubscription != null) { diff --git a/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart new file mode 100644 index 0000000000..02ab672779 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/stream_poll_vote_list_controller.dart'; + +import 'mocks.dart'; + +const _pollId = 'poll-a'; +const _otherPollId = 'poll-b'; + +void main() { + setUpAll(() { + registerFallbackValue(const PaginationParams()); + }); + + test('a vote-casted event for another poll leaves the list unchanged', () async { + final existingVote = PollVote(id: 'vote-1', pollId: _pollId, optionId: 'option-1'); + final (:controller, :events) = await _loadedController(votes: [existingVote]); + + events.add( + Event( + type: EventType.pollVoteCasted, + pollVote: PollVote(id: 'vote-2', pollId: _otherPollId, optionId: 'option-2'), + ), + ); + await pumpEventQueue(); + + expect(controller.currentItems, [existingVote]); + }); + + test('a vote-casted event for the same poll adds the vote', () async { + final (:controller, :events) = await _loadedController(); + final vote = PollVote(id: 'vote-1', pollId: _pollId, optionId: 'option-1'); + + events.add(Event(type: EventType.pollVoteCasted, pollVote: vote)); + await pumpEventQueue(); + + expect(controller.currentItems, [vote]); + }); + + test('an answer-casted event for another poll leaves the list unchanged', () async { + final (:controller, :events) = await _loadedController(); + + events.add( + Event( + type: EventType.pollAnswerCasted, + pollVote: PollVote(id: 'answer-1', pollId: _otherPollId, answerText: 'Beach!'), + ), + ); + await pumpEventQueue(); + + expect(controller.currentItems, isEmpty); + }); + + test('a vote-changed event for another poll leaves the list unchanged', () async { + final (:controller, :events) = await _loadedController(); + + events.add( + Event( + type: EventType.pollVoteChanged, + pollVote: PollVote(id: 'vote-2', pollId: _otherPollId, optionId: 'option-2'), + ), + ); + await pumpEventQueue(); + + expect(controller.currentItems, isEmpty); + }); + + test('a vote-casted event whose vote has no poll id falls back to the event poll', () async { + final (:controller, :events) = await _loadedController(); + + events.add( + Event( + type: EventType.pollVoteCasted, + poll: Poll(id: _otherPollId, name: 'Team offsite', options: const []), + pollVote: PollVote(id: 'vote-2', optionId: 'option-2'), + ), + ); + await pumpEventQueue(); + + expect(controller.currentItems, isEmpty); + }); + + test('eventListener still receives events for another poll', () async { + final (:controller, :events) = await _loadedController(); + final received = []; + controller.eventListener = (event) { + received.add(event); + return false; + }; + final event = Event( + type: EventType.pollVoteCasted, + pollVote: PollVote(id: 'vote-2', pollId: _otherPollId, optionId: 'option-2'), + ); + + events.add(event); + await pumpEventQueue(); + + expect(received, [event]); + }); +} + +// Returns a controller for [_pollId] that has loaded [votes] and listens to the returned channel events. +Future<({StreamPollVoteListController controller, StreamController events})> _loadedController({ + List votes = const [], +}) async { + final channel = MockChannel(); + final events = StreamController.broadcast(); + addTearDown(events.close); + + when(channel.on).thenAnswer((_) => events.stream); + when( + () => channel.queryPollVotes( + _pollId, + sort: any(named: 'sort'), + filter: any(named: 'filter'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryPollVotesResponse() + ..votes = votes + ..next = null, + ); + + final controller = StreamPollVoteListController(channel: channel, pollId: _pollId); + addTearDown(controller.dispose); + await controller.doInitialLoad(); + + return (controller: controller, events: events); +} From 457fa838e17ee094ca8ff55e2cf4594dbdbc1bca Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 13 Oct 2026 17:21:45 +0200 Subject: [PATCH 2/2] test(core): tighten StreamPollVoteListController poll-id tests Co-Authored-By: Claude Opus 5.5 --- ...stream_poll_vote_list_controller_test.dart | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart index 02ab672779..8e58215f64 100644 --- a/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart @@ -68,37 +68,34 @@ void main() { expect(controller.currentItems, isEmpty); }); - test('a vote-casted event whose vote has no poll id falls back to the event poll', () async { + test('a vote-casted event without a vote poll id is added when the event poll matches', () async { final (:controller, :events) = await _loadedController(); + final vote = PollVote(id: 'vote-1', optionId: 'option-1'); events.add( Event( type: EventType.pollVoteCasted, - poll: Poll(id: _otherPollId, name: 'Team offsite', options: const []), - pollVote: PollVote(id: 'vote-2', optionId: 'option-2'), + poll: Poll(id: _pollId, name: 'Lunch', options: const []), + pollVote: vote, ), ); await pumpEventQueue(); - expect(controller.currentItems, isEmpty); + expect(controller.currentItems, [vote]); }); - test('eventListener still receives events for another poll', () async { + test('a vote-casted event with no poll id leaves the list unchanged', () async { final (:controller, :events) = await _loadedController(); - final received = []; - controller.eventListener = (event) { - received.add(event); - return false; - }; - final event = Event( - type: EventType.pollVoteCasted, - pollVote: PollVote(id: 'vote-2', pollId: _otherPollId, optionId: 'option-2'), - ); - events.add(event); + events.add( + Event( + type: EventType.pollVoteCasted, + pollVote: PollVote(id: 'vote-2', optionId: 'option-2'), + ), + ); await pumpEventQueue(); - expect(received, [event]); + expect(controller.currentItems, isEmpty); }); }