From b4ca365a805b2a3f1a1f3e4fb2ae912db5f9e0e2 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 13 Nov 2025 15:53:18 +0200 Subject: [PATCH 1/5] Add activityBatchUpdate for publishing batch operation results --- .../Extensions/Array+Extensions.swift | 10 ++++-- Sources/StreamFeeds/FeedsClient.swift | 31 ++++++++++++++----- Sources/StreamFeeds/Models/ModelUpdates.swift | 12 +++++-- .../StateLayer/ActivityState.swift | 6 ++++ .../StateLayer/Common/StateLayerEvent.swift | 1 + .../StreamFeeds/StateLayer/FeedState.swift | 26 ++++++++++++++-- .../ActivityCommentListState.swift | 5 +++ .../PaginatedLists/ActivityListState.swift | 17 ++++++++++ Tests/StreamFeedsTests/FeedsClient_Test.swift | 4 +-- 9 files changed, 95 insertions(+), 17 deletions(-) diff --git a/Sources/StreamFeeds/Extensions/Array+Extensions.swift b/Sources/StreamFeeds/Extensions/Array+Extensions.swift index 801e532..700fcf2 100644 --- a/Sources/StreamFeeds/Extensions/Array+Extensions.swift +++ b/Sources/StreamFeeds/Extensions/Array+Extensions.swift @@ -42,9 +42,15 @@ extension Array { /// /// - Parameter ids: Ids of elements to remove. mutating func remove(byIds ids: [Element.ID]) where Element: Identifiable { + remove(byIds: Set(ids)) + } + + /// Removes elements from the non-sorted array based on ID. + /// + /// - Parameter ids: Ids of elements to remove. + mutating func remove(byIds ids: Set) where Element: Identifiable { guard !ids.isEmpty else { return } - let lookup = Set(ids) - removeAll(where: { lookup.contains($0.id) }) + removeAll(where: { ids.contains($0.id) }) } /// Replaces an element from the non-sorted array based on its ID. diff --git a/Sources/StreamFeeds/FeedsClient.swift b/Sources/StreamFeeds/FeedsClient.swift index e16f08f..61ea6c5 100644 --- a/Sources/StreamFeeds/FeedsClient.swift +++ b/Sources/StreamFeeds/FeedsClient.swift @@ -374,14 +374,20 @@ public final class FeedsClient: Sendable { ActivityReactionList(query: query, client: self) } - /// Adds a new activity to the specified feeds. + // MARK: - Activity Batch Operations + + /// Adds a new activity to one or multiple feeds. /// - /// - Parameter request: The request containing the activity data to add + /// - Parameter request: The request containing the activity data to add and the list of feeds /// - Returns: A response containing the created activity /// - Throws: `APIError` if the network request fails or the server returns an error @discardableResult - public func addActivity(request: AddActivityRequest) async throws -> AddActivityResponse { - try await apiClient.addActivity(addActivityRequest: request) + public func addActivity(request: AddActivityRequest) async throws -> ActivityData { + let activityData = try await activitiesRepository.addActivity(request: request) + for feed in request.feeds.map(FeedId.init) { + await stateLayerEventPublisher.sendEvent(.activityAdded(activityData, feed)) + } + return activityData } /// Upserts (inserts or updates) multiple activities. @@ -391,7 +397,16 @@ public final class FeedsClient: Sendable { /// - Throws: `APIError` if the network request fails or the server returns an error @discardableResult public func upsertActivities(_ activities: [ActivityRequest]) async throws -> [ActivityData] { - try await activitiesRepository.upsertActivities(activities) + let activities = try await activitiesRepository.upsertActivities(activities) + let updates = activities.reduce(into: ModelUpdates()) { partialResult, activityData in + if activityData.updatedAt.timeIntervalSince(activityData.createdAt) > 0.1 || activityData.editedAt != nil { + partialResult.updated.append(activityData) + } else { + partialResult.added.append(activityData) + } + } + await stateLayerEventPublisher.sendEvent(.activityBatchUpdate(updates)) + return activities } /// Deletes multiple activities from the specified feeds. @@ -400,8 +415,10 @@ public final class FeedsClient: Sendable { /// - Returns: A response confirming the deletion of activities /// - Throws: `APIError` if the network request fails or the server returns an error @discardableResult - public func deleteActivities(request: DeleteActivitiesRequest) async throws -> DeleteActivitiesResponse { - try await apiClient.deleteActivities(deleteActivitiesRequest: request) + public func deleteActivities(request: DeleteActivitiesRequest) async throws -> Set { + let response = try await apiClient.deleteActivities(deleteActivitiesRequest: request) + await stateLayerEventPublisher.sendEvent(.activityBatchUpdate(.init(added: [], removedIds: response.deletedIds, updated: []))) + return Set(response.deletedIds) } // MARK: - Bookmark Lists diff --git a/Sources/StreamFeeds/Models/ModelUpdates.swift b/Sources/StreamFeeds/Models/ModelUpdates.swift index ba0a6bd..e69859d 100644 --- a/Sources/StreamFeeds/Models/ModelUpdates.swift +++ b/Sources/StreamFeeds/Models/ModelUpdates.swift @@ -5,7 +5,13 @@ import Foundation public struct ModelUpdates: Sendable where Model: Sendable { - public let added: [Model] - public let removedIds: [String] - public let updated: [Model] + public internal(set) var added: [Model] + public internal(set) var removedIds: Set + public internal(set) var updated: [Model] +} + +extension ModelUpdates { + init(added: [Model] = [], removedIds: [String] = [], updated: [Model] = []) { + self.init(added: added, removedIds: Set(removedIds), updated: updated) + } } diff --git a/Sources/StreamFeeds/StateLayer/ActivityState.swift b/Sources/StreamFeeds/StateLayer/ActivityState.swift index ff17e89..8d47461 100644 --- a/Sources/StreamFeeds/StateLayer/ActivityState.swift +++ b/Sources/StreamFeeds/StateLayer/ActivityState.swift @@ -54,6 +54,12 @@ extension ActivityState { case .activityUpdated(let activityData, let eventFeedId): guard activityData.id == activityId, eventFeedId == feed else { return } await self?.setActivity(activityData) + case .activityBatchUpdate(let updates): + if let updated = updates.updated.first(where: { $0.id == activityId }) { + await self?.setActivity(updated) + } else if updates.removedIds.contains(activityId) { + await self?.setActivity(nil) + } case .activityReactionAdded(let reactionData, let activityData, let eventFeedId): guard activityData.id == activityId, eventFeedId == feed else { return } await self?.access { state in diff --git a/Sources/StreamFeeds/StateLayer/Common/StateLayerEvent.swift b/Sources/StreamFeeds/StateLayer/Common/StateLayerEvent.swift index b7d287f..5195067 100644 --- a/Sources/StreamFeeds/StateLayer/Common/StateLayerEvent.swift +++ b/Sources/StreamFeeds/StateLayer/Common/StateLayerEvent.swift @@ -10,6 +10,7 @@ enum StateLayerEvent: Sendable { case activityAdded(ActivityData, FeedId) case activityDeleted(String, FeedId) case activityUpdated(ActivityData, FeedId) + case activityBatchUpdate(ModelUpdates) case activityReactionAdded(FeedsReactionData, ActivityData, FeedId) case activityReactionDeleted(FeedsReactionData, ActivityData, FeedId) diff --git a/Sources/StreamFeeds/StateLayer/FeedState.swift b/Sources/StreamFeeds/StateLayer/FeedState.swift index 5220066..7392688 100644 --- a/Sources/StreamFeeds/StateLayer/FeedState.swift +++ b/Sources/StreamFeeds/StateLayer/FeedState.swift @@ -91,13 +91,15 @@ import StreamCore extension FeedState { private func subscribe(to publisher: StateLayerEventPublisher) { + let matchesActivityQuery: @Sendable (ActivityData) -> Bool = { [feedQuery] activity in + guard let filter = feedQuery.activityFilter else { return true } + return filter.matches(activity) + } eventSubscription = publisher.subscribe { [weak self, currentUserId, feed, feedQuery] event in switch event { case .activityAdded(let activityData, let eventFeedId): guard feed == eventFeedId else { return } - if let filter = feedQuery.activityFilter, !filter.matches(activityData) { - return - } + guard matchesActivityQuery(activityData) else { return } await self?.access { $0.activities.sortedInsert(activityData, sorting: $0.activitiesSorting) } case .activityDeleted(let activityId, let eventFeedId): guard feed == eventFeedId else { return } @@ -108,6 +110,24 @@ extension FeedState { case .activityUpdated(let activityData, let eventFeedId): guard feed == eventFeedId else { return } await self?.updateActivity(activityData) + case .activityBatchUpdate(let updates): + let added = updates.added.filter(matchesActivityQuery) + let updated = updates.updated.filter(matchesActivityQuery) + let removedIds = updates.removedIds + guard !added.isEmpty || !updated.isEmpty || !removedIds.isEmpty else { return } + await self?.access { state in + let sorting = state.activitiesSorting + if !added.isEmpty { + state.activities = state.activities.sortedMerge(added.sorted(by: sorting.areInIncreasingOrder()), sorting: sorting) + } + if !updated.isEmpty { + state.activities = state.activities.sortedMerge(updated.sorted(by: sorting.areInIncreasingOrder()), sorting: sorting) + } + if !removedIds.isEmpty { + state.activities.removeAll(where: { removedIds.contains($0.id) }) + state.pinnedActivities.removeAll(where: { removedIds.contains($0.activity.id) }) + } + } case .activityReactionAdded(let reactionData, let activityData, let eventFeedId): guard feed == eventFeedId else { return } await self?.access { state in diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityCommentListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityCommentListState.swift index a34c3f4..29181aa 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityCommentListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityCommentListState.swift @@ -133,6 +133,11 @@ extension ActivityCommentListState { await self?.access { state in state.comments.removeAll() } + case .activityBatchUpdate(let updates): + guard updates.removedIds.contains(query.objectId) else { return } + await self?.access { state in + state.comments.removeAll() + } case .commentAdded(let commentData, _, _): guard query.objectId == commentData.objectId, query.objectType == commentData.objectType else { return } await self?.access { state in diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityListState.swift index 21aafbc..b1749e5 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityListState.swift @@ -100,6 +100,23 @@ extension ActivityListState { await self?.access { state in state.activities.removeAll { $0.id == activityId } } + case .activityBatchUpdate(let updates): + let added = updates.added.filter(matchesQuery) + let updated = updates.updated.filter(matchesQuery) + let removedIds = updates.removedIds + guard !added.isEmpty || !updated.isEmpty || !removedIds.isEmpty else { return } + await self?.access { state in + let sorting = state.activitiesSorting + if !added.isEmpty { + state.activities = state.activities.sortedMerge(added.sorted(by: sorting.areInIncreasingOrder()), sorting: sorting) + } + if !updated.isEmpty { + state.activities = state.activities.sortedMerge(updated.sorted(by: sorting.areInIncreasingOrder()), sorting: sorting) + } + if !removedIds.isEmpty { + state.activities.removeAll(where: { removedIds.contains($0.id) }) + } + } case .activityReactionAdded(let reactionData, let activityData, _): guard matchesQuery(activityData) else { return } await self?.access { state in diff --git a/Tests/StreamFeedsTests/FeedsClient_Test.swift b/Tests/StreamFeedsTests/FeedsClient_Test.swift index 4e60ed0..8bc7372 100644 --- a/Tests/StreamFeedsTests/FeedsClient_Test.swift +++ b/Tests/StreamFeedsTests/FeedsClient_Test.swift @@ -168,7 +168,7 @@ struct FeedsClient_Test { ) let response = try await client.addActivity(request: activityRequest) - #expect(response.activity.id == mockResponse.activity.id) + #expect(response.id == mockResponse.activity.id) } @Test func upsertActivities() async throws { @@ -201,7 +201,7 @@ struct FeedsClient_Test { ) let response = try await client.deleteActivities(request: deleteRequest) - #expect(response.deletedIds == mockResponse.deletedIds) + #expect(response == Set(mockResponse.deletedIds)) } // MARK: - Properties Tests From 2af2d2453333e5f642f83ef6dc4a306a550f18e6 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 13 Nov 2025 16:24:15 +0200 Subject: [PATCH 2/5] Add tests --- .../StreamFeeds/StateLayer/FeedState.swift | 4 +- .../ActivityCommentList_Tests.swift | 34 +++++++ .../StateLayer/ActivityList_Tests.swift | 94 +++++++++++++++++++ .../StateLayer/Activity_Tests.swift | 70 ++++++++++++++ .../StateLayer/Feed_Tests.swift | 89 ++++++++++++++++++ .../TestTools/ActivityResponse+Testing.swift | 3 +- .../DeleteActivitiesResponse+Testing.swift | 21 +++++ 7 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 Tests/StreamFeedsTests/TestTools/DeleteActivitiesResponse+Testing.swift diff --git a/Sources/StreamFeeds/StateLayer/FeedState.swift b/Sources/StreamFeeds/StateLayer/FeedState.swift index 7392688..910d04b 100644 --- a/Sources/StreamFeeds/StateLayer/FeedState.swift +++ b/Sources/StreamFeeds/StateLayer/FeedState.swift @@ -111,8 +111,8 @@ extension FeedState { guard feed == eventFeedId else { return } await self?.updateActivity(activityData) case .activityBatchUpdate(let updates): - let added = updates.added.filter(matchesActivityQuery) - let updated = updates.updated.filter(matchesActivityQuery) + let added = updates.added.filter { $0.feeds.contains(feed.rawValue) }.filter(matchesActivityQuery) + let updated = updates.updated.filter { $0.feeds.contains(feed.rawValue) }.filter(matchesActivityQuery) let removedIds = updates.removedIds guard !added.isEmpty || !updated.isEmpty || !removedIds.isEmpty else { return } await self?.access { state in diff --git a/Tests/StreamFeedsTests/StateLayer/ActivityCommentList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/ActivityCommentList_Tests.swift index 589ad2c..b878cd7 100644 --- a/Tests/StreamFeedsTests/StateLayer/ActivityCommentList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/ActivityCommentList_Tests.swift @@ -370,6 +370,40 @@ struct ActivityCommentList_Tests { #expect(commentsAfterDeletion.isEmpty) // All comments should be removed } + @Test func activityBatchUpdateEventUpdatesState() async throws { + let client = defaultClient( + comments: [ + .dummy(id: "comment-1", objectId: Self.activityId, objectType: "activity"), + .dummy(id: "comment-2", objectId: Self.activityId, objectType: "activity") + ], + additionalPayloads: [ + DeleteActivitiesResponse.dummy( + deletedIds: ["different-activity"] + ), + DeleteActivitiesResponse.dummy( + deletedIds: [Self.activityId] + ) + ] + ) + let commentList = client.activityCommentList( + for: ActivityCommentsQuery(objectId: Self.activityId, objectType: "activity") + ) + try await commentList.get() + await #expect(commentList.state.comments.map(\.id) == ["comment-1", "comment-2"]) + + // Send batch update with unrelated activity removed - should not affect comments + _ = try await client.deleteActivities( + request: DeleteActivitiesRequest(ids: ["different-activity"]) + ) + await #expect(commentList.state.comments.map(\.id) == ["comment-1", "comment-2"]) + + // Send batch update with matching activity removed - should clear all comments + _ = try await client.deleteActivities( + request: DeleteActivitiesRequest(ids: [Self.activityId]) + ) + await #expect(commentList.state.comments.isEmpty) + } + // MARK: - private func defaultClient( diff --git a/Tests/StreamFeedsTests/StateLayer/ActivityList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/ActivityList_Tests.swift index db1dbbf..15e8a22 100644 --- a/Tests/StreamFeedsTests/StateLayer/ActivityList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/ActivityList_Tests.swift @@ -485,6 +485,100 @@ struct ActivityList_Tests { #expect(result == ["activity-1"]) // Should not include activity-2 } + @Test func activityBatchUpdateEventUpdatesState() async throws { + let client = defaultClient( + activities: [ + .dummy(id: "activity-1", user: .dummy(id: "current-user-id")), + .dummy(id: "activity-2", user: .dummy(id: "current-user-id")) + ], + additionalPayloads: [ + UpsertActivitiesResponse( + activities: [ + ActivityResponse.dummy( + id: "activity-3", + user: .dummy(id: "current-user-id") + ) + ], + duration: "1.23ms" + ), + UpsertActivitiesResponse( + activities: [ + ActivityResponse.dummy( + createdAt: .fixed(), + editedAt: .fixed(), + id: "activity-2", + text: "UPDATED TEXT", + user: .dummy(id: "current-user-id") + ) + ], + duration: "1.23ms" + ), + DeleteActivitiesResponse.dummy( + deletedIds: ["activity-1"] + ), + UpsertActivitiesResponse( + activities: [ + ActivityResponse.dummy( + createdAt: .fixed(), + editedAt: .fixed(), + id: "unrelated-activity", + user: .dummy(id: "different-user") + ) + ], + duration: "1.23ms" + ) + ] + ) + let activityList = client.activityList( + for: ActivitiesQuery( + filter: .equal(.userId, "current-user-id") + ) + ) + try await activityList.get() + + await #expect(activityList.state.activities.map(\.id) == ["activity-1", "activity-2"]) + + // Send batch update with added activity + _ = try await client.upsertActivities([ + ActivityRequest( + feeds: [Self.feedId.rawValue], + id: "activity-3", + type: "post" + ) + ]) + await #expect(activityList.state.activities.map(\.id).sorted() == ["activity-1", "activity-2", "activity-3"]) + + // Send batch update with updated activity + _ = try await client.upsertActivities([ + ActivityRequest( + feeds: [Self.feedId.rawValue], + id: "activity-2", + text: "UPDATED TEXT", + type: "post" + ) + ]) + let afterUpdate = await activityList.state.activities + let updatedActivity = try #require(afterUpdate.first(where: { $0.id == "activity-2" })) + #expect(updatedActivity.text == "UPDATED TEXT") + + // Send batch update with removed activity + _ = try await client.deleteActivities( + request: DeleteActivitiesRequest(ids: ["activity-1"]) + ) + let afterRemove = await activityList.state.activities + #expect(afterRemove.map(\.id) == ["activity-2", "activity-3"]) + + // Send batch update with unrelated activity - should be ignored + _ = try await client.upsertActivities([ + ActivityRequest( + feeds: [Self.feedId.rawValue], + id: "unrelated-activity", + type: "post" + ) + ]) + await #expect(activityList.state.activities.map(\.id) == ["activity-2", "activity-3"]) + } + // MARK: - private func defaultClient( diff --git a/Tests/StreamFeedsTests/StateLayer/Activity_Tests.swift b/Tests/StreamFeedsTests/StateLayer/Activity_Tests.swift index 745c051..0edd438 100644 --- a/Tests/StreamFeedsTests/StateLayer/Activity_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/Activity_Tests.swift @@ -499,6 +499,76 @@ struct Activity_Tests { await #expect(activity.state.activity?.text == "NEW TEXT") } + @Test func activityBatchUpdateEventUpdatesState() async throws { + let feedId = FeedId(group: "user", id: "jane") + let client = defaultClientWithActivityAndCommentsResponses( + [ + UpsertActivitiesResponse( + activities: [ + ActivityResponse.dummy( + createdAt: .fixed(), + editedAt: .fixed(), + id: "activity-123", + text: "UPDATED TEXT" + ) + ], + duration: "1.23ms" + ), + UpsertActivitiesResponse( + activities: [ + ActivityResponse.dummy( + createdAt: .fixed(), + editedAt: .fixed(), + id: "different-activity", + text: "SHOULD NOT CHANGE" + ) + ], + duration: "1.23ms" + ), + DeleteActivitiesResponse.dummy( + deletedIds: ["activity-123"] + ) + ] + ) + let activity = client.activity( + for: "activity-123", + in: feedId + ) + try await activity.get() + + await #expect(activity.state.activity?.id == "activity-123") + await #expect(activity.state.activity?.text == "Test activity content") + + // Send batch update with updated activity + _ = try await client.upsertActivities([ + ActivityRequest( + feeds: [feedId.rawValue], + id: "activity-123", + text: "UPDATED TEXT", + type: "post" + ) + ]) + await #expect(activity.state.activity?.id == "activity-123") + await #expect(activity.state.activity?.text == "UPDATED TEXT") + + // Send batch update with unrelated activity - should be ignored + _ = try await client.upsertActivities([ + ActivityRequest( + feeds: [feedId.rawValue], + id: "different-activity", + text: "SHOULD NOT CHANGE", + type: "post" + ) + ]) + await #expect(activity.state.activity?.text == "UPDATED TEXT") + + // Send batch update with removed activity + _ = try await client.deleteActivities( + request: DeleteActivitiesRequest(ids: ["activity-123"]) + ) + await #expect(activity.state.activity == nil) + } + @Test func pollDeletedFeedEventUpdatesState() async throws { let client = defaultClientWithActivityAndCommentsResponses() let feedId = FeedId(group: "user", id: "jane") diff --git a/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift b/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift index 2ace350..171286a 100644 --- a/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift @@ -945,6 +945,95 @@ struct Feed_Tests { await #expect(feed.state.activities.first?.text == "New From WS") } + + @Test func activityBatchUpdateEventUpdatesState() async throws { + let feedId = FeedId(group: "user", id: "jane") + let client = defaultClientWithActivities( + feed: feedId.rawValue, + [ + UpsertActivitiesResponse( + activities: [ + ActivityResponse.dummy( + feeds: [feedId.rawValue], + id: "2" + ) + ], + duration: "1.23ms" + ), + UpsertActivitiesResponse( + activities: [ + ActivityResponse.dummy( + createdAt: .fixed(), + editedAt: .fixed(), + feeds: [feedId.rawValue], + id: "1", + text: "UPDATED TEXT" + ) + ], + duration: "1.23ms" + ), + DeleteActivitiesResponse.dummy( + deletedIds: ["1"] + ), + UpsertActivitiesResponse( + activities: [ + ActivityResponse.dummy( + createdAt: .fixed(), + editedAt: .fixed(), + feeds: ["user:someoneelse"], + id: "unrelated-activity" + ) + ], + duration: "1.23ms" + ) + ] + ) + let feed = client.feed(for: feedId) + try await feed.getOrCreate() + + await #expect(feed.state.activities.map(\.id) == ["1"]) + await #expect(feed.state.pinnedActivities.map(\.activity.id) == ["1"]) + + // Send batch update with added activity + _ = try await client.upsertActivities([ + ActivityRequest( + feeds: [feedId.rawValue], + id: "2", + type: "post" + ) + ]) + await #expect(feed.state.activities.map(\.id).sorted() == ["1", "2"]) + + // Send batch update with updated activity + _ = try await client.upsertActivities([ + ActivityRequest( + feeds: [feedId.rawValue], + id: "1", + text: "UPDATED TEXT", + type: "post" + ) + ]) + let afterUpdate = await feed.state.activities + let updatedActivity = try #require(afterUpdate.first(where: { $0.id == "1" })) + #expect(updatedActivity.text == "UPDATED TEXT") + + // Send batch update with removed activity - should remove from both activities and pinnedActivities + _ = try await client.deleteActivities( + request: DeleteActivitiesRequest(ids: ["1"]) + ) + await #expect(feed.state.activities.map(\.id) == ["2"]) + await #expect(feed.state.pinnedActivities.isEmpty) + + // Send batch update with unrelated activity - should be ignored + _ = try await client.upsertActivities([ + ActivityRequest( + feeds: ["user:someoneelse"], + id: "unrelated-activity", + type: "post" + ) + ]) + await #expect(feed.state.activities.map(\.id) == ["2"]) + } @Test func activityPinnedEventUpdatesState() async throws { let feedId = FeedId(group: "user", id: "jane") diff --git a/Tests/StreamFeedsTests/TestTools/ActivityResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/ActivityResponse+Testing.swift index 667fa6c..65d6f0b 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(), + editedAt: Date? = nil, expiresAt: Date? = nil, feeds: [String] = ["user:test"], id: String = "activity-123", @@ -33,7 +34,7 @@ extension ActivityResponse { currentFeed: FeedResponse.dummy(), custom: [:], deletedAt: nil, - editedAt: nil, + editedAt: editedAt, expiresAt: expiresAt, feeds: feeds, filterTags: [], diff --git a/Tests/StreamFeedsTests/TestTools/DeleteActivitiesResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/DeleteActivitiesResponse+Testing.swift new file mode 100644 index 0000000..dd79054 --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/DeleteActivitiesResponse+Testing.swift @@ -0,0 +1,21 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension DeleteActivitiesResponse { + private static let defaultDuration = "1.23ms" + + static func dummy( + deletedIds: [String] = [], + duration: String = defaultDuration + ) -> DeleteActivitiesResponse { + DeleteActivitiesResponse( + deletedIds: deletedIds, + duration: duration + ) + } +} From d242f2b4ad3fc7794e906d4c233d36e4bbe1d9ee Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 13 Nov 2025 16:32:41 +0200 Subject: [PATCH 3/5] Add CHANGELOG entry --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cff0e07..2b3cbd8 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 +- Update local state when using activity batch operations in `FeedsClient` [#52](https://github.com/GetStream/stream-feeds-swift/pull/52) # [0.4.0](https://github.com/GetStream/stream-feeds-swift/releases/tag/0.4.0) _September 25, 2025_ From c475f74856f0662a091d29e70807766439f31f85 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 13 Nov 2025 16:38:16 +0200 Subject: [PATCH 4/5] Change timeout to see if it causes random failures --- Tests/StreamFeedsTests/StreamFeeds.xctestplan | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/StreamFeedsTests/StreamFeeds.xctestplan b/Tests/StreamFeedsTests/StreamFeeds.xctestplan index 9bc18ee..b5f3bea 100644 --- a/Tests/StreamFeedsTests/StreamFeeds.xctestplan +++ b/Tests/StreamFeedsTests/StreamFeeds.xctestplan @@ -18,7 +18,7 @@ } ] }, - "defaultTestExecutionTimeAllowance" : 60, + "defaultTestExecutionTimeAllowance" : 180, "language" : "en", "region" : "US", "testTimeoutsEnabled" : true From 5efa1f6afc3af92de52acd6ffa90e1ee95daef7f Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Fri, 14 Nov 2025 14:51:33 +0200 Subject: [PATCH 5/5] Update tests to include activityBatchUpdate --- ...abilitiesStateLayerEventMiddleware_Tests.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Tests/StreamFeedsTests/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware_Tests.swift b/Tests/StreamFeedsTests/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware_Tests.swift index 07bba0d..2f28655 100644 --- a/Tests/StreamFeedsTests/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware_Tests.swift @@ -261,6 +261,19 @@ struct OwnCapabilitiesStateLayerEventMiddleware_Tests { 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) + feedIdCounter += 1 + + let user20FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user20Capabilities: Set = [.readFeed, .addActivity] + feedIdCounter += 1 + let user21FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user21Capabilities: Set = [.readFeed, .updateOwnActivity] + let batchUpdates = ModelUpdates( + added: [makeActivityData(user20FeedId.rawValue, user20Capabilities)], + removedIds: [], + updated: [makeActivityData(user21FeedId.rawValue, user21Capabilities)] + ) + await client.stateLayerEventPublisher.sendEvent(.activityBatchUpdate(batchUpdates), source: .local) #expect(client.ownCapabilitiesRepository.capabilities(for: user1FeedId) == user1Capabilities) #expect(client.ownCapabilitiesRepository.capabilities(for: user2FeedId) == user2Capabilities) @@ -281,5 +294,7 @@ struct OwnCapabilitiesStateLayerEventMiddleware_Tests { #expect(client.ownCapabilitiesRepository.capabilities(for: user17FeedId) == user17Capabilities) #expect(client.ownCapabilitiesRepository.capabilities(for: user18FeedId) == user18Capabilities) #expect(client.ownCapabilitiesRepository.capabilities(for: user19FeedId) == user19Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user20FeedId) == user20Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user21FeedId) == user21Capabilities) } }