Files
element-x-ios/UnitTests/Sources/HealthChat/HealthChatMemberDirectoryScreenViewModelTests.swift
T
Sedat Qaja b70e77f7c5 feat(healthchat): show default contacts while approval is pending
An unapproved account only gets the server's default contacts, which are
its way to reach support — list them instead of discarding them, and hide
the search field that cannot return anything.
2026-10-09 14:23:39 +02:00

260 lines
11 KiB
Swift

//
// 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 showsPendingApprovalMessageWithTheDefaultContacts() async throws {
apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@natascha.katumba:healthchat.ch"],
isApproved: false,
pendingMessage: "Ihr Konto wird geprüft"))
let viewModel = makeViewModel()
let deferred = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in
guard case .pendingApproval(let message, let members) = content else { return false }
return message == "Ihr Konto wird geprüft" && members.map(\.userID) == ["@natascha.katumba:healthchat.ch"]
}
try await deferred.fulfill()
}
@Test
func refreshPicksUpAnApprovalWithoutClearingTheList() async throws {
apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@natascha.katumba:healthchat.ch"],
isApproved: false,
pendingMessage: "Ihr Konto wird geprüft"))
let viewModel = makeViewModel()
let deferredPending = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in
guard case .pendingApproval = content else { return false }
return true
}
try await deferredPending.fulfill()
apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"]))
let deferredMembers = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in
guard case .members(let members) = content else { return false }
return members.map(\.userID) == ["@alice:healthchat.ch"]
}
viewModel.context.send(viewAction: .refresh)
try await deferredMembers.fulfill()
#expect(viewModel.context.viewState.isApproved)
}
@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 pendingApprovalDisablesSearchingAndInviting() async throws {
apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: [],
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],
isApproved: Bool = true,
pendingMessage: String? = nil) throws -> HealthChatMembersResponse {
try .init(users: userIDs.map(makeMember(userID:)), isApproved: isApproved, pendingMessage: pendingMessage)
}
private func makeMember(userID: String) throws -> HealthChatMember {
try JSONDecoder().decode(HealthChatMember.self, from: Data(#"{ "userId": "\#(userID)" }"#.utf8))
}
}