diff --git a/features/healthchat/api/build.gradle.kts b/features/healthchat/api/build.gradle.kts new file mode 100644 index 0000000000..9c5cf852f9 --- /dev/null +++ b/features/healthchat/api/build.gradle.kts @@ -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) +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatApiException.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatApiException.kt new file mode 100644 index 0000000000..53947d9a1f --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatApiException.kt @@ -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>, + ) : 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) +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatCompleteness.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatCompleteness.kt new file mode 100644 index 0000000000..3db5dd6f47 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatCompleteness.kt @@ -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, + val message: String?, + val hasProfilePhoto: Boolean, +) diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatDirectoryService.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatDirectoryService.kt new file mode 100644 index 0000000000..c7b94fdca1 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatDirectoryService.kt @@ -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 + + /** + * 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 +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatInviteService.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatInviteService.kt new file mode 100644 index 0000000000..31ff63533a --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatInviteService.kt @@ -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 +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMember.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMember.kt new file mode 100644 index 0000000000..107bc05bb3 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMember.kt @@ -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, +) diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDirectory.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDirectory.kt new file mode 100644 index 0000000000..5ed9676c46 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDirectory.kt @@ -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, + val totalCount: Int, + val searchQuery: String, + val isApproved: Boolean, + val pendingMessage: String?, +) diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt new file mode 100644 index 0000000000..df1c894e13 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatMemberDisplay.kt @@ -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 +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileRepository.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileRepository.kt new file mode 100644 index 0000000000..8cf9f40b99 --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileRepository.kt @@ -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> + + /** The own profile completeness. Updated by [refreshCompleteness]. */ + val completeness: StateFlow> + + suspend fun refreshOwnProfile(): Result + + suspend fun refreshCompleteness(): Result + + /** + * 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 + + /** + * 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 + + /** + * Remove the current avatar. On success [ownProfile] is refreshed. + * + * @return the German confirmation message from the server. + */ + suspend fun deleteAvatar(): Result +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileUpdate.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileUpdate.kt new file mode 100644 index 0000000000..eeffe46f0c --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatProfileUpdate.kt @@ -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() +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatStrings.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatStrings.kt new file mode 100644 index 0000000000..8d99a6eedd --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatStrings.kt @@ -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 diff --git a/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml new file mode 100644 index 0000000000..4ed4da1bc8 --- /dev/null +++ b/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml @@ -0,0 +1,14 @@ + + + + Ein Netzwerkfehler ist aufgetreten. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut. + Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut. + diff --git a/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml new file mode 100644 index 0000000000..8538d72b49 --- /dev/null +++ b/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml @@ -0,0 +1,14 @@ + + + + Une erreur réseau est survenue. Veuillez vérifier votre connexion et réessayer. + Une erreur est survenue. Veuillez réessayer. + diff --git a/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml new file mode 100644 index 0000000000..7e43442ee6 --- /dev/null +++ b/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml @@ -0,0 +1,14 @@ + + + + Si è verificato un errore di rete. Controlla la connessione e riprova. + Qualcosa è andato storto. Riprova. + diff --git a/features/healthchat/api/src/main/res/values/strings_healthchat.xml b/features/healthchat/api/src/main/res/values/strings_healthchat.xml new file mode 100644 index 0000000000..e210d59366 --- /dev/null +++ b/features/healthchat/api/src/main/res/values/strings_healthchat.xml @@ -0,0 +1,15 @@ + + + + A network error occurred. Please check your connection and try again. + Something went wrong. Please try again. + diff --git a/features/healthchat/impl/build.gradle.kts b/features/healthchat/impl/build.gradle.kts new file mode 100644 index 0000000000..f4eabb30b5 --- /dev/null +++ b/features/healthchat/impl/build.gradle.kts @@ -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) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatDirectoryService.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatDirectoryService.kt new file mode 100644 index 0000000000..35ec1420f2 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatDirectoryService.kt @@ -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 { + return runHealthChatApiCatching(jsonProvider()) { + sessionApi.api.getUsers(search = query.takeIf { it.isNotBlank() }) + } + .map { it.toHealthChatMemberDirectory() } + } + + override suspend fun getMember(userId: UserId): Result { + 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") + ) + ) + } + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatInviteService.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatInviteService.kt new file mode 100644 index 0000000000..c6c83c5e1c --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatInviteService.kt @@ -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 { + return runHealthChatApiCatching(jsonProvider()) { + sessionApi.api.invite(InvitationRequestDto(email = email, personalNote = personalNote)) + } + .map { it.message.orEmpty() } + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatProfileRepository.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatProfileRepository.kt new file mode 100644 index 0000000000..1b231883b3 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatProfileRepository.kt @@ -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.Uninitialized) + override val ownProfile: StateFlow> = ownProfileFlow.asStateFlow() + + private val completenessFlow = MutableStateFlow>(AsyncData.Uninitialized) + override val completeness: StateFlow> = completenessFlow.asStateFlow() + + override suspend fun refreshOwnProfile(): Result { + 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 { + 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 { + 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 { + 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 { + return runHealthChatApiCatching(jsonProvider()) { + sessionApi.api.deleteAvatar() + } + .map { it.message.orEmpty() } + .onSuccess { refreshOwnProfile() } + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatApiConfig.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatApiConfig.kt new file mode 100644 index 0000000000..8f26e439f6 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatApiConfig.kt @@ -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 +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatSessionApi.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatSessionApi.kt new file mode 100644 index 0000000000..ee21e0cf27 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatSessionApi.kt @@ -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) + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/mapper/HealthChatErrorMapper.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/mapper/HealthChatErrorMapper.kt new file mode 100644 index 0000000000..3b92222f72 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/mapper/HealthChatErrorMapper.kt @@ -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 runHealthChatApiCatching(json: Json, block: suspend () -> T): Result { + 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(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(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 Json.decodeOrNull(body: String): T? { + return try { + decodeFromString(body) + } catch (ignored: Exception) { + null + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/mapper/MatrixUserDtoMapper.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/mapper/MatrixUserDtoMapper.kt new file mode 100644 index 0000000000..25e2ee2ce8 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/mapper/MatrixUserDtoMapper.kt @@ -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() }, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/AvatarUploadResponseDto.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/AvatarUploadResponseDto.kt new file mode 100644 index 0000000000..182dd43983 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/AvatarUploadResponseDto.kt @@ -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, +) diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/HealthChatApi.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/HealthChatApi.kt new file mode 100644 index 0000000000..0df7fbea39 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/HealthChatApi.kt @@ -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 +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/HealthChatApiFactory.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/HealthChatApiFactory.kt new file mode 100644 index 0000000000..6949e72d7f --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/HealthChatApiFactory.kt @@ -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) + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/HealthChatAuthInterceptor.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/HealthChatAuthInterceptor.kt new file mode 100644 index 0000000000..6bb651e5dd --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/HealthChatAuthInterceptor.kt @@ -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 ` 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") diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/InvitationRequestDto.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/InvitationRequestDto.kt new file mode 100644 index 0000000000..dd191f4fd9 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/InvitationRequestDto.kt @@ -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, +) diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/MatrixUserDto.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/MatrixUserDto.kt new file mode 100644 index 0000000000..3191e08514 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/MatrixUserDto.kt @@ -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, +) diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/MatrixUserSearchResultDto.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/MatrixUserSearchResultDto.kt new file mode 100644 index 0000000000..965381bf51 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/MatrixUserSearchResultDto.kt @@ -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 = emptyList(), + val totalCount: Int = 0, + val searchQuery: String? = null, + val isApproved: Boolean = true, + val pendingMessage: String? = null, +) diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/MessageResponseDto.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/MessageResponseDto.kt new file mode 100644 index 0000000000..01f7007361 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/MessageResponseDto.kt @@ -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, +) diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/ProfileCompletenessDto.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/ProfileCompletenessDto.kt new file mode 100644 index 0000000000..ad979ae9dc --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/ProfileCompletenessDto.kt @@ -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 = emptyList(), + val message: String? = null, + val hasProfilePhoto: Boolean = true, +) diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/UpdateProfileRequestDto.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/UpdateProfileRequestDto.kt new file mode 100644 index 0000000000..d2ffdf1c19 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/UpdateProfileRequestDto.kt @@ -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, +) diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/ValidationProblemDto.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/ValidationProblemDto.kt new file mode 100644 index 0000000000..3ee25f63b7 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/network/ValidationProblemDto.kt @@ -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> = emptyMap(), +) diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatDirectoryServiceTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatDirectoryServiceTest.kt new file mode 100644 index 0000000000..7304988a84 --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatDirectoryServiceTest.kt @@ -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) + } +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatInviteServiceTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatInviteServiceTest.kt new file mode 100644 index 0000000000..7472f3b109 --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatInviteServiceTest.kt @@ -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(), + ) + } +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatProfileRepositoryTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatProfileRepositoryTest.kt new file mode 100644 index 0000000000..146695168f --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatProfileRepositoryTest.kt @@ -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(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"}""") + } +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/HealthChatTestHelpers.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/HealthChatTestHelpers.kt new file mode 100644 index 0000000000..01317b1e36 --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/HealthChatTestHelpers.kt @@ -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, + ) +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/mapper/HealthChatErrorMapperTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/mapper/HealthChatErrorMapperTest.kt new file mode 100644 index 0000000000..fb64f08173 --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/mapper/HealthChatErrorMapperTest.kt @@ -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, "Bad Gateway").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(code, body.toResponseBody("application/json".toMediaType())) + ) + } +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/network/HealthChatAuthInterceptorTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/network/HealthChatAuthInterceptorTest.kt new file mode 100644 index 0000000000..e461ae9e7f --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/network/HealthChatAuthInterceptorTest.kt @@ -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() + 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}"}""") + } +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/network/UpdateProfileRequestDtoTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/network/UpdateProfileRequestDtoTest.kt new file mode 100644 index 0000000000..aeaaa208ad --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/network/UpdateProfileRequestDtoTest.kt @@ -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", + ) + ) + } +} diff --git a/features/healthchat/test/build.gradle.kts b/features/healthchat/test/build.gradle.kts new file mode 100644 index 0000000000..e8d41f2387 --- /dev/null +++ b/features/healthchat/test/build.gradle.kts @@ -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) +} diff --git a/features/healthchat/test/src/main/kotlin/io/element/android/features/healthchat/test/FakeHealthChatDirectoryService.kt b/features/healthchat/test/src/main/kotlin/io/element/android/features/healthchat/test/FakeHealthChatDirectoryService.kt new file mode 100644 index 0000000000..afb1b27c59 --- /dev/null +++ b/features/healthchat/test/src/main/kotlin/io/element/android/features/healthchat/test/FakeHealthChatDirectoryService.kt @@ -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 = { lambdaError() }, + private val getMemberLambda: suspend (UserId) -> Result = { lambdaError() }, +) : HealthChatDirectoryService { + override suspend fun searchMembers(query: String): Result { + return searchMembersLambda(query) + } + + override suspend fun getMember(userId: UserId): Result { + return getMemberLambda(userId) + } +} diff --git a/features/healthchat/test/src/main/kotlin/io/element/android/features/healthchat/test/FakeHealthChatInviteService.kt b/features/healthchat/test/src/main/kotlin/io/element/android/features/healthchat/test/FakeHealthChatInviteService.kt new file mode 100644 index 0000000000..b0647a5af9 --- /dev/null +++ b/features/healthchat/test/src/main/kotlin/io/element/android/features/healthchat/test/FakeHealthChatInviteService.kt @@ -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 = { _, _ -> lambdaError() }, +) : HealthChatInviteService { + override suspend fun invite(email: String, personalNote: String): Result { + return inviteLambda(email, personalNote) + } +} diff --git a/features/healthchat/test/src/main/kotlin/io/element/android/features/healthchat/test/FakeHealthChatProfileRepository.kt b/features/healthchat/test/src/main/kotlin/io/element/android/features/healthchat/test/FakeHealthChatProfileRepository.kt new file mode 100644 index 0000000000..28586ec97e --- /dev/null +++ b/features/healthchat/test/src/main/kotlin/io/element/android/features/healthchat/test/FakeHealthChatProfileRepository.kt @@ -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 = { lambdaError() }, + private val refreshCompletenessLambda: suspend () -> Result = { lambdaError() }, + private val updateOwnProfileLambda: suspend (HealthChatProfileUpdate) -> Result = { lambdaError() }, + private val uploadAvatarLambda: suspend (String, String, ByteArray) -> Result = { _, _, _ -> lambdaError() }, + private val deleteAvatarLambda: suspend () -> Result = { lambdaError() }, +) : HealthChatProfileRepository { + private val ownProfileFlow = MutableStateFlow>(AsyncData.Uninitialized) + override val ownProfile: StateFlow> = ownProfileFlow + + private val completenessFlow = MutableStateFlow>(AsyncData.Uninitialized) + override val completeness: StateFlow> = completenessFlow + + fun givenOwnProfile(value: AsyncData) { + ownProfileFlow.value = value + } + + fun givenCompleteness(value: AsyncData) { + completenessFlow.value = value + } + + override suspend fun refreshOwnProfile(): Result { + return refreshOwnProfileLambda() + .onSuccess { ownProfileFlow.value = AsyncData.Success(it) } + .onFailure { ownProfileFlow.value = AsyncData.Failure(it) } + } + + override suspend fun refreshCompleteness(): Result { + return refreshCompletenessLambda() + .onSuccess { completenessFlow.value = AsyncData.Success(it) } + .onFailure { completenessFlow.value = AsyncData.Failure(it) } + } + + override suspend fun updateOwnProfile(update: HealthChatProfileUpdate): Result { + return updateOwnProfileLambda(update) + } + + override suspend fun uploadAvatar(fileName: String, mimeType: String, data: ByteArray): Result { + return uploadAvatarLambda(fileName, mimeType, data) + } + + override suspend fun deleteAvatar(): Result { + return deleteAvatarLambda() + } +} diff --git a/features/healthchat/test/src/main/kotlin/io/element/android/features/healthchat/test/HealthChatFixtures.kt b/features/healthchat/test/src/main/kotlin/io/element/android/features/healthchat/test/HealthChatFixtures.kt new file mode 100644 index 0000000000..2ecd62f172 --- /dev/null +++ b/features/healthchat/test/src/main/kotlin/io/element/android/features/healthchat/test/HealthChatFixtures.kt @@ -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 = 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 = persistentListOf(), + message: String? = null, + hasProfilePhoto: Boolean = true, +) = HealthChatCompleteness( + isComplete = isComplete, + missingFields = missingFields, + message = message, + hasProfilePhoto = hasProfilePhoto, +)