diff --git a/Sources/StreamFeeds/Extensions/Array+Extensions.swift b/Sources/StreamFeeds/Extensions/Array+Extensions.swift index 777dab5..5b25a88 100644 --- a/Sources/StreamFeeds/Extensions/Array+Extensions.swift +++ b/Sources/StreamFeeds/Extensions/Array+Extensions.swift @@ -13,11 +13,14 @@ extension Array { /// Otherwise, the new element will be inserted at the start of the array. /// /// - Parameter element: The element to insert or replace. - mutating func insert(byId element: Element) where Element: Identifiable { + /// - Returns: True, if the element was inserted, false if the element exists and was updated. + @discardableResult mutating func insert(byId element: Element) -> Bool where Element: Identifiable { if let index = firstIndex(where: { $0.id == element.id }) { replaceSubrange(index...index, with: CollectionOfOne(element)) + return false } else { insert(element, at: startIndex) + return true } } @@ -25,11 +28,14 @@ extension Array { /// /// - Parameter id: The ID of the element to remove. /// - Parameter nesting: Optional key path to search for nested elements. - mutating func remove( + /// - Returns: True, if the element was removed, false if it did not exist. + @discardableResult mutating func remove( byId id: Element.ID, nesting nestingKeyPath: WritableKeyPath? = nil - ) where Element: Identifiable { + ) -> Bool where Element: Identifiable { + let count = count _unsortedUpdate(ofId: id, nesting: nestingKeyPath, changes: { _ in nil }) + return count != self.count } /// Removes elements from the non-sorted array based on ID. @@ -71,7 +77,9 @@ extension Array where Element: Identifiable { /// /// - Parameter element: The element to insert. /// - Parameter sorting: Closure that defines the sorting order. - mutating func sortedInsert(_ element: Element, sorting: (Element, Element) -> Bool) { + /// - Returns: True, if the element was inserted, false if existed and was updated. + @discardableResult mutating func sortedInsert(_ element: Element, sorting: (Element, Element) -> Bool) -> Bool { + let count = count let insertionIndex = binarySearchInsertionIndex(for: element, sorting: sorting) insert(element, at: insertionIndex) // Look for duplicates @@ -89,6 +97,7 @@ extension Array where Element: Identifiable { } distance += 1 } + return self.count != count } /// Inserts an element into a sorted array using SortField configuration. diff --git a/Sources/StreamFeeds/FeedsClient.swift b/Sources/StreamFeeds/FeedsClient.swift index d35fc78..e42e854 100644 --- a/Sources/StreamFeeds/FeedsClient.swift +++ b/Sources/StreamFeeds/FeedsClient.swift @@ -30,6 +30,7 @@ public final class FeedsClient: Sendable { let eventsMiddleware = WSEventsMiddleware() let eventNotificationCenter: EventNotificationCenter + let stateLayerEventPublisher = StateLayerEventPublisher() let activitiesRepository: ActivitiesRepository let bookmarksRepository: BookmarksRepository @@ -133,6 +134,7 @@ public final class FeedsClient: Sendable { moderation = Moderation(apiClient: apiClient) eventsMiddleware.add(subscriber: self) + eventsMiddleware.add(subscriber: stateLayerEventPublisher) eventNotificationCenter.add(middlewares: [eventsMiddleware]) } diff --git a/Sources/StreamFeeds/Models/ActivityData.swift b/Sources/StreamFeeds/Models/ActivityData.swift index e50b9aa..9817771 100644 --- a/Sources/StreamFeeds/Models/ActivityData.swift +++ b/Sources/StreamFeeds/Models/ActivityData.swift @@ -51,26 +51,34 @@ public typealias ActivityDataVisibility = ActivityResponse.ActivityResponseVisib extension ActivityData { mutating func addComment(_ comment: CommentData) { - comments.insert(byId: comment) - commentCount += 1 + if comments.insert(byId: comment) { + commentCount += 1 + } } mutating func deleteComment(_ comment: CommentData) { - commentCount = max(0, commentCount - 1) - comments.remove(byId: comment.id) + if comments.remove(byId: comment.id) { + commentCount = max(0, commentCount - 1) + } } mutating func addBookmark(_ bookmark: BookmarkData, currentUserId: String) { if bookmark.user.id == currentUserId { - ownBookmarks.insert(byId: bookmark) + if ownBookmarks.insert(byId: bookmark) { + bookmarkCount += 1 + } + } else { + bookmarkCount += 1 } - bookmarkCount += 1 } mutating func deleteBookmark(_ bookmark: BookmarkData, currentUserId: String) { - bookmarkCount = max(0, bookmarkCount - 1) if bookmark.user.id == currentUserId { - ownBookmarks.remove(byId: bookmark.id) + if ownBookmarks.remove(byId: bookmark.id) { + bookmarkCount = max(0, bookmarkCount - 1) + } + } else { + bookmarkCount = max(0, bookmarkCount - 1) } } diff --git a/Sources/StreamFeeds/Models/FeedsReactionData.swift b/Sources/StreamFeeds/Models/FeedsReactionData.swift index b7df528..61716a5 100644 --- a/Sources/StreamFeeds/Models/FeedsReactionData.swift +++ b/Sources/StreamFeeds/Models/FeedsReactionData.swift @@ -28,7 +28,7 @@ extension FeedsReactionData { to latestReactions: inout [FeedsReactionData], reactionGroups: inout [String: ReactionGroupData] ) { - latestReactions.insert(byId: reaction) + guard latestReactions.insert(byId: reaction) else { return } var reactionGroup = reactionGroups[reaction.type] ?? ReactionGroupData(count: 1, firstReactionAt: reaction.createdAt, lastReactionAt: reaction.createdAt) reactionGroup.increment(with: reaction.createdAt) reactionGroups[reaction.type] = reactionGroup @@ -39,7 +39,7 @@ extension FeedsReactionData { from latestReactions: inout [FeedsReactionData], reactionGroups: inout [String: ReactionGroupData] ) { - latestReactions.remove(byId: reaction.id) + guard latestReactions.remove(byId: reaction.id) else { return } if var reactionGroup = reactionGroups[reaction.type] { reactionGroup.decrement(with: reaction.createdAt) if !reactionGroup.isEmpty { diff --git a/Sources/StreamFeeds/Repositories/ActivitiesRepository.swift b/Sources/StreamFeeds/Repositories/ActivitiesRepository.swift index 43eb7a3..055339e 100644 --- a/Sources/StreamFeeds/Repositories/ActivitiesRepository.swift +++ b/Sources/StreamFeeds/Repositories/ActivitiesRepository.swift @@ -96,8 +96,13 @@ final class ActivitiesRepository: Sendable { // MARK: - Activity Interactions func pin(_ flag: Bool, activityId: String, in feed: FeedId) async throws -> ActivityData { - let response = try await apiClient.pinActivity(feedGroupId: feed.group, feedId: feed.id, activityId: activityId) - return response.activity.toModel() + if flag { + let response = try await apiClient.pinActivity(feedGroupId: feed.group, feedId: feed.id, activityId: activityId) + return response.activity.toModel() + } else { + let response = try await apiClient.unpinActivity(feedGroupId: feed.group, feedId: feed.id, activityId: activityId) + return response.activity.toModel() + } } func markActivity(feedGroupId: String, feedId: String, request: MarkActivityRequest) async throws { @@ -116,14 +121,14 @@ final class ActivitiesRepository: Sendable { // MARK: - Reactions - func addReaction(activityId: String, request: AddReactionRequest) async throws -> FeedsReactionData { + func addReaction(activityId: String, request: AddReactionRequest) async throws -> (reaction: FeedsReactionData, activity: ActivityData) { let response = try await apiClient.addReaction(activityId: activityId, addReactionRequest: request) - return response.reaction.toModel() + return (response.reaction.toModel(), response.activity.toModel()) } - func deleteReaction(activityId: String, type: String) async throws -> FeedsReactionData { + func deleteReaction(activityId: String, type: String) async throws -> (reaction: FeedsReactionData, activity: ActivityData) { let response = try await apiClient.deleteActivityReaction(activityId: activityId, type: type) - return response.reaction.toModel() + return (response.reaction.toModel(), response.activity.toModel()) } // MARK: - Activity Reactions Pagination diff --git a/Sources/StreamFeeds/StateLayer/Activity.swift b/Sources/StreamFeeds/StateLayer/Activity.swift index 83a293c..0260b4b 100644 --- a/Sources/StreamFeeds/StateLayer/Activity.swift +++ b/Sources/StreamFeeds/StateLayer/Activity.swift @@ -15,6 +15,7 @@ public final class Activity: Sendable { private let activitiesRepository: ActivitiesRepository private let commentsRepository: CommentsRepository private let pollsRepository: PollsRepository + private let eventPublisher: StateLayerEventPublisher @MainActor private let stateBuilder: StateBuilder /// The unique identifier of this activity. @@ -36,17 +37,17 @@ public final class Activity: Sendable { activityId = id activitiesRepository = client.activitiesRepository commentsRepository = client.commentsRepository + eventPublisher = client.stateLayerEventPublisher self.feed = feed pollsRepository = client.pollsRepository let currentUserId = client.user.id - let events = client.eventsMiddleware - stateBuilder = StateBuilder { [currentUserId] in + stateBuilder = StateBuilder { [currentUserId, eventPublisher] in ActivityState( activityId: id, feed: feed, data: data, currentUserId: currentUserId, - events: events, + eventPublisher: eventPublisher, commentListState: commentList.state ) } @@ -67,7 +68,7 @@ public final class Activity: Sendable { async let activity = activitiesRepository.getActivity(activityId: activityId) async let comments = queryComments() let (activityData, _) = try await (activity, comments) - await state.updateActivity(activityData) + await state.setActivity(activityData) return activityData } @@ -162,7 +163,31 @@ public final class Activity: Sendable { return comment } - // MARK: - Comment Reactions + // MARK: - Activity and Comment Reactions + + /// Adds a reaction to an activity. + /// + /// - Parameter request: The request containing the reaction data + /// - Returns: The created reaction data + /// - Throws: `APIError` if the network request fails or the server returns an error + @discardableResult + public func addReaction(request: AddReactionRequest) async throws -> FeedsReactionData { + let result = try await activitiesRepository.addReaction(activityId: activityId, request: request) + await eventPublisher.sendEvent(.activityUpdated(result.activity, feed)) + return result.reaction + } + + /// Removes a reaction from an activity. + /// + /// - Parameter type: The type of reaction to remove + /// - Returns: The removed reaction data + /// - Throws: `APIError` if the network request fails or the server returns an error + @discardableResult + public func deleteReaction(type: String) async throws -> FeedsReactionData { + let result = try await activitiesRepository.deleteReaction(activityId: activityId, type: type) + await eventPublisher.sendEvent(.activityUpdated(result.activity, feed)) + return result.reaction + } /// Adds a reaction to a comment. /// @@ -200,7 +225,7 @@ public final class Activity: Sendable { /// - Throws: `APIError` if the network request fails or the server returns an error public func pin() async throws { let activity = try await activitiesRepository.pin(true, activityId: activityId, in: feed) - await state.changeHandlers.activityUpdated(activity) + await eventPublisher.sendEvent(.activityUpdated(activity, feed)) } /// Unpins an activity from the feed. @@ -209,7 +234,7 @@ public final class Activity: Sendable { /// - Throws: `APIError` if the network request fails or the server returns an error public func unpin() async throws { let activity = try await activitiesRepository.pin(false, activityId: activityId, in: feed) - await state.changeHandlers.activityUpdated(activity) + await eventPublisher.sendEvent(.activityUpdated(activity, feed)) } // MARK: - Polls @@ -220,9 +245,9 @@ public final class Activity: Sendable { /// - Throws: `APIError` if the network request fails or the server returns an error @discardableResult public func closePoll() async throws -> PollData { - let pollData = try await updatePollPartial(request: .init(set: ["is_closed": .bool(true)])) - await state.changeHandlers.pollUpdated(pollData) - return pollData + let poll = try await updatePollPartial(request: .init(set: ["is_closed": .bool(true)])) + await eventPublisher.sendEvent(.pollUpdated(poll, feed)) + return poll } /// Deletes a poll. @@ -233,7 +258,7 @@ public final class Activity: Sendable { public func deletePoll(userId: String? = nil) async throws { let pollId = try await pollId() try await pollsRepository.deletePoll(pollId: pollId, userId: userId) - await state.changeHandlers.pollDeleted(pollId) + await eventPublisher.sendEvent(.pollDeleted(pollId, feed)) } /// Gets a specific poll by its identifier. @@ -245,7 +270,7 @@ public final class Activity: Sendable { @discardableResult public func getPoll(userId: String? = nil) async throws -> PollData { let poll = try await pollsRepository.getPoll(pollId: pollId(), userId: userId) - await state.changeHandlers.pollUpdated(poll) + await eventPublisher.sendEvent(.pollUpdated(poll, feed)) return poll } @@ -259,7 +284,7 @@ public final class Activity: Sendable { @discardableResult public func updatePollPartial(request: UpdatePollPartialRequest) async throws -> PollData { let poll = try await pollsRepository.updatePollPartial(pollId: pollId(), request: request) - await state.changeHandlers.pollUpdated(poll) + await eventPublisher.sendEvent(.pollUpdated(poll, feed)) return poll } @@ -272,7 +297,7 @@ public final class Activity: Sendable { @discardableResult public func updatePoll(request: UpdatePollRequest) async throws -> PollData { let poll = try await pollsRepository.updatePoll(request: request) - await state.changeHandlers.pollUpdated(poll) + await eventPublisher.sendEvent(.pollUpdated(poll, feed)) return poll } @@ -287,7 +312,10 @@ public final class Activity: Sendable { @discardableResult public func createPollOption(request: CreatePollOptionRequest) async throws -> PollOptionData { let option = try await pollsRepository.createPollOption(pollId: pollId(), request: request) - await state.access { $0.poll?.addOption(option) } + if var poll = await state.poll { + poll.addOption(option) + await eventPublisher.sendEvent(.pollUpdated(poll, feed)) + } return option } @@ -302,7 +330,10 @@ public final class Activity: Sendable { userId: String? = nil ) async throws { try await pollsRepository.deletePollOption(pollId: pollId(), optionId: optionId, userId: userId) - await state.access { $0.poll?.removeOption(withId: optionId) } + if var poll = await state.poll { + poll.removeOption(withId: optionId) + await eventPublisher.sendEvent(.pollUpdated(poll, feed)) + } } /// Gets a specific poll option by its identifier. @@ -317,9 +348,12 @@ public final class Activity: Sendable { optionId: String, userId: String? ) async throws -> PollOptionData { - let pollOption = try await pollsRepository.getPollOption(pollId: pollId(), optionId: optionId, userId: userId) - await state.access { $0.poll?.updateOption(pollOption) } - return pollOption + let option = try await pollsRepository.getPollOption(pollId: pollId(), optionId: optionId, userId: userId) + if var poll = await state.poll { + poll.updateOption(option) + await eventPublisher.sendEvent(.pollUpdated(poll, feed)) + } + return option } /// Updates a poll option. @@ -334,7 +368,10 @@ public final class Activity: Sendable { pollId: pollId(), request: request ) - await state.access { $0.poll?.updateOption(option) } + if var poll = await state.poll { + poll.updateOption(option) + await eventPublisher.sendEvent(.pollUpdated(poll, feed)) + } return option } @@ -350,7 +387,10 @@ public final class Activity: Sendable { public func castPollVote(request: CastPollVoteRequest) async throws -> PollVoteData? { let vote = try await pollsRepository.castPollVote(activityId: activityId, pollId: pollId(), request: request) if let vote { - await state.access { $0.poll?.castVote(vote, currentUserId: $0.currentUserId) } + if var poll = await state.poll { + await poll.castVote(vote, currentUserId: state.currentUserId) + await eventPublisher.sendEvent(.pollUpdated(poll, feed)) + } } return vote } @@ -374,7 +414,10 @@ public final class Activity: Sendable { userId: userId ) if let vote { - await state.access { $0.poll?.removeVote(vote, currentUserId: $0.currentUserId) } + if var poll = await state.poll { + await poll.removeVote(vote, currentUserId: state.currentUserId) + await eventPublisher.sendEvent(.pollUpdated(poll, feed)) + } } return vote } diff --git a/Sources/StreamFeeds/StateLayer/ActivityState+Observer.swift b/Sources/StreamFeeds/StateLayer/ActivityState+Observer.swift deleted file mode 100644 index f8b3648..0000000 --- a/Sources/StreamFeeds/StateLayer/ActivityState+Observer.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamCore - -extension ActivityState { - final class WebSocketObserver: WSEventsSubscriber { - private let activityId: String - private let feed: FeedId - private let handlers: ActivityState.ChangeHandlers - - init( - activityId: String, - feed: FeedId, - subscribing events: WSEventsSubscribing, - handlers: ActivityState.ChangeHandlers - ) { - self.activityId = activityId - self.feed = feed - self.handlers = handlers - events.add(subscriber: self) - } - - // MARK: - Event Subscription - - func onEvent(_ event: any Event) async { - switch event { - case let event as PollClosedFeedEvent: - guard feed.rawValue == event.fid else { return } - await handlers.pollClosed(event.poll.toModel()) - case let event as PollDeletedFeedEvent: - guard feed.rawValue == event.fid else { return } - await handlers.pollDeleted(event.poll.id) - case let event as PollUpdatedFeedEvent: - guard feed.rawValue == event.fid else { return } - await handlers.pollUpdated(event.poll.toModel()) - case let event as PollVoteCastedFeedEvent: - guard feed.rawValue == event.fid else { return } - let poll = event.poll.toModel() - let vote = event.pollVote.toModel() - await handlers.pollVoteCasted(vote, poll) - case let event as PollVoteChangedFeedEvent: - guard feed.rawValue == event.fid else { return } - let poll = event.poll.toModel() - let vote = event.pollVote.toModel() - await handlers.pollVoteChanged(vote, poll) - case let event as PollVoteRemovedFeedEvent: - guard feed.rawValue == event.fid else { return } - let poll = event.poll.toModel() - let vote = event.pollVote.toModel() - await handlers.pollVoteRemoved(vote, poll) - default: - break - } - } - } -} diff --git a/Sources/StreamFeeds/StateLayer/ActivityState.swift b/Sources/StreamFeeds/StateLayer/ActivityState.swift index f6aa832..1048fcb 100644 --- a/Sources/StreamFeeds/StateLayer/ActivityState.swift +++ b/Sources/StreamFeeds/StateLayer/ActivityState.swift @@ -12,25 +12,21 @@ import StreamCore /// It automatically updates when WebSocket events are received and provides change handlers for state modifications. @MainActor public class ActivityState: ObservableObject { private var cancellables = Set() - private(set) lazy var changeHandlers = makeChangeHandlers() private let commentListState: ActivityCommentListState let currentUserId: String - private var webSocketObserver: WebSocketObserver? + private let activityId: String + private let feed: FeedId + private var eventSubscription: StateLayerEventPublisher.Subscription? - init(activityId: String, feed: FeedId, data: ActivityData?, currentUserId: String, events: WSEventsSubscribing, commentListState: ActivityCommentListState) { + init(activityId: String, feed: FeedId, data: ActivityData?, currentUserId: String, eventPublisher: StateLayerEventPublisher, commentListState: ActivityCommentListState) { + self.activityId = activityId self.commentListState = commentListState self.currentUserId = currentUserId - let webSocketObserver = WebSocketObserver( - activityId: activityId, - feed: feed, - subscribing: events, - handlers: changeHandlers - ) - self.webSocketObserver = webSocketObserver + self.feed = feed if let data, data.id == activityId { - updateActivity(data) + setActivity(data) } - + subscribe(to: eventPublisher) commentListState.$comments .assign(to: \.comments, onWeak: self) .store(in: &cancellables) @@ -49,60 +45,34 @@ import StreamCore // MARK: - Updating the State extension ActivityState { - /// Handlers for various activity state change events. - /// - /// These handlers are called when WebSocket events are received and automatically update the state accordingly. - struct ChangeHandlers: Sendable { - let activityUpdated: @MainActor (ActivityData) -> Void - let pollClosed: @MainActor (PollData) -> Void - let pollDeleted: @MainActor (String) -> Void - let pollUpdated: @MainActor (PollData) -> Void - let pollVoteCasted: @MainActor (PollVoteData, PollData) -> Void - let pollVoteChanged: @MainActor (PollVoteData, PollData) -> Void - let pollVoteRemoved: @MainActor (PollVoteData, PollData) -> Void - } - - /// Creates the change handlers for activity state updates. - /// - /// - Returns: A ChangeHandlers instance with all the necessary update functions - private func makeChangeHandlers() -> ChangeHandlers { - ChangeHandlers( - activityUpdated: { [weak self] activity in - self?.updateActivity(activity) - }, - pollClosed: { [weak self] poll in - guard poll.id == self?.poll?.id else { return } - self?.poll = poll - }, - pollDeleted: { [weak self] pollId in - guard pollId == self?.poll?.id else { return } - self?.poll = nil - }, - pollUpdated: { [weak self] poll in - guard poll.id == self?.poll?.id else { return } - self?.poll = poll - }, - pollVoteCasted: { [weak self] _, poll in - guard poll.id == self?.poll?.id else { return } - self?.poll = poll - }, - pollVoteChanged: { [weak self] _, poll in - guard poll.id == self?.poll?.id else { return } - self?.poll = poll - }, - pollVoteRemoved: { [weak self] _, poll in - guard poll.id == self?.poll?.id else { return } - self?.poll = poll + private func subscribe(to publisher: StateLayerEventPublisher) { + eventSubscription = publisher.subscribe { [weak self, activityId, feed] event in + switch event { + case .activityUpdated(let activityData, let eventFeedId): + guard activityData.id == activityId, eventFeedId == feed else { return } + await self?.setActivity(activityData) + case .pollDeleted(let pollId, let eventFeedId): + guard eventFeedId == feed else { return } + await self?.access { state in + guard state.poll?.id == pollId else { return } + state.poll = nil + } + case .pollUpdated(let pollData, let eventFeedId): + guard eventFeedId == feed else { return } + await self?.access { state in + guard state.poll?.id == pollData.id else { return } + state.poll = pollData + } } - ) + } } - func updateActivity(_ activity: ActivityData) { + func setActivity(_ activity: ActivityData) { self.activity = activity poll = activity.poll } - func access(_ actions: @MainActor (ActivityState) -> T) -> T { + private func access(_ actions: @MainActor (ActivityState) -> T) -> T { actions(self) } } diff --git a/Sources/StreamFeeds/StateLayer/Common/StateLayerEvent.swift b/Sources/StreamFeeds/StateLayer/Common/StateLayerEvent.swift new file mode 100644 index 0000000..fd65e9f --- /dev/null +++ b/Sources/StreamFeeds/StateLayer/Common/StateLayerEvent.swift @@ -0,0 +1,38 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore + +/// An event intended to be used for state-layer state mutations. +/// +/// - Note: Many of the WS events are merged into a single update event. +enum StateLayerEvent: Sendable { + case activityUpdated(ActivityData, FeedId) + case pollDeleted(String, FeedId) + case pollUpdated(PollData, FeedId) +} + +extension StateLayerEvent { + init?(event: Event) { + switch event { + case let event as ActivityUpdatedEvent: + self = .activityUpdated(event.activity.toModel(), FeedId(rawValue: event.fid)) + case let event as PollDeletedFeedEvent: + self = .pollDeleted(event.poll.id, FeedId(rawValue: event.fid)) + case let event as PollUpdatedFeedEvent: + self = .pollUpdated(event.poll.toModel(), FeedId(rawValue: event.fid)) + case let event as PollClosedFeedEvent: + self = .pollUpdated(event.poll.toModel(), FeedId(rawValue: event.fid)) + case let event as PollVoteCastedFeedEvent: + self = .pollUpdated(event.poll.toModel(), FeedId(rawValue: event.fid)) + case let event as PollVoteChangedFeedEvent: + self = .pollUpdated(event.poll.toModel(), FeedId(rawValue: event.fid)) + case let event as PollVoteRemovedFeedEvent: + self = .pollUpdated(event.poll.toModel(), FeedId(rawValue: event.fid)) + default: + return nil + } + } +} diff --git a/Sources/StreamFeeds/StateLayer/Common/StateLayerEventPublisher.swift b/Sources/StreamFeeds/StateLayer/Common/StateLayerEventPublisher.swift new file mode 100644 index 0000000..5ed7ed1 --- /dev/null +++ b/Sources/StreamFeeds/StateLayer/Common/StateLayerEventPublisher.swift @@ -0,0 +1,65 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation +import StreamCore + +/// A centralized publisher for events state-layer uses. +/// +/// Reduces the amount of data model conversions which is done once per event data. +/// Ensures that observation handlers run on a background thread allowing additional +/// filtering before consuming the event on the main thread. +final class StateLayerEventPublisher: WSEventsSubscriber, Sendable { + private let subscriptions = AllocatedUnfairLock<[UUID: @Sendable (StateLayerEvent) async -> Void]>([:]) + + /// Send individual events to all the subscribers. + /// + /// Triggered by incoming web-socket events and manually after API calls. + /// + /// - Parameter event: The state layer change event. + func sendEvent(_ event: StateLayerEvent) async { + let handlers = Array(subscriptions.value.values) + await withTaskGroup(of: Void.self) { group in + for handler in handlers { + group.addTask { + await handler(event) + } + } + } + } + + func subscribe(_ handler: @escaping @Sendable (StateLayerEvent) async -> Void) -> Subscription { + let id = UUID() + subscriptions.withLock { $0[id] = handler } + return Subscription { [weak self] in + self?.unsubscribe(id) + } + } + + private func unsubscribe(_ id: UUID) { + subscriptions.withLock { $0.removeValue(forKey: id) } + } + + // MARK: - WSEventsSubscriber + + func onEvent(_ event: any Event) async { + guard let stateLayerEvent = StateLayerEvent(event: event) else { return } + await sendEvent(stateLayerEvent) + } +} + +extension StateLayerEventPublisher { + final class Subscription: Sendable { + private let cancel: @Sendable () -> Void + + init(cancel: @escaping @Sendable () -> Void) { + self.cancel = cancel + } + + deinit { + cancel() + } + } +} diff --git a/Sources/StreamFeeds/StateLayer/Feed.swift b/Sources/StreamFeeds/StateLayer/Feed.swift index c7c0525..346384a 100644 --- a/Sources/StreamFeeds/StateLayer/Feed.swift +++ b/Sources/StreamFeeds/StateLayer/Feed.swift @@ -480,9 +480,9 @@ public final class Feed: Sendable { /// - Throws: `APIError` if the network request fails or the server returns an error @discardableResult public func addReaction(activityId: String, request: AddReactionRequest) async throws -> FeedsReactionData { - let reaction = try await activitiesRepository.addReaction(activityId: activityId, request: request) - await state.changeHandlers.reactionAdded(reaction) - return reaction + let result = try await activitiesRepository.addReaction(activityId: activityId, request: request) + await state.changeHandlers.reactionAdded(result.reaction) + return result.reaction } /// Removes a reaction from an activity. @@ -494,9 +494,9 @@ public final class Feed: Sendable { /// - Throws: `APIError` if the network request fails or the server returns an error @discardableResult public func deleteReaction(activityId: String, type: String) async throws -> FeedsReactionData { - let reaction = try await activitiesRepository.deleteReaction(activityId: activityId, type: type) - await state.changeHandlers.reactionRemoved(reaction) - return reaction + let result = try await activitiesRepository.deleteReaction(activityId: activityId, type: type) + await state.changeHandlers.reactionRemoved(result.reaction) + return result.reaction } /// Adds a reaction to a comment. diff --git a/Tests/StreamFeedsTests/StateLayer/ActivityCommentList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/ActivityCommentList_Tests.swift index 21eff2d..e9a6ac6 100644 --- a/Tests/StreamFeedsTests/StateLayer/ActivityCommentList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/ActivityCommentList_Tests.swift @@ -150,8 +150,8 @@ struct ActivityCommentList_Tests { await client.eventsMiddleware.sendEvent( CommentReactionAddedEvent.dummy( comment: .dummy(id: "comment-1"), - reaction: .dummy(type: "heart"), - fid: "user:test" + fid: "user:test", + reaction: .dummy(type: "heart") ) ) @@ -174,8 +174,8 @@ struct ActivityCommentList_Tests { await client.eventsMiddleware.sendEvent( CommentReactionAddedEvent.dummy( comment: .dummy(id: "comment-1"), - reaction: .dummy(type: "heart"), - fid: "user:test" + fid: "user:test", + reaction: .dummy(type: "heart") ) ) @@ -183,8 +183,8 @@ struct ActivityCommentList_Tests { await client.eventsMiddleware.sendEvent( CommentReactionDeletedEvent.dummy( comment: .dummy(id: "comment-1"), - reaction: .dummy(type: "heart"), - fid: "user:test" + fid: "user:test", + reaction: .dummy(type: "heart") ) ) diff --git a/Tests/StreamFeedsTests/StateLayer/Activity_Tests.swift b/Tests/StreamFeedsTests/StateLayer/Activity_Tests.swift new file mode 100644 index 0000000..e52328e --- /dev/null +++ b/Tests/StreamFeedsTests/StateLayer/Activity_Tests.swift @@ -0,0 +1,782 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamCore +@testable import StreamFeeds +import Testing + +struct Activity_Tests { + // MARK: - Actions + + @Test func getOrCreateUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses() + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + let activityData = try await activity.get() + let stateActivity = try #require(await activity.state.activity) + #expect(stateActivity.id == "activity-123") + #expect(stateActivity.text == "Test activity content") + #expect(stateActivity == activityData) + await #expect(activity.state.comments.map(\.id) == ["comment-1", "comment-2"]) + let statePoll = try #require(await activity.state.poll) + #expect(statePoll.id == "poll-123") + #expect(statePoll.name == "Test Poll") + #expect(statePoll.options.map(\.id) == ["option-1", "option-2"]) + } + + @Test func pinUpdatesState() async throws { + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + PinActivityResponse.dummy( + activity: .dummy(id: "activity-123", text: "Pinned activity"), + feed: "user:jane" + ) + ] + ) + ) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + await #expect(activity.state.activity == nil) + try await activity.pin() + + let stateActivity = try #require(await activity.state.activity) + #expect(stateActivity.id == "activity-123") + #expect(stateActivity.text == "Pinned activity") + } + + @Test func unpinUpdatesState() async throws { + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + UnpinActivityResponse.dummy( + activity: .dummy(id: "activity-123", text: "Unpinned activity"), + feed: "user:jane" + ) + ] + ) + ) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + await #expect(activity.state.activity == nil) + try await activity.unpin() + + let stateActivity = try #require(await activity.state.activity) + #expect(stateActivity.id == "activity-123") + #expect(stateActivity.text == "Unpinned activity") + } + + @Test func addReactionUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses([ + AddReactionResponse.dummy( + activity: .dummy( + id: "activity-123", + latestReactions: [.dummy(type: "like"), .dummy(type: "like")], + ownReactions: [.dummy(type: "like"), .dummy(type: "like")], + reactionCount: 2, + reactionGroups: ["like": .dummy(count: 2)], + text: "Test activity content" + ), + reaction: .dummy(type: "like") + ) + ]) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + try await activity.get() + + // Verify initial state has 1 reaction + let initialState = try #require(await activity.state.activity) + #expect(initialState.reactionCount == 1) + #expect(initialState.ownReactions.count == 1) + #expect(initialState.ownReactions.map(\.type) == ["like"]) + #expect(initialState.latestReactions.count == 1) + #expect(initialState.latestReactions.map(\.type) == ["like"]) + #expect(initialState.reactionGroups["like"]?.count == 1) + + // Add reaction + let reaction = try await activity.addReaction(request: .init(type: "like")) + + // Verify state is updated + let updatedState = try #require(await activity.state.activity) + #expect(reaction.type == "like") + #expect(updatedState.reactionCount == 2) + #expect(updatedState.ownReactions.count == 2) + #expect(updatedState.ownReactions.map(\.type) == ["like", "like"]) + #expect(updatedState.latestReactions.count == 2) + #expect(updatedState.latestReactions.map(\.type) == ["like", "like"]) + #expect(updatedState.reactionGroups["like"]?.count == 2) + } + + @Test func deleteReactionUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses( + [ + DeleteActivityReactionResponse.dummy( + activity: .dummy( + id: "activity-123", + latestReactions: [], + ownReactions: [], + reactionCount: 0, + reactionGroups: [:], + text: "Test activity content" + ), + reaction: .dummy(type: "like") + ) + ] + ) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + try await activity.get() + + // Verify initial state has one reaction + let initialState = try #require(await activity.state.activity) + #expect(initialState.reactionCount == 1) + #expect(initialState.ownReactions.count == 1) + #expect(initialState.ownReactions.first?.type == "like") + #expect(initialState.latestReactions.count == 1) + #expect(initialState.latestReactions.first?.type == "like") + #expect(initialState.reactionGroups["like"]?.count == 1) + + // Delete reaction + let reaction = try await activity.deleteReaction(type: "like") + + // Verify state is updated + let updatedState = try #require(await activity.state.activity) + #expect(reaction.type == "like") + #expect(updatedState.reactionCount == 0) + #expect(updatedState.ownReactions.isEmpty) + #expect(updatedState.latestReactions.isEmpty) + #expect(updatedState.reactionGroups.isEmpty) + } + + @Test func closePollUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses([ + PollResponse.dummy( + poll: .dummy( + id: "poll-123", + isClosed: true, + name: "Test Poll", + options: [ + .dummy(id: "option-1", text: "Option 1"), + .dummy(id: "option-2", text: "Option 2") + ], + ownVotes: [ + .dummy(id: "vote-1", optionId: "option-1", pollId: "poll-123") + ], + voteCount: 1, + voteCountsByOption: ["option-1": 1, "option-2": 0] + ) + ) + ]) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + try await activity.get() + + let poll = try await activity.closePoll() + let statePoll = try #require(await activity.state.poll) + #expect(poll.id == "poll-123") + #expect(poll.isClosed == true) + #expect(statePoll.id == "poll-123") + #expect(statePoll.isClosed == true) + } + + @Test func deletePollUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses([ + Response.dummy() + ]) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + try await activity.get() + + // Verify poll exists initially + let initialPoll = try #require(await activity.state.poll) + #expect(initialPoll.id == "poll-123") + + try await activity.deletePoll() + + // Verify poll is removed from state + let finalPoll = await activity.state.poll + #expect(finalPoll == nil) + } + + @Test func getPollUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses([ + PollResponse.dummy( + poll: .dummy( + id: "poll-123", + name: "Updated Poll", + options: [ + .dummy(id: "option-1", text: "Option 1"), + .dummy(id: "option-2", text: "Option 2") + ], + ownVotes: [ + .dummy(id: "vote-1", optionId: "option-1", pollId: "poll-123") + ], + voteCount: 1, + voteCountsByOption: ["option-1": 1, "option-2": 0] + ) + ) + ]) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + try await activity.get() + + let poll = try await activity.getPoll() + let statePoll = try #require(await activity.state.poll) + #expect(poll.id == "poll-123") + #expect(poll.name == "Updated Poll") + #expect(statePoll.id == "poll-123") + #expect(statePoll.name == "Updated Poll") + } + + @Test func updatePollPartialUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses([ + PollResponse.dummy( + poll: .dummy( + id: "poll-123", + name: "Partially Updated Poll", + options: [ + .dummy(id: "option-1", text: "Option 1"), + .dummy(id: "option-2", text: "Option 2") + ], + ownVotes: [ + .dummy(id: "vote-1", optionId: "option-1", pollId: "poll-123") + ], + voteCount: 1, + voteCountsByOption: ["option-1": 1, "option-2": 0] + ) + ) + ]) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + try await activity.get() + + let poll = try await activity.updatePollPartial(request: .init(set: ["name": .string("Partially Updated Poll")])) + let statePoll = try #require(await activity.state.poll) + #expect(poll.id == "poll-123") + #expect(poll.name == "Partially Updated Poll") + #expect(statePoll.id == "poll-123") + #expect(statePoll.name == "Partially Updated Poll") + } + + @Test func updatePollUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses([ + PollResponse.dummy( + poll: .dummy( + id: "poll-123", + name: "Fully Updated Poll", + options: [ + .dummy(id: "option-1", text: "Option 1"), + .dummy(id: "option-2", text: "Option 2") + ], + ownVotes: [ + .dummy(id: "vote-1", optionId: "option-1", pollId: "poll-123") + ], + voteCount: 1, + voteCountsByOption: ["option-1": 1, "option-2": 0] + ) + ) + ]) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + try await activity.get() + + let poll = try await activity.updatePoll(request: .init(id: "poll-123", name: "Fully Updated Poll")) + let statePoll = try #require(await activity.state.poll) + #expect(poll.id == "poll-123") + #expect(poll.name == "Fully Updated Poll") + #expect(statePoll.id == "poll-123") + #expect(statePoll.name == "Fully Updated Poll") + } + + @Test func createPollOptionUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses([ + PollOptionResponse.dummy( + pollOption: .dummy(id: "option-3", text: "New Option") + ) + ]) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + try await activity.get() + + let option = try await activity.createPollOption(request: .init(text: "New Option")) + let statePoll = try #require(await activity.state.poll) + #expect(option.id == "option-3") + #expect(option.text == "New Option") + #expect(statePoll.options.count == 3) + #expect(statePoll.options.contains { $0.id == "option-3" }) + } + + @Test func deletePollOptionUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses([ + StreamFeeds.Response.dummy() + ]) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + try await activity.get() + + // Verify option exists initially + let initialPoll = try #require(await activity.state.poll) + #expect(initialPoll.options.count == 2) + #expect(initialPoll.options.contains { $0.id == "option-1" }) + + try await activity.deletePollOption(optionId: "option-1") + + // Verify option is removed from state + let finalPoll = try #require(await activity.state.poll) + #expect(finalPoll.options.count == 1) + #expect(!finalPoll.options.contains { $0.id == "option-1" }) + } + + @Test func getPollOptionUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses([ + PollOptionResponse.dummy( + pollOption: .dummy(id: "option-1", text: "Updated Option 1") + ) + ]) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + try await activity.get() + + let option = try await activity.getPollOption(optionId: "option-1", userId: nil) + let statePoll = try #require(await activity.state.poll) + #expect(option.id == "option-1") + #expect(option.text == "Updated Option 1") + #expect(statePoll.options.first { $0.id == "option-1" }?.text == "Updated Option 1") + } + + @Test func updatePollOptionUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses([ + PollOptionResponse.dummy( + pollOption: .dummy(id: "option-1", text: "Updated Option 1") + ) + ]) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + try await activity.get() + + let option = try await activity.updatePollOption(request: .init(id: "option-1", text: "Updated Option 1")) + let statePoll = try #require(await activity.state.poll) + #expect(option.id == "option-1") + #expect(option.text == "Updated Option 1") + #expect(statePoll.options.first { $0.id == "option-1" }?.text == "Updated Option 1") + } + + @Test func castPollVoteUpdatesStateWhenEnforceUniqueVotes() async throws { + let client = defaultClientWithActivityAndCommentsResponses([ + PollVoteResponse.dummy( + vote: .dummy(id: "vote-2", optionId: "option-2", pollId: "poll-123") + ) + ]) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + try await activity.get() + + let vote = try await activity.castPollVote(request: .init(vote: .init(optionId: "option-2"))) + let statePoll = try #require(await activity.state.poll) + #expect(vote?.id == "vote-2") + #expect(vote?.optionId == "option-2") + #expect(statePoll.ownVotes.map(\.id) == ["vote-2"]) + } + + @Test func castPollVoteUpdatesStateWhenNotEnforcingUniqueVotes() async throws { + let client = defaultClientWithActivityAndCommentsResponses( + uniqueVotes: false, + [ + PollVoteResponse.dummy( + vote: .dummy(id: "vote-2", optionId: "option-2", pollId: "poll-123") + ) + ] + ) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + try await activity.get() + + let vote = try await activity.castPollVote(request: .init(vote: .init(optionId: "option-2"))) + let statePoll = try #require(await activity.state.poll) + #expect(vote?.id == "vote-2") + #expect(vote?.optionId == "option-2") + #expect(statePoll.ownVotes.map(\.id).sorted() == ["vote-1", "vote-2"]) + } + + @Test func deletePollVoteUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses([ + PollVoteResponse.dummy( + vote: .dummy(id: "vote-1", optionId: "option-1", pollId: "poll-123") + ) + ]) + let activity = client.activity( + for: "activity-123", + in: .init(group: "user", id: "jane") + ) + try await activity.get() + + // Verify vote exists initially + let initialPoll = try #require(await activity.state.poll) + #expect(initialPoll.ownVotes.count == 1) + #expect(initialPoll.ownVotes.contains { $0.id == "vote-1" }) + + let vote = try await activity.deletePollVote(voteId: "vote-1") + + // Verify vote is removed from state + let finalPoll = try #require(await activity.state.poll) + #expect(vote?.id == "vote-1") + #expect(finalPoll.ownVotes.isEmpty) + } + + // MARK: - Web-Socket Events + + @Test func activityUpdatedEventUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses() + let feedId = FeedId(group: "user", id: "jane") + let activity = client.activity( + for: "activity-123", + in: feedId + ) + try await activity.get() + + // Matching event + await client.eventsMiddleware.sendEvent( + ActivityUpdatedEvent.dummy( + activity: .dummy(id: "activity-123", text: "NEW TEXT"), + fid: feedId.rawValue + ) + ) + await #expect(activity.state.activity?.text == "NEW TEXT") + + // Unrelated event + await client.eventsMiddleware.sendEvent( + ActivityUpdatedEvent.dummy( + activity: .dummy(id: "some-other-activity", text: "SHOULD NOT CHANGE TO TEXT"), + fid: feedId.rawValue + ) + ) + await #expect(activity.state.activity?.text == "NEW TEXT") + } + + @Test func pollDeletedFeedEventUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses() + let feedId = FeedId(group: "user", id: "jane") + let activity = client.activity( + for: "activity-123", + in: feedId + ) + try await activity.get() + + let initialPoll = try #require(await activity.state.poll) + #expect(initialPoll.id == "poll-123") + + // Unrelated event - should not affect poll + await client.eventsMiddleware.sendEvent( + PollDeletedFeedEvent.dummy( + fid: feedId.rawValue, + poll: .dummy(id: "some-other-poll") + ) + ) + await #expect(activity.state.poll != nil) + + // Matching event - should remove poll + await client.eventsMiddleware.sendEvent( + PollDeletedFeedEvent.dummy( + fid: feedId.rawValue, + poll: .dummy(id: "poll-123") + ) + ) + await #expect(activity.state.poll == nil) + } + + @Test func pollUpdatedFeedEventUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses() + let feedId = FeedId(group: "user", id: "jane") + let activity = client.activity( + for: "activity-123", + in: feedId + ) + try await activity.get() + + let initialPoll = try #require(await activity.state.poll) + #expect(initialPoll.id == "poll-123") + #expect(initialPoll.name == "Test Poll") + + // Unrelated event - should not affect poll + await client.eventsMiddleware.sendEvent( + PollUpdatedFeedEvent.dummy( + fid: feedId.rawValue, + poll: .dummy(id: "some-other-poll", name: "Other Poll") + ) + ) + await #expect(activity.state.poll?.name == "Test Poll") + + // Matching event - should update poll + await client.eventsMiddleware.sendEvent( + PollUpdatedFeedEvent.dummy( + fid: feedId.rawValue, + poll: .dummy(id: "poll-123", name: "Updated Poll") + ) + ) + await #expect(activity.state.poll?.name == "Updated Poll") + } + + @Test func pollClosedFeedEventUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses() + let feedId = FeedId(group: "user", id: "jane") + let activity = client.activity( + for: "activity-123", + in: feedId + ) + try await activity.get() + + let initialPoll = try #require(await activity.state.poll) + #expect(initialPoll.id == "poll-123") + #expect(initialPoll.isClosed == false) + + // Unrelated event - should not affect poll + await client.eventsMiddleware.sendEvent( + PollClosedFeedEvent.dummy( + fid: feedId.rawValue, + poll: .dummy(id: "some-other-poll", isClosed: true) + ) + ) + await #expect(activity.state.poll?.isClosed == false) + + // Matching event - should close poll + await client.eventsMiddleware.sendEvent( + PollClosedFeedEvent.dummy( + fid: feedId.rawValue, + poll: .dummy(id: "poll-123", isClosed: true) + ) + ) + await #expect(activity.state.poll?.isClosed == true) + } + + @Test func pollVoteCastedFeedEventUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses() + let feedId = FeedId(group: "user", id: "jane") + let activity = client.activity( + for: "activity-123", + in: feedId + ) + try await activity.get() + + let initialPoll = try #require(await activity.state.poll) + #expect(initialPoll.id == "poll-123") + #expect(initialPoll.ownVotes.map(\.id) == ["vote-1"]) + #expect(initialPoll.voteCount == 1) + #expect(initialPoll.voteCountsByOption == ["option-1": 1, "option-2": 0]) + + // Unrelated event - should not affect poll + await client.eventsMiddleware.sendEvent( + PollVoteCastedFeedEvent.dummy( + fid: feedId.rawValue, + poll: .dummy( + id: "some-other-poll", + ownVotes: [.dummy(id: "vote-other", optionId: "option-1", pollId: "some-other-poll")], + voteCount: 1, + voteCountsByOption: ["option-1": 1] + ), + pollVote: .dummy(id: "vote-other", optionId: "option-1", pollId: "some-other-poll") + ) + ) + await #expect(activity.state.poll?.ownVotes.map(\.id) == ["vote-1"]) + await #expect(activity.state.poll?.voteCount == 1) + await #expect(activity.state.poll?.voteCountsByOption == ["option-1": 1, "option-2": 0]) + + // Matching event - should add vote + await client.eventsMiddleware.sendEvent( + PollVoteCastedFeedEvent.dummy( + fid: feedId.rawValue, + poll: .dummy( + id: "poll-123", + ownVotes: [ + .dummy(id: "vote-1", optionId: "option-1", pollId: "poll-123"), + .dummy(id: "vote-2", optionId: "option-2", pollId: "poll-123") + ], + voteCount: 2, + voteCountsByOption: ["option-1": 1, "option-2": 1] + ), + pollVote: .dummy(id: "vote-2", optionId: "option-2", pollId: "poll-123") + ) + ) + await #expect(activity.state.poll?.ownVotes.map(\.id).sorted() == ["vote-1", "vote-2"]) + await #expect(activity.state.poll?.voteCount == 2) + await #expect(activity.state.poll?.voteCountsByOption == ["option-1": 1, "option-2": 1]) + } + + @Test func pollVoteChangedFeedEventUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses() + let feedId = FeedId(group: "user", id: "jane") + let activity = client.activity( + for: "activity-123", + in: feedId + ) + try await activity.get() + + let initialPoll = try #require(await activity.state.poll) + #expect(initialPoll.id == "poll-123") + #expect(initialPoll.ownVotes.map(\.id) == ["vote-1"]) + #expect(initialPoll.voteCount == 1) + #expect(initialPoll.voteCountsByOption == ["option-1": 1, "option-2": 0]) + + // Unrelated event - should not affect poll + await client.eventsMiddleware.sendEvent( + PollVoteChangedFeedEvent.dummy( + fid: feedId.rawValue, + poll: .dummy( + id: "some-other-poll", + ownVotes: [.dummy(id: "vote-other", optionId: "option-2", pollId: "some-other-poll")], + voteCount: 1, + voteCountsByOption: ["option-2": 1] + ), + pollVote: .dummy(id: "vote-other", optionId: "option-2", pollId: "some-other-poll") + ) + ) + await #expect(activity.state.poll?.ownVotes.map(\.id) == ["vote-1"]) + await #expect(activity.state.poll?.voteCount == 1) + await #expect(activity.state.poll?.voteCountsByOption == ["option-1": 1, "option-2": 0]) + + // Matching event - should change vote + await client.eventsMiddleware.sendEvent( + PollVoteChangedFeedEvent.dummy( + fid: feedId.rawValue, + poll: .dummy( + id: "poll-123", + ownVotes: [.dummy(id: "vote-1-changed", optionId: "option-2", pollId: "poll-123")], + voteCount: 1, + voteCountsByOption: ["option-1": 0, "option-2": 1] + ), + pollVote: .dummy(id: "vote-1", optionId: "option-2", pollId: "poll-123") + ) + ) + await #expect(activity.state.poll?.ownVotes.map(\.id) == ["vote-1-changed"]) + await #expect(activity.state.poll?.voteCount == 1) + await #expect(activity.state.poll?.voteCountsByOption == ["option-1": 0, "option-2": 1]) + } + + @Test func pollVoteRemovedFeedEventUpdatesState() async throws { + let client = defaultClientWithActivityAndCommentsResponses() + let feedId = FeedId(group: "user", id: "jane") + let activity = client.activity( + for: "activity-123", + in: feedId + ) + try await activity.get() + + let initialPoll = try #require(await activity.state.poll) + #expect(initialPoll.id == "poll-123") + #expect(initialPoll.ownVotes.map(\.id) == ["vote-1"]) + #expect(initialPoll.voteCount == 1) + #expect(initialPoll.voteCountsByOption == ["option-1": 1, "option-2": 0]) + + // Unrelated event - should not affect poll + await client.eventsMiddleware.sendEvent( + PollVoteRemovedFeedEvent.dummy( + fid: feedId.rawValue, + poll: .dummy( + id: "some-other-poll", + ownVotes: [], + voteCount: 0, + voteCountsByOption: [:] + ), + pollVote: .dummy(id: "vote-other", optionId: "option-1", pollId: "some-other-poll") + ) + ) + await #expect(activity.state.poll?.ownVotes.map(\.id) == ["vote-1"]) + await #expect(activity.state.poll?.voteCount == 1) + await #expect(activity.state.poll?.voteCountsByOption == ["option-1": 1, "option-2": 0]) + + // Matching event - should remove vote + await client.eventsMiddleware.sendEvent( + PollVoteRemovedFeedEvent.dummy( + fid: feedId.rawValue, + poll: .dummy( + id: "poll-123", + ownVotes: [], + voteCount: 0, + voteCountsByOption: ["option-1": 0, "option-2": 0] + ), + pollVote: .dummy(id: "vote-1", optionId: "option-1", pollId: "poll-123") + ) + ) + await #expect(activity.state.poll?.ownVotes.map(\.id) == []) + await #expect(activity.state.poll?.voteCount == 0) + await #expect(activity.state.poll?.voteCountsByOption == ["option-1": 0, "option-2": 0]) + } + + // MARK: - + + private func defaultClientWithActivityAndCommentsResponses( + uniqueVotes: Bool = true, + _ additionalPayloads: [any Encodable] = [] + ) -> FeedsClient { + FeedsClient.mock( + apiTransport: .withPayloads( + [ + GetActivityResponse.dummy( + activity: .dummy( + id: "activity-123", + latestReactions: [.dummy(type: "like")], + ownReactions: [.dummy(type: "like")], + poll: .dummy( + enforceUniqueVote: uniqueVotes, + id: "poll-123", + name: "Test Poll", + options: [ + .dummy(id: "option-1", text: "Option 1"), + .dummy(id: "option-2", text: "Option 2") + ], + ownVotes: [ + .dummy( + id: "vote-1", + optionId: "option-1", + pollId: "poll-123" + ) + ], + voteCount: 1, + voteCountsByOption: ["option-1": 1, "option-2": 0] + ), + reactionCount: 1, + reactionGroups: ["like": .dummy(count: 1)], + text: "Test activity content" + ) + ), + GetCommentsResponse.dummy(comments: [ + .dummy(id: "comment-1", text: "First comment"), + .dummy(id: "comment-2", text: "Second comment") + ]) + ] + additionalPayloads + ) + ) + } +} diff --git a/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift b/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift index 226713c..1384375 100644 --- a/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift @@ -35,8 +35,8 @@ struct Feed_Tests { await client.eventsMiddleware.sendEvent( ActivityAddedEvent.dummy( - fid: feed.feed.rawValue, - activity: .dummy(id: "new-activity", text: "New activity content") + activity: .dummy(id: "new-activity", text: "New activity content"), + fid: feed.feed.rawValue ) ) @@ -64,24 +64,24 @@ struct Feed_Tests { // Send activity with expiresAt - should be inserted await client.eventsMiddleware.sendEvent( ActivityAddedEvent.dummy( - fid: feed.feed.rawValue, activity: .dummy( + expiresAt: Date.fixed().addingTimeInterval(3600), id: "expiring-activity", - text: "This activity expires", - expiresAt: Date.fixed().addingTimeInterval(3600) - ) + text: "This activity expires" + ), + fid: feed.feed.rawValue ) ) // Send activity without expiresAt - should be ignored await client.eventsMiddleware.sendEvent( ActivityAddedEvent.dummy( - fid: feed.feed.rawValue, activity: .dummy( + expiresAt: nil, id: "non-expiring-activity", - text: "This activity doesn't expire", - expiresAt: nil - ) + text: "This activity doesn't expire" + ), + fid: feed.feed.rawValue ) ) @@ -113,12 +113,12 @@ struct Feed_Tests { // Send activity without expiresAt - should be ignored due to filter await client.eventsMiddleware.sendEvent( ActivityAddedEvent.dummy( - fid: feed.feed.rawValue, activity: .dummy( + expiresAt: nil, id: "non-expiring-activity", - text: "This activity doesn't expire", - expiresAt: nil - ) + text: "This activity doesn't expire" + ), + fid: feed.feed.rawValue ) ) @@ -138,8 +138,8 @@ struct Feed_Tests { await client.eventsMiddleware.sendEvent( ActivityUpdatedEvent.dummy( - fid: feed.feed.rawValue, - activity: .dummy(id: "1", text: "NEW TEXT") + activity: .dummy(id: "1", text: "NEW TEXT"), + fid: feed.feed.rawValue ) ) let result = await feed.state.activities.first?.text @@ -159,8 +159,8 @@ struct Feed_Tests { await client.eventsMiddleware.sendEvent( ActivityUpdatedEvent.dummy( - fid: feed.feed.rawValue, - activity: .dummy(id: "2") + activity: .dummy(id: "2"), + fid: feed.feed.rawValue ) ) await #expect(feed.state.activities.map(\.id) == ["1"]) diff --git a/Tests/StreamFeedsTests/TestTools/APITransportMock.swift b/Tests/StreamFeedsTests/TestTools/APITransportMock.swift index f0f27c2..881e71f 100644 --- a/Tests/StreamFeedsTests/TestTools/APITransportMock.swift +++ b/Tests/StreamFeedsTests/TestTools/APITransportMock.swift @@ -4,14 +4,14 @@ import Foundation import StreamCore +import StreamFeeds final class APITransportMock: DefaultAPITransport { let responsePayloads = AllocatedUnfairLock<[any Encodable]>([]) func execute(request: StreamCore.Request) async throws -> (Data, URLResponse) { let payload = try responsePayloads.withLock { payloads in - guard !payloads.isEmpty else { throw ClientError.Unexpected("Please setup responses") } - return payloads.removeFirst() + try Self.consumeResponsePayload(for: request, from: &payloads) } let data = try CodableHelper.jsonEncoder.encode(payload) let response = HTTPURLResponse( @@ -22,6 +22,26 @@ final class APITransportMock: DefaultAPITransport { )! return (data, response) } + + private static func consumeResponsePayload(for request: StreamCore.Request, from payloads: inout [any Encodable]) throws -> any Encodable { + let payloadIndex = payloads.firstIndex { payload in + switch payload.self { + case is GetActivityResponse: + request.url.path.hasPrefix("/api/v2/feeds/activities/") + case is GetCommentsResponse: + request.url.path.hasPrefix("/api/v2/feeds/comments") + default: + // Otherwise just pick the first. Custom matching is needed only for tests which run API + // requests in parallel so the order of responsePayload does not match with the order of + // execute(request:) calls. + true + } + } + guard let payloadIndex else { + throw ClientError("Response payload is not available for request: \(request)") + } + return payloads.remove(at: payloadIndex) + } } extension APITransportMock { diff --git a/Tests/StreamFeedsTests/TestTools/ActivityAddedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/ActivityAddedEvent+Testing.swift index 0f67d60..93878fb 100644 --- a/Tests/StreamFeedsTests/TestTools/ActivityAddedEvent+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/ActivityAddedEvent+Testing.swift @@ -8,8 +8,8 @@ import StreamCore extension ActivityAddedEvent { static func dummy( - fid: String = "test-feed-id", activity: ActivityResponse = ActivityResponse.dummy(), + fid: String = "test-feed-id", user: UserResponseCommonFields? = UserResponseCommonFields.dummy() ) -> ActivityAddedEvent { ActivityAddedEvent( diff --git a/Tests/StreamFeedsTests/TestTools/ActivityResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/ActivityResponse+Testing.swift index 31c9e7e..40dbb5f 100644 --- a/Tests/StreamFeedsTests/TestTools/ActivityResponse+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/ActivityResponse+Testing.swift @@ -8,9 +8,14 @@ import StreamCore extension ActivityResponse { static func dummy( + expiresAt: Date? = nil, id: String = "activity-123", - text: String = "Test activity content", - expiresAt: Date? = nil + latestReactions: [FeedsReactionResponse]? = nil, + ownReactions: [FeedsReactionResponse]? = nil, + poll: PollResponseData? = nil, + reactionCount: Int? = nil, + reactionGroups: [String: ReactionGroupResponse]? = nil, + text: String = "Test activity content" ) -> ActivityResponse { ActivityResponse( attachments: [], @@ -27,18 +32,18 @@ extension ActivityResponse { filterTags: [], id: id, interestTags: [], - latestReactions: [], + latestReactions: latestReactions ?? [], location: nil, mentionedUsers: [UserResponse.dummy()], moderation: nil, notificationContext: nil, ownBookmarks: [], - ownReactions: [], + ownReactions: ownReactions ?? [], parent: nil, - poll: nil, + poll: poll, popularity: 100, - reactionCount: 25, - reactionGroups: [:], + reactionCount: reactionCount ?? 25, + reactionGroups: reactionGroups ?? [:], score: 1.0, searchData: [:], shareCount: 3, diff --git a/Tests/StreamFeedsTests/TestTools/ActivityUpdatedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/ActivityUpdatedEvent+Testing.swift index 1b94831..5560b51 100644 --- a/Tests/StreamFeedsTests/TestTools/ActivityUpdatedEvent+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/ActivityUpdatedEvent+Testing.swift @@ -8,8 +8,8 @@ import StreamCore extension ActivityUpdatedEvent { static func dummy( - fid: String = "test-feed-id", activity: ActivityResponse = ActivityResponse.dummy(), + fid: String = "test-feed-id", user: UserResponseCommonFields? = UserResponseCommonFields.dummy() ) -> ActivityUpdatedEvent { ActivityUpdatedEvent( diff --git a/Tests/StreamFeedsTests/TestTools/AddReactionResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/AddReactionResponse+Testing.swift new file mode 100644 index 0000000..507a328 --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/AddReactionResponse+Testing.swift @@ -0,0 +1,21 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension AddReactionResponse { + static func dummy( + activity: ActivityResponse = .dummy(), + duration: String = "1.23ms", + reaction: FeedsReactionResponse = .dummy() + ) -> AddReactionResponse { + AddReactionResponse( + activity: activity, + duration: duration, + reaction: reaction + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/CommentAddedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/CommentAddedEvent+Testing.swift index a9badf7..9826370 100644 --- a/Tests/StreamFeedsTests/TestTools/CommentAddedEvent+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/CommentAddedEvent+Testing.swift @@ -9,9 +9,9 @@ import StreamCore extension CommentAddedEvent { static func dummy( comment: CommentResponse = .dummy(), - fid: String = "user:test", createdAt: Date = Date(), custom: [String: RawJSON] = [:], + fid: String = "user:test", user: UserResponseCommonFields? = nil ) -> CommentAddedEvent { CommentAddedEvent( diff --git a/Tests/StreamFeedsTests/TestTools/CommentDeletedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/CommentDeletedEvent+Testing.swift index eb66340..b3fb9b8 100644 --- a/Tests/StreamFeedsTests/TestTools/CommentDeletedEvent+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/CommentDeletedEvent+Testing.swift @@ -9,9 +9,9 @@ import StreamCore extension CommentDeletedEvent { static func dummy( comment: CommentResponse = .dummy(), - fid: String = "user:test", createdAt: Date = Date(), custom: [String: RawJSON] = [:], + fid: String = "user:test", user: UserResponseCommonFields? = nil ) -> CommentDeletedEvent { CommentDeletedEvent( diff --git a/Tests/StreamFeedsTests/TestTools/CommentReactionAddedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/CommentReactionAddedEvent+Testing.swift index 7bfa13d..886bebb 100644 --- a/Tests/StreamFeedsTests/TestTools/CommentReactionAddedEvent+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/CommentReactionAddedEvent+Testing.swift @@ -9,10 +9,10 @@ import StreamCore extension CommentReactionAddedEvent { static func dummy( comment: CommentResponse = .dummy(), - reaction: FeedsReactionResponse = .dummy(), - fid: String = "user:test", createdAt: Date = Date(), custom: [String: RawJSON] = [:], + fid: String = "user:test", + reaction: FeedsReactionResponse = .dummy(), user: UserResponseCommonFields? = nil ) -> CommentReactionAddedEvent { CommentReactionAddedEvent( diff --git a/Tests/StreamFeedsTests/TestTools/CommentReactionDeletedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/CommentReactionDeletedEvent+Testing.swift index 6bba9be..2d2f448 100644 --- a/Tests/StreamFeedsTests/TestTools/CommentReactionDeletedEvent+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/CommentReactionDeletedEvent+Testing.swift @@ -9,10 +9,10 @@ import StreamCore extension CommentReactionDeletedEvent { static func dummy( comment: CommentResponse = .dummy(), - reaction: FeedsReactionResponse = .dummy(), - fid: String = "user:test", createdAt: Date = Date(), - custom: [String: RawJSON] = [:] + custom: [String: RawJSON] = [:], + fid: String = "user:test", + reaction: FeedsReactionResponse = .dummy() ) -> CommentReactionDeletedEvent { CommentReactionDeletedEvent( comment: comment, diff --git a/Tests/StreamFeedsTests/TestTools/CommentResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/CommentResponse+Testing.swift index 86b2537..9b9e469 100644 --- a/Tests/StreamFeedsTests/TestTools/CommentResponse+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/CommentResponse+Testing.swift @@ -10,10 +10,10 @@ extension CommentResponse { static func dummy( createdAt: Date = .fixed(), id: String = "comment-123", - text: String = "Test comment", objectId: String = "activity-123", objectType: String = "activity", - parentId: String? = nil + parentId: String? = nil, + text: String = "Test comment" ) -> CommentResponse { CommentResponse( attachments: nil, diff --git a/Tests/StreamFeedsTests/TestTools/CommentUpdatedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/CommentUpdatedEvent+Testing.swift index 47d9462..e58f67b 100644 --- a/Tests/StreamFeedsTests/TestTools/CommentUpdatedEvent+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/CommentUpdatedEvent+Testing.swift @@ -9,9 +9,9 @@ import StreamCore extension CommentUpdatedEvent { static func dummy( comment: CommentResponse = .dummy(), - fid: String = "user:test", createdAt: Date = Date(), custom: [String: RawJSON] = [:], + fid: String = "user:test", user: UserResponseCommonFields? = nil ) -> CommentUpdatedEvent { CommentUpdatedEvent( diff --git a/Tests/StreamFeedsTests/TestTools/DeleteActivityReactionResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/DeleteActivityReactionResponse+Testing.swift new file mode 100644 index 0000000..9e0a77e --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/DeleteActivityReactionResponse+Testing.swift @@ -0,0 +1,21 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension DeleteActivityReactionResponse { + static func dummy( + activity: ActivityResponse = .dummy(), + duration: String = "1.23ms", + reaction: FeedsReactionResponse = .dummy() + ) -> DeleteActivityReactionResponse { + DeleteActivityReactionResponse( + activity: activity, + duration: duration, + reaction: reaction + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/FeedsReactionResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/FeedsReactionResponse+Testing.swift index 2c6bf0f..65c993e 100644 --- a/Tests/StreamFeedsTests/TestTools/FeedsReactionResponse+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/FeedsReactionResponse+Testing.swift @@ -8,12 +8,12 @@ import StreamCore extension FeedsReactionResponse { static func dummy( - type: String = "like", activityId: String = "activity-123", commentId: String? = nil, - user: UserResponse = .dummy(), createdAt: Date = Date(timeIntervalSince1970: 1_640_995_200), - custom: [String: RawJSON]? = nil + custom: [String: RawJSON]? = nil, + type: String = "like", + user: UserResponse = .dummy() ) -> FeedsReactionResponse { FeedsReactionResponse( activityId: activityId, diff --git a/Tests/StreamFeedsTests/TestTools/GetActivityResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/GetActivityResponse+Testing.swift new file mode 100644 index 0000000..f7d5179 --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/GetActivityResponse+Testing.swift @@ -0,0 +1,19 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension GetActivityResponse { + static func dummy( + activity: ActivityResponse = .dummy(), + duration: String = "0.123s" + ) -> GetActivityResponse { + GetActivityResponse( + activity: activity, + duration: duration + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/GetCommentsResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/GetCommentsResponse+Testing.swift index 9ad3adc..2432a34 100644 --- a/Tests/StreamFeedsTests/TestTools/GetCommentsResponse+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/GetCommentsResponse+Testing.swift @@ -9,9 +9,9 @@ import StreamCore extension GetCommentsResponse { static func dummy( comments: [ThreadedCommentResponse] = [.dummy()], + duration: String = "0.123s", next: String? = nil, - prev: String? = nil, - duration: String = "0.123s" + prev: String? = nil ) -> GetCommentsResponse { GetCommentsResponse( comments: comments, diff --git a/Tests/StreamFeedsTests/TestTools/PinActivityResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/PinActivityResponse+Testing.swift new file mode 100644 index 0000000..5243272 --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/PinActivityResponse+Testing.swift @@ -0,0 +1,25 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension PinActivityResponse { + static func dummy( + activity: ActivityResponse = .dummy(), + createdAt: Date = Date(timeIntervalSince1970: 1_640_995_200), + duration: String = "0.123s", + feed: String = "user:jane", + userId: String = "user-123" + ) -> PinActivityResponse { + PinActivityResponse( + activity: activity, + createdAt: createdAt, + duration: duration, + feed: feed, + userId: userId + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/PollClosedFeedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/PollClosedFeedEvent+Testing.swift new file mode 100644 index 0000000..068f768 --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/PollClosedFeedEvent+Testing.swift @@ -0,0 +1,27 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension PollClosedFeedEvent { + static func dummy( + createdAt: Date = .fixed(), + custom: [String: RawJSON] = [:], + feedVisibility: String? = nil, + fid: String = "user:test", + poll: PollResponseData = .dummy(), + receivedAt: Date? = nil + ) -> PollClosedFeedEvent { + PollClosedFeedEvent( + createdAt: createdAt, + custom: custom, + feedVisibility: feedVisibility, + fid: fid, + poll: poll, + receivedAt: receivedAt + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/PollDeletedFeedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/PollDeletedFeedEvent+Testing.swift new file mode 100644 index 0000000..f3bf7db --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/PollDeletedFeedEvent+Testing.swift @@ -0,0 +1,27 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension PollDeletedFeedEvent { + static func dummy( + createdAt: Date = .fixed(), + custom: [String: RawJSON] = [:], + feedVisibility: String? = nil, + fid: String = "user:test", + poll: PollResponseData = .dummy(), + receivedAt: Date? = nil + ) -> PollDeletedFeedEvent { + PollDeletedFeedEvent( + createdAt: createdAt, + custom: custom, + feedVisibility: feedVisibility, + fid: fid, + poll: poll, + receivedAt: receivedAt + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/PollOptionResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/PollOptionResponse+Testing.swift new file mode 100644 index 0000000..6cd7a5a --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/PollOptionResponse+Testing.swift @@ -0,0 +1,19 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension PollOptionResponse { + static func dummy( + duration: String = "0.123s", + pollOption: PollOptionResponseData = .dummy() + ) -> PollOptionResponse { + PollOptionResponse( + duration: duration, + pollOption: pollOption + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/PollOptionResponseData+Testing.swift b/Tests/StreamFeedsTests/TestTools/PollOptionResponseData+Testing.swift new file mode 100644 index 0000000..35d4387 --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/PollOptionResponseData+Testing.swift @@ -0,0 +1,21 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension PollOptionResponseData { + static func dummy( + custom: [String: RawJSON] = [:], + id: String = "option-123", + text: String = "Test option" + ) -> PollOptionResponseData { + PollOptionResponseData( + custom: custom, + id: id, + text: text + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/PollResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/PollResponse+Testing.swift new file mode 100644 index 0000000..71746fe --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/PollResponse+Testing.swift @@ -0,0 +1,19 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension PollResponse { + static func dummy( + duration: String = "0.123s", + poll: PollResponseData = .dummy() + ) -> PollResponse { + PollResponse( + duration: duration, + poll: poll + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/PollResponseData+Testing.swift b/Tests/StreamFeedsTests/TestTools/PollResponseData+Testing.swift new file mode 100644 index 0000000..b34e82e --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/PollResponseData+Testing.swift @@ -0,0 +1,60 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension PollResponseData { + static func dummy( + allowAnswers: Bool = true, + allowUserSuggestedOptions: Bool = false, + answersCount: Int = 0, + createdAt: Date = Date(timeIntervalSince1970: 1_640_995_200), + createdBy: UserResponse? = UserResponse.dummy(), + createdById: String = "user-123", + custom: [String: RawJSON] = [:], + description: String = "Test poll description", + enforceUniqueVote: Bool = true, + id: String = "poll-123", + isClosed: Bool = false, + latestAnswers: [PollVoteResponseData] = [], + latestVotesByOption: [String: [PollVoteResponseData]] = [:], + maxVotesAllowed: Int = 1, + name: String = "Test Poll", + options: [PollOptionResponseData] = [ + .dummy(id: "option-1", text: "Option 1"), + .dummy(id: "option-2", text: "Option 2") + ], + ownVotes: [PollVoteResponseData] = [], + updatedAt: Date = Date(timeIntervalSince1970: 1_640_995_200), + voteCount: Int = 0, + voteCountsByOption: [String: Int] = [:], + votingVisibility: String = "public" + ) -> PollResponseData { + PollResponseData( + allowAnswers: allowAnswers, + allowUserSuggestedOptions: allowUserSuggestedOptions, + answersCount: answersCount, + createdAt: createdAt, + createdBy: createdBy, + createdById: createdById, + custom: custom, + description: description, + enforceUniqueVote: enforceUniqueVote, + id: id, + isClosed: isClosed, + latestAnswers: latestAnswers, + latestVotesByOption: latestVotesByOption, + maxVotesAllowed: maxVotesAllowed, + name: name, + options: options, + ownVotes: ownVotes, + updatedAt: updatedAt, + voteCount: voteCount, + voteCountsByOption: voteCountsByOption, + votingVisibility: votingVisibility + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/PollUpdatedFeedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/PollUpdatedFeedEvent+Testing.swift new file mode 100644 index 0000000..f5e956b --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/PollUpdatedFeedEvent+Testing.swift @@ -0,0 +1,27 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension PollUpdatedFeedEvent { + static func dummy( + createdAt: Date = .fixed(), + custom: [String: RawJSON] = [:], + feedVisibility: String? = nil, + fid: String = "user:test", + poll: PollResponseData = .dummy(), + receivedAt: Date? = nil + ) -> PollUpdatedFeedEvent { + PollUpdatedFeedEvent( + createdAt: createdAt, + custom: custom, + feedVisibility: feedVisibility, + fid: fid, + poll: poll, + receivedAt: receivedAt + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/PollVoteCastedFeedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/PollVoteCastedFeedEvent+Testing.swift new file mode 100644 index 0000000..5734c4a --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/PollVoteCastedFeedEvent+Testing.swift @@ -0,0 +1,29 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension PollVoteCastedFeedEvent { + static func dummy( + createdAt: Date = .fixed(), + custom: [String: RawJSON] = [:], + feedVisibility: String? = nil, + fid: String = "user:test", + poll: PollResponseData = .dummy(), + pollVote: PollVoteResponseData = .dummy(), + receivedAt: Date? = nil + ) -> PollVoteCastedFeedEvent { + PollVoteCastedFeedEvent( + createdAt: createdAt, + custom: custom, + feedVisibility: feedVisibility, + fid: fid, + poll: poll, + pollVote: pollVote, + receivedAt: receivedAt + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/PollVoteChangedFeedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/PollVoteChangedFeedEvent+Testing.swift new file mode 100644 index 0000000..be8307f --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/PollVoteChangedFeedEvent+Testing.swift @@ -0,0 +1,29 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension PollVoteChangedFeedEvent { + static func dummy( + createdAt: Date = .fixed(), + custom: [String: RawJSON] = [:], + feedVisibility: String? = nil, + fid: String = "user:test", + poll: PollResponseData = .dummy(), + pollVote: PollVoteResponseData = .dummy(), + receivedAt: Date? = nil + ) -> PollVoteChangedFeedEvent { + PollVoteChangedFeedEvent( + createdAt: createdAt, + custom: custom, + feedVisibility: feedVisibility, + fid: fid, + poll: poll, + pollVote: pollVote, + receivedAt: receivedAt + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/PollVoteRemovedFeedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/PollVoteRemovedFeedEvent+Testing.swift new file mode 100644 index 0000000..4010adf --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/PollVoteRemovedFeedEvent+Testing.swift @@ -0,0 +1,29 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension PollVoteRemovedFeedEvent { + static func dummy( + createdAt: Date = .fixed(), + custom: [String: RawJSON] = [:], + feedVisibility: String? = nil, + fid: String = "user:test", + poll: PollResponseData = .dummy(), + pollVote: PollVoteResponseData = .dummy(), + receivedAt: Date? = nil + ) -> PollVoteRemovedFeedEvent { + PollVoteRemovedFeedEvent( + createdAt: createdAt, + custom: custom, + feedVisibility: feedVisibility, + fid: fid, + poll: poll, + pollVote: pollVote, + receivedAt: receivedAt + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/PollVoteResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/PollVoteResponse+Testing.swift new file mode 100644 index 0000000..8394d82 --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/PollVoteResponse+Testing.swift @@ -0,0 +1,19 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension PollVoteResponse { + static func dummy( + duration: String = "0.123s", + vote: PollVoteResponseData = .dummy() + ) -> PollVoteResponse { + PollVoteResponse( + duration: duration, + vote: vote + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/PollVoteResponseData+Testing.swift b/Tests/StreamFeedsTests/TestTools/PollVoteResponseData+Testing.swift new file mode 100644 index 0000000..ab530f6 --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/PollVoteResponseData+Testing.swift @@ -0,0 +1,33 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension PollVoteResponseData { + static func dummy( + answerText: String? = nil, + createdAt: Date = Date(timeIntervalSince1970: 1_640_995_200), + id: String = "vote-123", + isAnswer: Bool = false, + optionId: String = "option-1", + pollId: String = "poll-123", + updatedAt: Date = Date(timeIntervalSince1970: 1_640_995_200), + user: UserResponse? = UserResponse.dummy(), + userId: String = "user-123" + ) -> PollVoteResponseData { + PollVoteResponseData( + answerText: answerText, + createdAt: createdAt, + id: id, + isAnswer: isAnswer, + optionId: optionId, + pollId: pollId, + updatedAt: updatedAt, + user: user, + userId: userId + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/ReactionGroupResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/ReactionGroupResponse+Testing.swift new file mode 100644 index 0000000..86bccc6 --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/ReactionGroupResponse+Testing.swift @@ -0,0 +1,23 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension ReactionGroupResponse { + static func dummy( + count: Int = 1, + firstReactionAt: Date = Date(timeIntervalSince1970: 1_640_995_200), + lastReactionAt: Date = Date(timeIntervalSince1970: 1_640_995_200), + sumScores: Int? = nil + ) -> ReactionGroupResponse { + ReactionGroupResponse( + count: count, + firstReactionAt: firstReactionAt, + lastReactionAt: lastReactionAt, + sumScores: sumScores + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/Response+Testing.swift b/Tests/StreamFeedsTests/TestTools/Response+Testing.swift new file mode 100644 index 0000000..7b32868 --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/Response+Testing.swift @@ -0,0 +1,17 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension StreamFeeds.Response { + static func dummy( + duration: String = "0.123s" + ) -> StreamFeeds.Response { + StreamFeeds.Response( + duration: duration + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/ThreadedCommentResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/ThreadedCommentResponse+Testing.swift index 710793b..f1f71da 100644 --- a/Tests/StreamFeedsTests/TestTools/ThreadedCommentResponse+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/ThreadedCommentResponse+Testing.swift @@ -10,11 +10,11 @@ extension ThreadedCommentResponse { static func dummy( createdAt: Date = .fixed(), id: String = "comment-123", - text: String = "Test comment", objectId: String = "activity-123", objectType: String = "activity", parentId: String? = nil, - replies: [ThreadedCommentResponse]? = nil + replies: [ThreadedCommentResponse]? = nil, + text: String = "Test comment" ) -> ThreadedCommentResponse { ThreadedCommentResponse( attachments: nil, diff --git a/Tests/StreamFeedsTests/TestTools/UnpinActivityResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/UnpinActivityResponse+Testing.swift new file mode 100644 index 0000000..3837ea3 --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/UnpinActivityResponse+Testing.swift @@ -0,0 +1,23 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension UnpinActivityResponse { + static func dummy( + activity: ActivityResponse = .dummy(), + duration: String = "0.123s", + feed: String = "user:jane", + userId: String = "user-123" + ) -> UnpinActivityResponse { + UnpinActivityResponse( + activity: activity, + duration: duration, + feed: feed, + userId: userId + ) + } +}