mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
00f08ab2dc
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
252 lines
8.8 KiB
Swift
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
|
|
}
|
|
}
|