Files
element-x-ios/UnitTests/Sources/HealthChat/HealthChatAPIServiceTests.swift
T
joel.poyet 00f08ab2dc Add HealthChat API and extended profile services (#9)
Expose OpenID tokens and homeserver credentials from the client proxy, and
add fork-only services for the HealthChat member directory backend
(api.healthchat.ch) plus a service that reads MSC4133 extended profile
fields directly from the homeserver. Groundwork for the members tab and
the profile screens.

Claude-Session: https://claude.ai/code/session_013qdTbAWsZj2quQzEkagXaS
2026-09-22 18:21:58 +02:00

252 lines
8.8 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 Synchronization
import Testing
final class HealthChatAPIServiceTests {
private let tokenProvider = HealthChatOpenIDTokenProviderMock()
private let service: HealthChatAPIService
init() {
MockAPIURLProtocol.reset()
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockAPIURLProtocol.self]
service = HealthChatAPIService(baseURL: "https://api.test",
tokenProvider: tokenProvider,
urlSession: URLSession(configuration: configuration))
tokenProvider.validTokenReturnValue = .success(MockAPIURLProtocol.validToken)
}
@Test
func membersRequestsCorrectURL() async throws {
let result = await service.members(searchQuery: nil)
let response = try result.get()
#expect(response.users.map(\.userID) == ["@alice:healthchat.ch"])
#expect(response.isApproved)
let request = try #require(MockAPIURLProtocol.lastRequest)
#expect(request.httpMethod == "GET")
#expect(request.url?.path() == "/api/usermatrix/users")
#expect(request.url?.query() == nil)
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(MockAPIURLProtocol.validToken)")
}
@Test
func membersAppendsSearchQuery() async throws {
_ = await service.members(searchQuery: "Zürich")
let url = try #require(MockAPIURLProtocol.lastRequest?.url)
let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
#expect(queryItems == [URLQueryItem(name: "search", value: "Zürich")])
}
@Test
func ownProfileRequestsCorrectURL() async throws {
let result = await service.ownProfile()
let member = try result.get()
#expect(member.userID == "@me:healthchat.ch")
let request = try #require(MockAPIURLProtocol.lastRequest)
#expect(request.httpMethod == "GET")
#expect(request.url?.path() == "/api/usermatrix/profile")
}
@Test
func updateOwnProfileSendsPartialJSONBody() async throws {
var update = HealthChatProfileUpdate()
update.businessTel = "+41 44 123 45 67"
update.specialty = "Physiotherapie"
let result = await service.updateOwnProfile(update)
try result.get()
let request = try #require(MockAPIURLProtocol.lastRequest)
#expect(request.httpMethod == "PUT")
#expect(request.url?.path() == "/api/usermatrix/profile")
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
let body = try #require(MockAPIURLProtocol.lastRequestBody)
let object = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(object.keys.sorted() == ["businessTel", "specialty"])
}
@Test
func profileCompletenessRequestsCorrectURL() async throws {
let result = await service.profileCompleteness()
let completeness = try result.get()
#expect(!completeness.isComplete)
#expect(MockAPIURLProtocol.lastRequest?.url?.path() == "/api/usermatrix/profile/completeness")
}
@Test
func inviteMemberSendsEmailAndPersonalNote() async throws {
let result = await service.inviteMember(email: "new@example.ch", personalNote: "Bitte tritt uns bei!")
try result.get()
let request = try #require(MockAPIURLProtocol.lastRequest)
#expect(request.httpMethod == "POST")
#expect(request.url?.path() == "/api/usermatrix/invite")
let body = try #require(MockAPIURLProtocol.lastRequestBody)
let object = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(object["email"] as? String == "new@example.ch")
#expect(object["personalNote"] as? String == "Bitte tritt uns bei!")
}
@Test
func retriesOnceWithFreshTokenAfter401() async {
let tokenRequests = Mutex(0)
tokenProvider.validTokenClosure = {
let count = tokenRequests.withLock { count in
count += 1
return count
}
return .success(count == 1 ? "staleToken" : MockAPIURLProtocol.validToken)
}
let result = await service.members(searchQuery: nil)
#expect((try? result.get()) != nil)
#expect(tokenProvider.invalidateTokenCallsCount == 1)
#expect(tokenRequests.withLock { $0 } == 2)
}
@Test
func persistent401FailsAsUnauthorized() async {
tokenProvider.validTokenReturnValue = .success("staleToken")
let result = await service.members(searchQuery: nil)
#expect(result == .failure(.unauthorized))
#expect(tokenProvider.invalidateTokenCallsCount == 1)
}
@Test
func tokenFailurePropagatesWithoutRequest() async {
tokenProvider.validTokenReturnValue = .failure(.tokenAcquisitionFailed)
let result = await service.members(searchQuery: nil)
#expect(result == .failure(.tokenAcquisitionFailed))
#expect(MockAPIURLProtocol.lastRequest == nil)
}
}
// MARK: - MockAPIURLProtocol
private nonisolated class MockAPIURLProtocol: URLProtocol {
static let validToken = "freshOpenIDToken"
private struct Captured {
var request: URLRequest?
var body: Data?
}
private static let captured: Mutex<Captured> = .init(.init())
static var lastRequest: URLRequest? {
captured.withLock { $0.request }
}
static var lastRequestBody: Data? {
captured.withLock { $0.body }
}
static func reset() {
captured.withLock { $0 = .init() }
}
private static let membersJSON = """
{
"users": [{ "userId": "@alice:healthchat.ch" }],
"isApproved": true
}
"""
private static let profileJSON = """
{ "userId": "@me:healthchat.ch" }
"""
private static let responses: [String: (Int, Data)] = [
"/api/usermatrix/users": (200, Data(membersJSON.utf8)),
"/api/usermatrix/profile": (200, Data(profileJSON.utf8)),
"/api/usermatrix/profile/completeness": (200, Data(#"{ "isComplete": false, "missingFields": [], "message": "", "hasProfilePhoto": false }"#.utf8)),
"/api/usermatrix/invite": (200, Data(#"{ "message": "ok" }"#.utf8))
]
override func startLoading() {
let body = Self.bodyData(of: request)
MockAPIURLProtocol.captured.withLock {
$0.request = request
$0.body = body
}
guard let url = request.url else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
return
}
let isAuthenticated = request.value(forHTTPHeaderField: "Authorization") == "Bearer \(MockAPIURLProtocol.validToken)"
let (statusCode, data) = isAuthenticated ? MockAPIURLProtocol.responses[url.path()] ?? (404, Data()) : (401, Data())
guard let response = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil) else {
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
return
}
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() { }
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override class func canInit(with request: URLRequest) -> Bool {
true
}
/// `URLProtocol` never sees `httpBody` for data tasks — the body only arrives as a stream.
private static func bodyData(of request: URLRequest) -> Data? {
if let body = request.httpBody {
return body
}
guard let stream = request.httpBodyStream else {
return nil
}
stream.open()
defer { stream.close() }
var data = Data()
let bufferSize = 4096
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
defer { buffer.deallocate() }
while stream.hasBytesAvailable {
let read = stream.read(buffer, maxLength: bufferSize)
guard read > 0 else { break }
data.append(buffer, count: read)
}
return data
}
}