mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user