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
This commit is contained in:
2026-09-22 16:56:20 +02:00
committed by Sedat Qaja
parent ea07f76c60
commit e5bfc3a26d
19 changed files with 1807 additions and 0 deletions
@@ -0,0 +1,117 @@
//
// 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 ExtendedProfileServiceTests {
private let clientProxy = ClientProxyMock(.init())
private let service: ExtendedProfileService
init() {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockProfileURLProtocol.self]
service = ExtendedProfileService(clientProxy: clientProxy,
urlSession: URLSession(configuration: configuration))
clientProxy.homeserverCredentialsReturnValue = .success(.init(homeserverURL: "https://matrix.example.com",
accessToken: MockProfileURLProtocol.accessToken))
}
@Test
func fetchesProfileWithBearerToken() async throws {
let result = await service.profile(for: "@alice:healthchat.ch")
let profile = try result.get()
#expect(profile.displayName == "Dr. Alice Alpha")
#expect(profile.businessTel == "+41 44 123 45 67")
let request = try #require(MockProfileURLProtocol.lastRequest)
#expect(request.url?.path(percentEncoded: false) == "/_matrix/client/v3/profile/@alice:healthchat.ch")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(MockProfileURLProtocol.accessToken)")
}
@Test
func missingCredentialsFailWithoutRequest() async {
clientProxy.homeserverCredentialsReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
let result = await service.profile(for: "@alice:healthchat.ch")
#expect(result == .failure(.missingCredentials))
}
@Test
func unknownUserFailsWithHTTPStatus() async {
let result = await service.profile(for: "@unknown:healthchat.ch")
#expect(result == .failure(.httpStatus(404)))
}
@Test
func credentialsDescriptionRedactsAccessToken() {
let credentials = HomeserverCredentials(homeserverURL: "https://matrix.example.com",
accessToken: "supersecret")
#expect(!String(describing: credentials).contains("supersecret"))
}
}
// MARK: - MockProfileURLProtocol
private nonisolated class MockProfileURLProtocol: URLProtocol {
static let accessToken = "testAccessToken"
private static let _lastRequest: Mutex<URLRequest?> = .init(nil)
static var lastRequest: URLRequest? {
_lastRequest.withLock { $0 }
}
private static let profileJSON = """
{
"displayname": "Dr. Alice Alpha",
"io.element.profile.business_tel": "+41 44 123 45 67"
}
"""
private static let responses: [String: (Int, Data)] = [
"/_matrix/client/v3/profile/@alice:healthchat.ch": (200, Data(profileJSON.utf8))
]
override func startLoading() {
MockProfileURLProtocol._lastRequest.withLock { $0 = request }
guard let url = request.url else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
return
}
let isAuthenticated = request.value(forHTTPHeaderField: "Authorization") == "Bearer \(MockProfileURLProtocol.accessToken)"
let path = url.path(percentEncoded: false)
let (statusCode, data) = isAuthenticated ? MockProfileURLProtocol.responses[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
}
}
@@ -0,0 +1,149 @@
//
// 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 ExtendedProfileTests {
@Test
func decodesFullProfile() throws {
let json = """
{
"displayname": "Dr. Alice Alpha",
"avatar_url": "mxc://healthchat.ch/abc123",
"io.element.profile.title": "Dr. med.",
"io.element.profile.first_name": "Alice",
"io.element.profile.last_name": "Alpha",
"io.element.profile.specialization": "Physiotherapie",
"io.element.profile.practice_name": "Praxis Alpha",
"io.element.profile.further_info": "Multi\\nline",
"io.element.profile.business_email": "praxis@example.ch",
"io.element.profile.business_tel": "+41 44 123 45 67",
"io.element.profile.business_street": "Friedaustrasse",
"io.element.profile.business_street_nr": "17",
"io.element.profile.business_plz": "8003",
"io.element.profile.business_city": "Zürich",
"io.element.profile.website": "https://example.ch"
}
"""
let profile = try JSONDecoder().decode(ExtendedProfile.self, from: Data(json.utf8))
#expect(profile.displayName == "Dr. Alice Alpha")
#expect(profile.avatarURL == URL(string: "mxc://healthchat.ch/abc123"))
#expect(profile.title == "Dr. med.")
#expect(profile.firstName == "Alice")
#expect(profile.lastName == "Alpha")
#expect(profile.specialty == "Physiotherapie")
#expect(profile.practiceName == "Praxis Alpha")
#expect(profile.furtherInfo == "Multi\nline")
#expect(profile.businessEmail == "praxis@example.ch")
#expect(profile.businessTel == "+41 44 123 45 67")
#expect(profile.street == "Friedaustrasse")
#expect(profile.houseNumber == "17")
#expect(profile.postalCode == "8003")
#expect(profile.city == "Zürich")
#expect(profile.website == "https://example.ch")
#expect(profile.hasContent)
}
@Test
func decodesLegacyAddressKeysAsFallback() throws {
let json = """
{
"io.element.profile.street": "Altweg",
"io.element.profile.house_number": "3",
"io.element.profile.postal_code": "9000"
}
"""
let profile = try JSONDecoder().decode(ExtendedProfile.self, from: Data(json.utf8))
#expect(profile.street == "Altweg")
#expect(profile.houseNumber == "3")
#expect(profile.postalCode == "9000")
}
@Test
func canonicalKeyPresenceWinsOverLegacyEvenWhenEmpty() throws {
let json = """
{
"io.element.profile.business_street": "",
"io.element.profile.street": "Altweg"
}
"""
let profile = try JSONDecoder().decode(ExtendedProfile.self, from: Data(json.utf8))
#expect(profile.street == nil)
}
@Test
func emptyAndNonStringValuesBecomeNil() throws {
let json = """
{
"displayname": "Alice",
"io.element.profile.title": "",
"io.element.profile.specialization": 5,
"us.cloke.msc4175.tz": "Europe/Zurich"
}
"""
let profile = try JSONDecoder().decode(ExtendedProfile.self, from: Data(json.utf8))
#expect(profile.title == nil)
#expect(profile.specialty == nil)
#expect(!profile.hasContent)
}
@Test
func hasContentWithSingleField() throws {
let json = """
{ "io.element.profile.business_tel": "+41 44 123 45 67" }
"""
let profile = try JSONDecoder().decode(ExtendedProfile.self, from: Data(json.utf8))
#expect(profile.hasContent)
}
@Test
func formattedAddressWithAllComponents() throws {
let json = """
{
"io.element.profile.business_street": "Friedaustrasse",
"io.element.profile.business_street_nr": "17",
"io.element.profile.business_plz": "8003",
"io.element.profile.business_city": "Zürich"
}
"""
let profile = try JSONDecoder().decode(ExtendedProfile.self, from: Data(json.utf8))
#expect(profile.formattedAddress == "Friedaustrasse 17, 8003 Zürich")
}
@Test
func formattedAddressWithPartialComponents() throws {
let cityOnly = try JSONDecoder().decode(ExtendedProfile.self, from: Data("""
{
"io.element.profile.business_plz": "8003",
"io.element.profile.business_city": "Zürich"
}
""".utf8))
#expect(cityOnly.formattedAddress == "8003 Zürich")
let streetOnly = try JSONDecoder().decode(ExtendedProfile.self, from: Data("""
{ "io.element.profile.business_street": "Friedaustrasse" }
""".utf8))
#expect(streetOnly.formattedAddress == "Friedaustrasse")
let empty = try JSONDecoder().decode(ExtendedProfile.self, from: Data("{}".utf8))
#expect(empty.formattedAddress == nil)
}
}
@@ -0,0 +1,173 @@
//
// 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 HealthChatAPIModelsTests {
@Test
func decodesMemberFromFullJSON() throws {
let json = """
{
"userId": "@alice:healthchat.ch",
"displayName": "Dr. Alice Alpha",
"avatarMxcUrl": "mxc://healthchat.ch/abc123",
"title": "Dr. med.",
"firstName": "Alice",
"lastName": "Alpha",
"specialty": "Physiotherapie",
"practiceName": "Praxis Alpha",
"businessCity": "Zürich",
"businessEmail": "praxis@example.ch",
"businessTel": "+41 44 123 45 67",
"furtherInfo": "Info",
"street": "Friedaustrasse",
"houseNumber": "17",
"postalCode": "8003",
"website": "https://example.ch",
"isApproved": true,
"someFutureField": { "nested": true }
}
"""
let member = try JSONDecoder().decode(HealthChatMember.self, from: Data(json.utf8))
#expect(member.userID == "@alice:healthchat.ch")
#expect(member.id == "@alice:healthchat.ch")
#expect(member.displayName == "Dr. Alice Alpha")
#expect(member.avatarURL == URL(string: "mxc://healthchat.ch/abc123"))
#expect(member.title == "Dr. med.")
#expect(member.firstName == "Alice")
#expect(member.lastName == "Alpha")
#expect(member.specialty == "Physiotherapie")
#expect(member.practiceName == "Praxis Alpha")
#expect(member.businessCity == "Zürich")
#expect(member.businessEmail == "praxis@example.ch")
#expect(member.businessTel == "+41 44 123 45 67")
#expect(member.furtherInfo == "Info")
#expect(member.street == "Friedaustrasse")
#expect(member.houseNumber == "17")
#expect(member.postalCode == "8003")
#expect(member.website == "https://example.ch")
#expect(member.isApproved)
}
@Test
func decodesMinimalMemberWithDefaults() throws {
let json = """
{ "userId": "@bob:healthchat.ch" }
"""
let member = try JSONDecoder().decode(HealthChatMember.self, from: Data(json.utf8))
#expect(member.userID == "@bob:healthchat.ch")
#expect(member.displayName == nil)
#expect(member.avatarURL == nil)
#expect(member.isApproved)
}
@Test
func emptyStringsBecomeNil() throws {
let json = """
{
"userId": "@bob:healthchat.ch",
"displayName": "",
"avatarMxcUrl": "",
"businessTel": ""
}
"""
let member = try JSONDecoder().decode(HealthChatMember.self, from: Data(json.utf8))
#expect(member.displayName == nil)
#expect(member.avatarURL == nil)
#expect(member.businessTel == nil)
}
@Test
func decodesMembersResponse() throws {
let json = """
{
"users": [{ "userId": "@alice:healthchat.ch" }],
"totalCount": 1,
"searchQuery": "",
"isApproved": true,
"pendingMessage": null
}
"""
let response = try JSONDecoder().decode(HealthChatMembersResponse.self, from: Data(json.utf8))
#expect(response.users.count == 1)
#expect(response.users.first?.userID == "@alice:healthchat.ch")
#expect(response.isApproved)
#expect(response.pendingMessage == nil)
}
@Test
func decodesPendingMembersResponse() throws {
let json = """
{
"users": [],
"isApproved": false,
"pendingMessage": "Ihr Konto wird geprüft"
}
"""
let response = try JSONDecoder().decode(HealthChatMembersResponse.self, from: Data(json.utf8))
#expect(response.users.isEmpty)
#expect(!response.isApproved)
#expect(response.pendingMessage == "Ihr Konto wird geprüft")
}
@Test
func profileUpdateEncodesOnlySetFields() throws {
var update = HealthChatProfileUpdate()
update.businessTel = "+41 44 123 45 67"
update.specialty = "Physiotherapie"
update.furtherInfo = ""
let data = try JSONEncoder().encode(update)
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(object.keys.sorted() == ["businessTel", "furtherInfo", "specialty"])
#expect(object["businessTel"] as? String == "+41 44 123 45 67")
#expect(object["specialty"] as? String == "Physiotherapie")
#expect(object["furtherInfo"] as? String == "")
}
@Test
func decodesProfileCompleteness() throws {
let json = """
{
"isComplete": false,
"missingFields": ["Fachgebiet", "Ort"],
"message": "Profil unvollständig",
"hasProfilePhoto": true
}
"""
let completeness = try JSONDecoder().decode(HealthChatProfileCompleteness.self, from: Data(json.utf8))
#expect(!completeness.isComplete)
#expect(completeness.missingFields == ["Fachgebiet", "Ort"])
#expect(completeness.message == "Profil unvollständig")
#expect(completeness.hasProfilePhoto)
}
@Test
func decodesProfileCompletenessWithDefaults() throws {
let completeness = try JSONDecoder().decode(HealthChatProfileCompleteness.self, from: Data("{}".utf8))
#expect(!completeness.isComplete)
#expect(completeness.missingFields.isEmpty)
#expect(completeness.message == nil)
#expect(!completeness.hasProfilePhoto)
}
}
@@ -0,0 +1,251 @@
//
// 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
}
}
@@ -0,0 +1,86 @@
//
// 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 HealthChatOpenIDTokenProviderTests {
private let clientProxy = ClientProxyMock(.init())
@Test
func fetchesTokenOnFirstCall() async {
clientProxy.requestOpenIDTokenReturnValue = .success(.init(accessToken: "tokenA",
matrixServerName: "healthchat.ch",
expiresIn: 3600))
let provider = HealthChatOpenIDTokenProvider(clientProxy: clientProxy)
let result = await provider.validToken()
#expect(result == .success("tokenA"))
#expect(clientProxy.requestOpenIDTokenCallsCount == 1)
}
@Test
func cachesTokenUntilExpiry() async {
clientProxy.requestOpenIDTokenReturnValue = .success(.init(accessToken: "tokenA",
matrixServerName: "healthchat.ch",
expiresIn: 3600))
let provider = HealthChatOpenIDTokenProvider(clientProxy: clientProxy)
_ = await provider.validToken()
let result = await provider.validToken()
#expect(result == .success("tokenA"))
#expect(clientProxy.requestOpenIDTokenCallsCount == 1)
}
@Test
func refreshesTokenWithinExpiryBuffer() async {
clientProxy.requestOpenIDTokenReturnValue = .success(.init(accessToken: "tokenA",
matrixServerName: "healthchat.ch",
expiresIn: 120))
var now = Date(timeIntervalSince1970: 1_000_000)
let provider = HealthChatOpenIDTokenProvider(clientProxy: clientProxy) { now }
_ = await provider.validToken()
// 59s of validity left is inside the 60s buffer and must trigger a refresh.
now = now.addingTimeInterval(61)
clientProxy.requestOpenIDTokenReturnValue = .success(.init(accessToken: "tokenB",
matrixServerName: "healthchat.ch",
expiresIn: 120))
let result = await provider.validToken()
#expect(result == .success("tokenB"))
#expect(clientProxy.requestOpenIDTokenCallsCount == 2)
}
@Test
func invalidateForcesRefetch() async {
clientProxy.requestOpenIDTokenReturnValue = .success(.init(accessToken: "tokenA",
matrixServerName: "healthchat.ch",
expiresIn: 3600))
let provider = HealthChatOpenIDTokenProvider(clientProxy: clientProxy)
_ = await provider.validToken()
provider.invalidateToken()
_ = await provider.validToken()
#expect(clientProxy.requestOpenIDTokenCallsCount == 2)
}
@Test
func failedRequestReturnsTokenAcquisitionFailure() async {
clientProxy.requestOpenIDTokenReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
let provider = HealthChatOpenIDTokenProvider(clientProxy: clientProxy)
let result = await provider.validToken()
#expect(result == .failure(.tokenAcquisitionFailed))
}
}