Add HealthChat API service layer (#1)

* feat(healthchat): add HealthChat API service layer

New features/healthchat modules: typed Retrofit client with
per-request Matrix token auth, member directory, profile and
invite services, plus test fakes and fixtures.

* test(healthchat): use runCatchingExceptions

Detekt's RunCatchingNotAllowed rule forbids plain runCatching.
This commit is contained in:
2026-08-05 14:13:00 +02:00
committed by Sedat Qaja
parent 5eee7737b9
commit 9f8a39bb74
46 changed files with 2328 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.healthchat.api"
}
dependencies {
api(libs.kotlinx.collections.immutable)
implementation(libs.coroutines.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
api(projects.libraries.matrix.api)
}
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
/**
* Typed error for all HealthChat API failures.
*
* Invariant: every failed [Result] returned by a HealthChat service carries a
* [HealthChatApiException] (never a raw Retrofit/OkHttp exception).
*
* [serverMessage] values are German texts generated by the server and are rendered verbatim;
* they can be null (e.g. a bare 403 Forbid has no body), in which case the UI falls back
* to a localized string.
*/
sealed class HealthChatApiException(
override val message: String,
override val cause: Throwable?,
) : Exception(message, cause) {
/** HTTP 401: the Matrix access token was rejected. */
data class Unauthorized(
val serverMessage: String?,
) : HealthChatApiException(serverMessage ?: "unauthorized", null)
/** HTTP 403: the calling account is not approved (yet). */
data class NotApproved(
val serverMessage: String?,
) : HealthChatApiException(serverMessage ?: "not approved", null)
/** HTTP 404: the requested resource does not exist. */
data class NotFound(
val serverMessage: String?,
) : HealthChatApiException(serverMessage ?: "not found", null)
/** Any other HTTP error with the regular `{message}` error body shape. */
data class Http(
val status: Int,
val serverMessage: String?,
) : HealthChatApiException(serverMessage ?: "HTTP $status", null)
/**
* ASP.NET model validation error (`{title, status, errors: {Field: [msg]}}`).
*
* [fieldErrors] keys are lower-cased field names (e.g. "email", "personalnote").
*/
data class Validation(
val status: Int,
val title: String?,
val fieldErrors: ImmutableMap<String, ImmutableList<String>>,
) : HealthChatApiException(title ?: "validation failed", null)
/** The request never reached the server (I/O, timeout, no token). */
data class Network(
override val cause: Throwable,
) : HealthChatApiException("network error", cause)
/** Anything else (unparseable response, mapping failure, programming error). */
data class Unexpected(
override val cause: Throwable?,
) : HealthChatApiException("unexpected error", cause)
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
/**
* Profile completeness as reported by the HealthChat API.
*
* [missingFields] contains German labels generated by the server and is rendered verbatim
* (with a best-effort localisation mapping on top).
*/
@Immutable
data class HealthChatCompleteness(
val isComplete: Boolean,
val missingFields: ImmutableList<String>,
val message: String?,
val hasProfilePhoto: Boolean,
)
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api
import io.element.android.libraries.matrix.api.core.UserId
/**
* Stateless access to the HealthChat member directory.
*
* All failures are [HealthChatApiException]s. There is no caching: every call hits the API.
*/
interface HealthChatDirectoryService {
/**
* Search the member directory. A blank [query] returns the full directory.
* Search is server-side (name, practice, city, specialty, further info).
*/
suspend fun searchMembers(query: String): Result<HealthChatMemberDirectory>
/**
* Fetch a single member by Matrix user id.
* Fails with [HealthChatApiException.NotApproved] when the calling account is not approved,
* and [HealthChatApiException.NotFound] when the user is not a directory member.
*/
suspend fun getMember(userId: UserId): Result<HealthChatMember>
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api
/**
* Invite a new member to HealthChat by email.
*
* All failures are [HealthChatApiException]s; server-side field validation surfaces as
* [HealthChatApiException.Validation] with lower-cased field keys ("email", "personalnote").
*/
interface HealthChatInviteService {
/**
* @param email the invitee's email address.
* @param personalNote a personal message, at least 10 characters (server-enforced).
* @return the German confirmation message from the server.
*/
suspend fun invite(email: String, personalNote: String): Result<String>
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId
/**
* A member of the HealthChat directory, as returned by the HealthChat API.
*
* All profile fields are nullable: the server sanitizer blanks fields in list responses,
* and members may simply not have filled them in. Blank values are normalized to null.
*/
@Immutable
data class HealthChatMember(
val userId: UserId,
val displayName: String?,
/** Avatar as a mxc:// URI, resolved by the app's regular media loading. */
val avatarUrl: String?,
val title: String?,
val firstName: String?,
val lastName: String?,
val specialty: String?,
val practiceName: String?,
val businessCity: String?,
val furtherInfo: String?,
val businessEmail: String?,
val businessTel: String?,
val street: String?,
val houseNumber: String?,
val postalCode: String?,
val website: String?,
val isApproved: Boolean,
)
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
/**
* The result of a HealthChat member directory query.
*
* When the calling account is not approved yet, [isApproved] is false, [members] only contains
* the server's default members and [pendingMessage] carries a German explanation from the server.
*/
@Immutable
data class HealthChatMemberDirectory(
val members: ImmutableList<HealthChatMember>,
val totalCount: Int,
val searchQuery: String,
val isApproved: Boolean,
val pendingMessage: String?,
)
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api
/**
* Display name fallback chain, mirroring the web client:
* displayName → "title firstName lastName" → businessEmail → [fallback].
*/
fun HealthChatMember.bestDisplayName(fallback: String): String {
displayName?.takeIf { it.isNotBlank() }?.let { return it }
val composedName = listOfNotNull(title, firstName, lastName)
.filter { it.isNotBlank() }
.joinToString(" ")
if (composedName.isNotBlank()) return composedName
businessEmail?.takeIf { it.isNotBlank() }?.let { return it }
return fallback
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api
import io.element.android.libraries.architecture.AsyncData
import kotlinx.coroutines.flow.StateFlow
/**
* Access to the own HealthChat profile.
*
* The [StateFlow]s exist so that a successful write on one screen propagates to every other
* screen observing the profile (same role as `MatrixClient.userProfile`). They are in-memory
* only, never persisted, and never used as a fallback for failed requests.
*
* All failures are [HealthChatApiException]s.
*/
interface HealthChatProfileRepository {
/** The own profile. Updated by [refreshOwnProfile] and [updateOwnProfile]. */
val ownProfile: StateFlow<AsyncData<HealthChatMember>>
/** The own profile completeness. Updated by [refreshCompleteness]. */
val completeness: StateFlow<AsyncData<HealthChatCompleteness>>
suspend fun refreshOwnProfile(): Result<HealthChatMember>
suspend fun refreshCompleteness(): Result<HealthChatCompleteness>
/**
* Apply a partial profile update ([HealthChatProfileUpdate] semantics), then re-fetch the
* profile so [ownProfile] reflects the server-derived state (e.g. the display name).
*
* @return the German confirmation message from the server.
*/
suspend fun updateOwnProfile(update: HealthChatProfileUpdate): Result<String>
/**
* Upload a new avatar (max 2 MiB, image mime types only) and return its `mxc://` URL.
* On success [ownProfile] is refreshed.
*/
suspend fun uploadAvatar(fileName: String, mimeType: String, data: ByteArray): Result<String>
/**
* Remove the current avatar. On success [ownProfile] is refreshed.
*
* @return the German confirmation message from the server.
*/
suspend fun deleteAvatar(): Result<String>
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api
/**
* A partial update of the own HealthChat profile.
*
* Semantics per field (mirrors the HealthChat API contract):
* - `null`: the field is left untouched on the server.
* - `""`: the field is cleared on the server.
* - any other value: the field is set to that value.
*
* Writing [firstName]/[lastName] makes the server re-derive the Matrix display name and
* sync it to Zitadel; the client must never call `setDisplayName` itself.
*/
data class HealthChatProfileUpdate(
val title: String? = null,
val firstName: String? = null,
val lastName: String? = null,
val specialty: String? = null,
val practiceName: String? = null,
val businessCity: String? = null,
val furtherInfo: String? = null,
val businessEmail: String? = null,
val businessTel: String? = null,
val street: String? = null,
val houseNumber: String? = null,
val postalCode: String? = null,
val website: String? = null,
) {
fun isEmpty(): Boolean = this == HealthChatProfileUpdate()
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.api
/**
* Fork-owned HealthChat strings (de/en/fr/it), maintained in the `strings_healthchat.xml`
* files under `features/healthchat/api/src/main/res`.
* They are intentionally NOT part of the Localazy workflow.
*/
typealias HealthChatStrings = R.string
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Novaloop AG
~ Copyright (c) 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.
~
~ Fork-owned HealthChat strings (German). NOT managed by Localazy.
-->
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">Ein Netzwerkfehler ist aufgetreten. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.</string>
<string name="healthchat_error_unknown">Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.</string>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Novaloop AG
~ Copyright (c) 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.
~
~ Fork-owned HealthChat strings (French). NOT managed by Localazy.
-->
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">Une erreur réseau est survenue. Veuillez vérifier votre connexion et réessayer.</string>
<string name="healthchat_error_unknown">Une erreur est survenue. Veuillez réessayer.</string>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Novaloop AG
~ Copyright (c) 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.
~
~ Fork-owned HealthChat strings (Italian). NOT managed by Localazy.
-->
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">Si è verificato un errore di rete. Controlla la connessione e riprova.</string>
<string name="healthchat_error_unknown">Qualcosa è andato storto. Riprova.</string>
</resources>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Novaloop AG
~ Copyright (c) 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.
~
~ Fork-owned HealthChat strings. NOT managed by Localazy — edit directly and keep
~ values-de/, values-fr/ and values-it/ in sync.
-->
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">A network error occurred. Please check your connection and try again.</string>
<string name="healthchat_error_unknown">Something went wrong. Please try again.</string>
</resources>
+68
View File
@@ -0,0 +1,68 @@
import extension.buildConfigFieldStr
import extension.readLocalProperty
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.features.healthchat.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
buildFeatures {
buildConfig = true
}
defaultConfig {
buildConfigFieldStr(
name = "HEALTHCHAT_API_BASE_URL",
value = System.getenv("HEALTHCHAT_API_BASE_URL")
?: readLocalProperty("healthchat.api.base.url")
?: "https://api.healthchat.ch",
)
}
}
setupDependencyInjection()
dependencies {
api(projects.features.healthchat.api)
implementation(libs.coroutines.core)
implementation(platform(libs.network.okhttp.bom))
implementation(libs.network.okhttp.okhttp)
implementation(libs.network.okhttp.logging)
implementation(platform(libs.network.retrofit.bom))
implementation(libs.network.retrofit)
implementation(libs.network.retrofit.converter.serialization)
implementation(libs.serialization.json)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.network)
implementation(projects.libraries.sessionStorage.api)
testCommonDependencies(libs)
testImplementation(libs.network.mockwebserver)
testImplementation(projects.features.healthchat.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.api)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test)
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatDirectoryService
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
import io.element.android.features.healthchat.impl.mapper.runHealthChatApiCatching
import io.element.android.features.healthchat.impl.mapper.toHealthChatMemberDirectory
import io.element.android.features.healthchat.impl.mapper.toHealthChatMemberOrNull
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.UserId
@ContributesBinding(SessionScope::class)
class DefaultHealthChatDirectoryService(
private val sessionApi: HealthChatSessionApi,
private val jsonProvider: JsonProvider,
) : HealthChatDirectoryService {
override suspend fun searchMembers(query: String): Result<HealthChatMemberDirectory> {
return runHealthChatApiCatching(jsonProvider()) {
sessionApi.api.getUsers(search = query.takeIf { it.isNotBlank() })
}
.map { it.toHealthChatMemberDirectory() }
}
override suspend fun getMember(userId: UserId): Result<HealthChatMember> {
return runHealthChatApiCatching(jsonProvider()) {
sessionApi.api.getUser(userId.value)
}
.flatMap { dto ->
dto.toHealthChatMemberOrNull()
?.let { Result.success(it) }
?: Result.failure(
HealthChatApiException.Unexpected(
IllegalStateException("Member payload has no valid userId")
)
)
}
}
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.healthchat.api.HealthChatInviteService
import io.element.android.features.healthchat.impl.mapper.runHealthChatApiCatching
import io.element.android.features.healthchat.impl.network.InvitationRequestDto
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
class DefaultHealthChatInviteService(
private val sessionApi: HealthChatSessionApi,
private val jsonProvider: JsonProvider,
) : HealthChatInviteService {
override suspend fun invite(email: String, personalNote: String): Result<String> {
return runHealthChatApiCatching(jsonProvider()) {
sessionApi.api.invite(InvitationRequestDto(email = email, personalNote = personalNote))
}
.map { it.message.orEmpty() }
}
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatCompleteness
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatProfileRepository
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
import io.element.android.features.healthchat.impl.mapper.runHealthChatApiCatching
import io.element.android.features.healthchat.impl.mapper.toHealthChatMemberOrNull
import io.element.android.features.healthchat.impl.network.toDto
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.di.SessionScope
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
class DefaultHealthChatProfileRepository(
private val sessionApi: HealthChatSessionApi,
private val jsonProvider: JsonProvider,
) : HealthChatProfileRepository {
private val ownProfileFlow = MutableStateFlow<AsyncData<HealthChatMember>>(AsyncData.Uninitialized)
override val ownProfile: StateFlow<AsyncData<HealthChatMember>> = ownProfileFlow.asStateFlow()
private val completenessFlow = MutableStateFlow<AsyncData<HealthChatCompleteness>>(AsyncData.Uninitialized)
override val completeness: StateFlow<AsyncData<HealthChatCompleteness>> = completenessFlow.asStateFlow()
override suspend fun refreshOwnProfile(): Result<HealthChatMember> {
ownProfileFlow.value = AsyncData.Loading(ownProfileFlow.value.dataOrNull())
return runHealthChatApiCatching(jsonProvider()) {
sessionApi.api.getProfile()
}
.flatMap { dto ->
dto.toHealthChatMemberOrNull()
?.let { Result.success(it) }
?: Result.failure(
HealthChatApiException.Unexpected(
IllegalStateException("Own profile payload has no valid userId")
)
)
}
.onSuccess { ownProfileFlow.value = AsyncData.Success(it) }
.onFailure { ownProfileFlow.value = AsyncData.Failure(it) }
}
override suspend fun refreshCompleteness(): Result<HealthChatCompleteness> {
completenessFlow.value = AsyncData.Loading(completenessFlow.value.dataOrNull())
return runHealthChatApiCatching(jsonProvider()) {
sessionApi.api.getProfileCompleteness()
}
.map { dto ->
HealthChatCompleteness(
isComplete = dto.isComplete,
missingFields = dto.missingFields.toImmutableList(),
message = dto.message?.takeIf { it.isNotBlank() },
hasProfilePhoto = dto.hasProfilePhoto,
)
}
.onSuccess { completenessFlow.value = AsyncData.Success(it) }
.onFailure { completenessFlow.value = AsyncData.Failure(it) }
}
override suspend fun updateOwnProfile(update: HealthChatProfileUpdate): Result<String> {
return runHealthChatApiCatching(jsonProvider()) {
sessionApi.api.updateProfile(update.toDto())
}
.map { it.message.orEmpty() }
// Re-fetch so ownProfile reflects the server-derived state (e.g. display name).
.onSuccess { refreshOwnProfile() }
}
override suspend fun uploadAvatar(fileName: String, mimeType: String, data: ByteArray): Result<String> {
return runHealthChatApiCatching(jsonProvider()) {
val part = MultipartBody.Part.createFormData(
name = "file",
filename = fileName,
body = data.toRequestBody(mimeType.toMediaType()),
)
sessionApi.api.uploadAvatar(part)
}
.flatMap { dto ->
dto.mxcUrl
?.let { Result.success(it) }
?: Result.failure(
HealthChatApiException.Unexpected(
IllegalStateException("Avatar upload response has no mxcUrl")
)
)
}
.onSuccess { refreshOwnProfile() }
}
override suspend fun deleteAvatar(): Result<String> {
return runHealthChatApiCatching(jsonProvider()) {
sessionApi.api.deleteAvatar()
}
.map { it.message.orEmpty() }
.onSuccess { refreshOwnProfile() }
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl
/**
* HealthChat API configuration.
*
* The base URL defaults to production and can be overridden at build time via the
* `HEALTHCHAT_API_BASE_URL` environment variable or the `healthchat.api.base.url` entry
* in the (gitignored) `local.properties`, so a dev override never ships.
*/
internal object HealthChatApiConfig {
val baseUrl: String = BuildConfig.HEALTHCHAT_API_BASE_URL
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.healthchat.impl.network.HealthChatApi
import io.element.android.features.healthchat.impl.network.HealthChatApiFactory
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.SessionId
/**
* Holds the single per-session [HealthChatApi] instance (one derived OkHttp client and
* Retrofit instance per session, shared by all HealthChat services).
*/
@Inject
@SingleIn(SessionScope::class)
class HealthChatSessionApi(
apiFactory: HealthChatApiFactory,
sessionId: SessionId,
) {
val api: HealthChatApi by lazy {
apiFactory.create(HealthChatApiConfig.baseUrl, sessionId)
}
}
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.mapper
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.impl.network.MessageResponseDto
import io.element.android.features.healthchat.impl.network.ValidationProblemDto
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import java.io.IOException
/**
* Runs an API call and maps every failure to a [HealthChatApiException].
* CancellationExceptions are rethrown by [runCatchingExceptions].
*/
suspend fun <T> runHealthChatApiCatching(json: Json, block: suspend () -> T): Result<T> {
return runCatchingExceptions { block() }
.mapFailure { it.toHealthChatApiException(json) }
}
/**
* Maps any throwable to the typed [HealthChatApiException] hierarchy, understanding both
* HealthChat error body shapes: the regular `{message}` and ASP.NET ValidationProblemDetails
* (`{title, status, errors}`, whose PascalCase field keys are lower-cased here).
*/
fun Throwable.toHealthChatApiException(json: Json): HealthChatApiException {
return when (this) {
is HealthChatApiException -> this
is HttpException -> toHealthChatHttpException(json)
is IOException -> HealthChatApiException.Network(this)
else -> HealthChatApiException.Unexpected(this)
}
}
private fun HttpException.toHealthChatHttpException(json: Json): HealthChatApiException {
val errorBody = try {
response()?.errorBody()?.string()?.takeIf { it.isNotBlank() }
} catch (ignored: IOException) {
null
}
val validation = errorBody
?.let { json.decodeOrNull<ValidationProblemDto>(it) }
?.takeIf { it.errors.isNotEmpty() }
if (validation != null) {
return HealthChatApiException.Validation(
status = code(),
title = validation.title,
fieldErrors = validation.errors.entries
.associate { (field, messages) -> field.lowercase() to messages.toImmutableList() }
.toImmutableMap(),
)
}
val serverMessage = errorBody
?.let { json.decodeOrNull<MessageResponseDto>(it) }
?.message
?.takeIf { it.isNotBlank() }
return when (code()) {
401 -> HealthChatApiException.Unauthorized(serverMessage)
403 -> HealthChatApiException.NotApproved(serverMessage)
404 -> HealthChatApiException.NotFound(serverMessage)
else -> HealthChatApiException.Http(code(), serverMessage)
}
}
private inline fun <reified T> Json.decodeOrNull(body: String): T? {
return try {
decodeFromString<T>(body)
} catch (ignored: Exception) {
null
}
}
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.mapper
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
import io.element.android.features.healthchat.impl.network.MatrixUserDto
import io.element.android.features.healthchat.impl.network.MatrixUserSearchResultDto
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.toImmutableList
import timber.log.Timber
/**
* Maps a member DTO to the domain model, or null when the record has no valid Matrix user id.
*
* The MXID is validated BEFORE constructing [UserId]: its init block crashes debug builds on
* malformed ids, so one bad server row must never take the app down. Matrix ids are safe to log.
*/
fun MatrixUserDto.toHealthChatMemberOrNull(): HealthChatMember? {
val validUserId = userId?.takeIf { MatrixPatterns.isUserId(it) }
if (validUserId == null) {
Timber.w("Dropping HealthChat member with invalid userId: $userId")
return null
}
return HealthChatMember(
userId = UserId(validUserId),
displayName = displayName?.takeIf { it.isNotBlank() },
avatarUrl = avatarMxcUrl?.takeIf { it.isNotBlank() },
title = title?.takeIf { it.isNotBlank() },
firstName = firstName?.takeIf { it.isNotBlank() },
lastName = lastName?.takeIf { it.isNotBlank() },
specialty = specialty?.takeIf { it.isNotBlank() },
practiceName = practiceName?.takeIf { it.isNotBlank() },
businessCity = businessCity?.takeIf { it.isNotBlank() },
furtherInfo = furtherInfo?.takeIf { it.isNotBlank() },
businessEmail = businessEmail?.takeIf { it.isNotBlank() },
businessTel = businessTel?.takeIf { it.isNotBlank() },
street = street?.takeIf { it.isNotBlank() },
houseNumber = houseNumber?.takeIf { it.isNotBlank() },
postalCode = postalCode?.takeIf { it.isNotBlank() },
website = website?.takeIf { it.isNotBlank() },
isApproved = isApproved,
)
}
fun MatrixUserSearchResultDto.toHealthChatMemberDirectory(): HealthChatMemberDirectory {
return HealthChatMemberDirectory(
members = users.mapNotNull { it.toHealthChatMemberOrNull() }.toImmutableList(),
totalCount = totalCount,
searchQuery = searchQuery.orEmpty(),
isApproved = isApproved,
pendingMessage = pendingMessage?.takeIf { it.isNotBlank() },
)
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.network
import kotlinx.serialization.Serializable
@Serializable
data class AvatarUploadResponseDto(
val mxcUrl: String? = null,
)
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.network
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for the HealthChat API (`https://api.healthchat.ch`).
* Authentication is added by [HealthChatAuthInterceptor].
*/
interface HealthChatApi {
@GET("api/usermatrix/users")
suspend fun getUsers(
@Query("search") search: String?,
): MatrixUserSearchResultDto
@GET("api/usermatrix/users/{userId}")
suspend fun getUser(
@Path("userId") userId: String,
): MatrixUserDto
@GET("api/usermatrix/profile")
suspend fun getProfile(): MatrixUserDto
@PUT("api/usermatrix/profile")
suspend fun updateProfile(
@Body request: UpdateProfileRequestDto,
): MessageResponseDto
@GET("api/usermatrix/profile/completeness")
suspend fun getProfileCompleteness(): ProfileCompletenessDto
@Multipart
@POST("api/usermatrix/profile/avatar")
suspend fun uploadAvatar(
@Part file: MultipartBody.Part,
): AvatarUploadResponseDto
@DELETE("api/usermatrix/profile/avatar")
suspend fun deleteAvatar(): MessageResponseDto
@POST("api/usermatrix/invite")
suspend fun invite(
@Body request: InvitationRequestDto,
): MessageResponseDto
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.network
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.uri.ensureTrailingSlash
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor
import io.element.android.libraries.sessionstorage.api.SessionStore
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
interface HealthChatApiFactory {
fun create(baseUrl: String, sessionId: SessionId): HealthChatApi
}
@ContributesBinding(AppScope::class)
class DefaultHealthChatApiFactory(
private val okHttpClient: () -> OkHttpClient,
private val sessionStore: () -> SessionStore,
private val json: () -> JsonProvider,
) : HealthChatApiFactory {
override fun create(baseUrl: String, sessionId: SessionId): HealthChatApi {
val client = okHttpClient()
.newBuilder()
.apply {
// PII: member directory and profile bodies must never reach BODY-level HTTP
// logs, so the shared client's logging interceptor is dropped here.
interceptors().removeAll { it is DynamicHttpLoggingInterceptor }
addInterceptor(HealthChatAuthInterceptor(sessionId, sessionStore()))
}
.build()
return Retrofit.Builder()
.baseUrl(baseUrl.ensureTrailingSlash())
.addConverterFactory(json()().asConverterFactory("application/json".toMediaType()))
.client(client)
.build()
.create(HealthChatApi::class.java)
}
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.network
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import kotlin.time.Duration.Companion.seconds
/**
* Adds `Authorization: Bearer <matrix access token>` to every HealthChat API request.
*
* The token is read FRESH from [SessionStore] on every request: the Rust SDK rotates the
* access token and writes it back through `RustClientSessionDelegate`, so caching it here
* would eventually send a stale token.
*
* `runBlocking` inside an OkHttp interceptor follows the in-repo precedent of
* [io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor]:
* interceptors run on OkHttp's own worker threads, never on the main thread.
*/
class HealthChatAuthInterceptor(
private val sessionId: SessionId,
private val sessionStore: SessionStore,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val accessToken = runBlocking {
withTimeoutOrNull(TOKEN_READ_TIMEOUT) {
sessionStore.getSession(sessionId.value)?.accessToken
}
} ?: throw HealthChatTokenUnavailableException()
val request = chain.request()
.newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
return chain.proceed(request)
}
companion object {
private val TOKEN_READ_TIMEOUT = 5.seconds
}
}
/**
* Thrown (as an [IOException] so OkHttp surfaces it as a network failure) when no access
* token is available for the session, e.g. right after a logout.
*/
class HealthChatTokenUnavailableException : IOException("No access token available for the HealthChat API")
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.network
import kotlinx.serialization.Serializable
@Serializable
data class InvitationRequestDto(
val email: String,
val personalNote: String,
)
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.network
import kotlinx.serialization.Serializable
/**
* A member record as returned by the HealthChat API (camelCase).
*
* Every field is defaulted so the client stays tolerant to additive server-side changes
* (the API contract guarantees only additive evolution).
*/
@Serializable
data class MatrixUserDto(
val userId: String? = null,
val displayName: String? = null,
val firstName: String? = null,
val lastName: String? = null,
val title: String? = null,
val specialty: String? = null,
val practiceName: String? = null,
val businessCity: String? = null,
val businessEmail: String? = null,
val businessTel: String? = null,
val furtherInfo: String? = null,
val avatarMxcUrl: String? = null,
val street: String? = null,
val houseNumber: String? = null,
val postalCode: String? = null,
val website: String? = null,
// Server-side default is true for legacy accounts.
val isApproved: Boolean = true,
val isActive: Boolean = true,
)
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.network
import kotlinx.serialization.Serializable
@Serializable
data class MatrixUserSearchResultDto(
val users: List<MatrixUserDto> = emptyList(),
val totalCount: Int = 0,
val searchQuery: String? = null,
val isApproved: Boolean = true,
val pendingMessage: String? = null,
)
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.network
import kotlinx.serialization.Serializable
/** The regular HealthChat API response/error body: a single German message. */
@Serializable
data class MessageResponseDto(
val message: String? = null,
)
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.network
import kotlinx.serialization.Serializable
@Serializable
data class ProfileCompletenessDto(
// Fail quiet: a missing flag must not nag the user with an "incomplete profile" banner.
val isComplete: Boolean = true,
val missingFields: List<String> = emptyList(),
val message: String? = null,
val hasProfilePhoto: Boolean = true,
)
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.network
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
import kotlinx.serialization.Serializable
/**
* Diff-only profile update body.
*
* Serialized with `encodeDefaults = false` (the app's default Json), so `null` fields are
* OMITTED from the JSON (server: untouched) while an explicit `""` is serialized
* (server: clear the field). Guarded by a serializer test.
*/
@Serializable
data class UpdateProfileRequestDto(
val title: String? = null,
val firstName: String? = null,
val lastName: String? = null,
val specialty: String? = null,
val practiceName: String? = null,
val businessCity: String? = null,
val furtherInfo: String? = null,
val businessEmail: String? = null,
val businessTel: String? = null,
val street: String? = null,
val houseNumber: String? = null,
val postalCode: String? = null,
val website: String? = null,
)
fun HealthChatProfileUpdate.toDto() = UpdateProfileRequestDto(
title = title,
firstName = firstName,
lastName = lastName,
specialty = specialty,
practiceName = practiceName,
businessCity = businessCity,
furtherInfo = furtherInfo,
businessEmail = businessEmail,
businessTel = businessTel,
street = street,
houseNumber = houseNumber,
postalCode = postalCode,
website = website,
)
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.network
import kotlinx.serialization.Serializable
/**
* ASP.NET model validation error body (`ValidationProblemDetails`) — a DIFFERENT shape from
* the regular `{message}` errors. Field keys arrive PascalCase (e.g. "Email", "PersonalNote").
*/
@Serializable
data class ValidationProblemDto(
val title: String? = null,
val status: Int? = null,
val errors: Map<String, List<String>> = emptyMap(),
)
@@ -0,0 +1,198 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Test
class DefaultHealthChatDirectoryServiceTest {
@Test
fun `searchMembers with a blank query requests the full directory and maps members`() = runTest {
val server = MockWebServer()
server.enqueue(
aJsonResponse(
"""
{
"users": [
{
"userId": "@erika.muster:healthchat.ch",
"displayName": "Dr. med. Erika Muster",
"specialty": "Allgemeinmedizin",
"practiceName": "Praxis Muster",
"businessCity": "Zürich",
"businessEmail": "",
"avatarMxcUrl": "mxc://healthchat.ch/abc",
"isApproved": true
}
],
"totalCount": 1,
"searchQuery": "",
"isApproved": true,
"pendingMessage": null
}
""".trimIndent()
)
)
server.start()
val sut = createDirectoryService(server)
val result = sut.searchMembers("")
val request = server.takeRequest()
assertThat(request.method).isEqualTo("GET")
assertThat(request.requestUrl!!.encodedPath).isEqualTo("/api/usermatrix/users")
assertThat(request.requestUrl!!.queryParameter("search")).isNull()
val directory = result.getOrThrow()
assertThat(directory.totalCount).isEqualTo(1)
assertThat(directory.isApproved).isTrue()
assertThat(directory.pendingMessage).isNull()
val member = directory.members.single()
assertThat(member.userId).isEqualTo(UserId("@erika.muster:healthchat.ch"))
assertThat(member.displayName).isEqualTo("Dr. med. Erika Muster")
assertThat(member.specialty).isEqualTo("Allgemeinmedizin")
// Blank fields are normalized to null.
assertThat(member.businessEmail).isNull()
assertThat(member.avatarUrl).isEqualTo("mxc://healthchat.ch/abc")
server.shutdown()
}
@Test
fun `searchMembers sends the query as an url encoded search parameter`() = runTest {
val server = MockWebServer()
server.enqueue(aJsonResponse("""{"users":[],"totalCount":0,"searchQuery":"Dr. Müller","isApproved":true}"""))
server.start()
val sut = createDirectoryService(server)
sut.searchMembers("Dr. Müller")
val request = server.takeRequest()
assertThat(request.requestUrl!!.queryParameter("search")).isEqualTo("Dr. Müller")
server.shutdown()
}
@Test
fun `searchMembers maps an unapproved caller with the verbatim pending message`() = runTest {
val server = MockWebServer()
server.enqueue(
aJsonResponse(
"""
{
"users": [],
"totalCount": 0,
"isApproved": false,
"pendingMessage": "Ihr Konto wird derzeit geprüft."
}
""".trimIndent()
)
)
server.start()
val sut = createDirectoryService(server)
val directory = sut.searchMembers("").getOrThrow()
assertThat(directory.isApproved).isFalse()
assertThat(directory.pendingMessage).isEqualTo("Ihr Konto wird derzeit geprüft.")
server.shutdown()
}
@Test
fun `a member row with an invalid userId is dropped instead of failing the whole directory`() = runTest {
val server = MockWebServer()
server.enqueue(
aJsonResponse(
"""
{
"users": [
{"userId": "not-a-matrix-id", "displayName": "Broken"},
{"userId": "@valid:healthchat.ch", "displayName": "Valid"}
],
"totalCount": 2,
"isApproved": true
}
""".trimIndent()
)
)
server.start()
val sut = createDirectoryService(server)
val directory = sut.searchMembers("").getOrThrow()
assertThat(directory.members.map { it.userId }).containsExactly(UserId("@valid:healthchat.ch"))
server.shutdown()
}
@Test
fun `an unknown json field does not fail the mapping`() = runTest {
val server = MockWebServer()
server.enqueue(
aJsonResponse(
"""{"users":[],"totalCount":0,"isApproved":true,"aBrandNewField":{"nested":1}}"""
)
)
server.start()
val sut = createDirectoryService(server)
assertThat(sut.searchMembers("").isSuccess).isTrue()
server.shutdown()
}
@Test
fun `getMember requests the user by id and maps 403 to NotApproved`() = runTest {
val server = MockWebServer()
server.enqueue(MockResponse().setResponseCode(403))
server.start()
val sut = createDirectoryService(server)
val result = sut.getMember(UserId("@bob:healthchat.ch"))
val request = server.takeRequest()
assertThat(request.requestUrl!!.encodedPath).isEqualTo("/api/usermatrix/users/@bob:healthchat.ch")
assertThat(result.exceptionOrNull()).isEqualTo(HealthChatApiException.NotApproved(null))
server.shutdown()
}
@Test
fun `getMember maps 404 to NotFound with the verbatim server message`() = runTest {
val server = MockWebServer()
server.enqueue(
MockResponse()
.setResponseCode(404)
.setBody("""{"message":"Benutzer nicht gefunden"}""")
)
server.start()
val sut = createDirectoryService(server)
val result = sut.getMember(UserId("@ghost:healthchat.ch"))
assertThat(result.exceptionOrNull()).isEqualTo(HealthChatApiException.NotFound("Benutzer nicht gefunden"))
server.shutdown()
}
@Test
fun `getMember returns the mapped member on success`() = runTest {
val server = MockWebServer()
server.enqueue(
aJsonResponse(
"""{"userId":"@bob:healthchat.ch","displayName":"Bob","businessTel":"+41 44 111 22 33"}"""
)
)
server.start()
val sut = createDirectoryService(server)
val member = sut.getMember(UserId("@bob:healthchat.ch")).getOrThrow()
assertThat(member.displayName).isEqualTo("Bob")
assertThat(member.businessTel).isEqualTo("+41 44 111 22 33")
server.shutdown()
}
private fun createDirectoryService(server: MockWebServer): DefaultHealthChatDirectoryService {
return DefaultHealthChatDirectoryService(
sessionApi = aHealthChatSessionApi(server),
jsonProvider = DefaultJsonProvider(),
)
}
private fun aJsonResponse(body: String): MockResponse {
return MockResponse()
.setResponseCode(200)
.setBody(body)
}
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Test
class DefaultHealthChatInviteServiceTest {
@Test
fun `invite posts email and personal note and returns the server message`() = runTest {
val server = MockWebServer()
server.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{"message":"Einladung wurde erfolgreich versendet"}""")
)
server.start()
val sut = createInviteService(server)
val result = sut.invite(
email = "neu@beispiel.ch",
personalNote = "Willkommen bei HealthChat!",
)
val request = server.takeRequest()
assertThat(request.method).isEqualTo("POST")
assertThat(request.requestUrl!!.encodedPath).isEqualTo("/api/usermatrix/invite")
assertThat(request.body.readUtf8())
.isEqualTo("""{"email":"neu@beispiel.ch","personalNote":"Willkommen bei HealthChat!"}""")
assertThat(result.getOrThrow()).isEqualTo("Einladung wurde erfolgreich versendet")
server.shutdown()
}
@Test
fun `a server side validation error surfaces as Validation with lower cased keys`() = runTest {
val server = MockWebServer()
server.enqueue(
MockResponse()
.setResponseCode(400)
.setBody(
"""
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"PersonalNote": ["Die persönliche Nachricht muss mindestens 10 Zeichen lang sein"]
}
}
""".trimIndent()
)
)
server.start()
val sut = createInviteService(server)
val result = sut.invite(email = "neu@beispiel.ch", personalNote = "kurz")
val error = result.exceptionOrNull() as HealthChatApiException.Validation
assertThat(error.fieldErrors.keys).containsExactly("personalnote")
server.shutdown()
}
private fun createInviteService(server: MockWebServer): DefaultHealthChatInviteService {
return DefaultHealthChatInviteService(
sessionApi = aHealthChatSessionApi(server),
jsonProvider = DefaultJsonProvider(),
)
}
}
@@ -0,0 +1,197 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.test.A_SESSION_ID
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Test
class DefaultHealthChatProfileRepositoryTest {
@Test
fun `refreshOwnProfile fetches the profile and emits it into ownProfile`() = runTest {
val server = MockWebServer()
server.enqueue(aOwnProfileResponse(displayName = "Dr. med. Alice"))
server.start()
val sut = createRepository(server)
assertThat(sut.ownProfile.value).isEqualTo(AsyncData.Uninitialized)
val result = sut.refreshOwnProfile()
val request = server.takeRequest()
assertThat(request.method).isEqualTo("GET")
assertThat(request.requestUrl!!.encodedPath).isEqualTo("/api/usermatrix/profile")
val member = result.getOrThrow()
assertThat(member.userId).isEqualTo(A_SESSION_ID)
assertThat(member.displayName).isEqualTo("Dr. med. Alice")
assertThat(sut.ownProfile.value).isEqualTo(AsyncData.Success(member))
server.shutdown()
}
@Test
fun `a failed refreshOwnProfile emits Failure with the typed exception`() = runTest {
val server = MockWebServer()
server.enqueue(MockResponse().setResponseCode(401).setBody("""{"message":"Ungültiges Token"}"""))
server.start()
val sut = createRepository(server)
val result = sut.refreshOwnProfile()
val expected = HealthChatApiException.Unauthorized("Ungültiges Token")
assertThat(result.exceptionOrNull()).isEqualTo(expected)
assertThat(sut.ownProfile.value).isEqualTo(AsyncData.Failure<Nothing>(expected))
server.shutdown()
}
@Test
fun `updateOwnProfile sends only the changed fields and re-fetches the profile`() = runTest {
val server = MockWebServer()
server.enqueue(aJsonResponse("""{"message":"Profil erfolgreich aktualisiert"}"""))
server.enqueue(aOwnProfileResponse(displayName = "Dr. med. Alice Neu"))
server.start()
val sut = createRepository(server)
val result = sut.updateOwnProfile(HealthChatProfileUpdate(specialty = "Kardiologie"))
val putRequest = server.takeRequest()
assertThat(putRequest.method).isEqualTo("PUT")
assertThat(putRequest.requestUrl!!.encodedPath).isEqualTo("/api/usermatrix/profile")
// The diff contract: exactly the set fields, nothing else.
assertThat(putRequest.body.readUtf8()).isEqualTo("""{"specialty":"Kardiologie"}""")
val refreshRequest = server.takeRequest()
assertThat(refreshRequest.method).isEqualTo("GET")
assertThat(result.getOrThrow()).isEqualTo("Profil erfolgreich aktualisiert")
assertThat(sut.ownProfile.value.dataOrNull()?.displayName).isEqualTo("Dr. med. Alice Neu")
server.shutdown()
}
@Test
fun `updateOwnProfile serializes an empty string to clear a field`() = runTest {
val server = MockWebServer()
server.enqueue(aJsonResponse("""{"message":"Profil erfolgreich aktualisiert"}"""))
server.enqueue(aOwnProfileResponse())
server.start()
val sut = createRepository(server)
sut.updateOwnProfile(HealthChatProfileUpdate(website = ""))
assertThat(server.takeRequest().body.readUtf8()).isEqualTo("""{"website":""}""")
server.shutdown()
}
@Test
fun `a failed updateOwnProfile does not touch ownProfile`() = runTest {
val server = MockWebServer()
server.enqueue(MockResponse().setResponseCode(500).setBody("""{"message":"Interner Fehler"}"""))
server.start()
val sut = createRepository(server)
val result = sut.updateOwnProfile(HealthChatProfileUpdate(specialty = "Kardiologie"))
assertThat(result.exceptionOrNull()).isEqualTo(HealthChatApiException.Http(500, "Interner Fehler"))
assertThat(sut.ownProfile.value).isEqualTo(AsyncData.Uninitialized)
assertThat(server.requestCount).isEqualTo(1)
server.shutdown()
}
@Test
fun `uploadAvatar posts a multipart file part and returns the mxc url`() = runTest {
val server = MockWebServer()
server.enqueue(aJsonResponse("""{"mxcUrl":"mxc://healthchat.ch/newAvatar"}"""))
server.enqueue(aOwnProfileResponse())
server.start()
val sut = createRepository(server)
val result = sut.uploadAvatar(
fileName = "avatar.jpg",
mimeType = "image/jpeg",
data = byteArrayOf(1, 2, 3),
)
val request = server.takeRequest()
assertThat(request.method).isEqualTo("POST")
assertThat(request.requestUrl!!.encodedPath).isEqualTo("/api/usermatrix/profile/avatar")
assertThat(request.getHeader("Content-Type")).startsWith("multipart/form-data")
val body = request.body.readUtf8()
assertThat(body).contains("""name="file"""")
assertThat(body).contains("""filename="avatar.jpg"""")
assertThat(result.getOrThrow()).isEqualTo("mxc://healthchat.ch/newAvatar")
// The own profile is refreshed after the upload.
assertThat(server.takeRequest().method).isEqualTo("GET")
server.shutdown()
}
@Test
fun `deleteAvatar issues a DELETE and refreshes the profile`() = runTest {
val server = MockWebServer()
server.enqueue(aJsonResponse("""{"message":"Profilbild entfernt"}"""))
server.enqueue(aOwnProfileResponse())
server.start()
val sut = createRepository(server)
val result = sut.deleteAvatar()
val request = server.takeRequest()
assertThat(request.method).isEqualTo("DELETE")
assertThat(request.requestUrl!!.encodedPath).isEqualTo("/api/usermatrix/profile/avatar")
assertThat(result.getOrThrow()).isEqualTo("Profilbild entfernt")
assertThat(server.takeRequest().method).isEqualTo("GET")
server.shutdown()
}
@Test
fun `refreshCompleteness maps the payload and emits it into completeness`() = runTest {
val server = MockWebServer()
server.enqueue(
aJsonResponse(
"""
{
"isComplete": false,
"missingFields": ["Fachgebiet", "Praxisname / Firma"],
"message": "Bitte vervollständigen Sie Ihr Profil",
"hasProfilePhoto": false
}
""".trimIndent()
)
)
server.start()
val sut = createRepository(server)
val result = sut.refreshCompleteness()
assertThat(server.takeRequest().requestUrl!!.encodedPath)
.isEqualTo("/api/usermatrix/profile/completeness")
val completeness = result.getOrThrow()
assertThat(completeness.isComplete).isFalse()
assertThat(completeness.missingFields).containsExactly("Fachgebiet", "Praxisname / Firma").inOrder()
assertThat(completeness.message).isEqualTo("Bitte vervollständigen Sie Ihr Profil")
assertThat(completeness.hasProfilePhoto).isFalse()
assertThat(sut.completeness.value).isEqualTo(AsyncData.Success(completeness))
server.shutdown()
}
@Test
fun `a failed refreshCompleteness emits Failure`() = runTest {
val server = MockWebServer()
server.enqueue(MockResponse().setResponseCode(500))
server.start()
val sut = createRepository(server)
val result = sut.refreshCompleteness()
assertThat(result.exceptionOrNull()).isEqualTo(HealthChatApiException.Http(500, null))
assertThat(sut.completeness.value.isFailure()).isTrue()
server.shutdown()
}
private fun createRepository(server: MockWebServer): DefaultHealthChatProfileRepository {
return DefaultHealthChatProfileRepository(
sessionApi = aHealthChatSessionApi(server),
jsonProvider = DefaultJsonProvider(),
)
}
private fun aJsonResponse(body: String): MockResponse {
return MockResponse()
.setResponseCode(200)
.setBody(body)
}
private fun aOwnProfileResponse(displayName: String = "Alice"): MockResponse {
return aJsonResponse("""{"userId":"${A_SESSION_ID.value}","displayName":"$displayName"}""")
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl
import io.element.android.features.healthchat.impl.network.DefaultHealthChatApiFactory
import io.element.android.features.healthchat.impl.network.HealthChatApi
import io.element.android.features.healthchat.impl.network.HealthChatApiFactory
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockWebServer
const val AN_ACCESS_TOKEN = "aHealthChatAccessToken"
fun aHealthChatSessionStore(
accessToken: String = AN_ACCESS_TOKEN,
): InMemorySessionStore {
return InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_SESSION_ID.value,
accessToken = accessToken,
),
),
)
}
fun aHealthChatApiFactory(
baseOkHttpClient: OkHttpClient = OkHttpClient.Builder().build(),
sessionStore: SessionStore = aHealthChatSessionStore(),
): HealthChatApiFactory {
return DefaultHealthChatApiFactory(
okHttpClient = { baseOkHttpClient },
sessionStore = { sessionStore },
json = { DefaultJsonProvider() },
)
}
fun aHealthChatApi(
server: MockWebServer,
baseOkHttpClient: OkHttpClient = OkHttpClient.Builder().build(),
sessionStore: SessionStore = aHealthChatSessionStore(),
): HealthChatApi {
return aHealthChatApiFactory(
baseOkHttpClient = baseOkHttpClient,
sessionStore = sessionStore,
).create(server.url("/").toString(), A_SESSION_ID)
}
/**
* A [HealthChatSessionApi] whose requests are redirected to the given [server] instead of the
* production base URL.
*/
fun aHealthChatSessionApi(
server: MockWebServer,
sessionStore: SessionStore = aHealthChatSessionStore(),
): HealthChatSessionApi {
return HealthChatSessionApi(
apiFactory = object : HealthChatApiFactory {
override fun create(baseUrl: String, sessionId: SessionId): HealthChatApi {
return aHealthChatApi(server = server, sessionStore = sessionStore)
}
},
sessionId = A_SESSION_ID,
)
}
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.mapper
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import kotlinx.collections.immutable.persistentListOf
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
class HealthChatErrorMapperTest {
private val json = DefaultJsonProvider()()
@Test
fun `a 401 with a message body maps to Unauthorized with the verbatim message`() {
val result = anHttpException(401, """{"message":"Ungültiges Token"}""").toHealthChatApiException(json)
assertThat(result).isEqualTo(HealthChatApiException.Unauthorized("Ungültiges Token"))
}
@Test
fun `a 403 without body maps to NotApproved with a null message`() {
val result = anHttpException(403, "").toHealthChatApiException(json)
assertThat(result).isEqualTo(HealthChatApiException.NotApproved(null))
}
@Test
fun `a 404 with a message body maps to NotFound with the verbatim message`() {
val result = anHttpException(404, """{"message":"Benutzer nicht gefunden"}""").toHealthChatApiException(json)
assertThat(result).isEqualTo(HealthChatApiException.NotFound("Benutzer nicht gefunden"))
}
@Test
fun `a 500 with a message body maps to Http with status and verbatim message`() {
val result = anHttpException(500, """{"message":"Interner Fehler"}""").toHealthChatApiException(json)
assertThat(result).isEqualTo(HealthChatApiException.Http(500, "Interner Fehler"))
}
@Test
fun `a 500 with a non json body maps to Http with a null message`() {
val result = anHttpException(500, "<html>Bad Gateway</html>").toHealthChatApiException(json)
assertThat(result).isEqualTo(HealthChatApiException.Http(500, null))
}
@Test
fun `a 400 with ProblemDetails maps to Validation with lower cased field keys`() {
val body = """
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Email": ["The Email field is not a valid e-mail address."],
"PersonalNote": ["Die persönliche Nachricht muss mindestens 10 Zeichen lang sein"]
}
}
""".trimIndent()
val result = anHttpException(400, body).toHealthChatApiException(json)
assertThat(result).isInstanceOf(HealthChatApiException.Validation::class.java)
result as HealthChatApiException.Validation
assertThat(result.status).isEqualTo(400)
assertThat(result.title).isEqualTo("One or more validation errors occurred.")
assertThat(result.fieldErrors.keys).containsExactly("email", "personalnote")
assertThat(result.fieldErrors["personalnote"])
.isEqualTo(persistentListOf("Die persönliche Nachricht muss mindestens 10 Zeichen lang sein"))
}
@Test
fun `a 400 with a plain message body maps to Http not Validation`() {
val result = anHttpException(400, """{"message":"Keine Datei hochgeladen"}""").toHealthChatApiException(json)
assertThat(result).isEqualTo(HealthChatApiException.Http(400, "Keine Datei hochgeladen"))
}
@Test
fun `an IOException maps to Network`() {
val cause = IOException("timeout")
val result = cause.toHealthChatApiException(json)
assertThat(result).isEqualTo(HealthChatApiException.Network(cause))
}
@Test
fun `any other exception maps to Unexpected`() {
val cause = IllegalStateException("boom")
val result = cause.toHealthChatApiException(json)
assertThat(result).isEqualTo(HealthChatApiException.Unexpected(cause))
}
@Test
fun `an existing HealthChatApiException passes through unchanged`() {
val existing = HealthChatApiException.NotFound("Benutzer nicht gefunden")
assertThat(existing.toHealthChatApiException(json)).isSameInstanceAs(existing)
}
private fun anHttpException(code: Int, body: String): HttpException {
return HttpException(
Response.error<Any>(code, body.toResponseBody("application/json".toMediaType()))
)
}
}
@@ -0,0 +1,124 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.network
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.impl.AN_ACCESS_TOKEN
import io.element.android.features.healthchat.impl.aHealthChatApi
import io.element.android.features.healthchat.impl.aHealthChatSessionStore
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.tracing.LogLevel
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Test
class HealthChatAuthInterceptorTest {
@Test
fun `requests carry the current access token as Bearer authorization`() = runTest {
val server = MockWebServer()
server.enqueue(aProfileResponse())
server.start()
val api = aHealthChatApi(server)
api.getProfile()
val request = server.takeRequest()
assertThat(request.getHeader("Authorization")).isEqualTo("Bearer $AN_ACCESS_TOKEN")
server.shutdown()
}
@Test
fun `a rotated access token is used on the next request without recreating the client`() = runTest {
val server = MockWebServer()
server.enqueue(aProfileResponse())
server.enqueue(aProfileResponse())
server.start()
val sessionStore = aHealthChatSessionStore()
val api = aHealthChatApi(server, sessionStore = sessionStore)
api.getProfile()
assertThat(server.takeRequest().getHeader("Authorization")).isEqualTo("Bearer $AN_ACCESS_TOKEN")
// The SDK rotates the token and persists it through the session store.
sessionStore.updateData(
aSessionData(
sessionId = A_SESSION_ID.value,
accessToken = "aRotatedAccessToken",
),
)
api.getProfile()
assertThat(server.takeRequest().getHeader("Authorization")).isEqualTo("Bearer aRotatedAccessToken")
server.shutdown()
}
@Test
fun `a missing session fails the request before it reaches the server`() = runTest {
val server = MockWebServer()
server.start()
val api = aHealthChatApi(server, sessionStore = InMemorySessionStore())
val result = runCatchingExceptions { api.getProfile() }
assertThat(result.exceptionOrNull()).isInstanceOf(HealthChatTokenUnavailableException::class.java)
assertThat(server.requestCount).isEqualTo(0)
server.shutdown()
}
@Test
fun `interceptors of the base client are preserved by the derived client`() = runTest {
val server = MockWebServer()
server.enqueue(aProfileResponse())
server.start()
val baseClient = OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(
chain.request().newBuilder().header("X-Test-Marker", "aMarker").build()
)
}
.build()
val api = aHealthChatApi(server, baseOkHttpClient = baseClient)
api.getProfile()
assertThat(server.takeRequest().getHeader("X-Test-Marker")).isEqualTo("aMarker")
server.shutdown()
}
@Test
fun `the http body logging interceptor of the base client is removed from the derived client`() = runTest {
val server = MockWebServer()
server.enqueue(aProfileResponse())
server.enqueue(aProfileResponse())
server.start()
val loggedLines = mutableListOf<String>()
val loggingInterceptor = DynamicHttpLoggingInterceptor(
appPreferencesStore = InMemoryAppPreferencesStore(logLevel = LogLevel.DEBUG),
loggingInterceptor = HttpLoggingInterceptor { message -> loggedLines.add(message) },
)
val baseClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()
// Positive control: the base client itself does log.
baseClient.newCall(Request.Builder().url(server.url("/control")).build()).execute().close()
assertThat(loggedLines).isNotEmpty()
loggedLines.clear()
// The derived HealthChat client must not log anything (PII in bodies).
val api = aHealthChatApi(server, baseOkHttpClient = baseClient)
api.getProfile()
assertThat(loggedLines).isEmpty()
server.shutdown()
}
private fun aProfileResponse(): MockResponse {
return MockResponse()
.setResponseCode(200)
.setBody("""{"userId":"${A_SESSION_ID.value}"}""")
}
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.impl.network
import com.google.common.truth.Truth.assertThat
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import kotlinx.serialization.encodeToString
import org.junit.Test
/**
* Guards the diff-only PUT contract: `null` fields must be OMITTED from the JSON
* (server: untouched) while an explicit `""` must be serialized (server: clear).
*/
class UpdateProfileRequestDtoTest {
private val json = DefaultJsonProvider()()
@Test
fun `null fields are omitted from the serialized body`() {
val body = json.encodeToString(UpdateProfileRequestDto(specialty = "Kardiologie"))
assertThat(body).isEqualTo("""{"specialty":"Kardiologie"}""")
}
@Test
fun `empty strings are serialized to clear a field`() {
val body = json.encodeToString(UpdateProfileRequestDto(website = ""))
assertThat(body).isEqualTo("""{"website":""}""")
}
@Test
fun `an empty update serializes to an empty object`() {
val body = json.encodeToString(UpdateProfileRequestDto())
assertThat(body).isEqualTo("{}")
}
@Test
fun `the domain update maps field by field`() {
val dto = HealthChatProfileUpdate(
title = "Dr. med.",
firstName = "Erika",
lastName = "Muster",
specialty = "Allgemeinmedizin",
practiceName = "Praxis Muster",
businessCity = "Zürich",
furtherInfo = "FMH",
businessEmail = "praxis@muster.ch",
businessTel = "+41 44 123 45 67",
street = "Musterstrasse",
houseNumber = "12",
postalCode = "8001",
website = "https://praxis-muster.ch",
).toDto()
assertThat(dto).isEqualTo(
UpdateProfileRequestDto(
title = "Dr. med.",
firstName = "Erika",
lastName = "Muster",
specialty = "Allgemeinmedizin",
practiceName = "Praxis Muster",
businessCity = "Zürich",
furtherInfo = "FMH",
businessEmail = "praxis@muster.ch",
businessTel = "+41 44 123 45 67",
street = "Musterstrasse",
houseNumber = "12",
postalCode = "8001",
website = "https://praxis-muster.ch",
)
)
}
}
+24
View File
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.healthchat.test"
}
dependencies {
api(projects.features.healthchat.api)
implementation(libs.coroutines.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.test)
implementation(projects.tests.testutils)
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.test
import io.element.android.features.healthchat.api.HealthChatDirectoryService
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.lambda.lambdaError
class FakeHealthChatDirectoryService(
private val searchMembersLambda: suspend (String) -> Result<HealthChatMemberDirectory> = { lambdaError() },
private val getMemberLambda: suspend (UserId) -> Result<HealthChatMember> = { lambdaError() },
) : HealthChatDirectoryService {
override suspend fun searchMembers(query: String): Result<HealthChatMemberDirectory> {
return searchMembersLambda(query)
}
override suspend fun getMember(userId: UserId): Result<HealthChatMember> {
return getMemberLambda(userId)
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.test
import io.element.android.features.healthchat.api.HealthChatInviteService
import io.element.android.tests.testutils.lambda.lambdaError
class FakeHealthChatInviteService(
private val inviteLambda: suspend (String, String) -> Result<String> = { _, _ -> lambdaError() },
) : HealthChatInviteService {
override suspend fun invite(email: String, personalNote: String): Result<String> {
return inviteLambda(email, personalNote)
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.test
import io.element.android.features.healthchat.api.HealthChatCompleteness
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatProfileRepository
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
import io.element.android.libraries.architecture.AsyncData
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class FakeHealthChatProfileRepository(
private val refreshOwnProfileLambda: suspend () -> Result<HealthChatMember> = { lambdaError() },
private val refreshCompletenessLambda: suspend () -> Result<HealthChatCompleteness> = { lambdaError() },
private val updateOwnProfileLambda: suspend (HealthChatProfileUpdate) -> Result<String> = { lambdaError() },
private val uploadAvatarLambda: suspend (String, String, ByteArray) -> Result<String> = { _, _, _ -> lambdaError() },
private val deleteAvatarLambda: suspend () -> Result<String> = { lambdaError() },
) : HealthChatProfileRepository {
private val ownProfileFlow = MutableStateFlow<AsyncData<HealthChatMember>>(AsyncData.Uninitialized)
override val ownProfile: StateFlow<AsyncData<HealthChatMember>> = ownProfileFlow
private val completenessFlow = MutableStateFlow<AsyncData<HealthChatCompleteness>>(AsyncData.Uninitialized)
override val completeness: StateFlow<AsyncData<HealthChatCompleteness>> = completenessFlow
fun givenOwnProfile(value: AsyncData<HealthChatMember>) {
ownProfileFlow.value = value
}
fun givenCompleteness(value: AsyncData<HealthChatCompleteness>) {
completenessFlow.value = value
}
override suspend fun refreshOwnProfile(): Result<HealthChatMember> {
return refreshOwnProfileLambda()
.onSuccess { ownProfileFlow.value = AsyncData.Success(it) }
.onFailure { ownProfileFlow.value = AsyncData.Failure(it) }
}
override suspend fun refreshCompleteness(): Result<HealthChatCompleteness> {
return refreshCompletenessLambda()
.onSuccess { completenessFlow.value = AsyncData.Success(it) }
.onFailure { completenessFlow.value = AsyncData.Failure(it) }
}
override suspend fun updateOwnProfile(update: HealthChatProfileUpdate): Result<String> {
return updateOwnProfileLambda(update)
}
override suspend fun uploadAvatar(fileName: String, mimeType: String, data: ByteArray): Result<String> {
return uploadAvatarLambda(fileName, mimeType, data)
}
override suspend fun deleteAvatar(): Result<String> {
return deleteAvatarLambda()
}
}
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2026 Novaloop AG
* Copyright (c) 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.
*/
package io.element.android.features.healthchat.test
import io.element.android.features.healthchat.api.HealthChatCompleteness
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
fun aHealthChatMember(
userId: UserId = UserId("@erika.muster:healthchat.ch"),
displayName: String? = "Dr. med. Erika Muster",
avatarUrl: String? = null,
title: String? = "Dr. med.",
firstName: String? = "Erika",
lastName: String? = "Muster",
specialty: String? = "Allgemeinmedizin",
practiceName: String? = "Praxis Muster",
businessCity: String? = "Zürich",
furtherInfo: String? = null,
businessEmail: String? = "praxis@muster.ch",
businessTel: String? = "+41 44 123 45 67",
street: String? = "Musterstrasse",
houseNumber: String? = "12",
postalCode: String? = "8001",
website: String? = "https://praxis-muster.ch",
isApproved: Boolean = true,
) = HealthChatMember(
userId = userId,
displayName = displayName,
avatarUrl = avatarUrl,
title = title,
firstName = firstName,
lastName = lastName,
specialty = specialty,
practiceName = practiceName,
businessCity = businessCity,
furtherInfo = furtherInfo,
businessEmail = businessEmail,
businessTel = businessTel,
street = street,
houseNumber = houseNumber,
postalCode = postalCode,
website = website,
isApproved = isApproved,
)
fun aHealthChatMemberDirectory(
members: List<HealthChatMember> = listOf(aHealthChatMember()),
totalCount: Int = members.size,
searchQuery: String = "",
isApproved: Boolean = true,
pendingMessage: String? = null,
) = HealthChatMemberDirectory(
members = members.toImmutableList(),
totalCount = totalCount,
searchQuery = searchQuery,
isApproved = isApproved,
pendingMessage = pendingMessage,
)
fun aHealthChatCompleteness(
isComplete: Boolean = true,
missingFields: ImmutableList<String> = persistentListOf(),
message: String? = null,
hasProfilePhoto: Boolean = true,
) = HealthChatCompleteness(
isComplete = isComplete,
missingFields = missingFields,
message = message,
hasProfilePhoto = hasProfilePhoto,
)