From 883dc1284179167fbf53e9f34cba2db5492b266a Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 21 Oct 2025 18:48:39 +0100 Subject: [PATCH 1/6] Fix poll vote not being updated in the vote list controller --- .../PollVoteListController.swift | 8 +- .../PollResultsVoteListVC.swift | 18 +- .../Repositories/PollsRepository_Mock.swift | 7 + .../PollVoteListController_Tests.swift | 298 ++++++++++++++++++ 4 files changed, 321 insertions(+), 10 deletions(-) diff --git a/Sources/StreamChat/Controllers/PollController/PollVoteListController.swift b/Sources/StreamChat/Controllers/PollController/PollVoteListController.swift index ab5d5f59942..684f3b1421a 100644 --- a/Sources/StreamChat/Controllers/PollController/PollVoteListController.swift +++ b/Sources/StreamChat/Controllers/PollController/PollVoteListController.swift @@ -210,7 +210,13 @@ extension PollVoteListController: EventsControllerDelegate { vote = event.vote } guard let vote else { return } - if vote.isAnswer == true && query.pollId == vote.pollId && query.optionId == nil { + if vote.isAnswer == true + && query.pollId == vote.pollId + && query.optionId == nil { + pollsRepository.link(pollVote: vote, to: query) + } else if vote.isAnswer == false + && query.pollId == vote.pollId + && query.optionId == vote.optionId { pollsRepository.link(pollVote: vote, to: query) } } diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVoteListVC/PollResultsVoteListVC.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVoteListVC/PollResultsVoteListVC.swift index d8363acf187..fdf3a6f674a 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVoteListVC/PollResultsVoteListVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVoteListVC/PollResultsVoteListVC.swift @@ -139,15 +139,6 @@ open class PollResultsVoteListVC: return view } - // MARK: - PollVoteListControllerDelegate - - public func controller(_ controller: PollVoteListController, didChangeVotes changes: [ListChange]) { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([option]) - snapshot.appendItems(Array(controller.votes)) - dataSource.apply(snapshot, animatingDifferences: true) - } - // MARK: - Actions /// Loads the next page of votes. @@ -166,4 +157,13 @@ open class PollResultsVoteListVC: open func didFinishLoadingMoreVotes(with error: Error?) { isPaginatingVotes = false } + + // MARK: - PollVoteListControllerDelegate + + public func controller(_ controller: PollVoteListController, didChangeVotes changes: [ListChange]) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([option]) + snapshot.appendItems(Array(controller.votes)) + dataSource.apply(snapshot, animatingDifferences: true) + } } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/PollsRepository_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/PollsRepository_Mock.swift index 02080fdee0b..a4dec6a0905 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/PollsRepository_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/PollsRepository_Mock.swift @@ -15,6 +15,9 @@ final class PollsRepository_Mock: PollsRepository, Spy { var recordedFunctions: [String] = [] var spyState: SpyState = .init() + + // Mock for link method + var link: ((PollVote, PollVoteListQuery) -> Void)? override func queryPollVotes( query: PollVoteListQuery, @@ -65,4 +68,8 @@ final class PollsRepository_Mock: PollsRepository, Spy { ) { deletePoll_completion = completion } + + override func link(pollVote: PollVote, to query: PollVoteListQuery) { + link?(pollVote, query) + } } diff --git a/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift b/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift index ba45a32507d..1122c1e403e 100644 --- a/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift @@ -208,4 +208,302 @@ final class PollVoteListController_Tests: XCTestCase { wait(for: [exp], timeout: defaultTimeout) XCTAssertTrue(controller.hasLoadedAllVotes) } + + // MARK: - EventsControllerDelegate Tests + + func test_eventsController_didReceiveEvent_PollVoteCastedEvent_withAnswerVote() { + // Create a vote list controller for answers (optionId = nil) + let answerQuery = PollVoteListQuery(pollId: pollId, optionId: nil) + let answerController = PollVoteListController( + query: answerQuery, + client: client, + environment: env + ) + + // Create an answer vote (isAnswer = true) + let answerVote = PollVote.mock( + pollId: pollId, + optionId: nil, + isAnswer: true, + answerText: "Test answer" + ) + + let poll = Poll.unique + let event = PollVoteCastedEvent(vote: answerVote, poll: poll, createdAt: Date()) + + // Track link method calls + var linkCallCount = 0 + var linkedVote: PollVote? + var linkedQuery: PollVoteListQuery? + + // Mock the link method to track calls + let originalLink = client.mockPollsRepository.link + client.mockPollsRepository.link = { pollVote, query in + linkCallCount += 1 + linkedVote = pollVote + linkedQuery = query + } + + // Simulate receiving the event + answerController.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + + // Verify the vote was linked + XCTAssertEqual(linkCallCount, 1) + XCTAssertEqual(linkedVote?.id, answerVote.id) + XCTAssertEqual(linkedQuery?.pollId, answerQuery.pollId) + XCTAssertEqual(linkedQuery?.optionId, answerQuery.optionId) + + // Restore original method + client.mockPollsRepository.link = originalLink + } + + func test_eventsController_didReceiveEvent_PollVoteCastedEvent_withRegularVote() { + // Create a vote list controller for a specific option + let regularQuery = PollVoteListQuery(pollId: pollId, optionId: optionId) + let regularController = PollVoteListController( + query: regularQuery, + client: client, + environment: env + ) + + // Create a regular vote (isAnswer = false) + let regularVote = PollVote.mock( + pollId: pollId, + optionId: optionId, + isAnswer: false + ) + + let poll = Poll.unique + let event = PollVoteCastedEvent(vote: regularVote, poll: poll, createdAt: Date()) + + // Track link method calls + var linkCallCount = 0 + var linkedVote: PollVote? + var linkedQuery: PollVoteListQuery? + + // Mock the link method to track calls + let originalLink = client.mockPollsRepository.link + client.mockPollsRepository.link = { pollVote, query in + linkCallCount += 1 + linkedVote = pollVote + linkedQuery = query + } + + // Simulate receiving the event + regularController.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + + // Verify the vote was linked + XCTAssertEqual(linkCallCount, 1) + XCTAssertEqual(linkedVote?.id, regularVote.id) + XCTAssertEqual(linkedQuery?.pollId, regularQuery.pollId) + XCTAssertEqual(linkedQuery?.optionId, regularQuery.optionId) + + // Restore original method + client.mockPollsRepository.link = originalLink + } + + func test_eventsController_didReceiveEvent_PollVoteChangedEvent_withAnswerVote() { + // Create a vote list controller for answers (optionId = nil) + let answerQuery = PollVoteListQuery(pollId: pollId, optionId: nil) + let answerController = PollVoteListController( + query: answerQuery, + client: client, + environment: env + ) + + // Create an answer vote (isAnswer = true) + let answerVote = PollVote.mock( + pollId: pollId, + optionId: nil, + isAnswer: true, + answerText: "Updated answer" + ) + + let poll = Poll.unique + let event = PollVoteChangedEvent(vote: answerVote, poll: poll, createdAt: Date()) + + // Track link method calls + var linkCallCount = 0 + var linkedVote: PollVote? + var linkedQuery: PollVoteListQuery? + + // Mock the link method to track calls + let originalLink = client.mockPollsRepository.link + client.mockPollsRepository.link = { pollVote, query in + linkCallCount += 1 + linkedVote = pollVote + linkedQuery = query + } + + // Simulate receiving the event + answerController.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + + // Verify the vote was linked + XCTAssertEqual(linkCallCount, 1) + XCTAssertEqual(linkedVote?.id, answerVote.id) + XCTAssertEqual(linkedQuery?.pollId, answerQuery.pollId) + XCTAssertEqual(linkedQuery?.optionId, answerQuery.optionId) + + // Restore original method + client.mockPollsRepository.link = originalLink + } + + func test_eventsController_didReceiveEvent_PollVoteChangedEvent_withRegularVote() { + // Create a vote list controller for a specific option + let regularQuery = PollVoteListQuery(pollId: pollId, optionId: optionId) + let regularController = PollVoteListController( + query: regularQuery, + client: client, + environment: env + ) + + // Create a regular vote (isAnswer = false) + let regularVote = PollVote.mock( + pollId: pollId, + optionId: optionId, + isAnswer: false + ) + + let poll = Poll.unique + let event = PollVoteChangedEvent(vote: regularVote, poll: poll, createdAt: Date()) + + // Track link method calls + var linkCallCount = 0 + var linkedVote: PollVote? + var linkedQuery: PollVoteListQuery? + + // Mock the link method to track calls + let originalLink = client.mockPollsRepository.link + client.mockPollsRepository.link = { pollVote, query in + linkCallCount += 1 + linkedVote = pollVote + linkedQuery = query + } + + // Simulate receiving the event + regularController.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + + // Verify the vote was linked + XCTAssertEqual(linkCallCount, 1) + XCTAssertEqual(linkedVote?.id, regularVote.id) + XCTAssertEqual(linkedQuery?.pollId, regularQuery.pollId) + XCTAssertEqual(linkedQuery?.optionId, regularQuery.optionId) + + // Restore original method + client.mockPollsRepository.link = originalLink + } + + func test_eventsController_didReceiveEvent_ignoresVotesWithDifferentPollId() { + // Create a vote list controller + let controller = PollVoteListController( + query: query, + client: client, + environment: env + ) + + // Create a vote with different poll ID + let differentPollId = String.unique + let vote = PollVote.mock( + pollId: differentPollId, + optionId: optionId, + isAnswer: false + ) + + let poll = Poll.unique + let event = PollVoteCastedEvent(vote: vote, poll: poll, createdAt: Date()) + + // Track link method calls + var linkCallCount = 0 + + // Mock the link method to track calls + let originalLink = client.mockPollsRepository.link + client.mockPollsRepository.link = { _, _ in + linkCallCount += 1 + } + + // Simulate receiving the event + controller.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + + // Verify the vote was NOT linked due to different poll ID + XCTAssertEqual(linkCallCount, 0) + + // Restore original method + client.mockPollsRepository.link = originalLink + } + + func test_eventsController_didReceiveEvent_ignoresAnswerVotesWhenOptionIdIsSet() { + // Create a vote list controller for a specific option + let regularQuery = PollVoteListController( + query: query, + client: client, + environment: env + ) + + // Create an answer vote (isAnswer = true) but controller is for specific option + let answerVote = PollVote.mock( + pollId: pollId, + optionId: nil, + isAnswer: true, + answerText: "Test answer" + ) + + let poll = Poll.unique + let event = PollVoteCastedEvent(vote: answerVote, poll: poll, createdAt: Date()) + + // Track link method calls + var linkCallCount = 0 + + // Mock the link method to track calls + let originalLink = client.mockPollsRepository.link + client.mockPollsRepository.link = { _, _ in + linkCallCount += 1 + } + + // Simulate receiving the event + regularQuery.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + + // Verify the vote was NOT linked because answer votes should only be linked when optionId is nil + XCTAssertEqual(linkCallCount, 0) + + // Restore original method + client.mockPollsRepository.link = originalLink + } + + func test_eventsController_didReceiveEvent_ignoresRegularVotesWhenOptionIdDoesNotMatch() { + // Create a vote list controller for a specific option + let controller = PollVoteListController( + query: query, + client: client, + environment: env + ) + + // Create a regular vote with different option ID + let differentOptionId = String.unique + let vote = PollVote.mock( + pollId: pollId, + optionId: differentOptionId, + isAnswer: false + ) + + let poll = Poll.unique + let event = PollVoteCastedEvent(vote: vote, poll: poll, createdAt: Date()) + + // Track link method calls + var linkCallCount = 0 + + // Mock the link method to track calls + let originalLink = client.mockPollsRepository.link + client.mockPollsRepository.link = { _, _ in + linkCallCount += 1 + } + + // Simulate receiving the event + controller.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + + // Verify the vote was NOT linked because option IDs don't match + XCTAssertEqual(linkCallCount, 0) + + // Restore original method + client.mockPollsRepository.link = originalLink + } } From 2bd903cfb8b25a9c51430fcec20399c9212cbc86 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 21 Oct 2025 19:16:35 +0100 Subject: [PATCH 2/6] Observe poll data changes in the `PollVoteListController` --- .../PollVoteListController+Combine.swift | 12 +++ .../PollVoteListController+SwiftUI.swift | 8 ++ .../PollVoteListController.swift | 80 ++++++++++++++++--- .../PollVoteListController_Mock.swift | 5 ++ ...PollVoteListController+Combine_Tests.swift | 22 +++++ ...PollVoteListController+SwiftUI_Tests.swift | 21 +++++ .../PollVoteListController_Tests.swift | 76 ++++++++++++++++++ 7 files changed, 213 insertions(+), 11 deletions(-) diff --git a/Sources/StreamChat/Controllers/PollController/PollVoteListController+Combine.swift b/Sources/StreamChat/Controllers/PollController/PollVoteListController+Combine.swift index 202e2d33eae..4ad2689c7d0 100644 --- a/Sources/StreamChat/Controllers/PollController/PollVoteListController+Combine.swift +++ b/Sources/StreamChat/Controllers/PollController/PollVoteListController+Combine.swift @@ -11,6 +11,11 @@ extension PollVoteListController { basePublishers.state.keepAlive(self) } + /// A publisher emitting a new value every time the votes change. + public var pollPublisher: AnyPublisher { + basePublishers.poll.keepAlive(self) + } + /// A publisher emitting a new value every time the votes change. public var voteChangesPublisher: AnyPublisher<[ListChange], Never> { basePublishers.voteChanges.keepAlive(self) @@ -26,6 +31,9 @@ extension PollVoteListController { /// A backing subject for `statePublisher`. let state: CurrentValueSubject + /// A backing subject for `pollPublisher`. + let poll: PassthroughSubject = .init() + /// A backing subject for `voteChangesPublisher`. let voteChanges: PassthroughSubject<[ListChange], Never> = .init() @@ -49,4 +57,8 @@ extension PollVoteListController.BasePublishers: PollVoteListControllerDelegate ) { voteChanges.send(changes) } + + func controller(_ controller: PollVoteListController, didUpdatePoll poll: Poll) { + self.poll.send(poll) + } } diff --git a/Sources/StreamChat/Controllers/PollController/PollVoteListController+SwiftUI.swift b/Sources/StreamChat/Controllers/PollController/PollVoteListController+SwiftUI.swift index 40b09c3ddca..25ca4d24f3e 100644 --- a/Sources/StreamChat/Controllers/PollController/PollVoteListController+SwiftUI.swift +++ b/Sources/StreamChat/Controllers/PollController/PollVoteListController+SwiftUI.swift @@ -18,6 +18,9 @@ extension PollVoteListController { /// The poll votes. @Published public private(set) var votes: LazyCachedMapCollection = [] + /// The poll which the votes belong to. + @Published public private(set) var poll: Poll? + /// The current state of the controller. @Published public private(set) var state: DataController.State @@ -29,6 +32,7 @@ extension PollVoteListController { controller.multicastDelegate.add(additionalDelegate: self) votes = controller.votes + poll = controller.poll } } } @@ -41,6 +45,10 @@ extension PollVoteListController.ObservableObject: PollVoteListControllerDelegat votes = controller.votes } + public func controller(_ controller: PollVoteListController, didUpdatePoll poll: Poll) { + self.poll = poll + } + public func controller(_ controller: DataController, didChangeState state: DataController.State) { self.state = state } diff --git a/Sources/StreamChat/Controllers/PollController/PollVoteListController.swift b/Sources/StreamChat/Controllers/PollController/PollVoteListController.swift index 684f3b1421a..86c4ae1c4d8 100644 --- a/Sources/StreamChat/Controllers/PollController/PollVoteListController.swift +++ b/Sources/StreamChat/Controllers/PollController/PollVoteListController.swift @@ -26,6 +26,24 @@ public protocol PollVoteListControllerDelegate: DataControllerStateDelegate { _ controller: PollVoteListController, didChangeVotes changes: [ListChange] ) + + /// The controller updated the poll. + /// + /// - Parameters: + /// - controller: The controller emitting the change callback. + /// - poll: The poll with the new data. + func controller( + _ controller: PollVoteListController, + didUpdatePoll poll: Poll + ) +} + +/// Optional delegate methods. +public extension PollVoteListControllerDelegate { + func controller( + _ controller: PollVoteListController, + didUpdatePoll poll: Poll + ) {} } /// A controller which allows querying and filtering the votes of a poll. @@ -37,14 +55,17 @@ public class PollVoteListController: DataController, DelegateCallable, DataStore public let client: ChatClient /// The votes of the poll the controller represents. - /// - /// To observe changes of the votes, set your class as a delegate of this controller or use the provided - /// `Combine` publishers. public var votes: LazyCachedMapCollection { - startPollVotesListObserverIfNeeded() + startObserversIfNeeded() return pollVotesObserver.items } + /// Returns the poll that this controller represents. + public var poll: Poll? { + startObserversIfNeeded() + return pollObserver?.item + } + /// A Boolean value that returns whether pagination is finished. public private(set) var hasLoadedAllVotes: Bool = false @@ -59,9 +80,7 @@ public class PollVoteListController: DataController, DelegateCallable, DataStore didSet { stateMulticastDelegate.set(mainDelegate: multicastDelegate.mainDelegate) stateMulticastDelegate.set(additionalDelegates: multicastDelegate.additionalDelegates) - - // After setting delegate local changes will be fetched and observed. - startPollVotesListObserverIfNeeded() + startObserversIfNeeded() } } @@ -78,7 +97,6 @@ public class PollVoteListController: DataController, DelegateCallable, DataStore observer.onDidChange = { [weak self] changes in self?.delegateCallback { [weak self] in guard let self = self else { - log.warning("Callback called while self is nil") return } @@ -89,6 +107,31 @@ public class PollVoteListController: DataController, DelegateCallable, DataStore return observer }() + /// Used for observing the poll for changes. + private lazy var pollObserver: BackgroundEntityDatabaseObserver? = { [weak self] in + guard let self = self else { + return nil + } + + let observer = environment.pollObserverBuilder( + self.client.databaseContainer, + PollDTO.fetchRequest(for: query.pollId), + { try $0.asModel() as Poll }, + NSFetchedResultsController.self + ) + .onChange { [weak self] change in + self?.delegateCallback { [weak self] delegate in + guard let self = self else { + log.warning("Callback called while self is nil") + return + } + delegate.controller(self, didUpdatePoll: change.item) + } + } + + return observer + }() + var _basePublishers: Any? /// An internal backing object for all publicly available Combine publishers. We use it to simplify the way we expose /// publishers. Instead of creating custom `Publisher` types, we use `CurrentValueSubject` and `PassthroughSubject` internally, @@ -122,7 +165,7 @@ public class PollVoteListController: DataController, DelegateCallable, DataStore } override public func synchronize(_ completion: ((_ error: Error?) -> Void)? = nil) { - startPollVotesListObserverIfNeeded() + startObserversIfNeeded() pollsRepository.queryPollVotes(query: query) { [weak self] result in guard let self else { return } @@ -140,12 +183,13 @@ public class PollVoteListController: DataController, DelegateCallable, DataStore } /// If the `state` of the controller is `initialized`, this method calls `startObserving` on the - /// `pollVotesObserver` to fetch the local data and start observing the changes. It also changes + /// `pollVotesObserver` and `pollObserver` to fetch the local data and start observing the changes. It also changes /// `state` based on the result. - private func startPollVotesListObserverIfNeeded() { + private func startObserversIfNeeded() { guard state == .initialized else { return } do { try pollVotesObserver.startObserving() + try pollObserver?.startObserving() state = .localDataFetched } catch { state = .localDataFetchFailed(ClientError(with: error)) @@ -198,6 +242,20 @@ extension PollVoteListController { itemReuseKeyPaths: (\PollVote.id, \PollVoteDTO.id) ) } + + var pollObserverBuilder: ( + _ database: DatabaseContainer, + _ fetchRequest: NSFetchRequest, + _ itemCreator: @escaping (PollDTO) throws -> Poll, + _ fetchedResultsControllerType: NSFetchedResultsController.Type + ) -> BackgroundEntityDatabaseObserver = { + BackgroundEntityDatabaseObserver( + database: $0, + fetchRequest: $1, + itemCreator: $2, + fetchedResultsControllerType: $3 + ) + } } } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/PollVoteListController_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/PollVoteListController_Mock.swift index 69e67689596..0b75b69c4bf 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/PollVoteListController_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/PollVoteListController_Mock.swift @@ -15,6 +15,11 @@ final class PollVoteListController_Mock: PollVoteListController { override var votes: LazyCachedMapCollection { votes_simulated } + + var poll_simulated: Poll? + override var poll: Poll? { + poll_simulated + } var state_simulated: DataController.State? override var state: DataController.State { diff --git a/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController+Combine_Tests.swift b/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController+Combine_Tests.swift index 6810450646e..40600ff4f3e 100644 --- a/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController+Combine_Tests.swift +++ b/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController+Combine_Tests.swift @@ -79,4 +79,26 @@ final class PollVoteListController_Combine_Tests: iOS13TestCase { XCTAssertEqual(recording.output, .init(arrayLiteral: [.insert(vote, index: .init())])) } + + func test_pollPublisher() { + // Setup Recording publishers + var recording = Record.Recording() + + // Setup the chain + voteListController + .pollPublisher + .sink(receiveValue: { recording.receive($0) }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: PollVoteListController? = voteListController + voteListController = nil + + let poll: Poll = .unique + controller?.delegateCallback { + $0.controller(controller!, didUpdatePoll: poll) + } + + XCTAssertEqual(recording.output, [poll]) + } } diff --git a/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController+SwiftUI_Tests.swift b/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController+SwiftUI_Tests.swift index f29bc3626c0..cd0459207c7 100644 --- a/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController+SwiftUI_Tests.swift +++ b/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController+SwiftUI_Tests.swift @@ -66,4 +66,25 @@ final class PollVoteListController_SwiftUI_Tests: iOS13TestCase { AssertAsync.willBeEqual(observableObject.state, newState) } + + func test_observableObject_initialPollValue() { + let observableObject = voteListController.observableObject + + // Initially poll should be nil + XCTAssertNil(observableObject.poll) + } + + func test_observableObject_reactsToDelegateUpdatePollCallback() { + let observableObject = voteListController.observableObject + + // Simulate poll update + let poll: Poll = .unique + voteListController.poll_simulated = poll + + voteListController.delegateCallback { + $0.controller(self.voteListController, didUpdatePoll: poll) + } + + AssertAsync.willBeEqual(observableObject.poll, poll) + } } diff --git a/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift b/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift index 1122c1e403e..050448b0d75 100644 --- a/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift @@ -506,4 +506,80 @@ final class PollVoteListController_Tests: XCTestCase { // Restore original method client.mockPollsRepository.link = originalLink } + + // MARK: - Poll Observer Tests + + func test_pollProperty_returnsPollFromObserver() { + // Create a poll in the database + let user = UserPayload.dummy(userId: currentUserId) + let poll = dummyPollPayload(id: pollId, user: user) + + try! client.databaseContainer.writeSynchronously { session in + try session.savePoll(payload: poll, cache: nil) + } + + // Synchronize to start observers + controller.synchronize() + + // Verify poll is returned + XCTAssertNotNil(controller.poll) + XCTAssertEqual(controller.poll?.id, pollId) + } + + func test_pollProperty_returnsNilWhenNoPollExists() { + // Don't create any poll in database + controller.synchronize() + + // Verify poll is nil + XCTAssertNil(controller.poll) + } + + func test_pollObserver_notifiesDelegateOnPollUpdate() { + // Create initial poll + let user = UserPayload.dummy(userId: currentUserId) + let initialPoll = dummyPollPayload(id: pollId, user: user) + + try! client.databaseContainer.writeSynchronously { session in + try session.savePoll(payload: initialPoll, cache: nil) + } + + // Set up delegate + let delegate = TestDelegate() + controller.delegate = delegate + + // Synchronize to start observers + controller.synchronize() + + // Update poll in database + let updatedPoll = dummyPollPayload( + id: pollId, + name: "Updated Poll Name", + user: user + ) + + try! client.databaseContainer.writeSynchronously { session in + try session.savePoll(payload: updatedPoll, cache: nil) + } + + // Verify delegate was notified + AssertAsync.willBeTrue(delegate.didUpdatePollCalled) + XCTAssertEqual(delegate.updatedPoll?.id, pollId) + XCTAssertEqual(delegate.updatedPoll?.name, "Updated Poll Name") + } +} + +// MARK: - Test Helper + +private class TestDelegate: PollVoteListControllerDelegate { + @Atomic var didUpdatePollCalled = false + @Atomic var updatedPoll: Poll? + + func controller(_ controller: PollVoteListController, didUpdatePoll poll: Poll) { + didUpdatePollCalled = true + updatedPoll = poll + } + + func controller(_ controller: PollVoteListController, didChangeVotes changes: [ListChange]) { + // Not used in these tests + } } From 1ddee0f672a761004070e2afaa7563e53012c565 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 21 Oct 2025 19:18:04 +0100 Subject: [PATCH 3/6] =?UTF-8?q?Fix=20`PollResultsVoteListVC`=C2=A0not=20up?= =?UTF-8?q?dating=20votes=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PollResultsVoteListVC/PollResultsVoteListVC.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVoteListVC/PollResultsVoteListVC.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVoteListVC/PollResultsVoteListVC.swift index fdf3a6f674a..d0c88b2bde2 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVoteListVC/PollResultsVoteListVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollResultsVC/PollResultsVoteListVC/PollResultsVoteListVC.swift @@ -166,4 +166,11 @@ open class PollResultsVoteListVC: snapshot.appendItems(Array(controller.votes)) dataSource.apply(snapshot, animatingDifferences: true) } + + public func controller(_ controller: PollVoteListController, didUpdatePoll poll: Poll) { + self.poll = poll + var newSnapshot = dataSource.snapshot() + newSnapshot.reloadSections([option]) + dataSource.apply(newSnapshot, animatingDifferences: true) + } } From f430d96479be21e72e1556b88b69b0d43bc301a2 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 21 Oct 2025 19:52:15 +0100 Subject: [PATCH 4/6] Update CHANGELOG.md --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3e4341228..810dc00fc62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add support for deleting messages only for the current user [#3836](https://github.com/GetStream/stream-chat-swift/pull/3836) - Add `ChatMessageController.deleteMessageForMe()` - Add `ChatMessage.deletedForMe` +- Allow observing poll changes in `PollVoteListController` [#3849](https://github.com/GetStream/stream-chat-swift/pull/3849) ### 🐞 Fixed - Fix logout not clearing token when current user had no device registered [#3838](https://github.com/GetStream/stream-chat-swift/pull/3838) +- Fix `PollVoteListController` not updating votes on the vote cast event [#3849](https://github.com/GetStream/stream-chat-swift/pull/3849) + +## StreamChatUI +### 🐞 Fixed +- Fix `PollResultsVoteListVC` not updating the vote count [#3849](https://github.com/GetStream/stream-chat-swift/pull/3849) # [4.90.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.90.0) _October 07, 2025_ From b3080d6e2008626f8f7e57418d2ba87d054853ca Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 21 Oct 2025 21:11:03 +0100 Subject: [PATCH 5/6] Apply suggestion from @nuno-vieira --- .../PollController/PollVoteListController+Combine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChat/Controllers/PollController/PollVoteListController+Combine.swift b/Sources/StreamChat/Controllers/PollController/PollVoteListController+Combine.swift index 4ad2689c7d0..9cdbbac1468 100644 --- a/Sources/StreamChat/Controllers/PollController/PollVoteListController+Combine.swift +++ b/Sources/StreamChat/Controllers/PollController/PollVoteListController+Combine.swift @@ -11,7 +11,7 @@ extension PollVoteListController { basePublishers.state.keepAlive(self) } - /// A publisher emitting a new value every time the votes change. + /// A publisher emitting a new value every time the poll changes. public var pollPublisher: AnyPublisher { basePublishers.poll.keepAlive(self) } From 643c0b7e015309302865b96a0b00da9abc355a92 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 21 Oct 2025 22:55:27 +0100 Subject: [PATCH 6/6] Fix flaky test --- .../PollVoteListController_Tests.swift | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift b/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift index 050448b0d75..9422eadadfd 100644 --- a/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift @@ -546,7 +546,14 @@ final class PollVoteListController_Tests: XCTestCase { // Set up delegate let delegate = TestDelegate() controller.delegate = delegate - + + // Wait for expection + let exp = expectation(description: "didUpdatePoll called") + exp.expectedFulfillmentCount = 2 + delegate.didUpdatePollCompletion = { + exp.fulfill() + } + // Synchronize to start observers controller.synchronize() @@ -562,7 +569,8 @@ final class PollVoteListController_Tests: XCTestCase { } // Verify delegate was notified - AssertAsync.willBeTrue(delegate.didUpdatePollCalled) + waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(delegate.didUpdatePollCalled, true) XCTAssertEqual(delegate.updatedPoll?.id, pollId) XCTAssertEqual(delegate.updatedPoll?.name, "Updated Poll Name") } @@ -571,12 +579,14 @@ final class PollVoteListController_Tests: XCTestCase { // MARK: - Test Helper private class TestDelegate: PollVoteListControllerDelegate { - @Atomic var didUpdatePollCalled = false - @Atomic var updatedPoll: Poll? - + var didUpdatePollCalled = false + var updatedPoll: Poll? + var didUpdatePollCompletion: (() -> Void)? + func controller(_ controller: PollVoteListController, didUpdatePoll poll: Poll) { didUpdatePollCalled = true updatedPoll = poll + didUpdatePollCompletion?() } func controller(_ controller: PollVoteListController, didChangeVotes changes: [ListChange]) {