diff --git a/CHANGELOG.md b/CHANGELOG.md index cff0e072..f85a34b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming -### 🔄 Changed +### ✅ Added +- Keep `FeedData.ownCapabilities` up to date in every model when handling web-socket events [#51](https://github.com/GetStream/stream-feeds-swift/pull/51) # [0.4.0](https://github.com/GetStream/stream-feeds-swift/releases/tag/0.4.0) _September 25, 2025_ diff --git a/DemoApp/FeedsView/FeedsListView.swift b/DemoApp/FeedsView/FeedsListView.swift index db3bf540..edb9ee35 100644 --- a/DemoApp/FeedsView/FeedsListView.swift +++ b/DemoApp/FeedsView/FeedsListView.swift @@ -211,7 +211,7 @@ struct ActivityView: View { @State var selectedAttachment: Attachment? let user: UserData - let ownCapabilities: [FeedOwnCapability] + let ownCapabilities: Set let text: String var attachments: [Attachment]? var activity: ActivityData diff --git a/Sources/StreamFeeds/Extensions/Dictionary+Extensions.swift b/Sources/StreamFeeds/Extensions/Dictionary+Extensions.swift new file mode 100644 index 00000000..b52d8667 --- /dev/null +++ b/Sources/StreamFeeds/Extensions/Dictionary+Extensions.swift @@ -0,0 +1,12 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension Dictionary { + func contains(_ key: Key?) -> Bool { + guard let key else { return false } + return self[key] != nil + } +} diff --git a/Sources/StreamFeeds/FeedsClient.swift b/Sources/StreamFeeds/FeedsClient.swift index e16f08f2..7049cd93 100644 --- a/Sources/StreamFeeds/FeedsClient.swift +++ b/Sources/StreamFeeds/FeedsClient.swift @@ -39,6 +39,7 @@ public final class FeedsClient: Sendable { let feedsRepository: FeedsRepository let moderationRepository: ModerationRepository let pollsRepository: PollsRepository + let ownCapabilitiesRepository: OwnCapabilitiesRepository private let _token: AllocatedUnfairLock private let _userAuth = AllocatedUnfairLock(nil) @@ -127,14 +128,24 @@ public final class FeedsClient: Sendable { commentsRepository = CommentsRepository(apiClient: apiClient) devicesRepository = DevicesRepository(devicesClient: devicesClient) feedsRepository = FeedsRepository(apiClient: apiClient) - pollsRepository = PollsRepository(apiClient: apiClient) moderationRepository = ModerationRepository(apiClient: apiClient) + ownCapabilitiesRepository = OwnCapabilitiesRepository(apiClient: apiClient) + pollsRepository = PollsRepository(apiClient: apiClient) moderation = Moderation(apiClient: apiClient) eventsMiddleware.add(subscriber: self) eventsMiddleware.add(subscriber: stateLayerEventPublisher) eventNotificationCenter.add(middlewares: [eventsMiddleware]) + + stateLayerEventPublisher.addMiddlewares( + [ + OwnCapabilitiesStateLayerEventMiddleware( + ownCapabilitiesRepository: ownCapabilitiesRepository, + sendEvent: { [weak stateLayerEventPublisher] in await stateLayerEventPublisher?.sendEvent($0) } + ) + ] + ) } // MARK: - Connecting the User diff --git a/Sources/StreamFeeds/Models/ActivityData.swift b/Sources/StreamFeeds/Models/ActivityData.swift index 3494f2df..f4c6181e 100644 --- a/Sources/StreamFeeds/Models/ActivityData.swift +++ b/Sources/StreamFeeds/Models/ActivityData.swift @@ -11,7 +11,7 @@ public struct ActivityData: Identifiable, Equatable, Sendable { public private(set) var commentCount: Int public private(set) var comments: [CommentData] public let createdAt: Date - public let currentFeed: FeedData? + public private(set) var currentFeed: FeedData? public let custom: [String: RawJSON] public let deletedAt: Date? public let editedAt: Date? @@ -137,6 +137,24 @@ extension ActivityData { changes: { $0.merge(with: incomingData, update: reaction, currentUserId: currentUserId) } ) } + + // MARK: - Current Feed Capabilities + + mutating func setFeedOwnCapabilities(_ capabilities: Set) { + currentFeed?.setOwnCapabilities(capabilities) + } + + mutating func mergeFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) { + guard let feedId = currentFeed?.feed else { return } + guard let capabilities = capabilitiesMap[feedId] else { return } + currentFeed?.setOwnCapabilities(capabilities) + } + + func withFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) -> ActivityData { + var updated = self + updated.mergeFeedOwnCapabilities(from: capabilitiesMap) + return updated + } } // MARK: - Model Conversions diff --git a/Sources/StreamFeeds/Models/BookmarkData.swift b/Sources/StreamFeeds/Models/BookmarkData.swift index e193e501..feb8f0a4 100644 --- a/Sources/StreamFeeds/Models/BookmarkData.swift +++ b/Sources/StreamFeeds/Models/BookmarkData.swift @@ -6,7 +6,7 @@ import Foundation import StreamCore public struct BookmarkData: Equatable, Sendable { - public let activity: ActivityData + public private(set) var activity: ActivityData public let createdAt: Date public let custom: [String: RawJSON]? public internal(set) var folder: BookmarkFolderData? @@ -20,6 +20,26 @@ extension BookmarkData: Identifiable { } } +// MARK: - Mutating the Data + +extension BookmarkData { + mutating func merge(with incomingData: ActivityData) { + activity.merge(with: incomingData) + } + + // MARK: - Current Feed Capabilities + + mutating func mergeFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) { + activity.mergeFeedOwnCapabilities(from: capabilitiesMap) + } + + func withFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) -> BookmarkData { + var updated = self + updated.mergeFeedOwnCapabilities(from: capabilitiesMap) + return updated + } +} + // MARK: - Model Conversions extension BookmarkResponse { diff --git a/Sources/StreamFeeds/Models/FeedData.swift b/Sources/StreamFeeds/Models/FeedData.swift index d138ffdc..90df9b64 100644 --- a/Sources/StreamFeeds/Models/FeedData.swift +++ b/Sources/StreamFeeds/Models/FeedData.swift @@ -19,14 +19,43 @@ public struct FeedData: Identifiable, Equatable, Sendable { public let id: String public let memberCount: Int public let name: String - public let ownCapabilities: [FeedOwnCapability]? - public let ownFollows: [FollowData]? - public let ownMembership: FeedMemberData? + public private(set) var ownCapabilities: Set? + public private(set) var ownFollows: [FollowData]? + public private(set) var ownMembership: FeedMemberData? public let pinCount: Int public let updatedAt: Date public let visibility: String? } +// MARK: - Mutating the Data + +extension FeedData { + mutating func merge(with incomingData: FeedData) { + let ownCapabilities = ownCapabilities + let ownFollows = ownFollows + let ownMembership = ownMembership + self = incomingData + self.ownCapabilities = incomingData.ownCapabilities ?? ownCapabilities + self.ownFollows = incomingData.ownFollows ?? ownFollows + self.ownMembership = incomingData.ownMembership ?? ownMembership + } + + mutating func setOwnCapabilities(_ capabilities: Set) { + self.ownCapabilities = capabilities + } + + mutating func mergeFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) { + guard let capabilities = capabilitiesMap[feed] else { return } + setOwnCapabilities(capabilities) + } + + func withFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) -> FeedData { + var updated = self + updated.mergeFeedOwnCapabilities(from: capabilitiesMap) + return updated + } +} + // MARK: - Model Conversions extension FeedResponse { @@ -45,7 +74,7 @@ extension FeedResponse { id: id, memberCount: memberCount, name: name, - ownCapabilities: ownCapabilities, + ownCapabilities: ownCapabilities.map(Set.init), ownFollows: ownFollows?.map { $0.toModel() }, ownMembership: ownMembership?.toModel(), pinCount: pinCount, diff --git a/Sources/StreamFeeds/Models/FollowData.swift b/Sources/StreamFeeds/Models/FollowData.swift index 0cbdfdc0..f6b44106 100644 --- a/Sources/StreamFeeds/Models/FollowData.swift +++ b/Sources/StreamFeeds/Models/FollowData.swift @@ -12,9 +12,9 @@ public struct FollowData: Equatable, Sendable { public let pushPreference: String public let requestAcceptedAt: Date? public let requestRejectedAt: Date? - public let sourceFeed: FeedData + public private(set) var sourceFeed: FeedData public let status: FollowStatus - public let targetFeed: FeedData + public private(set) var targetFeed: FeedData public let updatedAt: Date var isFollower: Bool { @@ -46,6 +46,27 @@ extension FollowData: Identifiable { } } +// MARK: - Mutating the Data + +extension FollowData { + // MARK: - Current Feed Capabilities + + mutating func mergeFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) { + if let capabilities = capabilitiesMap[sourceFeed.feed] { + sourceFeed.setOwnCapabilities(capabilities) + } + if let capabilities = capabilitiesMap[targetFeed.feed] { + targetFeed.setOwnCapabilities(capabilities) + } + } + + func withFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) -> FollowData { + var updated = self + updated.mergeFeedOwnCapabilities(from: capabilitiesMap) + return updated + } +} + // MARK: - Model Conversions extension FollowResponse { diff --git a/Sources/StreamFeeds/Repositories/FeedsRepository.swift b/Sources/StreamFeeds/Repositories/FeedsRepository.swift index 0bfcc22e..0086b5dd 100644 --- a/Sources/StreamFeeds/Repositories/FeedsRepository.swift +++ b/Sources/StreamFeeds/Repositories/FeedsRepository.swift @@ -26,27 +26,44 @@ final class FeedsRepository: Sendable { getOrCreateFeedRequest: request ) let rawFollowers = response.followers.map { $0.toModel() } + let rawFollowing = response.following.map { $0.toModel() } + let activities = response.activities.map { $0.toModel() } + let pinnedActivities = response.pinnedActivities.map { $0.toModel() } + let ownCapabilities = response.feed.ownCapabilities.map(Set.init) ?? Set() + let allFeedDatas: [FeedData] = + activities.compactMap(\.currentFeed) + + pinnedActivities.compactMap(\.activity.currentFeed) + + rawFollowers.compactMap(\.sourceFeed) + + rawFollowers.compactMap(\.targetFeed) + + rawFollowing.compactMap(\.sourceFeed) + + rawFollowing.compactMap(\.targetFeed) + let allOwnCapabilities = allFeedDatas + .reduce(into: [feed: ownCapabilities], { all, feedData in + guard let capabilities = feedData.ownCapabilities, !capabilities.isEmpty else { return } + all[feedData.feed] = capabilities + }) return GetOrCreateInfo( activities: PaginationResult( - models: response.activities.map { $0.toModel() }.sorted(using: Sort.defaultSorting), + models: activities.sorted(using: Sort.defaultSorting), pagination: PaginationData(next: response.next, previous: response.prev) ), activitiesQueryConfig: QueryConfiguration( filter: query.activityFilter, sort: Sort.defaultSorting ), + aggregatedActivities: response.aggregatedActivities.map { $0.toModel() }, + allOwnCapabilities: allOwnCapabilities, feed: response.feed.toModel(), - followers: rawFollowers.filter { $0.isFollower(of: feed) }, - following: response.following.map { $0.toModel() }.filter { $0.isFollowing(feed) }, followRequests: rawFollowers.filter(\.isFollowRequest), + followers: rawFollowers.filter { $0.isFollower(of: feed) }, + following: rawFollowing.filter { $0.isFollowing(feed) }, members: PaginationResult( models: response.members.map { $0.toModel() }, pagination: response.memberPagination?.toModel() ?? .empty ), - ownCapabilities: response.feed.ownCapabilities ?? [], - pinnedActivities: response.pinnedActivities.map { $0.toModel() }, - aggregatedActivities: response.aggregatedActivities.map { $0.toModel() }, - notificationStatus: response.notificationStatus?.toModel() + notificationStatus: response.notificationStatus?.toModel(), + ownCapabilities: ownCapabilities, + pinnedActivities: pinnedActivities ) } @@ -145,14 +162,15 @@ extension FeedsRepository { struct GetOrCreateInfo { let activities: PaginationResult let activitiesQueryConfig: QueryConfiguration + let aggregatedActivities: [AggregatedActivityData] + let allOwnCapabilities: [FeedId: Set] let feed: FeedData + let followRequests: [FollowData] let followers: [FollowData] let following: [FollowData] - let followRequests: [FollowData] let members: PaginationResult - let ownCapabilities: [FeedOwnCapability] - let pinnedActivities: [ActivityPinData] - let aggregatedActivities: [AggregatedActivityData] let notificationStatus: NotificationStatusData? + let ownCapabilities: Set + let pinnedActivities: [ActivityPinData] } } diff --git a/Sources/StreamFeeds/Repositories/OwnCapabilitiesRepository.swift b/Sources/StreamFeeds/Repositories/OwnCapabilitiesRepository.swift new file mode 100644 index 00000000..e3d1f3da --- /dev/null +++ b/Sources/StreamFeeds/Repositories/OwnCapabilitiesRepository.swift @@ -0,0 +1,67 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// This is a repository which holds a shared state and manages +/// a map of feed id to capabilities. +final class OwnCapabilitiesRepository: Sendable { + private let apiClient: DefaultAPI + private let storage = AllocatedUnfairLock([FeedId: Set]()) + + init(apiClient: DefaultAPI) { + self.apiClient = apiClient + } + + // MARK: - Get Capabilities + + /// Returns locally cached capabilities if available. + func capabilities(for feed: FeedId) -> Set? { + self.capabilities(for: Set(arrayLiteral: feed))?[feed] + } + + /// Returns locally cached capabilities if available. + func capabilities(for feeds: Set) -> [FeedId: Set]? { + let cached = storage.withLock { storage in + feeds.reduce(into: [FeedId: Set](), { all, feedId in + guard let cached = storage[feedId] else { return } + all[feedId] = cached + }) + } + if cached.count == feeds.count { + return cached + } + return nil + } + + func getCapabilities(for feeds: Set) async throws -> [FeedId: Set] { + let response = try await apiClient.ownCapabilitiesBatch(ownCapabilitiesBatchRequest: OwnCapabilitiesBatchRequest(feeds: feeds.map(\.rawValue))) + return Dictionary(uniqueKeysWithValues: response.capabilities.map { (FeedId(rawValue: $0), Set($1)) }) + } + + // MARK: - Saving Capabilities + + func saveCapabilities(_ newCapabilities: [FeedId: Set]) -> [FeedId: Set]? { + guard !newCapabilities.isEmpty else { return nil } + return storage.withLock { storage in + // Find only the ones which had a state before + let changed = newCapabilities.filter { storage[$0] != nil && storage[$0] != $1 } + storage.merge(newCapabilities, uniquingKeysWith: { _, new in new }) + return changed + } + } + + func saveCapabilities(in feedDatas: [FeedData]) -> [FeedId: Set]? { + guard !feedDatas.isEmpty else { return nil } + let all = feedDatas.reduce(into: [FeedId: Set](), { all, feedData in + guard let capabilities = feedData.ownCapabilities, !capabilities.isEmpty else { return } + all[feedData.feed] = capabilities + }) + return saveCapabilities(all) + } + + func saveCapabilities(in feedData: FeedData?) -> [FeedId: Set]? { + saveCapabilities(in: [feedData].compactMap { $0 }) + } +} diff --git a/Sources/StreamFeeds/StateLayer/Activity.swift b/Sources/StreamFeeds/StateLayer/Activity.swift index 22cb7454..473ba5c6 100644 --- a/Sources/StreamFeeds/StateLayer/Activity.swift +++ b/Sources/StreamFeeds/StateLayer/Activity.swift @@ -14,6 +14,7 @@ public final class Activity: Sendable { private let commentList: ActivityCommentList private let activitiesRepository: ActivitiesRepository private let commentsRepository: CommentsRepository + private let ownCapabilitiesRepository: OwnCapabilitiesRepository private let pollsRepository: PollsRepository private let eventPublisher: StateLayerEventPublisher @MainActor private let stateBuilder: StateBuilder @@ -39,6 +40,7 @@ public final class Activity: Sendable { commentsRepository = client.commentsRepository eventPublisher = client.stateLayerEventPublisher self.feed = feed + ownCapabilitiesRepository = client.ownCapabilitiesRepository pollsRepository = client.pollsRepository let currentUserId = client.user.id stateBuilder = StateBuilder { [currentUserId, eventPublisher] in @@ -69,6 +71,9 @@ public final class Activity: Sendable { async let comments = queryComments() let (activityData, _) = try await (activity, comments) await state.setActivity(activityData) + if let updated = ownCapabilitiesRepository.saveCapabilities(in: activityData.currentFeed) { + await eventPublisher.sendEvent(.feedOwnCapabilitiesUpdated(updated)) + } return activityData } diff --git a/Sources/StreamFeeds/StateLayer/ActivityState.swift b/Sources/StreamFeeds/StateLayer/ActivityState.swift index ff17e899..8c07c86c 100644 --- a/Sources/StreamFeeds/StateLayer/ActivityState.swift +++ b/Sources/StreamFeeds/StateLayer/ActivityState.swift @@ -108,6 +108,10 @@ extension ActivityState { state.activity?.addComment(commentData) } } + case .feedOwnCapabilitiesUpdated(let capabilitiesMap): + await self?.access { state in + state.activity?.mergeFeedOwnCapabilities(from: capabilitiesMap) + } case .pollDeleted(let pollId, let eventFeedId): guard eventFeedId == feed else { return } await self?.access { state in diff --git a/Sources/StreamFeeds/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware.swift b/Sources/StreamFeeds/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware.swift new file mode 100644 index 00000000..7c4fee9c --- /dev/null +++ b/Sources/StreamFeeds/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware.swift @@ -0,0 +1,153 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation +import StreamCore + +/// Extracts own capabilities from events and saves it to the shared +/// store in the capabilities repository. +final class OwnCapabilitiesStateLayerEventMiddleware: StateLayerEventMiddleware { + private let sendEvent: @Sendable (StateLayerEvent) async -> Void + let ownCapabilitiesRepository: OwnCapabilitiesRepository + + init(ownCapabilitiesRepository: OwnCapabilitiesRepository, sendEvent: @escaping @Sendable (StateLayerEvent) async -> Void) { + self.ownCapabilitiesRepository = ownCapabilitiesRepository + self.sendEvent = sendEvent + } + + // MARK: - Processing Events + + /// Adds own capabilities to web-socket added events and extracts own capabilities from local events. + /// + /// - Note: Added events are only enriched because state-layer merged WS event data by keeping own fields. Therefore, + /// updated events do not need to have capabilities correctly set. Secondly, state-layer uses feedOwnCapabilitiesUpdated + /// to automatically apply updated capablities. + func willPublish(_ event: StateLayerEvent, from source: StateLayerEventPublisher.EventSource) async -> StateLayerEvent { + switch source { + case .webSocket: + switch event { + case .activityAdded(let activityData, let feedId): + guard let capabilitiesMap = await cachedCapabilities(for: activityData) else { break } + return .activityAdded(activityData.withFeedOwnCapabilities(from: capabilitiesMap), feedId) + case .bookmarkAdded(let bookmarkData): + guard let capabilitiesMap = await cachedCapabilities(for: bookmarkData.activity) else { break } + return .bookmarkAdded(bookmarkData.withFeedOwnCapabilities(from: capabilitiesMap)) + case .feedAdded(let feedData, let feedId): + guard let capabilitiesMap = await cachedCapabilities(for: feedData.feed) else { break } + return .feedAdded(feedData.withFeedOwnCapabilities(from: capabilitiesMap), feedId) + case .feedFollowAdded(let followData, let feedId): + guard let capabilitiesMap = await cachedCapabilities(for: Set([followData.sourceFeed.feed, followData.targetFeed.feed])) else { break } + return .feedFollowAdded(followData.withFeedOwnCapabilities(from: capabilitiesMap), feedId) + default: + return event + } + case .local: + if let updated = ownCapabilitiesRepository.saveCapabilities(event.ownCapabilities) { + await sendEvent(.feedOwnCapabilitiesUpdated(updated)) + } + } + return event + } + + private func cachedCapabilities(for activity: ActivityData) async -> [FeedId: Set]? { + guard let feedData = activity.currentFeed else { return nil } + return await cachedCapabilities(for: feedData.feed) + } + + private func cachedCapabilities(for feed: FeedId) async -> [FeedId: Set]? { + guard let capabilities = ownCapabilitiesRepository.capabilities(for: feed) else { + scheduleFetchingMissingCapabilities(for: [feed]) + return nil + } + return [feed: capabilities] + } + + private func cachedCapabilities(for feeds: Set) async -> [FeedId: Set]? { + guard let capabilities = ownCapabilitiesRepository.capabilities(for: feeds) else { + scheduleFetchingMissingCapabilities(for: feeds) + return nil + } + return capabilities + } + + // MARK: - Fetch Missing Capabilities + + /// Most of the case capabilities are cached since we read them from HTTP responses. One example where this + /// can not be the case is where timeline feed is getting new activities from other feeds. For these, + /// we still fill in capabilities automatically. + /// + /// - Note: This is not async function because we don't want suspend event handling while fetching additional capabilities + private func scheduleFetchingMissingCapabilities(for feedIds: Set) { + Task { + do { + let fetchedCapabilities = try await ownCapabilitiesRepository.getCapabilities(for: feedIds) + _ = ownCapabilitiesRepository.saveCapabilities(fetchedCapabilities) + // Here we explicitly send the update for making state-layer to fill in capabilities (default case is that newly inserted capabilities do not trigger local events) + await sendEvent(.feedOwnCapabilitiesUpdated(fetchedCapabilities)) + } catch { + log.error("Failed to fetch missing feed capabilities for number of feeds: \(feedIds.count)", error: error) + } + } + } +} + +private extension StateLayerEvent { + var ownCapabilities: [FeedId: Set] { + guard let feedDatas else { return [:] } + return feedDatas.reduce(into: [FeedId: Set](), { all, feedData in + guard let capabilities = feedData.ownCapabilities, !capabilities.isEmpty else { return } + all[feedData.feed] = capabilities + }) + } + + var feedDatas: [FeedData]? { + switch self { + case .activityAdded(let activityData, _): + return activityData.currentFeed.map { [$0] } + case .activityUpdated(let activityData, _): + return activityData.currentFeed.map { [$0] } + case .activityReactionAdded(_, let activityData, _): + return activityData.currentFeed.map { [$0] } + case .activityReactionDeleted(_, let activityData, _): + return activityData.currentFeed.map { [$0] } + case .activityReactionUpdated(_, let activityData, _): + return activityData.currentFeed.map { [$0] } + case .activityPinned(let activityPinData, _): + return activityPinData.activity.currentFeed.map { [$0] } + case .activityUnpinned(let activityPinData, _): + return activityPinData.activity.currentFeed.map { [$0] } + case .bookmarkAdded(let bookmarkData): + return bookmarkData.activity.currentFeed.map { [$0] } + case .bookmarkDeleted(let bookmarkData): + return bookmarkData.activity.currentFeed.map { [$0] } + case .bookmarkUpdated(let bookmarkData): + return bookmarkData.activity.currentFeed.map { [$0] } + case .commentAdded(_, let activityData, _): + return activityData.currentFeed.map { [$0] } + case .feedAdded(let feedData, _): + return [feedData] + case .feedUpdated(let feedData, _): + return [feedData] + case .feedFollowAdded(let followData, _): + return [followData.sourceFeed, followData.targetFeed] + case .feedFollowDeleted(let followData, _): + return [followData.sourceFeed, followData.targetFeed] + case .feedFollowUpdated(let followData, _): + return [followData.sourceFeed, followData.targetFeed] + case .activityMarked, .activityDeleted, + .bookmarkFolderDeleted, .bookmarkFolderUpdated, + .commentDeleted, .commentUpdated, .commentsAddedBatch, + .commentReactionAdded, .commentReactionDeleted, .commentReactionUpdated, + .pollDeleted, .pollUpdated, .pollVoteCasted, .pollVoteChanged, .pollVoteDeleted, + .feedDeleted, + .feedGroupDeleted, .feedGroupUpdated, + .feedMemberAdded, .feedMemberDeleted, .feedMemberUpdated, .feedMemberBatchUpdate, + .notificationFeedUpdated, + .userUpdated, + .feedOwnCapabilitiesUpdated: + return nil + } + } +} diff --git a/Sources/StreamFeeds/StateLayer/Common/StateLayerEvent.swift b/Sources/StreamFeeds/StateLayer/EventHandling/StateLayerEvent.swift similarity index 91% rename from Sources/StreamFeeds/StateLayer/Common/StateLayerEvent.swift rename to Sources/StreamFeeds/StateLayer/EventHandling/StateLayerEvent.swift index b7d287f3..ff406311 100644 --- a/Sources/StreamFeeds/StateLayer/Common/StateLayerEvent.swift +++ b/Sources/StreamFeeds/StateLayer/EventHandling/StateLayerEvent.swift @@ -60,6 +60,21 @@ enum StateLayerEvent: Sendable { case notificationFeedUpdated(FeedId) case userUpdated(UserData) + + // + // Local events not related to any particular web-socket event + // + + /// Local capabilities tracking detected that capabilities changed for the feed. + /// + /// Web-socket events do not have `own_` fields set and therefore state layer uses + /// related WS events to keep the data up to date (e.g. activity reactions + /// are managed by WS events: added, updated, removed). + /// Capabilities are special because every feed has capabilities for the current + /// user. Therefore need to make sure (`ActivityData.currentFeed.ownCapabilities`) is set + /// when activities are added. For this particular case we have bookkeeping and we make + /// sure state-layer updates `ownCapabilities` for already fetched models as well. + case feedOwnCapabilitiesUpdated([FeedId: Set]) } extension StateLayerEvent { diff --git a/Sources/StreamFeeds/StateLayer/Common/StateLayerEventPublisher.swift b/Sources/StreamFeeds/StateLayer/EventHandling/StateLayerEventPublisher.swift similarity index 62% rename from Sources/StreamFeeds/StateLayer/Common/StateLayerEventPublisher.swift rename to Sources/StreamFeeds/StateLayer/EventHandling/StateLayerEventPublisher.swift index 607fea9b..e12ca290 100644 --- a/Sources/StreamFeeds/StateLayer/Common/StateLayerEventPublisher.swift +++ b/Sources/StreamFeeds/StateLayer/EventHandling/StateLayerEventPublisher.swift @@ -13,15 +13,25 @@ import StreamCore /// filtering before consuming the event on the main thread. final class StateLayerEventPublisher: WSEventsSubscriber, Sendable { private let subscriptions = AllocatedUnfairLock<[UUID: @Sendable (StateLayerEvent) async -> Void]>([:]) + private let middlewares = AllocatedUnfairLock([StateLayerEventMiddleware]()) + + func addMiddlewares(_ additionalMiddlewares: [StateLayerEventMiddleware]) { + middlewares.withLock { $0.append(contentsOf: additionalMiddlewares) } + } /// 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 { + /// - Parameter source: The source where the event is coming from: web-socket or internal. + func sendEvent(_ event: StateLayerEvent, source: EventSource = .local) async { + var event = event + for middleware in middlewares.value { + event = await middleware.willPublish(event, from: source) + } let handlers = Array(subscriptions.value.values) - await withTaskGroup(of: Void.self) { group in + await withTaskGroup(of: Void.self) { [event] group in for handler in handlers { group.addTask { await handler(event) @@ -46,7 +56,7 @@ final class StateLayerEventPublisher: WSEventsSubscriber, Sendable { func onEvent(_ event: any Event) async { guard let stateLayerEvent = StateLayerEvent(event: event) else { return } - await sendEvent(stateLayerEvent) + await sendEvent(stateLayerEvent, source: .webSocket) switch stateLayerEvent { case .activityAdded(let activityData, let eventFeedId): @@ -60,12 +70,29 @@ final class StateLayerEventPublisher: WSEventsSubscriber, Sendable { } } +extension StateLayerEventPublisher { + enum EventSource { + /// Events which are coming in from the web-socket. + case webSocket + /// Events which are internally published with HTTP response data. + case local + } +} + +protocol StateLayerEventMiddleware: Sendable { + func willPublish(_ event: StateLayerEvent, from source: StateLayerEventPublisher.EventSource) async -> StateLayerEvent +} + extension StateLayerEventPublisher { final class Subscription: Sendable { - private let cancel: @Sendable () -> Void + private let cancellationHandler: @Sendable () -> Void init(cancel: @escaping @Sendable () -> Void) { - self.cancel = cancel + self.cancellationHandler = cancel + } + + func cancel() { + cancellationHandler() } deinit { diff --git a/Sources/StreamFeeds/StateLayer/Feed.swift b/Sources/StreamFeeds/StateLayer/Feed.swift index 9498ad75..f74b8c58 100644 --- a/Sources/StreamFeeds/StateLayer/Feed.swift +++ b/Sources/StreamFeeds/StateLayer/Feed.swift @@ -33,8 +33,9 @@ public final class Feed: Sendable { private let bookmarksRepository: BookmarksRepository private let commentsRepository: CommentsRepository private let feedsRepository: FeedsRepository - private let pollsRepository: PollsRepository private let memberList: MemberList + private let ownCapabilitiesRepository: OwnCapabilitiesRepository + private let pollsRepository: PollsRepository init(query: FeedQuery, client: FeedsClient) { activitiesRepository = client.activitiesRepository @@ -45,6 +46,7 @@ public final class Feed: Sendable { feedsRepository = client.feedsRepository eventPublisher = client.stateLayerEventPublisher memberList = client.memberList(for: .init(feed: query.feed)) + ownCapabilitiesRepository = client.ownCapabilitiesRepository pollsRepository = client.pollsRepository let currentUserId = client.user.id stateBuilder = StateBuilder { [eventPublisher, memberList] in @@ -89,6 +91,9 @@ public final class Feed: Sendable { public func getOrCreate() async throws -> FeedData { let result = try await feedsRepository.getOrCreateFeed(with: feedQuery) await state.didQueryFeed(with: result) + if let updated = ownCapabilitiesRepository.saveCapabilities(result.allOwnCapabilities) { + await eventPublisher.sendEvent(.feedOwnCapabilitiesUpdated(updated)) + } return result.feed } diff --git a/Sources/StreamFeeds/StateLayer/FeedState.swift b/Sources/StreamFeeds/StateLayer/FeedState.swift index 5220066c..60d6e00a 100644 --- a/Sources/StreamFeeds/StateLayer/FeedState.swift +++ b/Sources/StreamFeeds/StateLayer/FeedState.swift @@ -60,7 +60,7 @@ import StreamCore @Published public private(set) var members = [FeedMemberData]() /// The capabilities that the current user has for this feed. - @Published public internal(set) var ownCapabilities = [FeedOwnCapability]() + @Published public internal(set) var ownCapabilities = Set() /// The list of pinned activities and its pinning state. @Published public private(set) var pinnedActivities = [ActivityPinData]() @@ -297,7 +297,7 @@ extension FeedState { } case .feedUpdated(let feedData, let eventFeedId): guard feed == eventFeedId else { return } - await self?.access { $0.feedData = feedData } + await self?.access { $0.feedData?.merge(with: feedData) } case .feedFollowAdded(let followData, let eventFeedId): guard feed == eventFeedId else { return } await self?.addFollow(followData) @@ -310,6 +310,33 @@ extension FeedState { case .feedMemberAdded, .feedMemberDeleted, .feedMemberUpdated: // Handled by member list break + case .feedOwnCapabilitiesUpdated(let capabilitiesMap): + await self?.access { state in + if let capabilities = capabilitiesMap[feed] { + state.feedData?.setOwnCapabilities(capabilities) + state.ownCapabilities = capabilities + } + state.activities.updateAll( + where: { capabilitiesMap.contains($0.currentFeed?.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + state.pinnedActivities.updateAll( + where: { capabilitiesMap.contains($0.activity.currentFeed?.feed) }, + changes: { $0.activity.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + state.followers.updateAll( + where: { capabilitiesMap.contains($0.sourceFeed.feed) || capabilitiesMap.contains($0.targetFeed.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + state.following.updateAll( + where: { capabilitiesMap.contains($0.sourceFeed.feed) || capabilitiesMap.contains($0.targetFeed.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + state.followRequests.updateAll( + where: { capabilitiesMap.contains($0.sourceFeed.feed) || capabilitiesMap.contains($0.targetFeed.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + } case .pollDeleted(let pollId, let eventFeedId): guard eventFeedId == feed else { return } await self?.access { state in diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityList.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityList.swift index b64ffca6..9274e4ad 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityList.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityList.swift @@ -13,11 +13,14 @@ import StreamCore public final class ActivityList: Sendable { @MainActor private let stateBuilder: StateBuilder private let activitiesRepository: ActivitiesRepository + private let eventPublisher: StateLayerEventPublisher + private let ownCapabilitiesRepository: OwnCapabilitiesRepository init(query: ActivitiesQuery, client: FeedsClient) { activitiesRepository = client.activitiesRepository + eventPublisher = client.stateLayerEventPublisher + ownCapabilitiesRepository = client.ownCapabilitiesRepository self.query = query - let eventPublisher = client.stateLayerEventPublisher let currentUserId = client.user.id stateBuilder = StateBuilder { [eventPublisher] in ActivityListState( @@ -87,6 +90,9 @@ public final class ActivityList: Sendable { with: result, for: .init(filter: query.filter, sort: query.sort) ) + if let updated = ownCapabilitiesRepository.saveCapabilities(in: result.models.compactMap(\.currentFeed)) { + await eventPublisher.sendEvent(.feedOwnCapabilitiesUpdated(updated)) + } return result.models } } diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityListState.swift index 21aafbc0..2ccc217e 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityListState.swift @@ -184,6 +184,13 @@ extension ActivityListState { } ) } + case .feedOwnCapabilitiesUpdated(let capabilitiesMap): + await self?.access { state in + state.activities.updateAll( + where: { capabilitiesMap.contains($0.currentFeed?.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + } case .pollUpdated(let pollData, _): await self?.access { state in state.activities.updateFirstElement( diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkList.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkList.swift index 7f62b348..ae45fffc 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkList.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkList.swift @@ -8,9 +8,13 @@ import StreamCore public final class BookmarkList: Sendable { @MainActor private let stateBuilder: StateBuilder private let bookmarksRepository: BookmarksRepository + private let eventPublisher: StateLayerEventPublisher + private let ownCapabilitiesRepository: OwnCapabilitiesRepository init(query: BookmarksQuery, client: FeedsClient) { bookmarksRepository = client.bookmarksRepository + eventPublisher = client.stateLayerEventPublisher + ownCapabilitiesRepository = client.ownCapabilitiesRepository self.query = query let eventPublisher = client.stateLayerEventPublisher stateBuilder = StateBuilder { BookmarkListState(query: query, eventPublisher: eventPublisher) } @@ -52,6 +56,9 @@ public final class BookmarkList: Sendable { with: result, for: .init(filter: query.filter, sort: query.sort) ) + if let updated = ownCapabilitiesRepository.saveCapabilities(in: result.models.compactMap(\.activity.currentFeed)) { + await eventPublisher.sendEvent(.feedOwnCapabilitiesUpdated(updated)) + } return result.models } } diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState.swift index 123847a7..176f6695 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState.swift @@ -71,6 +71,13 @@ extension BookmarkListState { state.bookmarks.remove(byId: bookmark.id) } } + case .feedOwnCapabilitiesUpdated(let capabilitiesMap): + await self?.access { state in + state.bookmarks.updateAll( + where: { capabilitiesMap.contains($0.activity.currentFeed?.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + } default: break } diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedList.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedList.swift index d402f83a..6c434add 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedList.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedList.swift @@ -9,12 +9,16 @@ import StreamCore public final class FeedList: Sendable { private let feedsRepository: FeedsRepository private let disposableBag = DisposableBag() + private let eventPublisher: StateLayerEventPublisher + private let ownCapabilitiesRepository: OwnCapabilitiesRepository private let refetchSubject = AllocatedUnfairLock(PassthroughSubject()) private let refetchDelay: Int @MainActor private let stateBuilder: StateBuilder init(query: FeedsQuery, client: FeedsClient, refetchDelay: Int = 5) { + eventPublisher = client.stateLayerEventPublisher feedsRepository = client.feedsRepository + ownCapabilitiesRepository = client.ownCapabilitiesRepository self.query = query self.refetchDelay = refetchDelay let eventPublisher = client.stateLayerEventPublisher @@ -61,6 +65,9 @@ public final class FeedList: Sendable { with: result, for: .init(filter: query.filter, sort: query.sort) ) + if let updated = ownCapabilitiesRepository.saveCapabilities(in: result.models) { + await eventPublisher.sendEvent(.feedOwnCapabilitiesUpdated(updated)) + } return result.models } diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedListState.swift index c1fe8734..30f5a8e6 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedListState.swift @@ -72,7 +72,12 @@ extension FeedListState { let matches = matchesQuery(feed) await self.access { state in if matches { - state.feeds.sortedReplace(feed, nesting: nil, sorting: state.feedsSorting) + state.feeds.sortedUpdate( + feed, + nesting: nil, + sorting: state.feedsSorting.areInIncreasingOrder(), + changes: { existing in existing.merge(with: feed) } + ) } else { state.feeds.remove(byId: feed.id) } @@ -92,6 +97,13 @@ extension FeedListState { guard needsRefetch else { return } refetchSubject.withLock { $0.send() } } + case .feedOwnCapabilitiesUpdated(let capabilitiesMap): + await self?.access { state in + state.feeds.updateAll( + where: { capabilitiesMap.contains($0.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + } default: break } diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowList.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowList.swift index 41470901..f1255469 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowList.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowList.swift @@ -7,10 +7,14 @@ import StreamCore public final class FollowList: Sendable { @MainActor private let stateBuilder: StateBuilder + private let eventPublisher: StateLayerEventPublisher private let feedsRepository: FeedsRepository + private let ownCapabilitiesRepository: OwnCapabilitiesRepository init(query: FollowsQuery, client: FeedsClient) { + eventPublisher = client.stateLayerEventPublisher feedsRepository = client.feedsRepository + ownCapabilitiesRepository = client.ownCapabilitiesRepository self.query = query let eventPublisher = client.stateLayerEventPublisher stateBuilder = StateBuilder { [eventPublisher] in @@ -57,6 +61,9 @@ public final class FollowList: Sendable { with: result, for: .init(filter: query.filter, sort: query.sort) ) + if let updated = ownCapabilitiesRepository.saveCapabilities(in: result.models.map(\.sourceFeed) + result.models.map(\.targetFeed)) { + await eventPublisher.sendEvent(.feedOwnCapabilitiesUpdated(updated)) + } return result.models } } diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowListState.swift index 88cb0760..29a74330 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowListState.swift @@ -66,6 +66,13 @@ extension FollowListState { state.follows.remove(byId: follow.id) } } + case .feedOwnCapabilitiesUpdated(let capabilitiesMap): + await self?.access { state in + state.follows.updateAll( + where: { capabilitiesMap.contains($0.sourceFeed.feed) || capabilitiesMap.contains($0.targetFeed.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + } default: break } diff --git a/Tests/StreamFeedsTests/StateLayer/ActivityList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/ActivityList_Tests.swift index db1dbbf4..f2b1c195 100644 --- a/Tests/StreamFeedsTests/StateLayer/ActivityList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/ActivityList_Tests.swift @@ -485,6 +485,128 @@ struct ActivityList_Tests { #expect(result == ["activity-1"]) // Should not include activity-2 } + // MARK: - Own Capabilities + + @Test func getCachesCapabilities() async throws { + let feed1Id = FeedId(rawValue: "user:feed-1") + let feed2Id = FeedId(rawValue: "user:feed-2") + let feed3Id = FeedId(rawValue: "user:feed-3") + + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryActivitiesResponse.dummy( + activities: [ + .dummy( + currentFeed: .dummy(feed: feed1Id.rawValue, ownCapabilities: [.readFeed, .addActivity]), + id: "activity-1", + user: .dummy(id: "current-user-id") + ), + .dummy( + currentFeed: .dummy(feed: feed2Id.rawValue, ownCapabilities: [.readFeed, .readActivities]), + id: "activity-2", + user: .dummy(id: "current-user-id") + ), + .dummy( + currentFeed: .dummy(feed: feed3Id.rawValue, ownCapabilities: [.readFeed, .addComment]), + id: "activity-3", + user: .dummy(id: "current-user-id") + ) + ], + next: nil + ) + ] + ) + ) + let activityList = client.activityList( + for: ActivitiesQuery( + filter: .equal(.userId, "current-user-id") + ) + ) + _ = try await activityList.get() + + let expectedCapabilitiesMap: [FeedId: Set] = [ + feed1Id: [.readFeed, .addActivity], + feed2Id: [.readFeed, .readActivities], + feed3Id: [.readFeed, .addComment] + ] + + let cached = try #require(client.ownCapabilitiesRepository.capabilities(for: Set(expectedCapabilitiesMap.keys))) + #expect(cached.keys.map(\.rawValue).sorted() == expectedCapabilitiesMap.keys.map(\.rawValue).sorted()) + + for (feedId, expectedCapabilities) in expectedCapabilitiesMap { + #expect(cached[feedId] == expectedCapabilities) + } + } + + @Test func feedOwnCapabilitiesUpdatedEventUpdatesState() async throws { + let activityListCapabilities: (ActivityList) async -> [FeedId: Set] = { activityList in + let pairs = await activityList.state.activities.compactMap { activity -> (FeedId, Set)? in + guard let feedData = activity.currentFeed, let capabilities = feedData.ownCapabilities else { return nil } + return (feedData.feed, capabilities) + } + return Dictionary(pairs, uniquingKeysWith: { $1 }) + } + let feed1Id = FeedId(group: "user", id: "feed-1") + let feed2Id = FeedId(group: "user", id: "feed-2") + let initialFeed1Capabilities: Set = [.readFeed, .addActivity] + let initialFeed2Capabilities: Set = [.readFeed, .readActivities] + let initialCapabilities = [feed1Id: initialFeed1Capabilities, feed2Id: initialFeed2Capabilities] + + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryActivitiesResponse.dummy( + activities: [ + .dummy( + currentFeed: .dummy(feed: feed1Id.rawValue, ownCapabilities: Array(initialFeed1Capabilities)), + id: "activity-1", + user: .dummy(id: "current-user-id") + ), + .dummy( + currentFeed: .dummy(feed: feed2Id.rawValue, ownCapabilities: Array(initialFeed2Capabilities)), + id: "activity-2", + user: .dummy(id: "current-user-id") + ) + ], + next: nil + ) + ] + ) + ) + let activityList = client.activityList( + for: ActivitiesQuery( + filter: .equal(.userId, "current-user-id") + ) + ) + try await activityList.get() + await #expect(activityListCapabilities(activityList) == initialCapabilities) + + // Send unmatching event first - should be ignored + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([ + FeedId(rawValue: "user:someoneelse"): [.readFeed, .addActivity, .deleteOwnActivity] + ]) + ) + await #expect(activityListCapabilities(activityList) == initialCapabilities) + + // Send matching event with updated capabilities for feed1 + let newFeed1Capabilities: Set = [.readFeed, .addActivity, .updateOwnActivity] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([feed1Id: newFeed1Capabilities]) + ) + await #expect(activityListCapabilities(activityList)[feed1Id] == newFeed1Capabilities) + await #expect(activityListCapabilities(activityList)[feed2Id] == initialFeed2Capabilities) + + // Send matching event with updated capabilities for feed2 + let newFeed2Capabilities: Set = [.readFeed, .readActivities, .addComment] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([feed2Id: newFeed2Capabilities]) + ) + await #expect(activityListCapabilities(activityList)[feed1Id] == newFeed1Capabilities) + await #expect(activityListCapabilities(activityList)[feed2Id] == newFeed2Capabilities) + } + // MARK: - private func defaultClient( diff --git a/Tests/StreamFeedsTests/StateLayer/Activity_Tests.swift b/Tests/StreamFeedsTests/StateLayer/Activity_Tests.swift index 745c051d..02e26093 100644 --- a/Tests/StreamFeedsTests/StateLayer/Activity_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/Activity_Tests.swift @@ -746,6 +746,76 @@ struct Activity_Tests { await #expect(activity.state.poll?.voteCountsByOption == ["option-1": 0, "option-2": 0]) } + // MARK: - Own Capabilities + + @Test func getCachesCapabilities() async throws { + let feedId = FeedId(group: "user", id: "jane") + + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + GetActivityResponse.dummy( + activity: .dummy( + currentFeed: .dummy(feed: feedId.rawValue, ownCapabilities: [.readFeed, .addActivity]), + id: "activity-123" + ) + ), + GetCommentsResponse.dummy(comments: []) + ] + ) + ) + let activity = client.activity(for: "activity-123", in: feedId) + _ = try await activity.get() + + let expectedCapabilitiesMap: [FeedId: Set] = [ + feedId: [.readFeed, .addActivity] + ] + + let cached = try #require(client.ownCapabilitiesRepository.capabilities(for: Set(expectedCapabilitiesMap.keys))) + #expect(cached.keys.map(\.rawValue).sorted() == expectedCapabilitiesMap.keys.map(\.rawValue).sorted()) + + for (feedId, expectedCapabilities) in expectedCapabilitiesMap { + #expect(cached[feedId] == expectedCapabilities) + } + } + + @Test func feedOwnCapabilitiesUpdatedEventUpdatesState() async throws { + let feedId = FeedId(group: "user", id: "jane") + let initialCapabilities: Set = [.readFeed, .addActivity] + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + GetActivityResponse.dummy( + activity: .dummy( + currentFeed: .dummy(feed: feedId.rawValue, ownCapabilities: Array(initialCapabilities)), + id: "activity-123" + ) + ), + GetCommentsResponse.dummy(comments: []) + ] + ) + ) + let activity = client.activity(for: "activity-123", in: feedId) + try await activity.get() + + await #expect(activity.state.activity?.currentFeed?.ownCapabilities == initialCapabilities) + + // Send unmatching event first - should be ignored + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([ + FeedId(rawValue: "user:someoneelse"): [.readFeed, .addActivity, .deleteOwnActivity] + ]) + ) + await #expect(activity.state.activity?.currentFeed?.ownCapabilities == initialCapabilities) + + // Send matching event with updated capabilities + let newCapabilities: Set = [.readFeed, .addActivity, .deleteOwnActivity] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([feedId: newCapabilities]) + ) + await #expect(activity.state.activity?.currentFeed?.ownCapabilities == newCapabilities) + } + // MARK: - private func defaultClientWithActivityAndCommentsResponses( @@ -764,37 +834,42 @@ struct Activity_Tests { ) ] } - return FeedsClient.mock( - apiTransport: .withPayloads( + apiTransport: .withMatchedResponses( [ - GetActivityResponse.dummy( - activity: .dummy( - id: "activity-123", - latestReactions: [.dummy(type: "like")], - ownReactions: [.dummy(type: "like", user: .dummy(id: "current-user-id"))], - 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: ownVotes, - voteCount: ownVotes.count, - voteCountsByOption: ["option-1": ownVotes.count, "option-2": 0] - ), - reactionCount: 1, - reactionGroups: ["like": .dummy(count: 1)], - text: "Test activity content" + .init( + matching: .pathPrefix("/api/v2/feeds/activities/"), + payload: GetActivityResponse.dummy( + activity: .dummy( + id: "activity-123", + latestReactions: [.dummy(type: "like")], + ownReactions: [.dummy(type: "like", user: .dummy(id: "current-user-id"))], + 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: ownVotes, + voteCount: ownVotes.count, + voteCountsByOption: ["option-1": ownVotes.count, "option-2": 0] + ), + reactionCount: 1, + reactionGroups: ["like": .dummy(count: 1)], + text: "Test activity content" + ) ) ), - GetCommentsResponse.dummy(comments: [ - .dummy(id: "comment-1", objectId: "activity-123", text: "First comment"), - .dummy(id: "comment-2", objectId: "activity-123", text: "Second comment") - ]) - ] + additionalPayloads + .init( + matching: .pathPrefix("/api/v2/feeds/comments"), + payload: GetCommentsResponse.dummy(comments: [ + .dummy(id: "comment-1", objectId: "activity-123", text: "First comment"), + .dummy(id: "comment-2", objectId: "activity-123", text: "Second comment") + ]) + ) + ] + additionalPayloads.map { .init(matching: .any, result: .success($0)) } ) ) } diff --git a/Tests/StreamFeedsTests/StateLayer/BookmarkList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/BookmarkList_Tests.swift index 89b886ea..94d02f8a 100644 --- a/Tests/StreamFeedsTests/StateLayer/BookmarkList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/BookmarkList_Tests.swift @@ -262,6 +262,130 @@ struct BookmarkList_Tests { let bookmarksAfterUpdate = await bookmarkList.state.bookmarks #expect(bookmarksAfterUpdate.isEmpty) } + + // MARK: - Own Capabilities + + @Test func getCachesCapabilities() async throws { + let feed1Id = FeedId(rawValue: "user:feed-1") + let feed2Id = FeedId(rawValue: "user:feed-2") + let feed3Id = FeedId(rawValue: "user:feed-3") + + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryBookmarksResponse.dummy( + bookmarks: [ + .dummy( + activity: .dummy( + currentFeed: .dummy(feed: feed1Id.rawValue, ownCapabilities: [.readFeed, .addActivityBookmark]), + id: "activity-1" + ), + user: .dummy(id: "user-1") + ), + .dummy( + activity: .dummy( + currentFeed: .dummy(feed: feed2Id.rawValue, ownCapabilities: [.readFeed, .deleteOwnActivityBookmark]), + id: "activity-2" + ), + user: .dummy(id: "user-1") + ), + .dummy( + activity: .dummy( + currentFeed: .dummy(feed: feed3Id.rawValue, ownCapabilities: [.readFeed, .updateOwnActivityBookmark]), + id: "activity-3" + ), + user: .dummy(id: "user-1") + ) + ], + next: nil + ) + ] + ) + ) + let bookmarkList = client.bookmarkList(for: BookmarksQuery()) + _ = try await bookmarkList.get() + + let expectedCapabilitiesMap: [FeedId: Set] = [ + feed1Id: [.readFeed, .addActivityBookmark], + feed2Id: [.readFeed, .deleteOwnActivityBookmark], + feed3Id: [.readFeed, .updateOwnActivityBookmark] + ] + + let cached = try #require(client.ownCapabilitiesRepository.capabilities(for: Set(expectedCapabilitiesMap.keys))) + #expect(cached.keys.map(\.rawValue).sorted() == expectedCapabilitiesMap.keys.map(\.rawValue).sorted()) + + for (feedId, expectedCapabilities) in expectedCapabilitiesMap { + #expect(cached[feedId] == expectedCapabilities) + } + } + + @Test func feedOwnCapabilitiesUpdatedEventUpdatesState() async throws { + let bookmarkListCapabilities: (BookmarkList) async -> [FeedId: Set] = { bookmarkList in + let pairs = await bookmarkList.state.bookmarks.compactMap { bookmark -> (FeedId, Set)? in + guard let feedData = bookmark.activity.currentFeed, let capabilities = feedData.ownCapabilities else { return nil } + return (feedData.feed, capabilities) + } + return Dictionary(pairs, uniquingKeysWith: { $1 }) + } + let feed1Id = FeedId(group: "user", id: "feed-1") + let feed2Id = FeedId(group: "user", id: "feed-2") + let initialFeed1Capabilities: Set = [.readFeed, .addActivityBookmark] + let initialFeed2Capabilities: Set = [.readFeed, .deleteOwnActivityBookmark] + let initialCapabilities = [feed1Id: initialFeed1Capabilities, feed2Id: initialFeed2Capabilities] + + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryBookmarksResponse.dummy( + bookmarks: [ + .dummy( + activity: .dummy( + currentFeed: .dummy(feed: feed1Id.rawValue, ownCapabilities: Array(initialFeed1Capabilities)), + id: "activity-1" + ), + user: .dummy(id: "user-1") + ), + .dummy( + activity: .dummy( + currentFeed: .dummy(feed: feed2Id.rawValue, ownCapabilities: Array(initialFeed2Capabilities)), + id: "activity-2" + ), + user: .dummy(id: "user-1") + ) + ], + next: nil + ) + ] + ) + ) + let bookmarkList = client.bookmarkList(for: BookmarksQuery()) + try await bookmarkList.get() + await #expect(bookmarkListCapabilities(bookmarkList) == initialCapabilities) + + // Send unmatching event first - should be ignored + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([ + FeedId(rawValue: "user:someoneelse"): [.readFeed, .addActivity, .deleteOwnActivity] + ]) + ) + await #expect(bookmarkListCapabilities(bookmarkList) == initialCapabilities) + + // Send matching event with updated capabilities for feed1 + let newFeed1Capabilities: Set = [.readFeed, .addActivityBookmark, .updateOwnActivityBookmark] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([feed1Id: newFeed1Capabilities]) + ) + await #expect(bookmarkListCapabilities(bookmarkList)[feed1Id] == newFeed1Capabilities) + await #expect(bookmarkListCapabilities(bookmarkList)[feed2Id] == initialFeed2Capabilities) + + // Send matching event with updated capabilities for feed2 + let newFeed2Capabilities: Set = [.readFeed, .deleteOwnActivityBookmark, .addActivityBookmark] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([feed2Id: newFeed2Capabilities]) + ) + await #expect(bookmarkListCapabilities(bookmarkList)[feed1Id] == newFeed1Capabilities) + await #expect(bookmarkListCapabilities(bookmarkList)[feed2Id] == newFeed2Capabilities) + } // MARK: - Helper Methods diff --git a/Tests/StreamFeedsTests/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware_Tests.swift b/Tests/StreamFeedsTests/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware_Tests.swift new file mode 100644 index 00000000..07bba0d0 --- /dev/null +++ b/Tests/StreamFeedsTests/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware_Tests.swift @@ -0,0 +1,285 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamCore +@testable import StreamFeeds +import Testing + +struct OwnCapabilitiesStateLayerEventMiddleware_Tests { + @Test func addCachedCapabilitiesToAddActivityEvent() async throws { + let client = FeedsClient.mock(apiTransport: .withPayloads([])) + _ = client.ownCapabilitiesRepository.saveCapabilities([FeedId(group: "user", id: "john"): [.readFeed]]) + + let event = await withCheckedContinuation { continuation in + let subscription = client.stateLayerEventPublisher.subscribe { event in + guard case .activityAdded(let activity, _) = event else { return } + continuation.resume(returning: activity) + } + + Task { + await client.eventsMiddleware.sendEvent( + ActivityAddedEvent.dummy( + activity: .dummy(currentFeed: .dummy(feed: "user:john")), + fid: "timeline:jane" + ) + ) + subscription.cancel() + } + } + + #expect(event.currentFeed?.ownCapabilities == [.readFeed]) + } + + @Test func addCachedCapabilitiesToBookmarkAddedEvent() async throws { + let client = FeedsClient.mock(apiTransport: .withPayloads([])) + _ = client.ownCapabilitiesRepository.saveCapabilities([FeedId(group: "user", id: "john"): [.readFeed, .addActivityBookmark]]) + + let event = await withCheckedContinuation { continuation in + let subscription = client.stateLayerEventPublisher.subscribe { event in + guard case .bookmarkAdded(let bookmark) = event else { return } + continuation.resume(returning: bookmark) + } + + Task { + await client.eventsMiddleware.sendEvent( + BookmarkAddedEvent.dummy( + bookmark: .dummy(activity: .dummy(currentFeed: .dummy(feed: "user:john"))) + ) + ) + subscription.cancel() + } + } + + #expect(event.activity.currentFeed?.ownCapabilities == [.readFeed, .addActivityBookmark]) + } + + @Test func addCachedCapabilitiesToFeedAddedEvent() async throws { + let client = FeedsClient.mock(apiTransport: .withPayloads([])) + _ = client.ownCapabilitiesRepository.saveCapabilities([FeedId(group: "user", id: "john"): [.readFeed, .createFeed]]) + + let event = await withCheckedContinuation { continuation in + let subscription = client.stateLayerEventPublisher.subscribe { event in + guard case .feedAdded(let feed, _) = event else { return } + continuation.resume(returning: feed) + } + + Task { + await client.eventsMiddleware.sendEvent( + FeedCreatedEvent.dummy( + feed: .dummy(feed: "user:john"), + fid: "user:john" + ) + ) + subscription.cancel() + } + } + + #expect(event.ownCapabilities == [.readFeed, .createFeed]) + } + + @Test func addCachedCapabilitiesToFeedFollowAddedEvent() async throws { + let client = FeedsClient.mock(apiTransport: .withPayloads([])) + let sourceFeedId = FeedId(group: "user", id: "john") + let targetFeedId = FeedId(group: "user", id: "jane") + _ = client.ownCapabilitiesRepository.saveCapabilities([ + sourceFeedId: [.readFeed, .follow], + targetFeedId: [.readFeed, .queryFollows] + ]) + + let event = await withCheckedContinuation { continuation in + let subscription = client.stateLayerEventPublisher.subscribe { event in + guard case .feedFollowAdded(let follow, _) = event else { return } + continuation.resume(returning: follow) + } + + Task { + await client.eventsMiddleware.sendEvent( + FollowCreatedEvent.dummy( + follow: .dummy( + sourceFeed: .dummy(feed: sourceFeedId.rawValue), + targetFeed: .dummy(feed: targetFeedId.rawValue) + ), + fid: sourceFeedId.rawValue + ) + ) + subscription.cancel() + } + } + + #expect(event.sourceFeed.ownCapabilities == [.readFeed, .follow]) + #expect(event.targetFeed.ownCapabilities == [.readFeed, .queryFollows]) + } + + @Test func automaticallyFetchCapabilitiesOnWebSocketEventWhenNotLocallyCached() async throws { + let client = FeedsClient.mock( + apiTransport: .withPayloads([ + OwnCapabilitiesBatchResponse.dummy(capabilities: [ + "user:john": [.addActivity, .readFeed] + ]) + ]) + ) + var subscription: StateLayerEventPublisher.Subscription? + let capabilities = await withCheckedContinuation { continuation in + // Added event triggers internal fetch of capabilities which in turn sends capabilities updated event + subscription = client.stateLayerEventPublisher.subscribe { event in + guard case .feedOwnCapabilitiesUpdated(let capabilities) = event else { return } + continuation.resume(returning: capabilities) + } + + Task { + await client.eventsMiddleware.sendEvent( + ActivityAddedEvent.dummy( + activity: .dummy(currentFeed: .dummy(feed: "user:john")), + fid: "timeline:jane" + ) + ) + } + } + + #expect(capabilities[FeedId(group: "user", id: "john")] == [.addActivity, .readFeed]) + subscription?.cancel() + } + + @Test func extractCapabilitiesFromLocalEvents() async throws { + let client = FeedsClient.mock(apiTransport: .withPayloads([])) + + let makeActivityData: (String, Set) -> ActivityData = { feedId, ownCapabilities in + ActivityResponse.dummy(currentFeed: .dummy(feed: feedId, ownCapabilities: Array(ownCapabilities))).toModel() + } + let makeFeedData: (String, Set) -> FeedData = { feedId, ownCapabilities in + FeedResponse.dummy(feed: feedId, ownCapabilities: Array(ownCapabilities)).toModel() + } + let makeBookmarkData: (String, Set) -> BookmarkData = { feedId, ownCapabilities in + BookmarkResponse.dummy(activity: .dummy(currentFeed: .dummy(feed: feedId, ownCapabilities: Array(ownCapabilities)))).toModel() + } + let makeCommentData: (String) -> CommentData = { activityId in + CommentResponse.dummy(objectId: activityId).toModel() + } + let makeReactionData: (String) -> FeedsReactionData = { activityId in + FeedsReactionResponse.dummy(activityId: activityId).toModel() + } + let makeActivityPinData: (String, Set) -> ActivityPinData = { feedId, ownCapabilities in + ActivityPinResponse.dummy( + activity: .dummy(currentFeed: .dummy(feed: feedId, ownCapabilities: Array(ownCapabilities))), + feed: feedId + ).toModel() + } + let makeFollowData: (String, String, Set, Set) -> FollowData = { sourceFeedId, targetFeedId, sourceCapabilities, targetCapabilities in + FollowResponse.dummy( + sourceFeed: .dummy(feed: sourceFeedId, ownCapabilities: Array(sourceCapabilities)), + targetFeed: .dummy(feed: targetFeedId, ownCapabilities: Array(targetCapabilities)) + ).toModel() + } + + var feedIdCounter = 1 + + let user1FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user1Capabilities: Set = [.readFeed, .addActivity] + await client.stateLayerEventPublisher.sendEvent(.activityAdded(makeActivityData(user1FeedId.rawValue, user1Capabilities), user1FeedId), source: .local) + feedIdCounter += 1 + + let user2FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user2Capabilities: Set = [.readFeed, .updateOwnActivity] + await client.stateLayerEventPublisher.sendEvent(.activityUpdated(makeActivityData(user2FeedId.rawValue, user2Capabilities), user2FeedId), source: .local) + feedIdCounter += 1 + + let user3FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user3Capabilities: Set = [.readFeed, .addActivityReaction, .addActivity] + await client.stateLayerEventPublisher.sendEvent(.activityReactionAdded(makeReactionData("activity-1"), makeActivityData(user3FeedId.rawValue, user3Capabilities), user3FeedId), source: .local) + feedIdCounter += 1 + + let user4FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user4Capabilities: Set = [.readFeed, .deleteOwnActivityReaction] + await client.stateLayerEventPublisher.sendEvent(.activityReactionDeleted(makeReactionData("activity-2"), makeActivityData(user4FeedId.rawValue, user4Capabilities), user4FeedId), source: .local) + feedIdCounter += 1 + + let user5FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user5Capabilities: Set = [.readFeed, .addActivityReaction] + await client.stateLayerEventPublisher.sendEvent(.activityReactionUpdated(makeReactionData("activity-3"), makeActivityData(user5FeedId.rawValue, user5Capabilities), user5FeedId), source: .local) + feedIdCounter += 1 + + let user6FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user6Capabilities: Set = [.readFeed, .pinActivity] + await client.stateLayerEventPublisher.sendEvent(.activityPinned(makeActivityPinData(user6FeedId.rawValue, user6Capabilities), user6FeedId), source: .local) + feedIdCounter += 1 + + let user7FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user7Capabilities: Set = [.readFeed, .pinActivity, .addActivity] + await client.stateLayerEventPublisher.sendEvent(.activityUnpinned(makeActivityPinData(user7FeedId.rawValue, user7Capabilities), user7FeedId), source: .local) + feedIdCounter += 1 + + let user8FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user8Capabilities: Set = [.readFeed, .addActivityBookmark] + await client.stateLayerEventPublisher.sendEvent(.bookmarkAdded(makeBookmarkData(user8FeedId.rawValue, user8Capabilities)), source: .local) + feedIdCounter += 1 + + let user9FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user9Capabilities: Set = [.readFeed, .deleteOwnActivityBookmark] + await client.stateLayerEventPublisher.sendEvent(.bookmarkDeleted(makeBookmarkData(user9FeedId.rawValue, user9Capabilities)), source: .local) + feedIdCounter += 1 + + let user10FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user10Capabilities: Set = [.readFeed, .updateOwnActivityBookmark] + await client.stateLayerEventPublisher.sendEvent(.bookmarkUpdated(makeBookmarkData(user10FeedId.rawValue, user10Capabilities)), source: .local) + feedIdCounter += 1 + + let user11FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user11Capabilities: Set = [.readFeed, .addComment] + await client.stateLayerEventPublisher.sendEvent(.commentAdded(makeCommentData("activity-4"), makeActivityData(user11FeedId.rawValue, user11Capabilities), user11FeedId), source: .local) + feedIdCounter += 1 + + let user12FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user12Capabilities: Set = [.readFeed, .createFeed] + await client.stateLayerEventPublisher.sendEvent(.feedAdded(makeFeedData(user12FeedId.rawValue, user12Capabilities), user12FeedId), source: .local) + feedIdCounter += 1 + + let user13FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user13Capabilities: Set = [.readFeed, .updateFeed] + await client.stateLayerEventPublisher.sendEvent(.feedUpdated(makeFeedData(user13FeedId.rawValue, user13Capabilities), user13FeedId), source: .local) + feedIdCounter += 1 + + let user14FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user14Capabilities: Set = [.readFeed, .follow] + feedIdCounter += 1 + let user15FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user15Capabilities: Set = [.readFeed, .queryFollows] + await client.stateLayerEventPublisher.sendEvent(.feedFollowAdded(makeFollowData(user14FeedId.rawValue, user15FeedId.rawValue, user14Capabilities, user15Capabilities), user14FeedId), source: .local) + feedIdCounter += 1 + + let user16FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user16Capabilities: Set = [.readFeed, .unfollow] + feedIdCounter += 1 + let user17FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user17Capabilities: Set = [.readFeed, .queryFollows] + await client.stateLayerEventPublisher.sendEvent(.feedFollowDeleted(makeFollowData(user16FeedId.rawValue, user17FeedId.rawValue, user16Capabilities, user17Capabilities), user16FeedId), source: .local) + feedIdCounter += 1 + + let user18FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user18Capabilities: Set = [.readFeed, .follow] + feedIdCounter += 1 + let user19FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user19Capabilities: Set = [.readFeed, .updateFeedFollowers] + await client.stateLayerEventPublisher.sendEvent(.feedFollowUpdated(makeFollowData(user18FeedId.rawValue, user19FeedId.rawValue, user18Capabilities, user19Capabilities), user18FeedId), source: .local) + + #expect(client.ownCapabilitiesRepository.capabilities(for: user1FeedId) == user1Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user2FeedId) == user2Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user3FeedId) == user3Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user4FeedId) == user4Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user5FeedId) == user5Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user6FeedId) == user6Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user7FeedId) == user7Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user8FeedId) == user8Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user9FeedId) == user9Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user10FeedId) == user10Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user11FeedId) == user11Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user12FeedId) == user12Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user13FeedId) == user13Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user14FeedId) == user14Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user15FeedId) == user15Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user16FeedId) == user16Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user17FeedId) == user17Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user18FeedId) == user18Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user19FeedId) == user19Capabilities) + } +} diff --git a/Tests/StreamFeedsTests/StateLayer/FeedList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/FeedList_Tests.swift index fa755e49..fdc21b66 100644 --- a/Tests/StreamFeedsTests/StateLayer/FeedList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/FeedList_Tests.swift @@ -26,12 +26,15 @@ struct FeedList_Tests { @Test func queryMoreFeedsUpdatesState() async throws { let client = defaultClientWithFeedsResponses([ - QueryFeedsResponse.dummy( - feeds: [ - .dummy(id: "feed-3", name: "Third Feed", createdAt: Date.fixed()), - .dummy(id: "feed-4", name: "Fourth Feed", createdAt: Date.fixed()) - ], - next: "next-cursor-2" + .init( + matching: .bodyType(QueryFeedsRequest.self), + payload: QueryFeedsResponse.dummy( + feeds: [ + .dummy(id: "feed-3", name: "Third Feed", createdAt: Date.fixed()), + .dummy(id: "feed-4", name: "Fourth Feed", createdAt: Date.fixed()) + ], + next: "next-cursor-2" + ) ) ]) let feedList = client.feedList(for: FeedsQuery()) @@ -185,17 +188,23 @@ struct FeedList_Tests { } @Test @MainActor func feedAddedEventWithMembersFilterAndZeroRefetchDelayTriggersImmediateRefetch() async throws { - let client = defaultClientWithFeedsResponses([ - // Additional response for the refetch - QueryFeedsResponse.dummy( - feeds: [ - .dummy(id: "feed-1", name: "First Feed", createdAt: Date.fixed()), - .dummy(id: "feed-2", name: "Second Feed", createdAt: Date.fixed(offset: 1)), - .dummy(id: "feed-3", name: "New Feed", createdAt: Date.fixed(offset: 2)) - ], - next: nil - ) - ]) + let client = defaultClientWithFeedsResponses( + [ + // Additional response for the refetch + .init( + matching: .bodyType(QueryFeedsRequest.self), + payload: + QueryFeedsResponse.dummy( + feeds: [ + .dummy(id: "feed-1", name: "First Feed", createdAt: Date.fixed()), + .dummy(id: "feed-2", name: "Second Feed", createdAt: Date.fixed(offset: 1)), + .dummy(id: "feed-3", name: "New Feed", createdAt: Date.fixed(offset: 2)) + ], + next: nil + ) + ) + ] + ) // Create FeedList with refetchDelay = 0 and members filter (which cannot be filtered locally) let feedList = FeedList( @@ -232,21 +241,139 @@ struct FeedList_Tests { } disposableBag.removeAll() } + + // MARK: - Own Capabilities + + @Test func getCachesCapabilities() async throws { + let feed1Id = FeedId(rawValue: "user:feed-1") + let feed2Id = FeedId(rawValue: "user:feed-2") + let feed3Id = FeedId(rawValue: "user:feed-3") + + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryFeedsResponse.dummy( + feeds: [ + .dummy( + createdAt: Date.fixed(), + feed: feed1Id.rawValue, + ownCapabilities: [.readFeed, .createFeed] + ), + .dummy( + createdAt: Date.fixed(offset: 1), + feed: feed2Id.rawValue, + ownCapabilities: [.readFeed, .updateFeed] + ), + .dummy( + createdAt: Date.fixed(offset: 2), + feed: feed3Id.rawValue, + ownCapabilities: [.readFeed, .deleteFeed] + ) + ], + next: nil + ) + ] + ) + ) + let feedList = client.feedList(for: FeedsQuery()) + _ = try await feedList.get() + + let expectedCapabilitiesMap: [FeedId: Set] = [ + feed1Id: [.readFeed, .createFeed], + feed2Id: [.readFeed, .updateFeed], + feed3Id: [.readFeed, .deleteFeed] + ] + + let cached = try #require(client.ownCapabilitiesRepository.capabilities(for: Set(expectedCapabilitiesMap.keys))) + #expect(cached.keys.map(\.rawValue).sorted() == expectedCapabilitiesMap.keys.map(\.rawValue).sorted()) + + for (feedId, expectedCapabilities) in expectedCapabilitiesMap { + #expect(cached[feedId] == expectedCapabilities) + } + } + + @Test func feedOwnCapabilitiesUpdatedEventUpdatesState() async throws { + let feedListCapabilities: (FeedList) async -> [FeedId: Set] = { feedList in + let pairs = await feedList.state.feeds.map { ($0.feed, $0.ownCapabilities) } + return Dictionary(uniqueKeysWithValues: pairs).compactMapValues { $0 } + } + let feed1Id = FeedId(group: "user", id: "feed-1") + let feed2Id = FeedId(group: "user", id: "feed-2") + let initialFeed1Capabilities: Set = [.readFeed, .createFeed] + let initialFeed2Capabilities: Set = [.readFeed, .updateFeed] + let initialCapabilities = [feed1Id: initialFeed1Capabilities, feed2Id: initialFeed2Capabilities] + + let client = FeedsClient.mock( + apiTransport: .withMatchedResponses( + [ + .init( + matching: .bodyType(QueryFeedsRequest.self), + payload: + QueryFeedsResponse.dummy( + feeds: [ + .dummy( + createdAt: Date.fixed(), + feed: feed1Id.rawValue, + ownCapabilities: Array(initialFeed1Capabilities) + ), + .dummy( + createdAt: Date.fixed(offset: 1), + feed: feed2Id.rawValue, + ownCapabilities: Array(initialFeed2Capabilities) + ) + ], + next: nil + ) + ) + ] + ) + ) + let feedList = client.feedList(for: FeedsQuery()) + try await feedList.get() + await #expect(feedListCapabilities(feedList) == initialCapabilities) + + // Send unmatching event first - should be ignored + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([ + FeedId(rawValue: "user:someoneelse"): [.readFeed, .addActivity, .deleteOwnActivity] + ]) + ) + await #expect(feedListCapabilities(feedList) == initialCapabilities) + + // Send matching event with updated capabilities for feed1 + let newFeed1Capabilities: Set = [.readFeed, .createFeed, .deleteFeed] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([feed1Id: newFeed1Capabilities]) + ) + await #expect(feedListCapabilities(feedList)[feed1Id] == newFeed1Capabilities) + await #expect(feedListCapabilities(feedList)[feed2Id] == initialFeed2Capabilities) + + // Send matching event with updated capabilities for feed2 + let newFeed2Capabilities: Set = [.readFeed, .updateFeed, .updateFeedMembers] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([feed2Id: newFeed2Capabilities]) + ) + await #expect(feedListCapabilities(feedList)[feed1Id] == newFeed1Capabilities) + await #expect(feedListCapabilities(feedList)[feed2Id] == newFeed2Capabilities) + } // MARK: - Helper Methods private func defaultClientWithFeedsResponses( - _ additionalPayloads: [any Encodable] = [] + _ additionalPayloads: [APITransportMock.APIResponse] = [] ) -> FeedsClient { FeedsClient.mock( - apiTransport: .withPayloads( + apiTransport: .withMatchedResponses( [ - QueryFeedsResponse.dummy( - feeds: [ - .dummy(id: "feed-1", name: "First Feed", createdAt: Date.fixed()), - .dummy(id: "feed-2", name: "Second Feed", createdAt: Date.fixed(offset: 1)) - ], - next: "next-cursor" + .init( + matching: .bodyType(QueryFeedsRequest.self), + payload: QueryFeedsResponse.dummy( + feeds: [ + .dummy(id: "feed-1", name: "First Feed", createdAt: Date.fixed()), + .dummy(id: "feed-2", name: "Second Feed", createdAt: Date.fixed(offset: 1)) + ], + next: "next-cursor" + ) ) ] + additionalPayloads ) diff --git a/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift b/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift index 2ace350b..e22a526c 100644 --- a/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift @@ -39,7 +39,8 @@ struct Feed_Tests { let updatedFeedData = try await feed.updateFeed(request: updateRequest) let stateFeedData = await feed.state.feedData - #expect(stateFeedData == updatedFeedData) + #expect(updatedFeedData.name == "Updated Feed Name") + #expect(updatedFeedData.custom == customData) #expect(stateFeedData?.name == "Updated Feed Name") #expect(stateFeedData?.custom == customData) } @@ -1384,6 +1385,95 @@ struct Feed_Tests { await #expect(feed.state.followRequests.map(\.status) == []) } + // MARK: - Own Capabilities + + @Test func getOrCreateCachesCapabilities() async throws { + let feedId = FeedId(group: "user", id: "jane") + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + GetOrCreateFeedResponse.dummy( + activities: [ + .dummy( + currentFeed: .dummy(feed: "user:john", ownCapabilities: [.readFeed, .addActivityBookmark]), id: "1" + ) + ], + feed: .dummy(feed: feedId.rawValue, ownCapabilities: [.readFeed, .addActivity]), + followers: [ + FollowResponse.dummy( + sourceFeed: .dummy(feed: "user:bob", ownCapabilities: [.readFeed, .addActivityReaction]), + status: .pending, + targetFeed: .dummy(feed: feedId.rawValue, ownCapabilities: [.readFeed, .addActivity]) + ), + FollowResponse.dummy( + sourceFeed: .dummy(feed: "user:alice", ownCapabilities: [.readFeed, .addComment]), + targetFeed: .dummy(feed: feedId.rawValue, ownCapabilities: [.readFeed, .addActivity]) + ) + ], + following: [ + FollowResponse.dummy( + sourceFeed: .dummy(feed: feedId.rawValue, ownCapabilities: [.readFeed, .addActivity]), + targetFeed: .dummy(feed: "user:melissa", ownCapabilities: [.readFeed, .addCommentReaction]) + ) + ], + members: [.dummy(user: .dummy(id: "feed-member-1"))], + pinnedActivities: [ + .dummy( + activity: .dummy( + currentFeed: .dummy(feed: "user:lisa", ownCapabilities: [.readFeed, .createFeed]), id: "1" + ), + feed: feedId.rawValue + ) + ] + ) + ] + ) + ) + let feed = client.feed(for: feedId) + _ = try await feed.getOrCreate() + + let expectedCapabilitiesMap: [FeedId: Set] = [ + feedId: [.readFeed, .addActivity], + FeedId(rawValue: "user:john"): [.readFeed, .addActivityBookmark], + FeedId(rawValue: "user:bob"): [.readFeed, .addActivityReaction], + FeedId(rawValue: "user:alice"): [.readFeed, .addComment], + FeedId(rawValue: "user:melissa"): [.readFeed, .addCommentReaction], + FeedId(rawValue: "user:lisa"): [.readFeed, .createFeed] + ] + + let cached = try #require(client.ownCapabilitiesRepository.capabilities(for: Set(expectedCapabilitiesMap.keys))) + #expect(cached.keys.map(\.rawValue).sorted() == expectedCapabilitiesMap.keys.map(\.rawValue).sorted()) + + for (feedId, expectedCapabilities) in expectedCapabilitiesMap { + #expect(cached[feedId] == expectedCapabilities) + } + } + + @Test func feedOwnCapabilitiesUpdatedEventUpdatesState() async throws { + let feedId = FeedId(group: "user", id: "jane") + let client = defaultClientWithActivities(feed: feedId.rawValue) + let feed = client.feed(for: feedId) + try await feed.getOrCreate() + + let initialCapabilities: Set = [.readFeed, .addActivity] + await verifyOwnCapabilities(in: feed, equalTo: initialCapabilities, for: feedId) + + // Send unmatching event first - should be ignored + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([ + FeedId(rawValue: "user:someoneelse"): [.readFeed, .addActivity, .deleteOwnActivity] + ]) + ) + await verifyOwnCapabilities(in: feed, equalTo: initialCapabilities, for: feedId) + + // Send matching event with updated capabilities + let newCapabilities: Set = [.readFeed, .addActivity, .deleteOwnActivity] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([feedId: newCapabilities]) + ) + await verifyOwnCapabilities(in: feed, equalTo: newCapabilities, for: feedId) + } + // MARK: - private func defaultClientWithActivities( @@ -1394,33 +1484,76 @@ struct Feed_Tests { apiTransport: .withPayloads( [ GetOrCreateFeedResponse.dummy( - activities: [.dummy(id: "1")], + activities: [ + .dummy( + currentFeed: .dummy(feed: feed, ownCapabilities: [.readFeed, .addActivity]), id: "1" + ) + ], feed: .dummy(feed: feed, ownCapabilities: [.readFeed, .addActivity]), followers: [ FollowResponse.dummy( sourceFeed: .dummy(feed: "user:bob"), status: .pending, - targetFeed: .dummy(feed: feed) + targetFeed: .dummy(feed: feed, ownCapabilities: [.readFeed, .addActivity]) ), FollowResponse.dummy( sourceFeed: .dummy(feed: "user:bob"), - targetFeed: .dummy(feed: feed) + targetFeed: .dummy(feed: feed, ownCapabilities: [.readFeed, .addActivity]) ) ], following: [ FollowResponse.dummy( - sourceFeed: .dummy(feed: feed), + sourceFeed: .dummy(feed: feed, ownCapabilities: [.readFeed, .addActivity]), targetFeed: .dummy(feed: "user:bob") ) ], members: [.dummy(user: .dummy(id: "feed-member-1"))], - pinnedActivities: [.dummy( - activity: .dummy(id: "1"), - feed: feed - )] + pinnedActivities: [ + .dummy( + activity: .dummy( + currentFeed: .dummy(feed: feed, ownCapabilities: [.readFeed, .addActivity]), id: "1" + ), + feed: feed + ) + ] ) ] + additionalPayloads ) ) } + + private func verifyOwnCapabilities(in feed: Feed, equalTo expectedCapabilities: Set, for feedId: FeedId) async { + await #expect(feed.state.ownCapabilities == expectedCapabilities) + await #expect(feed.state.feedData?.ownCapabilities == expectedCapabilities) + for activity in await feed.state.activities where activity.currentFeed?.feed == feedId { + #expect(activity.currentFeed?.ownCapabilities == expectedCapabilities) + } + for pinnedActivity in await feed.state.pinnedActivities where pinnedActivity.activity.currentFeed?.feed == feedId { + #expect(pinnedActivity.activity.currentFeed?.ownCapabilities == expectedCapabilities) + } + for follower in await feed.state.followers { + if follower.sourceFeed.feed == feedId { + #expect(follower.sourceFeed.ownCapabilities == expectedCapabilities) + } + if follower.targetFeed.feed == feedId { + #expect(follower.targetFeed.ownCapabilities == expectedCapabilities) + } + } + for follow in await feed.state.following { + if follow.sourceFeed.feed == feedId { + #expect(follow.sourceFeed.ownCapabilities == expectedCapabilities) + } + if follow.targetFeed.feed == feedId { + #expect(follow.targetFeed.ownCapabilities == expectedCapabilities) + } + } + for followRequest in await feed.state.followRequests { + if followRequest.sourceFeed.feed == feedId { + #expect(followRequest.sourceFeed.ownCapabilities == expectedCapabilities) + } + if followRequest.targetFeed.feed == feedId { + #expect(followRequest.targetFeed.ownCapabilities == expectedCapabilities) + } + } + } } diff --git a/Tests/StreamFeedsTests/StateLayer/FollowList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/FollowList_Tests.swift index ea4dc4cb..a18b801a 100644 --- a/Tests/StreamFeedsTests/StateLayer/FollowList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/FollowList_Tests.swift @@ -162,6 +162,67 @@ struct FollowList_Tests { #expect(followsAfterUpdate.isEmpty) } + // MARK: - Own Capabilities + + @Test func feedOwnCapabilitiesUpdatedEventUpdatesState() async throws { + let sourceFeedId = FeedId(group: "user", id: "current-user-id") + let targetFeedId = FeedId(group: "user", id: "user-1") + let initialSourceCapabilities: Set = [.readFeed, .follow] + let initialTargetCapabilities: Set = [.readFeed, .queryFollows] + + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryFollowsResponse.dummy( + follows: [ + .dummy( + sourceFeed: .dummy(feed: sourceFeedId.rawValue, ownCapabilities: Array(initialSourceCapabilities)), + targetFeed: .dummy(feed: targetFeedId.rawValue, ownCapabilities: Array(initialTargetCapabilities)) + ) + ], + next: nil + ) + ] + ) + ) + let followList = client.followList( + for: FollowsQuery(filter: .equal(.sourceFeed, sourceFeedId.rawValue)) + ) + try await followList.get() + + let initialFollow = try #require(await followList.state.follows.first) + #expect(initialFollow.sourceFeed.ownCapabilities == initialSourceCapabilities) + #expect(initialFollow.targetFeed.ownCapabilities == initialTargetCapabilities) + + // Send unmatching event first - should be ignored + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([ + FeedId(rawValue: "user:someoneelse"): [.readFeed, .addActivity, .deleteOwnActivity] + ]) + ) + let followAfterUnmatching = try #require(await followList.state.follows.first) + #expect(followAfterUnmatching.sourceFeed.ownCapabilities == initialSourceCapabilities) + #expect(followAfterUnmatching.targetFeed.ownCapabilities == initialTargetCapabilities) + + // Send matching event with updated capabilities for source feed + let newSourceCapabilities: Set = [.readFeed, .follow, .unfollow] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([sourceFeedId: newSourceCapabilities]) + ) + let followAfterSourceUpdate = try #require(await followList.state.follows.first) + #expect(followAfterSourceUpdate.sourceFeed.ownCapabilities == newSourceCapabilities) + #expect(followAfterSourceUpdate.targetFeed.ownCapabilities == initialTargetCapabilities) + + // Send matching event with updated capabilities for target feed + let newTargetCapabilities: Set = [.readFeed, .queryFollows, .updateFeedFollowers] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([targetFeedId: newTargetCapabilities]) + ) + let followAfterTargetUpdate = try #require(await followList.state.follows.first) + #expect(followAfterTargetUpdate.sourceFeed.ownCapabilities == newSourceCapabilities) + #expect(followAfterTargetUpdate.targetFeed.ownCapabilities == newTargetCapabilities) + } + // MARK: - private func defaultClient( diff --git a/Tests/StreamFeedsTests/TestTools/APITransportMock.swift b/Tests/StreamFeedsTests/TestTools/APITransportMock.swift index 059dfa46..41c9fe12 100644 --- a/Tests/StreamFeedsTests/TestTools/APITransportMock.swift +++ b/Tests/StreamFeedsTests/TestTools/APITransportMock.swift @@ -7,47 +7,84 @@ import StreamCore import StreamFeeds final class APITransportMock: DefaultAPITransport { - let responsePayloads = AllocatedUnfairLock<[any Encodable]>([]) - + let responseResults = AllocatedUnfairLock<[APIResponse]>([]) + func execute(request: Request) async throws -> (Data, URLResponse) { - let payload = try responsePayloads.withLock { payloads in - try Self.consumeResponsePayload(for: request, from: &payloads) + let response = try consumeResponseResult(for: request) + switch response { + case .success(let payload): + let data = try CodableHelper.jsonEncoder.encode(payload) + let response = HTTPURLResponse( + url: request.url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + return (data, response) + case .failure(let failure): + throw failure } - let data = try CodableHelper.jsonEncoder.encode(payload) - let response = HTTPURLResponse( - url: request.url, - statusCode: 200, - httpVersion: nil, - headerFields: nil - )! - return (data, response) } - - private static func consumeResponsePayload(for request: 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 + + func consumeResponseResult(for request: Request) throws -> Result { + try responseResults.withLock { responseResults in + let matchingIndex = responseResults.firstIndex { response in + switch response.matching { + case .any: + return true + case .pathPrefix(let prefix): + return request.url.path.hasPrefix(prefix) + case .bodyType(let bodyType): + guard let body = request.body else { return false } + return (try? CodableHelper.jsonDecoder.decode(bodyType, from: body)) != nil + } + } + guard let matchingIndex else { + throw ClientError.Unexpected("Mocked API request is not set for \(request)") } + return responseResults.remove(at: matchingIndex).result + } + } +} + +extension APITransportMock { + enum RequestMatching { + /// Response is used for any incoming request. + case any + /// Response is used only if path has the specified prefix. + case pathPrefix(String) + /// Response is used only if ``Request\body`` has data matching with the specified type. + /// + /// Example: `QueryFeedsRequest` should be matched when response is `QueryFeedsResponse` + case bodyType(Decodable.Type) + } + + struct APIResponse { + let matching: RequestMatching + let result: Result + + init(matching: RequestMatching, result: Result) { + self.matching = matching + self.result = result } - guard let payloadIndex else { - throw ClientError("Response payload is not available for request: \(request)") + + init(matching: RequestMatching, payload: any Encodable) { + self.matching = matching + self.result = .success(payload) } - return payloads.remove(at: payloadIndex) } } extension APITransportMock { + static func withMatchedResponses(_ responses: [APIResponse]) -> APITransportMock { + let transport = APITransportMock() + transport.responseResults.value = responses + return transport + } + static func withPayloads(_ payloads: [any Encodable]) -> APITransportMock { let transport = APITransportMock() - transport.responsePayloads.value = payloads + transport.responseResults.value = payloads.map { APIResponse(matching: .any, result: .success($0)) } return transport } } diff --git a/Tests/StreamFeedsTests/TestTools/ActivityResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/ActivityResponse+Testing.swift index 667fa6c4..f0b3336d 100644 --- a/Tests/StreamFeedsTests/TestTools/ActivityResponse+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/ActivityResponse+Testing.swift @@ -11,6 +11,7 @@ extension ActivityResponse { bookmarkCount: Int = 0, comments: [CommentResponse] = [], createdAt: Date = .fixed(), + currentFeed: FeedResponse? = FeedResponse.dummy(), expiresAt: Date? = nil, feeds: [String] = ["user:test"], id: String = "activity-123", @@ -30,7 +31,7 @@ extension ActivityResponse { commentCount: 1, comments: comments, createdAt: createdAt, - currentFeed: FeedResponse.dummy(), + currentFeed: currentFeed, custom: [:], deletedAt: nil, editedAt: nil, diff --git a/Tests/StreamFeedsTests/TestTools/FeedResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/FeedResponse+Testing.swift index fad5b194..d7b5fc1d 100644 --- a/Tests/StreamFeedsTests/TestTools/FeedResponse+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/FeedResponse+Testing.swift @@ -22,9 +22,9 @@ extension FeedResponse { followingCount: Int = 0, groupId: String = "user", memberCount: Int = 0, + ownCapabilities: [FeedOwnCapability]? = [.readFeed, .readActivities], pinCount: Int = 0, - visibility: String? = nil, - ownCapabilities: [FeedOwnCapability]? = nil + visibility: String? = nil ) -> FeedResponse { FeedResponse( createdAt: createdAt, diff --git a/Tests/StreamFeedsTests/TestTools/OwnCapabilitiesBatchResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/OwnCapabilitiesBatchResponse+Testing.swift new file mode 100644 index 00000000..ed7c72e1 --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/OwnCapabilitiesBatchResponse+Testing.swift @@ -0,0 +1,18 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension OwnCapabilitiesBatchResponse { + static func dummy( + capabilities: [String: [FeedOwnCapability]] + ) -> OwnCapabilitiesBatchResponse { + OwnCapabilitiesBatchResponse( + capabilities: capabilities, + duration: "1.23ms" + ) + } +}