// // Copyright 2026 Element Creations Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // @testable import ElementX import Foundation import Testing final class HealthChatMemberDirectoryScreenViewModelTests { private let apiService = HealthChatAPIServiceMock() private let clientProxy = ClientProxyMock(.init()) init() throws { apiService.profileCompletenessReturnValue = try .success(makeCompleteness(isComplete: true, message: nil)) } @Test func loadsMembersOnStart() async throws { apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) let viewModel = makeViewModel() let deferred = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in guard case .members(let members) = content else { return false } return members.map(\.userID) == ["@alice:healthchat.ch"] } try await deferred.fulfill() } @Test func showsPendingApprovalMessage() async throws { apiService.membersSearchQueryReturnValue = .success(.init(users: [], isApproved: false, pendingMessage: "Ihr Konto wird geprüft")) let viewModel = makeViewModel() let deferred = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in content == .pendingApproval(message: "Ihr Konto wird geprüft") } try await deferred.fulfill() } @Test func showsFailureAndRecoversOnRetry() async throws { apiService.membersSearchQueryReturnValue = .failure(.networkFailure) let viewModel = makeViewModel() let deferredFailure = deferFulfillment(viewModel.context.observe(\.viewState.content)) { $0 == .failure } try await deferredFailure.fulfill() apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) let deferredMembers = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in guard case .members = content else { return false } return true } viewModel.context.send(viewAction: .retry) try await deferredMembers.fulfill() } @Test func searchQueryTriggersServerSearch() async throws { let allMembers = try makeResponse(userIDs: ["@alice:healthchat.ch"]) let searchResults = try makeResponse(userIDs: ["@zoe:healthchat.ch"]) apiService.membersSearchQueryClosure = { searchQuery in searchQuery == "Zürich" ? .success(searchResults) : .success(allMembers) } let viewModel = makeViewModel() let deferred = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in guard case .members(let members) = content else { return false } return members.map(\.userID) == ["@zoe:healthchat.ch"] } viewModel.context.searchQuery = "Zürich" try await deferred.fulfill() #expect(apiService.membersSearchQueryReceivedInvocations.last == "Zürich") } @Test func startChatWithExistingDMPresentsRoom() async throws { apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) clientProxy.directRoomForUserIDReturnValue = .success("existingRoom") let viewModel = makeViewModel() let deferred = deferFulfillment(viewModel.actionsPublisher) { action in guard case .presentRoom(let roomID) = action else { return false } return roomID == "existingRoom" } try viewModel.context.send(viewAction: .startChat(makeMember(userID: "@alice:healthchat.ch"))) try await deferred.fulfill() } @Test func startChatWithoutDMCreatesOne() async throws { apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) clientProxy.directRoomForUserIDReturnValue = .success(nil) clientProxy.createDirectRoomWithExpectedRoomNameReturnValue = .success("newRoom") let viewModel = makeViewModel() let deferred = deferFulfillment(viewModel.actionsPublisher) { action in guard case .presentRoom(let roomID) = action else { return false } return roomID == "newRoom" } try viewModel.context.send(viewAction: .startChat(makeMember(userID: "@alice:healthchat.ch"))) try await deferred.fulfill() #expect(clientProxy.createDirectRoomWithExpectedRoomNameReceivedArguments?.userID == "@alice:healthchat.ch") } @Test func selectMemberEmitsAction() async throws { apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) let viewModel = makeViewModel() let deferred = deferFulfillment(viewModel.actionsPublisher) { action in guard case .selectMember(let userID) = action else { return false } return userID == "@alice:healthchat.ch" } try viewModel.context.send(viewAction: .selectMember(makeMember(userID: "@alice:healthchat.ch"))) try await deferred.fulfill() } @Test func showsCompletenessHintWhenProfileIsIncomplete() async throws { apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) apiService.profileCompletenessReturnValue = try .success(makeCompleteness(isComplete: false, message: "Profil unvollständig")) let viewModel = makeViewModel() let deferred = deferFulfillment(viewModel.context.observe(\.viewState.completenessMessage)) { $0 == "Profil unvollständig" } try await deferred.fulfill() #expect(viewModel.context.viewState.isApproved) } @Test func hidesCompletenessHintWhenProfileIsComplete() async throws { apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) let viewModel = makeViewModel() let deferred = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in guard case .members = content else { return false } return true } try await deferred.fulfill() #expect(viewModel.context.viewState.completenessMessage == nil) } @Test func pendingApprovalDisablesInviting() async throws { apiService.membersSearchQueryReturnValue = .success(.init(users: [], isApproved: false, pendingMessage: "Ihr Konto wird geprüft")) let viewModel = makeViewModel() let deferred = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in guard case .pendingApproval = content else { return false } return true } try await deferred.fulfill() #expect(!viewModel.context.viewState.isApproved) } @Test func editProfileEmitsAction() async throws { apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: [])) let viewModel = makeViewModel() let deferred = deferFulfillment(viewModel.actionsPublisher) { action in guard case .editProfile = action else { return false } return true } viewModel.context.send(viewAction: .editProfile) try await deferred.fulfill() } @Test func inviteEmitsAction() async throws { apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: [])) let viewModel = makeViewModel() let deferred = deferFulfillment(viewModel.actionsPublisher) { action in guard case .invite = action else { return false } return true } viewModel.context.send(viewAction: .invite) try await deferred.fulfill() } // MARK: - Helpers private func makeCompleteness(isComplete: Bool, message: String?) throws -> HealthChatProfileCompleteness { let messageJSON = message.map { "\"\($0)\"" } ?? "null" return try JSONDecoder().decode(HealthChatProfileCompleteness.self, from: Data(""" { "isComplete": \(isComplete), "message": \(messageJSON) } """.utf8)) } private func makeViewModel() -> HealthChatMemberDirectoryScreenViewModel { HealthChatMemberDirectoryScreenViewModel(apiService: apiService, clientProxy: clientProxy, mediaProvider: MediaProviderMock(.init()), userIndicatorController: UserIndicatorControllerMock()) } private func makeResponse(userIDs: [String]) throws -> HealthChatMembersResponse { try .init(users: userIDs.map(makeMember(userID:)), isApproved: true, pendingMessage: nil) } private func makeMember(userID: String) throws -> HealthChatMember { try JSONDecoder().decode(HealthChatMember.self, from: Data(#"{ "userId": "\#(userID)" }"#.utf8)) } }