diff --git a/Modules/Sources/WordPressUI/Views/Users/Components/UserListItem.swift b/Modules/Sources/WordPressUI/Views/Users/Components/UserListItem.swift index c528fe6e57cb..93c21253aa7a 100644 --- a/Modules/Sources/WordPressUI/Views/Users/Components/UserListItem.swift +++ b/Modules/Sources/WordPressUI/Views/Users/Components/UserListItem.swift @@ -9,18 +9,16 @@ struct UserListItem: View { var dynamicTypeSize private let user: DisplayUser - private let userProvider: UserDataProvider - private let actionDispatcher: UserManagementActionDispatcher + private let userService: UserServiceProtocol - init(user: DisplayUser, userProvider: UserDataProvider, actionDispatcher: UserManagementActionDispatcher) { + init(user: DisplayUser, userService: UserServiceProtocol) { self.user = user - self.userProvider = userProvider - self.actionDispatcher = actionDispatcher + self.userService = userService } var body: some View { NavigationLink { - UserDetailsView(user: user, userProvider: userProvider, actionDispatcher: actionDispatcher) + UserDetailsView(user: user, userService: userService) } label: { HStack(alignment: .top) { if !dynamicTypeSize.isAccessibilitySize { @@ -36,5 +34,5 @@ struct UserListItem: View { } #Preview { - UserListItem(user: DisplayUser.MockUser, userProvider: MockUserProvider(), actionDispatcher: UserManagementActionDispatcher()) + UserListItem(user: DisplayUser.MockUser, userService: MockUserProvider()) } diff --git a/Modules/Sources/WordPressUI/Views/Users/UserProvider.swift b/Modules/Sources/WordPressUI/Views/Users/UserProvider.swift index 4adea4d82f8c..59b1cda47221 100644 --- a/Modules/Sources/WordPressUI/Views/Users/UserProvider.swift +++ b/Modules/Sources/WordPressUI/Views/Users/UserProvider.swift @@ -1,32 +1,20 @@ import Foundation +import Combine -public protocol UserDataProvider { +public protocol UserServiceProtocol: Actor { + var users: [DisplayUser]? { get } + nonisolated var usersUpdates: AsyncStream<[DisplayUser]> { get } - typealias CachedUserListCallback = ([WordPressUI.DisplayUser]) async -> Void + func fetchUsers() async throws -> [DisplayUser] - func fetchCurrentUserCan(_ capability: String) async throws -> Bool - func fetchUsers(cachedResults: CachedUserListCallback?) async throws -> [WordPressUI.DisplayUser] + func isCurrentUserCapableOf(_ capability: String) async throws -> Bool - func invalidateCaches() async throws -} - -/// Subclass this and register it with the SwiftUI `.environmentObject` method -/// to perform user management actions. -/// -/// The default implementation is set up for testing with SwiftUI Previews -open class UserManagementActionDispatcher: ObservableObject { - public init() {} - - open func setNewPassword(id: Int32, newPassword: String) async throws { - try await Task.sleep(for: .seconds(2)) - } + func setNewPassword(id: Int32, newPassword: String) async throws - open func deleteUser(id: Int32, reassigningPostsTo userId: Int32) async throws { - try await Task.sleep(for: .seconds(2)) - } + func deleteUser(id: Int32, reassigningPostsTo newUserId: Int32) async throws } -package struct MockUserProvider: UserDataProvider { +package actor MockUserProvider: UserServiceProtocol { enum Scenario { case infinitLoading @@ -36,29 +24,48 @@ package struct MockUserProvider: UserDataProvider { var scenario: Scenario + package nonisolated let usersUpdates: AsyncStream<[DisplayUser]> + private let usersUpdatesContinuation: AsyncStream<[DisplayUser]>.Continuation + + package private(set) var users: [DisplayUser]? { + didSet { + if let users { + usersUpdatesContinuation.yield(users) + } + } + } + init(scenario: Scenario = .dummyData) { self.scenario = scenario + (usersUpdates, usersUpdatesContinuation) = AsyncStream<[DisplayUser]>.makeStream() } - package func fetchUsers(cachedResults: CachedUserListCallback? = nil) async throws -> [WordPressUI.DisplayUser] { + package func fetchUsers() async throws -> [DisplayUser] { switch scenario { case .infinitLoading: - try await Task.sleep(for: .seconds(1 * 24 * 60 * 60)) + // Do nothing + try await Task.sleep(for: .seconds(24 * 60 * 60)) return [] case .dummyData: let dummyDataUrl = URL(string: "https://my.api.mockaroo.com/users.json?key=067c9730")! let response = try await URLSession.shared.data(from: dummyDataUrl) - return try JSONDecoder().decode([DisplayUser].self, from: response.0) + let users = try JSONDecoder().decode([DisplayUser].self, from: response.0) + self.users = users + return users case .error: throw URLError(.timedOut) } } - package func fetchCurrentUserCan(_ capability: String) async throws -> Bool { + package func isCurrentUserCapableOf(_ capability: String) async throws -> Bool { true } - package func invalidateCaches() async throws { - // Do nothing + package func setNewPassword(id: Int32, newPassword: String) async throws { + // Not used in Preview + } + + package func deleteUser(id: Int32, reassigningPostsTo newUserId: Int32) async throws { + // Not used in Preview } } diff --git a/Modules/Sources/WordPressUI/Views/Users/ViewModel/UserDeleteViewModel.swift b/Modules/Sources/WordPressUI/Views/Users/ViewModel/UserDeleteViewModel.swift index c96607a9d324..6736f1e8365c 100644 --- a/Modules/Sources/WordPressUI/Views/Users/ViewModel/UserDeleteViewModel.swift +++ b/Modules/Sources/WordPressUI/Views/Users/ViewModel/UserDeleteViewModel.swift @@ -13,7 +13,7 @@ public class UserDeleteViewModel: ObservableObject { private(set) var error: Error? = nil @Published - var otherUserId: Int32 = 0 + var selectedUser: DisplayUser? = nil @Published private(set) var otherUsers: [DisplayUser] = [] @@ -21,84 +21,49 @@ public class UserDeleteViewModel: ObservableObject { @Published private(set) var deleteButtonIsDisabled: Bool = true - private let userProvider: UserDataProvider - private let actionDispatcher: UserManagementActionDispatcher + private let userService: UserServiceProtocol let user: DisplayUser - init(user: DisplayUser, userProvider: UserDataProvider, actionDispatcher: UserManagementActionDispatcher) { + init(user: DisplayUser, userService: UserServiceProtocol) { self.user = user - self.userProvider = userProvider - self.actionDispatcher = actionDispatcher - } + self.userService = userService - func fetchOtherUsers() async { - withAnimation { - isFetchingOtherUsers = true - deleteButtonIsDisabled = true - } + // Default `selectedUser` to be the first one in `otherUsers`. + // Using Combine here because `didSet` observers don't work with `@Published` properties. + // + // The implementation is equivalent to `if selectedUser == nil { selectedUser = otherUsers.first }` + $otherUsers.combineLatest($selectedUser) + .filter { _, selectedUser in selectedUser == nil } + .map { others, _ in others.first } + .assign(to: &$selectedUser) - do { - let otherUsers = try await userProvider.fetchUsers { self.didReceiveUsers($0) } + } - self.didReceiveUsers(otherUsers) - } catch { - withAnimation { - self.error = error - deleteButtonIsDisabled = true - } - } + func fetchOtherUsers() async { + isFetchingOtherUsers = true + deleteButtonIsDisabled = true - withAnimation { + defer { isFetchingOtherUsers = false + deleteButtonIsDisabled = otherUsers.isEmpty } - } - - func didReceiveUsers(_ users: [DisplayUser]) { - withAnimation { - if otherUserId == 0 { - otherUserId = otherUsers.first?.id ?? 0 - } - otherUsers = users + do { + let users = try await userService.fetchUsers() + self.otherUsers = users .filter { $0.id != self.user.id } // Don't allow re-assigning to yourself .sorted(using: KeyPathComparator(\.username)) - error = nil - deleteButtonIsDisabled = false - isFetchingOtherUsers = false + } catch { + self.error = error } } - func didTapDeleteUser(callback: @escaping () -> Void) { - debugPrint("Deleting \(user.username) and re-assigning their content to \(otherUserId)") + func deleteUser() async throws { + guard let otherUserId = selectedUser?.id, otherUserId != user.id else { return } - withAnimation { - error = nil - } + isDeletingUser = true + defer { isDeletingUser = false } - Task { - await MainActor.run { - withAnimation { - isDeletingUser = true - } - } - - do { - try await actionDispatcher.deleteUser(id: user.id, reassigningPostsTo: otherUserId) - } catch { - debugPrint(error.localizedDescription) - await MainActor.run { - withAnimation { - self.error = error - } - } - } - - await MainActor.run { - withAnimation { - isDeletingUser = false - callback() - } - } - } + try await userService.deleteUser(id: user.id, reassigningPostsTo: otherUserId) } } diff --git a/Modules/Sources/WordPressUI/Views/Users/ViewModel/UserDetailViewModel.swift b/Modules/Sources/WordPressUI/Views/Users/ViewModel/UserDetailViewModel.swift index e479d5740bdc..87179642d603 100644 --- a/Modules/Sources/WordPressUI/Views/Users/ViewModel/UserDetailViewModel.swift +++ b/Modules/Sources/WordPressUI/Views/Users/ViewModel/UserDetailViewModel.swift @@ -2,7 +2,7 @@ import SwiftUI @MainActor class UserDetailViewModel: ObservableObject { - private let userProvider: UserDataProvider + private let userService: UserServiceProtocol @Published private(set) var currentUserCanModifyUsers: Bool = false @@ -13,30 +13,20 @@ class UserDetailViewModel: ObservableObject { @Published private(set) var error: Error? = nil - init(userProvider: UserDataProvider) { - self.userProvider = userProvider + init(userService: UserServiceProtocol) { + self.userService = userService } func loadCurrentUserRole() async { - withAnimation { - isLoadingCurrentUser = true - } + error = nil - do { - let hasPermissions = try await userProvider.fetchCurrentUserCan("edit_users") - error = nil + isLoadingCurrentUser = true + defer { isLoadingCurrentUser = false} - withAnimation { - currentUserCanModifyUsers = hasPermissions - } + do { + currentUserCanModifyUsers = try await userService.isCurrentUserCapableOf("edit_users") } catch { - withAnimation { - self.error = error - } - } - - withAnimation { - isLoadingCurrentUser = false + self.error = error } } } diff --git a/Modules/Sources/WordPressUI/Views/Users/ViewModel/UserListViewModel.swift b/Modules/Sources/WordPressUI/Views/Users/ViewModel/UserListViewModel.swift index eb621c5cac1a..8ecab8e027d3 100644 --- a/Modules/Sources/WordPressUI/Views/Users/ViewModel/UserListViewModel.swift +++ b/Modules/Sources/WordPressUI/Views/Users/ViewModel/UserListViewModel.swift @@ -1,4 +1,5 @@ import SwiftUI +import Combine import WordPressShared @MainActor @@ -11,9 +12,13 @@ class UserListViewModel: ObservableObject { } /// The initial set of users fetched by `fetchItems` - private var users: [DisplayUser] = [] - private let userProvider: UserDataProvider - + private var users: [DisplayUser] = [] { + didSet { + sortedUsers = self.sortUsers(users) + } + } + private var updateUsersTask: Task? + private let userService: UserServiceProtocol private var initialLoad = false @Published @@ -37,43 +42,41 @@ class UserListViewModel: ObservableObject { } } - init(userProvider: UserDataProvider) { - self.userProvider = userProvider + init(userService: UserServiceProtocol) { + self.userService = userService + } + + deinit { + updateUsersTask?.cancel() } func onAppear() async { + if updateUsersTask == nil { + updateUsersTask = Task { @MainActor [weak self, usersUpdates = userService.usersUpdates] in + for await users in usersUpdates { + guard let self else { break } + + self.users = users + } + } + } + if !initialLoad { initialLoad = true await fetchItems() } } - func fetchItems() async { - withAnimation { - isLoadingItems = true - } + private func fetchItems() async { + isLoadingItems = true + defer { isLoadingItems = false } - do { - let users = try await userProvider.fetchUsers { cachedResults in - self.setUsers(cachedResults) - } - setUsers(users) - } catch { - self.error = error - isLoadingItems = false - } + _ = try? await userService.fetchUsers() } @Sendable func refreshItems() async { - do { - let users = try await userProvider.fetchUsers { cachedResults in - self.setUsers(cachedResults) - } - setUsers(users) - } catch { - // Do nothing for now – this should probably show a "Toast" notification or something - } + _ = try? await userService.fetchUsers() } func setUsers(_ newValue: [DisplayUser]) { diff --git a/Modules/Sources/WordPressUI/Views/Users/Views/UserDetailsView.swift b/Modules/Sources/WordPressUI/Views/Users/Views/UserDetailsView.swift index 5a4927751e24..39d8e0457f47 100644 --- a/Modules/Sources/WordPressUI/Views/Users/Views/UserDetailsView.swift +++ b/Modules/Sources/WordPressUI/Views/Users/Views/UserDetailsView.swift @@ -2,8 +2,7 @@ import SwiftUI struct UserDetailsView: View { - fileprivate let userProvider: UserDataProvider - fileprivate let actionDispatcher: UserManagementActionDispatcher + fileprivate let userService: UserServiceProtocol let user: DisplayUser @State private var presentPasswordAlert: Bool = false { @@ -16,7 +15,6 @@ struct UserDetailsView: View { @State fileprivate var newPasswordConfirmation: String = "" @State fileprivate var presentUserPicker: Bool = false - @State fileprivate var selectedUser: DisplayUser? = nil @State fileprivate var presentDeleteConfirmation: Bool = false @State fileprivate var presentDeleteUserError: Bool = false @@ -28,12 +26,11 @@ struct UserDetailsView: View { @Environment(\.dismiss) var dismissAction: DismissAction - init(user: DisplayUser, userProvider: UserDataProvider, actionDispatcher: UserManagementActionDispatcher) { + init(user: DisplayUser, userService: UserServiceProtocol) { self.user = user - self.userProvider = userProvider - self.actionDispatcher = actionDispatcher - _viewModel = StateObject(wrappedValue: UserDetailViewModel(userProvider: userProvider)) - _deleteUserViewModel = StateObject(wrappedValue: UserDeleteViewModel(user: user, userProvider: userProvider, actionDispatcher: actionDispatcher)) + self.userService = userService + _viewModel = StateObject(wrappedValue: UserDetailViewModel(userService: userService)) + _deleteUserViewModel = StateObject(wrappedValue: UserDeleteViewModel(user: user, userService: userService)) } var body: some View { @@ -87,7 +84,7 @@ struct UserDetailsView: View { SecureField(Strings.newPasswordConfirmationPlaceholder, text: $newPasswordConfirmation) Button(Strings.updatePasswordButton) { Task { - try await self.actionDispatcher.setNewPassword(id: user.id, newPassword: newPassword) + try await self.userService.setNewPassword(id: user.id, newPassword: newPassword) } } .disabled(newPassword.isEmpty || newPassword != newPasswordConfirmation) @@ -103,9 +100,11 @@ struct UserDetailsView: View { } ) .deleteUser(in: self) - .task { - await viewModel.loadCurrentUserRole() - await deleteUserViewModel.fetchOtherUsers() + .onAppear() { + Task { + await viewModel.loadCurrentUserRole() + await deleteUserViewModel.fetchOtherUsers() + } } } @@ -283,12 +282,15 @@ private extension View { .alert( UserDetailsView.Strings.deleteUserConfirmationTitle, isPresented: view.$presentDeleteConfirmation, - presenting: view.selectedUser, + presenting: view.deleteUserViewModel.selectedUser, actions: { attribution in Button(role: .destructive) { - Task { - view.deleteUserViewModel.didTapDeleteUser { + Task { @MainActor in + do { + try await view.deleteUserViewModel.deleteUser() view.dismissAction() + } catch { + view.presentDeleteUserError = true } } } label: { @@ -333,7 +335,7 @@ private extension View { ProgressView() } } else { - Picker(Strings.attributeContentToUserLabel, selection: view.$selectedUser) { + Picker(Strings.attributeContentToUserLabel, selection: view.$deleteUserViewModel.selectedUser) { ForEach(view.deleteUserViewModel.otherUsers) { user in Text("\(user.displayName) (\(user.username))").tag(user) } @@ -357,7 +359,7 @@ private extension View { } label: { Text(Strings.attributeContentConfirmationDeleteButton) } - .disabled(view.selectedUser == nil) + .disabled(view.deleteUserViewModel.deleteButtonIsDisabled) } } } @@ -380,6 +382,6 @@ private extension String { #Preview { NavigationStack { - UserDetailsView(user: DisplayUser.MockUser, userProvider: MockUserProvider(), actionDispatcher: UserManagementActionDispatcher()) + UserDetailsView(user: DisplayUser.MockUser, userService: MockUserProvider()) } } diff --git a/Modules/Sources/WordPressUI/Views/Users/Views/UserListView.swift b/Modules/Sources/WordPressUI/Views/Users/Views/UserListView.swift index a141e0036d60..31cb09e7775a 100644 --- a/Modules/Sources/WordPressUI/Views/Users/Views/UserListView.swift +++ b/Modules/Sources/WordPressUI/Views/Users/Views/UserListView.swift @@ -4,13 +4,11 @@ public struct UserListView: View { @StateObject private var viewModel: UserListViewModel - private let userProvider: UserDataProvider - private let actionDispatcher: UserManagementActionDispatcher + private let userService: UserServiceProtocol - public init(userProvider: UserDataProvider, actionDispatcher: UserManagementActionDispatcher) { - self.userProvider = userProvider - self.actionDispatcher = actionDispatcher - _viewModel = StateObject(wrappedValue: UserListViewModel(userProvider: userProvider)) + public init(userService: UserServiceProtocol) { + self.userService = userService + _viewModel = StateObject(wrappedValue: UserListViewModel(userService: userService)) } public var body: some View { @@ -33,7 +31,7 @@ public struct UserListView: View { .listRowBackground(Color.clear) } else { ForEach(section.users) { user in - UserListItem(user: user, userProvider: userProvider, actionDispatcher: actionDispatcher) + UserListItem(user: user, userService: userService) } } } @@ -72,18 +70,18 @@ public struct UserListView: View { #Preview("Loading") { NavigationView { - UserListView(userProvider: MockUserProvider(scenario: .infinitLoading), actionDispatcher: UserManagementActionDispatcher()) + UserListView(userService: MockUserProvider()) } } #Preview("Error") { NavigationView { - UserListView(userProvider: MockUserProvider(scenario: .error), actionDispatcher: UserManagementActionDispatcher()) + UserListView(userService: MockUserProvider(scenario: .error)) } } #Preview("List") { NavigationView { - UserListView(userProvider: MockUserProvider(scenario: .dummyData), actionDispatcher: UserManagementActionDispatcher()) + UserListView(userService: MockUserProvider(scenario: .dummyData)) } } diff --git a/WordPress/Classes/Services/UserService.swift b/WordPress/Classes/Services/UserService.swift index f57a50d26332..f684dc20b93f 100644 --- a/WordPress/Classes/Services/UserService.swift +++ b/WordPress/Classes/Services/UserService.swift @@ -1,107 +1,113 @@ import Foundation +import Combine import WordPressAPI import WordPressUI /// UserService is responsible for fetching user acounts via the .org REST API – it's the replacement for `UsersService` (the XMLRPC-based approach) /// -struct UserService { +actor UserService: UserServiceProtocol { + private let client: WordPressClient - final class ActionDispatcher: UserManagementActionDispatcher { + private var fetchUsersTask: Task<[DisplayUser], Error>? - private let client: WordPressClient - - init(client: WordPressClient) { - self.client = client - super.init() + private(set) var users: [DisplayUser]? { + didSet { + if let users { + usersUpdatesContinuation.yield(users) + } } + } + nonisolated let usersUpdates: AsyncStream<[DisplayUser]> + private nonisolated let usersUpdatesContinuation: AsyncStream<[DisplayUser]>.Continuation - override func setNewPassword(id: Int32, newPassword: String) async throws { - _ = try await client.api.users.update( - userId: Int32(id), - params: UserUpdateParams(password: newPassword) - ) - } + private var currentUser: UserWithEditContext? - override func deleteUser(id: Int32, reassigningPostsTo newUserId: Int32) async throws { - _ = try await client.api.users.delete( - userId: id, - params: UserDeleteParams(reassign: newUserId) - ) - } + init(client: WordPressClient) { + self.client = client + (usersUpdates, usersUpdatesContinuation) = AsyncStream<[DisplayUser]>.makeStream() + } + + deinit { + usersUpdatesContinuation.finish() + fetchUsersTask?.cancel() } - final actor UserCache { - var users: [DisplayUser] = [] + func fetchUsers() async throws -> [DisplayUser] { + let users = try await createFetchUsersTaskIfNeeded().value + self.users = users + return users + } - func setUsers(_ newValue: [DisplayUser]) { - self.users = newValue + private func createFetchUsersTaskIfNeeded() -> Task<[DisplayUser], Error> { + if let fetchUsersTask { + return fetchUsersTask } - - func clear() { - self.users = [] + let task = Task { [client] in + try await client + .api + .users + .listWithEditContext(params: UserListParams(perPage: 100)) + .compactMap { DisplayUser(user: $0) } } + fetchUsersTask = task + return task } - private let apiClient: WordPressClient - private let currentUserId: Int - fileprivate let userCache = UserCache() - - let actionDispatcher: ActionDispatcher + func isCurrentUserCapableOf(_ capability: String) async throws -> Bool { + let currentUser: UserWithEditContext + if let cached = self.currentUser { + currentUser = cached + } else { + currentUser = try await self.client.api.users.retrieveMeWithEditContext() + self.currentUser = currentUser + } - init(api: WordPressClient, currentUserId: Int) { - self.apiClient = api - self.currentUserId = currentUserId - self.actionDispatcher = ActionDispatcher(client: apiClient) + return currentUser.capabilities.keys.contains(capability) } -} -extension UserService: WordPressUI.UserDataProvider { + func deleteUser(id: Int32, reassigningPostsTo newUserId: Int32) async throws { + let result = try await client.api.users.delete( + userId: id, + params: UserDeleteParams(reassign: newUserId) + ) - func fetchCurrentUserCan(_ capability: String) async throws -> Bool { - try await apiClient.api.users.retrieveMeWithEditContext().capabilities.keys.contains(capability) + // Remove the deleted user from the cached users list. + if result.deleted, let index = users?.firstIndex(where: { $0.id == id }) { + users?.remove(at: index) + } } - func fetchUsers(cachedResults: CachedUserListCallback? = nil) async throws -> [WordPressUI.DisplayUser] { - - if await !userCache.users.isEmpty { - await cachedResults?(await userCache.users) - } + func setNewPassword(id: Int32, newPassword: String) async throws { + _ = try await client.api.users.update( + userId: Int32(id), + params: UserUpdateParams(password: newPassword) + ) + } - let users: [DisplayUser] = try await apiClient - .api - .users - .listWithEditContext(params: UserListParams(perPage: 100)) - .compactMap { - - guard let role = $0.roles.first else { - return nil - } - - return DisplayUser( - id: $0.id, - handle: $0.slug, - username: $0.username, - firstName: $0.firstName, - lastName: $0.lastName, - displayName: $0.name, - profilePhotoUrl: profilePhotoUrl(for: $0), - role: role, - emailAddress: $0.email, - websiteUrl: $0.link, - biography: $0.description - ) - } - - await userCache.setUsers(users) +} - return users - } +private extension DisplayUser { + init?(user: UserWithEditContext) { + guard let role = user.roles.first else { + return nil + } - func invalidateCaches() async throws { - await userCache.clear() + self.init( + id: user.id, + handle: user.slug, + username: user.username, + firstName: user.firstName, + lastName: user.lastName, + displayName: user.name, + profilePhotoUrl: Self.profilePhotoUrl(for: user), + role: role, + emailAddress: user.email, + websiteUrl: user.link, + biography: user.description + ) } - func profilePhotoUrl(for user: UserWithEditContext) -> URL? { + static func profilePhotoUrl(for user: UserWithEditContext) -> URL? { // The key is the size of the avatar. Get the largetst one, which is 96x96px. // https://github.com/WordPress/wordpress-develop/blob/6.6.2/src/wp-includes/rest-api.php#L1253-L1260 guard let url = user.avatarUrls? diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift index 0d2ae83ea00f..65fe07c64dd6 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift @@ -159,14 +159,14 @@ extension BlogDetailsViewController { } @objc func showUsers() { - guard let presentationDelegate, let userId = self.blog.userID?.intValue else { + guard let presentationDelegate else { return } let feature = NSLocalizedString("applicationPasswordRequired.feature.users", value: "User Management", comment: "Feature name for managing users in the app") let rootView = ApplicationPasswordRequiredView(blog: self.blog, localizedFeatureName: feature) { client in - let service = UserService(api: client, currentUserId: userId) - return UserListView(userProvider: service, actionDispatcher: service.actionDispatcher) + let service = UserService(client: client) + return UserListView(userService: service) } presentationDelegate.presentBlogDetailsViewController(UIHostingController(rootView: rootView)) } diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index a09fd05df2b9..4f5c29b75ea8 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -454,6 +454,7 @@ 4A76A4BB29D4381100AABF4B /* CommentService+LikesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A76A4BA29D4381000AABF4B /* CommentService+LikesTests.swift */; }; 4A76A4BD29D43BFD00AABF4B /* CommentService+MorderationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A76A4BC29D43BFD00AABF4B /* CommentService+MorderationTests.swift */; }; 4A76A4BF29D4F0A500AABF4B /* reader-post-comments-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 4A76A4BE29D4F0A500AABF4B /* reader-post-comments-success.json */; }; + 4A76F9112CE207FA0025F7FA /* UserServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A76F9102CE207FA0025F7FA /* UserServiceTests.swift */; }; 4A9314DC297790C300360232 /* PeopleServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9314DB297790C300360232 /* PeopleServiceTests.swift */; }; 4A9948E229714EF1006282A9 /* AccountSettingsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9948E129714EF1006282A9 /* AccountSettingsServiceTests.swift */; }; 4AA7EE0F2ADF7367007D261D /* PostRepositoryPostsListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA7EE0E2ADF7367007D261D /* PostRepositoryPostsListTests.swift */; }; @@ -2340,6 +2341,7 @@ 4A76A4BA29D4381000AABF4B /* CommentService+LikesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CommentService+LikesTests.swift"; sourceTree = ""; }; 4A76A4BC29D43BFD00AABF4B /* CommentService+MorderationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CommentService+MorderationTests.swift"; sourceTree = ""; }; 4A76A4BE29D4F0A500AABF4B /* reader-post-comments-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "reader-post-comments-success.json"; sourceTree = ""; }; + 4A76F9102CE207FA0025F7FA /* UserServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserServiceTests.swift; sourceTree = ""; }; 4A9314DB297790C300360232 /* PeopleServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeopleServiceTests.swift; sourceTree = ""; }; 4A98A9022C2274E100A2CE58 /* WordPressKitTests.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = WordPressKitTests.xcconfig; sourceTree = ""; }; 4A98A9032C2274E100A2CE58 /* WordPressKit.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = WordPressKit.xcconfig; sourceTree = ""; }; @@ -6407,6 +6409,7 @@ 4A9314DB297790C300360232 /* PeopleServiceTests.swift */, FEAA6F78298CE4A600ADB44C /* PluginJetpackProxyServiceTests.swift */, 1D91080629F847A2003F9A5E /* MediaServiceUpdateTests.m */, + 4A76F9102CE207FA0025F7FA /* UserServiceTests.swift */, ); name = Services; sourceTree = ""; @@ -10190,6 +10193,7 @@ 4AB6A3602B7C3EB500769115 /* PinghubWebSocketTests.swift in Sources */, F46546352AF550A20017E3D1 /* AllDomainsListItem+Helpers.swift in Sources */, FEA312842987FB0100FFD405 /* BlogJetpackTests.swift in Sources */, + 4A76F9112CE207FA0025F7FA /* UserServiceTests.swift in Sources */, 0C35FFF429CBA6DA00D224EB /* BlogDashboardPersonalizationViewModelTests.swift in Sources */, 7E8980B922E73F4000C567B0 /* EditorSettingsServiceTests.swift in Sources */, 1797373720EBAA4100377B4E /* RouteMatcherTests.swift in Sources */, diff --git a/WordPress/WordPressTest/UserServiceTests.swift b/WordPress/WordPressTest/UserServiceTests.swift new file mode 100644 index 000000000000..a00a4650f3ff --- /dev/null +++ b/WordPress/WordPressTest/UserServiceTests.swift @@ -0,0 +1,124 @@ +import Foundation +import XCTest +import OHHTTPStubs +import OHHTTPStubsSwift +import Combine +import WordPressUI + +@testable import WordPress + +class UserServiceTests: XCTestCase { + var service: UserService! + + override func setUpWithError() throws { + try super.setUpWithError() + + let client = try WordPressClient(api: .init(urlSession: .shared, baseUrl: .parse(input: "https://example.com"), authenticationStategy: .none), rootUrl: .parse(input: "https://example.com")) + service = UserService(client: client) + } + + override func tearDown() { + super.tearDown() + + HTTPStubs.removeAllStubs() + } + + func testMultipleFetchUsersTriggerOneUpdate() async throws { + stubSuccessfullUsersFetch() + + let expectation = XCTestExpectation(description: "Updated after fetch") + let task = Task.detached { [self] in + for await _ in self.service.usersUpdates { + expectation.fulfill() + } + } + + _ = try await [ + self.service.fetchUsers(), + self.service.fetchUsers(), + self.service.fetchUsers(), + self.service.fetchUsers(), + self.service.fetchUsers() + ] + + await fulfillment(of: [expectation], timeout: 0.3) + task.cancel() + } + + func testSequentialFetchUsersTriggerOneUpdateForEachFetch() async throws { + stubSuccessfullUsersFetch() + + let expectation = XCTestExpectation(description: "Updated after fetch") + expectation.expectedFulfillmentCount = 5 + let task = Task.detached { [self] in + for await _ in self.service.usersUpdates { + expectation.fulfill() + } + } + + for _ in 1...expectation.expectedFulfillmentCount { + _ = try await service.fetchUsers() + } + + await fulfillment(of: [expectation], timeout: 0.3) + + task.cancel() + } + + func testStreamTerminates() async throws { + stubSuccessfullUsersFetch() + + let termination = XCTestExpectation(description: "Stream has finished") + let task = Task.detached { [self] in + for await _ in self.service.usersUpdates { + // Do nothing + } + termination.fulfill() + } + + _ = try await service.fetchUsers() + _ = try await service.fetchUsers() + _ = try await service.fetchUsers() + + // Stream should be terminated once `service` is deallocated. + service = nil + + await fulfillment(of: [termination], timeout: 0.3) + + task.cancel() + } + + func testDeleteUserTriggersUsersUpdate() async throws { + stubSuccessfullUsersFetch() + stubDeleteUser(id: 34) + + _ = try await service.fetchUsers() + let userFetched = await service.users?.contains { $0.id == 34 } == true + XCTAssertTrue(userFetched) + + try await service.deleteUser(id: 34, reassigningPostsTo: 1) + let userDeleted = await service.users?.contains { $0.id == 34 } == false + XCTAssertTrue(userDeleted) + } + + private func stubSuccessfullUsersFetch() { + stub(condition: isPath("/wp-json/wp/v2/users")) { _ in + let json = #"[{"id":1,"username":"demo","name":"demo","first_name":"","last_name":"","email":"tony.li@automattic.com","url":"https:\/\/yellow-lemming-rail.jurassic.ninja","description":"","link":"https:\/\/yellow-lemming-rail.jurassic.ninja\/author\/demo\/","locale":"en_US","nickname":"demo","slug":"demo","roles":["administrator"],"registered_date":"2024-11-03T21:43:36+00:00","capabilities":{"switch_themes":true,"edit_themes":true,"activate_plugins":true,"edit_plugins":true,"edit_users":true,"edit_files":true,"manage_options":true,"moderate_comments":true,"manage_categories":true,"manage_links":true,"upload_files":true,"import":true,"unfiltered_html":true,"edit_posts":true,"edit_others_posts":true,"edit_published_posts":true,"publish_posts":true,"edit_pages":true,"read":true,"level_10":true,"level_9":true,"level_8":true,"level_7":true,"level_6":true,"level_5":true,"level_4":true,"level_3":true,"level_2":true,"level_1":true,"level_0":true,"edit_others_pages":true,"edit_published_pages":true,"publish_pages":true,"delete_pages":true,"delete_others_pages":true,"delete_published_pages":true,"delete_posts":true,"delete_others_posts":true,"delete_published_posts":true,"delete_private_posts":true,"edit_private_posts":true,"read_private_posts":true,"delete_private_pages":true,"edit_private_pages":true,"read_private_pages":true,"delete_users":true,"create_users":true,"unfiltered_upload":true,"edit_dashboard":true,"update_plugins":true,"delete_plugins":true,"install_plugins":true,"update_themes":true,"install_themes":true,"update_core":true,"list_users":true,"remove_users":true,"promote_users":true,"edit_theme_options":true,"delete_themes":true,"export":true,"administrator":true},"extra_capabilities":{"administrator":true},"avatar_urls":{"24":"https:\/\/secure.gravatar.com\/avatar\/ac05cde1cb014070c625b139e53a2899?s=24&d=mm&r=g","48":"https:\/\/secure.gravatar.com\/avatar\/ac05cde1cb014070c625b139e53a2899?s=48&d=mm&r=g","96":"https:\/\/secure.gravatar.com\/avatar\/ac05cde1cb014070c625b139e53a2899?s=96&d=mm&r=g"},"meta":{"persisted_preferences":{"core":{"isComplementaryAreaVisible":true},"core\/edit-post":{"welcomeGuide":false},"_modified":"2024-11-06T09:23:14.009Z"}},"_links":{"self":[{"href":"https:\/\/yellow-lemming-rail.jurassic.ninja\/wp-json\/wp\/v2\/users\/1"}],"collection":[{"href":"https:\/\/yellow-lemming-rail.jurassic.ninja\/wp-json\/wp\/v2\/users"}]}},{"id":34,"username":"user_1810","name":"User_2718","first_name":"","last_name":"","email":"user_5880@example.com","url":"","description":"","link":"https://yellow-lemming-rail.jurassic.ninja/author/user_1810/","locale":"en_US","nickname":"user_1810","slug":"user_1810","roles":["editor"],"registered_date":"2024-11-05T22:58:27+00:00","capabilities":{"moderate_comments":true,"manage_categories":true,"manage_links":true,"upload_files":true,"unfiltered_html":true,"edit_posts":true,"edit_others_posts":true,"edit_published_posts":true,"publish_posts":true,"edit_pages":true,"read":true,"level_7":true,"level_6":true,"level_5":true,"level_4":true,"level_3":true,"level_2":true,"level_1":true,"level_0":true,"edit_others_pages":true,"edit_published_pages":true,"publish_pages":true,"delete_pages":true,"delete_others_pages":true,"delete_published_pages":true,"delete_posts":true,"delete_others_posts":true,"delete_published_posts":true,"delete_private_posts":true,"edit_private_posts":true,"read_private_posts":true,"delete_private_pages":true,"edit_private_pages":true,"read_private_pages":true,"editor":true},"extra_capabilities":{"editor":true},"avatar_urls":{"24":"https://secure.gravatar.com/avatar/e1cf0e88fb26697102e09f3aa1fd40c7?s=24&d=mm&r=g","48":"https://secure.gravatar.com/avatar/e1cf0e88fb26697102e09f3aa1fd40c7?s=48&d=mm&r=g","96":"https://secure.gravatar.com/avatar/e1cf0e88fb26697102e09f3aa1fd40c7?s=96&d=mm&r=g"},"meta":{"persisted_preferences":[]}}]"# + return HTTPStubsResponse(data: json.data(using: .utf8)!, statusCode: 200, headers: ["Content-Type": "application/json"]) + } + } + + private func stubFailedUsersFetch() { + stub(condition: isPath("/wp-json/wp/v2/users")) { _ in + HTTPStubsResponse(error: URLError(.timedOut)) + } + } + + private func stubDeleteUser(id: Int32) { + stub(condition: isPath("/wp-json/wp/v2/users/\(id)")) { _ in + let json = #"{"deleted":true,"previous":{"id":34,"username":"user_1810","name":"User_2718","first_name":"","last_name":"","email":"user_5880@example.com","url":"","description":"","link":"https://yellow-lemming-rail.jurassic.ninja/author/user_1810/","locale":"en_US","nickname":"user_1810","slug":"user_1810","roles":["editor"],"registered_date":"2024-11-05T22:58:27+00:00","capabilities":{"moderate_comments":true,"manage_categories":true,"manage_links":true,"upload_files":true,"unfiltered_html":true,"edit_posts":true,"edit_others_posts":true,"edit_published_posts":true,"publish_posts":true,"edit_pages":true,"read":true,"level_7":true,"level_6":true,"level_5":true,"level_4":true,"level_3":true,"level_2":true,"level_1":true,"level_0":true,"edit_others_pages":true,"edit_published_pages":true,"publish_pages":true,"delete_pages":true,"delete_others_pages":true,"delete_published_pages":true,"delete_posts":true,"delete_others_posts":true,"delete_published_posts":true,"delete_private_posts":true,"edit_private_posts":true,"read_private_posts":true,"delete_private_pages":true,"edit_private_pages":true,"read_private_pages":true,"editor":true},"extra_capabilities":{"editor":true},"avatar_urls":{"24":"https://secure.gravatar.com/avatar/e1cf0e88fb26697102e09f3aa1fd40c7?s=24&d=mm&r=g","48":"https://secure.gravatar.com/avatar/e1cf0e88fb26697102e09f3aa1fd40c7?s=48&d=mm&r=g","96":"https://secure.gravatar.com/avatar/e1cf0e88fb26697102e09f3aa1fd40c7?s=96&d=mm&r=g"},"meta":{"persisted_preferences":[]}}}"# + return HTTPStubsResponse(data: json.data(using: .utf8)!, statusCode: 200, headers: ["Content-Type": "application/json"]) + } + + } +}