// // 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 = .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.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 } }