diff --git a/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreenTest.kt b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreenTest.kt index 6ad104b..297dd3c 100644 --- a/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreenTest.kt +++ b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreenTest.kt @@ -1,13 +1,18 @@ package com.interlinedlist.android.feature.documents.ui.collaborators import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.documents.domain.Collaborator import com.interlinedlist.android.feature.documents.domain.CollaboratorRole +import com.interlinedlist.android.feature.documents.domain.DocumentInvite +import com.interlinedlist.android.feature.documents.domain.InviteRole import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -23,6 +28,7 @@ class DocumentCollaboratorsScreenTest { state: DocumentCollaboratorsUiState, onChangeRole: (String, CollaboratorRole) -> Unit = { _, _ -> }, onRevoke: (String) -> Unit = {}, + onRevokeInvite: (String) -> Unit = {}, ) { composeRule.setContent { InterlinedListTheme { @@ -34,11 +40,23 @@ class DocumentCollaboratorsScreenTest { onInvite = {}, onChangeRole = onChangeRole, onRevoke = onRevoke, + onInviteEmailChange = {}, + onSelectInviteEmailRole = {}, + onSendInvite = {}, + onRevokeInvite = onRevokeInvite, ) } } } + private fun invite( + email: String, + token: String, + role: InviteRole = InviteRole.VIEWER, + expiresAt: String? = null, + accepted: Boolean = false, + ) = DocumentInvite(email, token, role, expiresAt, null, accepted, null, null) + @Test fun rendersCollaborators_withRoleControls() { setContent( @@ -98,4 +116,89 @@ class DocumentCollaboratorsScreenTest { setContent(DocumentCollaboratorsUiState(collaborators = emptyList(), isLoading = false)) composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.EMPTY).assertIsDisplayed() } + + // --- Email invites ----------------------------------------------------- + + @Test + fun pendingInvites_renderRoleStatusAndExpiry() { + setContent( + DocumentCollaboratorsUiState( + isLoading = false, + invites = DocumentInvitesUiState( + isLoading = false, + invites = listOf( + invite("friend@example.com", "tok-a", InviteRole.EDITOR), + invite("late@example.com", "tok-b", expiresAt = "2020-01-02T00:00:00Z"), + invite("done@example.com", "tok-c", InviteRole.ADMIN, accepted = true), + ), + ), + ), + ) + + composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.INVITE_LIST).assertIsDisplayed() + composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.inviteRow("tok-a")).assertIsDisplayed() + composeRule.onNodeWithText("friend@example.com").assertIsDisplayed() + composeRule.onNodeWithText("Editor · No expiry").assertIsDisplayed() + composeRule.onNodeWithText("Pending").assertIsDisplayed() + // An invite whose expiry has passed reads as expired, not pending. + composeRule.onNodeWithText("Expired").assertIsDisplayed() + composeRule.onNodeWithText("Accepted").assertIsDisplayed() + } + + @Test + fun revokeInvite_invokesCallback() { + var revoked: String? = null + setContent( + DocumentCollaboratorsUiState( + isLoading = false, + invites = DocumentInvitesUiState( + isLoading = false, + invites = listOf(invite("friend@example.com", "tok-a")), + ), + ), + onRevokeInvite = { revoked = it }, + ) + + composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.inviteRevoke("tok-a")).performClick() + assert(revoked == "tok-a") + } + + @Test + fun sendInvite_isDisabled_forAnIncompleteEmail() { + setContent( + DocumentCollaboratorsUiState( + isLoading = false, + invites = DocumentInvitesUiState(isLoading = false, email = "friend@"), + ), + ) + composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.INVITE_SEND).assertIsNotEnabled() + } + + @Test + fun sendInvite_isEnabled_forAValidEmail() { + setContent( + DocumentCollaboratorsUiState( + isLoading = false, + invites = DocumentInvitesUiState(isLoading = false, email = "friend@example.com"), + ), + ) + composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.INVITE_SEND).assertIsEnabled() + } + + @Test + fun subscriptionGate_isShown_whenSendingIsRefused() { + setContent( + DocumentCollaboratorsUiState( + isLoading = false, + invites = DocumentInvitesUiState( + isLoading = false, + subscriptionRequired = true, + errorMessage = "Subscribe to invite people to documents.", + ), + ), + ) + + composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.INVITE_GATE).assertIsDisplayed() + composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.INVITE_EMPTY).assertIsDisplayed() + } } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt index 057eff6..46a646f 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt @@ -4,6 +4,8 @@ import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.core.common.result.map +import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.interlinedlist.android.core.network.dto.toDomain as userDtoToDomain import com.interlinedlist.android.core.network.error.safeApiCall import com.interlinedlist.android.feature.documents.data.local.DocumentDao import com.interlinedlist.android.feature.documents.data.local.FolderDao @@ -21,6 +23,7 @@ import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi import com.interlinedlist.android.feature.documents.data.remote.dto.CreateDocumentRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderDocumentRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.CreateInviteRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateShareLinkRequest import com.interlinedlist.android.feature.documents.data.remote.dto.FromTemplateRequest import com.interlinedlist.android.feature.documents.data.remote.dto.InviteCollaboratorRequest @@ -34,11 +37,14 @@ import com.interlinedlist.android.feature.documents.domain.CollaboratorCandidate import com.interlinedlist.android.feature.documents.domain.CollaboratorRole import com.interlinedlist.android.feature.documents.domain.Document import com.interlinedlist.android.feature.documents.domain.DocumentFolder +import com.interlinedlist.android.feature.documents.domain.DocumentInvite import com.interlinedlist.android.feature.documents.domain.DocumentTemplate import com.interlinedlist.android.feature.documents.domain.FolderContents import com.interlinedlist.android.feature.documents.domain.FolderNode import com.interlinedlist.android.feature.documents.domain.FolderSummary import com.interlinedlist.android.feature.documents.domain.FolderTree +import com.interlinedlist.android.feature.documents.domain.InviteEmail +import com.interlinedlist.android.feature.documents.domain.InviteRole import com.interlinedlist.android.feature.documents.domain.Presence import com.interlinedlist.android.feature.documents.domain.ShareLink import com.interlinedlist.android.feature.documents.domain.ShareRole @@ -62,6 +68,8 @@ import javax.inject.Inject */ class DefaultDocumentsRepository @Inject constructor( private val api: DocumentsApi, + /** Shared current-user endpoint, used only for the subscriber gate on sending invites. */ + private val userApi: InterlinedListApi, private val documentDao: DocumentDao, private val folderDao: FolderDao, private val pendingOpDao: PendingOpDao, @@ -479,6 +487,69 @@ class DefaultDocumentsRepository @Inject constructor( } } + // --- Email invites ----------------------------------------------------- + + override suspend fun getInvites(documentId: String): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getInvites(documentId) } + .map { response -> response.items.map { it.toDomain() } } + } + + override suspend fun sendInvite( + documentId: String, + email: String, + role: InviteRole, + ): ApiResult = withContext(dispatchers.io) { + val address = InviteEmail.normalize(email) + if (!InviteEmail.isValid(address)) { + return@withContext ApiResult.Failure(AppError.Unknown(InviteEmail.INVALID_MESSAGE)) + } + // Sending is subscriber-only (the server 403s a free owner). Check first so a + // free account never issues the write at all. Fail OPEN when the status cannot + // be read — a flaky /api/user must not block a paying subscriber; the server + // remains the authority and answers with the same SubscriptionRequired error. + if (currentUserIsSubscriber() == false) { + return@withContext ApiResult.Failure(AppError.SubscriptionRequired(NOT_SUBSCRIBED_MESSAGE)) + } + when ( + val result = safeApiCall(json) { + api.createInvite(documentId, CreateInviteRequest(email = address, role = role.apiValue)) + } + ) { + is ApiResult.Success -> { + val dto = result.data.inviteOrSelf + ApiResult.Success( + dto?.toDomain() ?: DocumentInvite( + email = address, + token = "", + role = role, + expiresAt = null, + createdAt = null, + accepted = false, + revokedAt = null, + url = null, + ), + ) + } + is ApiResult.Failure -> result + } + } + + override suspend fun revokeInvite(documentId: String, token: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.revokeInvite(documentId, token) }.map { } + } + + /** + * The signed-in account's subscription tier, or null when it cannot be read. + * Mirrors the `customerStatus` check the rest of the app gates premium features on. + */ + private suspend fun currentUserIsSubscriber(): Boolean? = + when (val result = safeApiCall(json) { userApi.getCurrentUser().user }) { + is ApiResult.Success -> result.data.userDtoToDomain().customerStatus.isSubscriber + is ApiResult.Failure -> null + } + // --- Delta sync -------------------------------------------------------- override suspend fun pullDelta(): ApiResult = withContext(dispatchers.io) { @@ -660,4 +731,9 @@ class DefaultDocumentsRepository @Inject constructor( /** Treats the synthetic root id as "no parent" for API calls. */ private fun String.realOrNull(): String? = takeUnless { it == FolderNode.ROOT_ID } + + private companion object { + /** Matches the server's own copy for the 403 a free owner receives. */ + const val NOT_SUBSCRIBED_MESSAGE = "Subscribe to invite people to documents." + } } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt index 2c96447..19f0fe3 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt @@ -6,9 +6,11 @@ import com.interlinedlist.android.feature.documents.domain.CollaboratorCandidate import com.interlinedlist.android.feature.documents.domain.CollaboratorRole import com.interlinedlist.android.feature.documents.domain.Document import com.interlinedlist.android.feature.documents.domain.DocumentFolder +import com.interlinedlist.android.feature.documents.domain.DocumentInvite import com.interlinedlist.android.feature.documents.domain.DocumentTemplate import com.interlinedlist.android.feature.documents.domain.FolderContents import com.interlinedlist.android.feature.documents.domain.FolderSummary +import com.interlinedlist.android.feature.documents.domain.InviteRole import com.interlinedlist.android.feature.documents.domain.Presence import com.interlinedlist.android.feature.documents.domain.ShareLink import com.interlinedlist.android.feature.documents.domain.ShareRole @@ -154,6 +156,29 @@ interface DocumentsRepository { /** Claims edit/admin access to a shared document via its token. */ suspend fun claimSharedDocument(token: String): ApiResult + // --- Email invites ----------------------------------------------------- + + /** + * Pending email invites for a document. Free for any owner — a lapsed + * subscription must never hide invites the owner still needs to revoke. + */ + suspend fun getInvites(documentId: String): ApiResult> + + /** + * Invites [email] at [role]. Refuses locally — issuing no request at all — when + * the address is not a valid one, or when the signed-in account is known not to + * be a subscriber (sending is a subscriber feature), in which case the failure is + * [com.interlinedlist.android.core.common.result.AppError.SubscriptionRequired]. + */ + suspend fun sendInvite( + documentId: String, + email: String, + role: InviteRole, + ): ApiResult + + /** Revokes a pending invite, killing its link immediately. Never subscriber-gated. */ + suspend fun revokeInvite(documentId: String, token: String): ApiResult + // --- Delta sync -------------------------------------------------------- /** diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/InviteMappers.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/InviteMappers.kt new file mode 100644 index 0000000..3285199 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/InviteMappers.kt @@ -0,0 +1,27 @@ +package com.interlinedlist.android.feature.documents.data.mapper + +import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentInviteDto +import com.interlinedlist.android.feature.documents.domain.DocumentInvite +import com.interlinedlist.android.feature.documents.domain.InviteRole + +/** + * Maps an invite wire model into the domain [DocumentInvite]. + * + * The documented create response omits `token` and only returns the landing `url`, + * so the token is recovered from the URL's last path segment — without it the owner + * could not revoke an invite they had just sent. + */ +fun DocumentInviteDto.toDomain(): DocumentInvite = DocumentInvite( + email = email, + token = token.ifBlank { tokenFromInviteUrl(url) }, + role = InviteRole.fromApi(role), + expiresAt = expiresAt, + createdAt = createdAt, + accepted = accepted || acceptedAt != null, + revokedAt = revokedAt, + url = url, +) + +/** Last path segment of an invite landing URL (`.../documents/invite/`). */ +private fun tokenFromInviteUrl(url: String?): String = + url?.substringBefore('?')?.substringBefore('#')?.trimEnd('/')?.substringAfterLast('/').orEmpty() diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt index 8a4fe2d..b72364e 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt @@ -6,8 +6,11 @@ import com.interlinedlist.android.feature.documents.data.remote.dto.Collaborator import com.interlinedlist.android.feature.documents.data.remote.dto.CreateDocumentRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderDocumentRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.CreateInviteRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateShareLinkRequest import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentListResponse +import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentInviteEnvelope +import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentInvitesResponse import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentResponse import com.interlinedlist.android.feature.documents.data.remote.dto.FolderListResponse import com.interlinedlist.android.feature.documents.data.remote.dto.FolderResponse @@ -165,6 +168,29 @@ interface DocumentsApi { @POST("api/documents/shared/{token}") suspend fun claimSharedDocument(@Path("token") token: String) + // --- Email invites ----------------------------------------------------- + + /** Pending email invites for a document (owner only; free — not subscriber-gated). */ + @GET("api/documents/{id}/invites") + suspend fun getInvites(@Path("id") id: String): DocumentInvitesResponse + + /** + * Invites an email address (which need not belong to an account yet) at a role. + * Owner **and subscriber** only: a free owner is rejected with 403. + */ + @POST("api/documents/{id}/invites") + suspend fun createInvite( + @Path("id") id: String, + @Body body: CreateInviteRequest, + ): DocumentInviteEnvelope + + /** Revokes a pending invite by its token, killing the link immediately. Always free. */ + @DELETE("api/documents/{id}/invites/{token}") + suspend fun revokeInvite( + @Path("id") id: String, + @Path("token") token: String, + ) + // --- Delta sync -------------------------------------------------------- /** diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/InviteDtos.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/InviteDtos.kt new file mode 100644 index 0000000..96c0dd2 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/InviteDtos.kt @@ -0,0 +1,92 @@ +package com.interlinedlist.android.feature.documents.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Wire models for the document email-invite endpoints. + * + * The help centre (`/help/api/sharing` → *Email invites*) documents the list row as + * `{ email, role, expiresAt, accepted, createdAt, token }` and the 201 create body as + * `{ email, role, expiresAt, url }` — note the create response carries **no token**; + * it is embedded in `url`. The `DocumentShareInvite` schema additionally declares + * `id`, `documentId`, `invitedByUserId`, `revokedAt`, `acceptedAt`, `acceptedByUserId`, + * so all of those are modelled optionally: whichever projection the server returns, + * the DTO parses. Everything defaults, and the shared Json ignores unknown keys. + */ +@Serializable +data class DocumentInviteDto( + val id: String = "", + val documentId: String? = null, + val email: String = "", + val token: String = "", + val role: String? = null, + val invitedByUserId: String? = null, + val expiresAt: String? = null, + val revokedAt: String? = null, + /** Reported on the list projection; the full entity reports `acceptedAt` instead. */ + val accepted: Boolean = false, + val acceptedAt: String? = null, + val acceptedByUserId: String? = null, + val createdAt: String? = null, + /** The invite landing address; only the create response is documented to return it. */ + val url: String? = null, +) + +/** `GET /api/documents/{id}/invites` — documented as `{ "invites": [...] }`. */ +@Serializable +data class DocumentInvitesResponse( + val invites: List? = null, + val data: List? = null, +) { + val items: List get() = invites ?: data ?: emptyList() +} + +/** + * `POST /api/documents/{id}/invites` — the documented 201 returns the invite fields + * inline; a wrapped `{ "invite": ... }` / `{ "data": ... }` envelope (the convention + * other create endpoints use) is accepted too. + */ +@Serializable +data class DocumentInviteEnvelope( + val invite: DocumentInviteDto? = null, + val data: DocumentInviteDto? = null, + val id: String? = null, + val email: String? = null, + val token: String? = null, + val role: String? = null, + val expiresAt: String? = null, + val revokedAt: String? = null, + val accepted: Boolean = false, + val acceptedAt: String? = null, + val createdAt: String? = null, + val url: String? = null, +) { + /** The created invite, whether wrapped or inlined on the response root. */ + val inviteOrSelf: DocumentInviteDto? + get() = invite ?: data ?: (email ?: url)?.let { + DocumentInviteDto( + id = id.orEmpty(), + email = email.orEmpty(), + token = token.orEmpty(), + role = role, + expiresAt = expiresAt, + revokedAt = revokedAt, + accepted = accepted, + acceptedAt = acceptedAt, + createdAt = createdAt, + url = url, + ) + } +} + +/** + * Body for `POST /api/documents/{id}/invites`. `role` defaults server-side to + * `watcher`; `expiresAt` is optional (null == never expires) and is dropped by the + * shared Json when null. + */ +@Serializable +data class CreateInviteRequest( + val email: String, + val role: String, + val expiresAt: String? = null, +) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentInvite.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentInvite.kt new file mode 100644 index 0000000..c06f83b --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentInvite.kt @@ -0,0 +1,104 @@ +package com.interlinedlist.android.feature.documents.domain + +import java.time.Instant + +/** + * An email invite to a document: a role granted to an *email address* rather than + * to an account, so the invitee need not have signed up yet and the document stays + * private throughout. Unlike a [ShareLink] (a bearer capability), the [token] only + * becomes access once someone signed in with that verified address claims it. + * + * Timestamps stay as the API's raw ISO-8601 strings (the module's convention — see + * [ShareLink]); [statusAt] derives the displayed status from them. + */ +data class DocumentInvite( + val email: String, + val token: String, + val role: InviteRole, + /** ISO-8601 instant after which the invite stops resolving; null == no expiry. */ + val expiresAt: String?, + val createdAt: String?, + /** True once the invitee has claimed the invite. */ + val accepted: Boolean, + /** ISO-8601 instant the owner revoked the invite, when the server reports one. */ + val revokedAt: String?, + /** The landing URL the server generated, when it returned one. */ + val url: String?, +) { + /** + * The status to show against this invite. Revocation and acceptance are terminal + * facts the server reports; expiry is a function of the clock, so [now] is a + * parameter to keep rendering deterministic in tests. + */ + fun statusAt(now: Instant = Instant.now()): InviteStatus = when { + revokedAt != null -> InviteStatus.REVOKED + accepted -> InviteStatus.ACCEPTED + hasExpiredAt(now) -> InviteStatus.EXPIRED + else -> InviteStatus.PENDING + } + + /** True when [expiresAt] parses and is at or before [now]. Unparseable == not expired. */ + private fun hasExpiredAt(now: Instant): Boolean { + val expiry = expiresAt?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return false + return !expiry.isAfter(now) + } + + /** The landing address for this invite, falling back to the canonical path. */ + fun inviteUrl(baseUrl: String = ShareLink.INTERLINEDLIST_BASE_URL): String = + url?.takeIf { it.isNotBlank() } ?: "${baseUrl.trimEnd('/')}/documents/invite/$token" +} + +/** + * Displayed state of an invite. The API reports only `accepted` (plus optional + * `expiresAt` / `revokedAt`), so the four states the web app shows are derived + * client-side — see [DocumentInvite.statusAt]. + */ +enum class InviteStatus(val label: String) { + PENDING("Pending"), + ACCEPTED("Accepted"), + EXPIRED("Expired"), + REVOKED("Revoked"), +} + +/** + * Access an email invite grants. The invite endpoints take the server's sharing + * vocabulary (`watcher` / `collaborator` / `manager`) while the UI uses the web + * app's labels (Viewer / Editor / Admin). Unknown values map to [VIEWER] so an + * invite is never over-privileged. + */ +enum class InviteRole(val apiValue: String, val label: String) { + VIEWER("watcher", "Viewer"), + EDITOR("collaborator", "Editor"), + ADMIN("manager", "Admin"); + + companion object { + /** Maps an API role string (case-insensitive) to an [InviteRole]. */ + fun fromApi(raw: String?): InviteRole = when (raw?.trim()?.lowercase()) { + "collaborator", "editor", "edit", "write" -> EDITOR + "manager", "admin", "owner" -> ADMIN + else -> VIEWER + } + } +} + +/** + * Syntactic validation + normalisation of an invited address, matching what the + * server does (it stores the address lowercased/trimmed and 400s on an invalid + * one). Shared by the UI — to keep Send disabled and show an inline error — and + * by the repository, which refuses to issue a request for an invalid address. + */ +object InviteEmail { + + /** The message shown when [isValid] rejects an address. */ + const val INVALID_MESSAGE = "Enter a valid email address." + + private val PATTERN = Regex("^[^\\s@]+@[^\\s@.]+(\\.[^\\s@.]+)+$") + + /** Trims and lowercases, as the server stores it. */ + fun normalize(raw: String): String = raw.trim().lowercase() + + /** True when [raw] is syntactically a usable address. */ + fun isValid(raw: String): Boolean = normalize(raw).let { it.length <= MAX_LENGTH && PATTERN.matches(it) } + + private const val MAX_LENGTH = 254 +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreen.kt index 8cdc0d2..ab6b0b5 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreen.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreen.kt @@ -13,15 +13,21 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.outlined.Group +import androidx.compose.material.icons.outlined.MailOutline import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -29,12 +35,14 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -44,6 +52,10 @@ import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.documents.domain.Collaborator import com.interlinedlist.android.feature.documents.domain.CollaboratorCandidate import com.interlinedlist.android.feature.documents.domain.CollaboratorRole +import com.interlinedlist.android.feature.documents.domain.DocumentInvite +import com.interlinedlist.android.feature.documents.domain.InviteRole +import com.interlinedlist.android.feature.documents.domain.InviteStatus +import com.interlinedlist.android.feature.documents.ui.common.inviteExpiryLabel /** Stable test tags for the Manage-access sheet. */ object DocumentCollaboratorsTestTags { @@ -59,6 +71,17 @@ object DocumentCollaboratorsTestTags { fun role(userId: String, role: CollaboratorRole) = "collabRole_${userId}_${role.apiValue}" fun candidate(userId: String) = "collabCandidate_$userId" fun inviteRole(role: CollaboratorRole) = "collabInviteRole_${role.apiValue}" + + // Email invites. + const val INVITE_EMAIL_FIELD = "inviteEmailField" + const val INVITE_SEND = "inviteSend" + const val INVITE_LIST = "inviteList" + const val INVITE_EMPTY = "inviteEmpty" + const val INVITE_ERROR = "inviteError" + const val INVITE_GATE = "inviteGate" + fun inviteEmailRole(role: InviteRole) = "inviteEmailRole_${role.apiValue}" + fun inviteRow(token: String) = "inviteRow_$token" + fun inviteRevoke(token: String) = "inviteRevoke_$token" } /** @@ -87,6 +110,10 @@ fun DocumentCollaboratorsRoute( onInvite = viewModel::invite, onChangeRole = viewModel::changeRole, onRevoke = viewModel::revoke, + onInviteEmailChange = viewModel::onInviteEmailChange, + onSelectInviteEmailRole = viewModel::selectInviteRole, + onSendInvite = viewModel::sendInvite, + onRevokeInvite = viewModel::revokeInvite, ) } } @@ -101,6 +128,10 @@ fun DocumentCollaboratorsSheetContent( onInvite: (CollaboratorCandidate) -> Unit, onChangeRole: (String, CollaboratorRole) -> Unit, onRevoke: (String) -> Unit, + onInviteEmailChange: (String) -> Unit, + onSelectInviteEmailRole: (InviteRole) -> Unit, + onSendInvite: () -> Unit, + onRevokeInvite: (String) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -215,6 +246,181 @@ fun DocumentCollaboratorsSheetContent( } } } + + // --- Email invites ------------------------------------------------ + Spacer(Modifier.height(20.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + InviteByEmailSection( + state = state.invites, + onEmailChange = onInviteEmailChange, + onSelectRole = onSelectInviteEmailRole, + onSend = onSendInvite, + onRevoke = onRevokeInvite, + ) + } +} + +/** + * "Invite by email" — the form for inviting an address that need not have an + * account yet, plus the pending invites it produces. Sending is a subscriber + * feature; listing and revoking are always available. + */ +@Composable +private fun InviteByEmailSection( + state: DocumentInvitesUiState, + onEmailChange: (String) -> Unit, + onSelectRole: (InviteRole) -> Unit, + onSend: () -> Unit, + onRevoke: (String) -> Unit, +) { + Column(Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Outlined.MailOutline, contentDescription = null) + Text("Invite by email", style = MaterialTheme.typography.titleMedium) + } + Spacer(Modifier.height(4.dp)) + Text( + text = "Invite someone by email address — they don't need an account yet, " + + "and the document stays private.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = state.email, + onValueChange = onEmailChange, + label = { Text("Email") }, + singleLine = true, + isError = state.emailError != null, + supportingText = state.emailError?.let { { Text(it) } }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier + .fillMaxWidth() + .testTag(DocumentCollaboratorsTestTags.INVITE_EMAIL_FIELD), + ) + + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + InviteRole.entries.forEach { role -> + FilterChip( + selected = state.role == role, + onClick = { onSelectRole(role) }, + label = { Text(role.label) }, + modifier = Modifier.testTag(DocumentCollaboratorsTestTags.inviteEmailRole(role)), + ) + } + } + + Spacer(Modifier.height(8.dp)) + Button( + onClick = onSend, + enabled = state.canSend, + modifier = Modifier.testTag(DocumentCollaboratorsTestTags.INVITE_SEND), + ) { Text(if (state.isSending) "Sending…" else "Send invite") } + + if (state.subscriptionRequired) { + Spacer(Modifier.height(8.dp)) + Column(Modifier.testTag(DocumentCollaboratorsTestTags.INVITE_GATE)) { + Text( + text = "Subscriber feature", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = state.errorMessage ?: "Subscribe to invite people to documents.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else if (state.errorMessage != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = state.errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(DocumentCollaboratorsTestTags.INVITE_ERROR), + ) + } + + Spacer(Modifier.height(16.dp)) + Text("Pending invites", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + + when { + state.isLoading -> CircularProgressIndicator(Modifier.size(24.dp)) + + state.isEmpty -> Text( + text = "No invites yet.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(DocumentCollaboratorsTestTags.INVITE_EMPTY), + ) + + // A short, owner-managed list — a plain Column keeps it scrollable + // inside the sheet without nesting a lazy list. + else -> Column( + modifier = Modifier + .fillMaxWidth() + .testTag(DocumentCollaboratorsTestTags.INVITE_LIST), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.invites.forEach { invite -> + PendingInviteRow(invite = invite, onRevoke = { onRevoke(invite.token) }) + } + } + } + } +} + +/** One pending invite: address, role, derived status and expiry, with Revoke. */ +@Composable +private fun PendingInviteRow(invite: DocumentInvite, onRevoke: () -> Unit) { + val status = invite.statusAt() + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(DocumentCollaboratorsTestTags.inviteRow(invite.token)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(Modifier.weight(1f)) { + Text( + text = invite.email, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${invite.role.label} · ${inviteExpiryLabel(invite.expiresAt)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + AssistChip( + onClick = {}, + enabled = false, + label = { Text(status.label) }, + colors = AssistChipDefaults.assistChipColors( + disabledLabelColor = when (status) { + InviteStatus.ACCEPTED -> MaterialTheme.colorScheme.primary + InviteStatus.EXPIRED, InviteStatus.REVOKED -> MaterialTheme.colorScheme.error + InviteStatus.PENDING -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ), + ) + TextButton( + onClick = onRevoke, + modifier = Modifier.testTag(DocumentCollaboratorsTestTags.inviteRevoke(invite.token)), + ) { Text("Revoke") } } } @@ -319,6 +525,21 @@ private fun DocumentCollaboratorsSheetPreview() { Collaborator("u2", CollaboratorRole.VIEWER, "Bob", "bob", null, null), ), isLoading = false, + invites = DocumentInvitesUiState( + invites = listOf( + DocumentInvite( + email = "friend@example.com", + token = "tok-a", + role = InviteRole.EDITOR, + expiresAt = null, + createdAt = null, + accepted = false, + revokedAt = null, + url = null, + ), + ), + isLoading = false, + ), ), onSearchQueryChange = {}, onSearch = {}, @@ -326,6 +547,10 @@ private fun DocumentCollaboratorsSheetPreview() { onInvite = {}, onChangeRole = { _, _ -> }, onRevoke = {}, + onInviteEmailChange = {}, + onSelectInviteEmailRole = {}, + onSendInvite = {}, + onRevokeInvite = {}, ) } } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsViewModel.kt index 30263ce..f132b9e 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsViewModel.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsViewModel.kt @@ -8,6 +8,11 @@ import com.interlinedlist.android.feature.documents.data.DocumentsRepository import com.interlinedlist.android.feature.documents.domain.Collaborator import com.interlinedlist.android.feature.documents.domain.CollaboratorCandidate import com.interlinedlist.android.feature.documents.domain.CollaboratorRole +import com.interlinedlist.android.feature.documents.domain.DocumentInvite +import com.interlinedlist.android.feature.documents.domain.InviteEmail +import com.interlinedlist.android.feature.documents.domain.InviteRole +import com.interlinedlist.android.feature.documents.ui.common.isSubscriptionGate +import com.interlinedlist.android.feature.documents.ui.common.toInviteMessage import com.interlinedlist.android.feature.documents.ui.common.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -29,10 +34,35 @@ data class DocumentCollaboratorsUiState( val isLoading: Boolean = true, val isSearching: Boolean = false, val errorMessage: String? = null, + /** The "Invite by email" section, which sits alongside the collaborator roles. */ + val invites: DocumentInvitesUiState = DocumentInvitesUiState(), ) { val isEmpty: Boolean get() = collaborators.isEmpty() && !isLoading && errorMessage == null } +/** + * State of the email-invite section: the entry form plus the pending-invite list. + * Kept as its own type so the section stays self-contained (and so the identical + * list-invite section can mirror it). + */ +data class DocumentInvitesUiState( + val invites: List = emptyList(), + val email: String = "", + val role: InviteRole = InviteRole.VIEWER, + val isLoading: Boolean = true, + val isSending: Boolean = false, + /** Inline validation message for the address field. */ + val emailError: String? = null, + val errorMessage: String? = null, + /** True when sending was refused because the account is not a subscriber. */ + val subscriptionRequired: Boolean = false, +) { + /** True when the entered address is worth sending — drives the Send control. */ + val canSend: Boolean get() = !isSending && InviteEmail.isValid(email) + + val isEmpty: Boolean get() = invites.isEmpty() && !isLoading +} + /** * Drives the Manage-access sheet: list collaborators, search invitable users, invite * at a chosen role, change a role, and revoke — all with optimistic UI + rollback so @@ -53,6 +83,7 @@ class DocumentCollaboratorsViewModel @Inject constructor( init { load() + loadInvites() } fun load() { @@ -158,4 +189,83 @@ class DocumentCollaboratorsViewModel @Inject constructor( } fun clearError() = _uiState.update { it.copy(errorMessage = null) } + + // --- Email invites ----------------------------------------------------- + + /** Loads the pending invites. Free for any owner, so it is never gated. */ + fun loadInvites() { + updateInvites { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.getInvites(documentId)) { + is ApiResult.Success -> updateInvites { + it.copy(invites = result.data, isLoading = false) + } + is ApiResult.Failure -> updateInvites { + it.copy(isLoading = false, errorMessage = result.error.toInviteMessage()) + } + } + } + } + + fun onInviteEmailChange(email: String) = + updateInvites { it.copy(email = email, emailError = null, errorMessage = null) } + + fun selectInviteRole(role: InviteRole) = updateInvites { it.copy(role = role) } + + /** + * Sends the invite. An address that is not syntactically valid is rejected here, + * so no request is made; everything else (ownership, the subscriber gate, an + * address that cannot be invited) is reported by the server and surfaced as-is. + */ + fun sendInvite() { + val form = _uiState.value.invites + if (form.isSending) return + if (!InviteEmail.isValid(form.email)) { + updateInvites { it.copy(emailError = InviteEmail.INVALID_MESSAGE) } + return + } + updateInvites { + it.copy(isSending = true, emailError = null, errorMessage = null, subscriptionRequired = false) + } + viewModelScope.launch { + when (val result = repository.sendInvite(documentId, form.email, form.role)) { + is ApiResult.Success -> updateInvites { state -> + // Re-inviting an address is idempotent server-side (a fresh token + // replaces the old one), so replace any row for the same address. + val sent = result.data + state.copy( + invites = state.invites.filterNot { it.email.equals(sent.email, ignoreCase = true) } + sent, + email = "", + isSending = false, + ) + } + is ApiResult.Failure -> updateInvites { + it.copy( + isSending = false, + errorMessage = result.error.toInviteMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + } + } + + /** Optimistically drops the invite row; restores it if the revoke fails. */ + fun revokeInvite(token: String) { + val previous = _uiState.value.invites.invites + updateInvites { state -> + state.copy(invites = state.invites.filterNot { it.token == token }, errorMessage = null) + } + viewModelScope.launch { + when (val result = repository.revokeInvite(documentId, token)) { + is ApiResult.Success -> Unit + is ApiResult.Failure -> updateInvites { + it.copy(invites = previous, errorMessage = result.error.toInviteMessage()) + } + } + } + } + + private fun updateInvites(transform: (DocumentInvitesUiState) -> DocumentInvitesUiState) = + _uiState.update { it.copy(invites = transform(it.invites)) } } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/DocumentErrorMessages.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/DocumentErrorMessages.kt index 30e8bd3..6f8e742 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/DocumentErrorMessages.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/DocumentErrorMessages.kt @@ -15,3 +15,21 @@ fun AppError.toUserMessage(): String = when (this) { /** Whether the error is the subscriber-only gate, so the UI can show an upsell. */ val AppError.isSubscriptionGate: Boolean get() = this is AppError.SubscriptionRequired + +/** + * Invite-specific wording. The invite endpoints reject with a useful `error` + * string of their own (invalid address, invalid role, unparseable expiry, the + * subscriber gate), so the server's message is preferred wherever it exists and + * only the fallbacks are re-worded for the invite context — a 404 here means the + * invite (or the caller's ownership of the document) is gone, not the document. + */ +fun AppError.toInviteMessage(): String = when (this) { + is AppError.Network -> "No connection. Check your network and try again." + is AppError.SubscriptionRequired -> message ?: "Subscribe to invite people to documents." + is AppError.Forbidden -> message ?: "Only the document owner can manage invites." + is AppError.NotFound -> message ?: "That invite is no longer available." + is AppError.Conflict -> message ?: "That person already has access to this document." + is AppError.RateLimited -> "Too many invites just now. Please wait a moment and try again." + is AppError.Server -> "InterlinedList is having trouble right now. Try again shortly." + else -> message ?: "That invite could not be sent. Please try again." +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/InviteLabels.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/InviteLabels.kt new file mode 100644 index 0000000..385c1b4 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/InviteLabels.kt @@ -0,0 +1,30 @@ +package com.interlinedlist.android.feature.documents.ui.common + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +/** + * Renders an invite's expiry as a short label: "No expiry", "Expires Jun 12, 2026" + * or, once the moment has passed, "Expired Jun 12, 2026". Falls back to the raw + * value when it cannot be parsed, so an unexpected timestamp never blanks the row. + * + * [now], [zone] and [locale] are injectable to keep rendering deterministic in tests. + */ +fun inviteExpiryLabel( + isoExpiresAt: String?, + now: Instant = Instant.now(), + zone: ZoneId = ZoneId.systemDefault(), + locale: Locale = Locale.getDefault(), +): String { + if (isoExpiresAt.isNullOrBlank()) return "No expiry" + val expiry = runCatching { Instant.parse(isoExpiresAt) }.getOrNull() + ?: return "Expires $isoExpiresAt" + val formatted = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(locale) + .withZone(zone) + .format(expiry) + return if (expiry.isAfter(now)) "Expires $formatted" else "Expired $formatted" +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCollaboratorTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCollaboratorTest.kt index cb8bc68..5585009 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCollaboratorTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCollaboratorTest.kt @@ -3,6 +3,7 @@ package com.interlinedlist.android.feature.documents.data import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.network.api.InterlinedListApi import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi import com.interlinedlist.android.feature.documents.domain.CollaboratorRole import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory @@ -45,7 +46,8 @@ class DefaultDocumentsRepositoryCollaboratorTest { .build() api = retrofit.create(DocumentsApi::class.java) repository = DefaultDocumentsRepository( - api, FakeDocumentDao(), FakeFolderDao(), FakePendingOpDao(), FakeSyncMetaDao(), json, dispatchers, + api, retrofit.create(InterlinedListApi::class.java), + FakeDocumentDao(), FakeFolderDao(), FakePendingOpDao(), FakeSyncMetaDao(), json, dispatchers, ) } diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCompletenessTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCompletenessTest.kt index b4c4633..f731218 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCompletenessTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCompletenessTest.kt @@ -3,6 +3,7 @@ package com.interlinedlist.android.feature.documents.data import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.network.api.InterlinedListApi import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory @@ -54,7 +55,8 @@ class DefaultDocumentsRepositoryCompletenessTest { documentDao = FakeDocumentDao() folderDao = FakeFolderDao() repository = DefaultDocumentsRepository( - api, documentDao, folderDao, FakePendingOpDao(), FakeSyncMetaDao(), json, dispatchers, + api, retrofit.create(InterlinedListApi::class.java), + documentDao, folderDao, FakePendingOpDao(), FakeSyncMetaDao(), json, dispatchers, ) } diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryInviteTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryInviteTest.kt new file mode 100644 index 0000000..ac417c1 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryInviteTest.kt @@ -0,0 +1,273 @@ +package com.interlinedlist.android.feature.documents.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi +import com.interlinedlist.android.feature.documents.domain.InviteRole +import com.interlinedlist.android.feature.documents.domain.InviteStatus +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit +import java.time.Instant + +/** + * MockWebServer coverage for the document email-invite endpoints: send, list and + * revoke, the subscriber gate on sending, and the client-side email guard. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultDocumentsRepositoryInviteTest { + + private lateinit var server: MockWebServer + private lateinit var repository: DefaultDocumentsRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + private val testDispatcher = StandardTestDispatcher() + private val dispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher = testDispatcher + override val default: CoroutineDispatcher = testDispatcher + override val main: CoroutineDispatcher = testDispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + repository = DefaultDocumentsRepository( + retrofit.create(DocumentsApi::class.java), + retrofit.create(InterlinedListApi::class.java), + FakeDocumentDao(), FakeFolderDao(), FakePendingOpDao(), FakeSyncMetaDao(), json, dispatchers, + ) + } + + @After + fun tearDown() = server.shutdown() + + /** `GET /api/user` as the subscriber gate reads it. */ + private fun enqueueUser(customerStatus: String) = server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "user": { "id": "u1", "username": "me", "email": "me@x.io", "customerStatus": "$customerStatus" } }""", + ), + ) + + // --- Listing ----------------------------------------------------------- + + @Test + fun `getInvites parses the documented list shape`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "invites": [ + { + "email": "friend@example.com", "role": "collaborator", "expiresAt": null, + "accepted": false, "createdAt": "2026-06-11T09:00:00.000Z", "token": "tok-a" + }, + { + "email": "old@example.com", "role": "manager", + "expiresAt": "2026-01-01T00:00:00.000Z", "accepted": true, + "createdAt": "2025-12-01T09:00:00.000Z", "token": "tok-b" + } + ] + } + """.trimIndent(), + ), + ) + + val result = repository.getInvites("D1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val invites = (result as ApiResult.Success).data + assertThat(invites.map { it.email }).containsExactly("friend@example.com", "old@example.com").inOrder() + assertThat(invites[0].role).isEqualTo(InviteRole.EDITOR) + assertThat(invites[0].token).isEqualTo("tok-a") + assertThat(invites[1].role).isEqualTo(InviteRole.ADMIN) + assertThat(invites[1].accepted).isTrue() + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("GET") + assertThat(request.path).isEqualTo("/api/documents/D1/invites") + } + + @Test + fun `getInvites renders an already-expired invite as expired`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { "invites": [ { "email": "late@example.com", "role": "watcher", + "expiresAt": "2026-01-02T00:00:00.000Z", "accepted": false, "token": "tok-x" } ] } + """.trimIndent(), + ), + ) + + val invite = (repository.getInvites("D1") as ApiResult.Success).data.single() + + val now = Instant.parse("2026-06-01T00:00:00Z") + assertThat(invite.statusAt(now)).isEqualTo(InviteStatus.EXPIRED) + assertThat(invite.statusAt(Instant.parse("2026-01-01T00:00:00Z"))).isEqualTo(InviteStatus.PENDING) + } + + @Test + fun `getInvites is not subscriber-gated and issues no current-user lookup`() = + runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "invites": [] }""")) + + assertThat(repository.getInvites("D1")).isInstanceOf(ApiResult.Success::class.java) + + assertThat(server.requestCount).isEqualTo(1) + assertThat(server.takeRequest().path).isEqualTo("/api/documents/D1/invites") + } + + // --- Sending ----------------------------------------------------------- + + @Test + fun `sendInvite posts the normalised email and role, and recovers the token from the url`() = + runTest(testDispatcher) { + enqueueUser("subscriber") + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """ + { + "email": "friend@example.com", "role": "collaborator", "expiresAt": null, + "url": "https://interlinedlist.com/documents/invite/xN3v9Qk" + } + """.trimIndent(), + ), + ) + + val result = repository.sendInvite("D1", " Friend@Example.COM ", InviteRole.EDITOR) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val invite = (result as ApiResult.Success).data + assertThat(invite.email).isEqualTo("friend@example.com") + assertThat(invite.role).isEqualTo(InviteRole.EDITOR) + // The 201 carries no `token` — it must be recovered so the row can be revoked. + assertThat(invite.token).isEqualTo("xN3v9Qk") + assertThat(invite.statusAt(Instant.parse("2026-06-01T00:00:00Z"))).isEqualTo(InviteStatus.PENDING) + + assertThat(server.takeRequest().path).isEqualTo("/api/user") + val post = server.takeRequest() + assertThat(post.method).isEqualTo("POST") + assertThat(post.path).isEqualTo("/api/documents/D1/invites") + val body = post.body.readUtf8() + assertThat(body).contains("\"email\":\"friend@example.com\"") + assertThat(body).contains("\"role\":\"collaborator\"") + } + + @Test + fun `sendInvite tolerates a wrapped invite envelope`() = runTest(testDispatcher) { + enqueueUser("subscriber:annual") + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """{ "invite": { "email": "a@b.io", "role": "manager", "token": "tok-w" } }""", + ), + ) + + val result = repository.sendInvite("D1", "a@b.io", InviteRole.ADMIN) + + val invite = (result as ApiResult.Success).data + assertThat(invite.token).isEqualTo("tok-w") + assertThat(invite.role).isEqualTo(InviteRole.ADMIN) + } + + @Test + fun `a free account cannot send an invite and issues no write`() = runTest(testDispatcher) { + enqueueUser("free") + + val result = repository.sendInvite("D1", "friend@example.com", InviteRole.VIEWER) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error) + .isInstanceOf(AppError.SubscriptionRequired::class.java) + assertThat(result.error.message).isEqualTo("Subscribe to invite people to documents.") + + // Only the current-user lookup happened: no POST was ever issued. + assertThat(server.requestCount).isEqualTo(1) + assertThat(server.takeRequest().path).isEqualTo("/api/user") + } + + @Test + fun `an invalid email is rejected before any request`() = runTest(testDispatcher) { + val result = repository.sendInvite("D1", "not-an-email", InviteRole.VIEWER) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error.message).isEqualTo("Enter a valid email address.") + // Not even the subscriber lookup ran. + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `a server rejection is surfaced with its own message`() = runTest(testDispatcher) { + enqueueUser("subscriber") + server.enqueue( + MockResponse().setResponseCode(400) + .setBody("""{ "error": "A valid email address is required", "code": "bad_request" }"""), + ) + + val result = repository.sendInvite("D1", "friend@example.com", InviteRole.VIEWER) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error.message).isEqualTo("A valid email address is required") + } + + @Test + fun `an unreadable subscription status still attempts the send`() = runTest(testDispatcher) { + // The gate fails open: the server stays the authority on the subscription. + server.enqueue(MockResponse().setResponseCode(500).setBody("""{ "error": "boom" }""")) + server.enqueue( + MockResponse().setResponseCode(201) + .setBody("""{ "email": "friend@example.com", "role": "watcher", "token": "tok-ok" }"""), + ) + + val result = repository.sendInvite("D1", "friend@example.com", InviteRole.VIEWER) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(server.requestCount).isEqualTo(2) + } + + // --- Revoking ---------------------------------------------------------- + + @Test + fun `revokeInvite is free - it deletes by token with no subscription lookup`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "revoked": true }""")) + + val result = repository.revokeInvite("D1", "tok-gone") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + // No `/api/user` lookup: a lapsed owner must always be able to revoke. + assertThat(server.requestCount).isEqualTo(1) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("DELETE") + assertThat(request.path).isEqualTo("/api/documents/D1/invites/tok-gone") + } + + @Test + fun `revokeInvite maps an unknown token to NotFound`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(404) + .setBody("""{ "error": "Invite not found", "code": "not_found" }"""), + ) + + val result = repository.revokeInvite("D1", "nope") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryShareTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryShareTest.kt index a819b76..7ca19f5 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryShareTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryShareTest.kt @@ -3,6 +3,7 @@ package com.interlinedlist.android.feature.documents.data import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.network.api.InterlinedListApi import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi import com.interlinedlist.android.feature.documents.domain.ShareRole @@ -50,7 +51,8 @@ class DefaultDocumentsRepositoryShareTest { .build() api = retrofit.create(DocumentsApi::class.java) repository = DefaultDocumentsRepository( - api, FakeDocumentDao(), FakeFolderDao(), FakePendingOpDao(), FakeSyncMetaDao(), json, dispatchers, + api, retrofit.create(InterlinedListApi::class.java), + FakeDocumentDao(), FakeFolderDao(), FakePendingOpDao(), FakeSyncMetaDao(), json, dispatchers, ) } diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositorySyncTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositorySyncTest.kt index c7708c0..c03c61a 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositorySyncTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositorySyncTest.kt @@ -3,6 +3,7 @@ package com.interlinedlist.android.feature.documents.data import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.network.api.InterlinedListApi import com.interlinedlist.android.feature.documents.data.local.SyncMetaEntity import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory @@ -57,7 +58,8 @@ class DefaultDocumentsRepositorySyncTest { pendingDao = FakePendingOpDao() metaDao = FakeSyncMetaDao() repository = DefaultDocumentsRepository( - api, documentDao, folderDao, pendingDao, metaDao, json, dispatchers, + api, retrofit.create(InterlinedListApi::class.java), + documentDao, folderDao, pendingDao, metaDao, json, dispatchers, ) } @@ -223,7 +225,8 @@ class DefaultDocumentsRepositorySyncTest { .build() api = retrofit.create(DocumentsApi::class.java) repository = DefaultDocumentsRepository( - api, documentDao, folderDao, pendingDao, metaDao, json, dispatchers, + api, retrofit.create(InterlinedListApi::class.java), + documentDao, folderDao, pendingDao, metaDao, json, dispatchers, ) server.enqueue( MockResponse().setResponseCode(200) diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt index 5c9b2c0..51cfec9 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt @@ -3,6 +3,7 @@ package com.interlinedlist.android.feature.documents.data import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.network.api.InterlinedListApi import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi import com.interlinedlist.android.feature.documents.domain.FolderNode @@ -52,7 +53,8 @@ class DefaultDocumentsRepositoryTest { documentDao = FakeDocumentDao() folderDao = FakeFolderDao() repository = DefaultDocumentsRepository( - api, documentDao, folderDao, FakePendingOpDao(), FakeSyncMetaDao(), json, dispatchers, + api, retrofit.create(InterlinedListApi::class.java), + documentDao, folderDao, FakePendingOpDao(), FakeSyncMetaDao(), json, dispatchers, ) } diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentInviteTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentInviteTest.kt new file mode 100644 index 0000000..1f56623 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentInviteTest.kt @@ -0,0 +1,106 @@ +package com.interlinedlist.android.feature.documents.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.time.Instant + +/** The derived status, role mapping and email validation rules for email invites. */ +class DocumentInviteTest { + + private val now: Instant = Instant.parse("2026-06-01T12:00:00Z") + + private fun invite( + expiresAt: String? = null, + accepted: Boolean = false, + revokedAt: String? = null, + url: String? = null, + ) = DocumentInvite( + email = "friend@example.com", + token = "tok-1", + role = InviteRole.EDITOR, + expiresAt = expiresAt, + createdAt = "2026-05-01T09:00:00Z", + accepted = accepted, + revokedAt = revokedAt, + url = url, + ) + + @Test + fun `an unaccepted invite with no expiry is pending`() { + assertThat(invite().statusAt(now)).isEqualTo(InviteStatus.PENDING) + assertThat(invite().statusAt(now).label).isEqualTo("Pending") + } + + @Test + fun `an invite expiring in the future is still pending`() { + assertThat(invite(expiresAt = "2026-06-02T12:00:00Z").statusAt(now)) + .isEqualTo(InviteStatus.PENDING) + } + + @Test + fun `an invite whose expiry has passed is expired`() { + val expired = invite(expiresAt = "2026-05-30T12:00:00Z") + assertThat(expired.statusAt(now)).isEqualTo(InviteStatus.EXPIRED) + assertThat(expired.statusAt(now).label).isEqualTo("Expired") + } + + @Test + fun `an invite expiring exactly now is expired`() { + assertThat(invite(expiresAt = "2026-06-01T12:00:00Z").statusAt(now)) + .isEqualTo(InviteStatus.EXPIRED) + } + + @Test + fun `acceptance wins over expiry`() { + assertThat(invite(expiresAt = "2026-05-30T12:00:00Z", accepted = true).statusAt(now)) + .isEqualTo(InviteStatus.ACCEPTED) + } + + @Test + fun `revocation wins over everything`() { + val revoked = invite(accepted = true, revokedAt = "2026-05-31T00:00:00Z") + assertThat(revoked.statusAt(now)).isEqualTo(InviteStatus.REVOKED) + } + + @Test + fun `an unparseable expiry never expires the invite`() { + assertThat(invite(expiresAt = "not-a-date").statusAt(now)).isEqualTo(InviteStatus.PENDING) + } + + @Test + fun `inviteUrl prefers the server url and otherwise builds the canonical path`() { + assertThat(invite(url = "https://example.test/documents/invite/abc").inviteUrl()) + .isEqualTo("https://example.test/documents/invite/abc") + assertThat(invite().inviteUrl()) + .isEqualTo("https://interlinedlist.com/documents/invite/tok-1") + } + + @Test + fun `invite roles map to the server sharing vocabulary`() { + assertThat(InviteRole.VIEWER.apiValue).isEqualTo("watcher") + assertThat(InviteRole.EDITOR.apiValue).isEqualTo("collaborator") + assertThat(InviteRole.ADMIN.apiValue).isEqualTo("manager") + } + + @Test + fun `fromApi maps known roles and defaults unknown ones to viewer`() { + assertThat(InviteRole.fromApi("collaborator")).isEqualTo(InviteRole.EDITOR) + assertThat(InviteRole.fromApi("Manager")).isEqualTo(InviteRole.ADMIN) + assertThat(InviteRole.fromApi("watcher")).isEqualTo(InviteRole.VIEWER) + assertThat(InviteRole.fromApi("wat")).isEqualTo(InviteRole.VIEWER) + assertThat(InviteRole.fromApi(null)).isEqualTo(InviteRole.VIEWER) + } + + @Test + fun `email validation accepts ordinary addresses and normalises them`() { + assertThat(InviteEmail.isValid("Friend@Example.COM")).isTrue() + assertThat(InviteEmail.normalize(" Friend@Example.COM ")).isEqualTo("friend@example.com") + assertThat(InviteEmail.isValid("first.last+tag@mail.example.co.uk")).isTrue() + } + + @Test + fun `email validation rejects malformed addresses`() { + listOf("", " ", "friend", "friend@", "@example.com", "friend@example", "a b@example.com", "friend@@example.com") + .forEach { assertThat(InviteEmail.isValid(it)).isFalse() } + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt index a97c297..3b8ac34 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt @@ -9,11 +9,14 @@ import com.interlinedlist.android.feature.documents.domain.CollaboratorCandidate import com.interlinedlist.android.feature.documents.domain.CollaboratorRole import com.interlinedlist.android.feature.documents.domain.Document import com.interlinedlist.android.feature.documents.domain.DocumentFolder +import com.interlinedlist.android.feature.documents.domain.DocumentInvite import com.interlinedlist.android.feature.documents.domain.DocumentTemplate import com.interlinedlist.android.feature.documents.domain.FolderContents import com.interlinedlist.android.feature.documents.domain.FolderNode import com.interlinedlist.android.feature.documents.domain.FolderSummary import com.interlinedlist.android.feature.documents.domain.FolderTree +import com.interlinedlist.android.feature.documents.domain.InviteEmail +import com.interlinedlist.android.feature.documents.domain.InviteRole import com.interlinedlist.android.feature.documents.domain.Presence import com.interlinedlist.android.feature.documents.domain.ShareLink import com.interlinedlist.android.feature.documents.domain.ShareRole @@ -65,6 +68,9 @@ class FakeDocumentsRepository : DocumentsRepository { var inviteResult: ApiResult? = null var updateRoleResult: ApiResult = ApiResult.Success(Unit) var removeCollaboratorResult: ApiResult = ApiResult.Success(Unit) + var invitesResult: ApiResult> = ApiResult.Success(emptyList()) + var sendInviteResult: ApiResult? = null + var revokeInviteResult: ApiResult = ApiResult.Success(Unit) var sendPresenceResult: ApiResult> = ApiResult.Success(emptyList()) var leavePresenceResult: ApiResult = ApiResult.Success(Unit) @@ -73,11 +79,16 @@ class FakeDocumentsRepository : DocumentsRepository { var lastRoleChange: RoleChange? = null var lastRemovedUserId: String? = null var lastSearchUsersQuery: String? = null + var sendInviteCount = 0 + var revokeInviteCount = 0 + var lastSentInvite: EmailInvite? = null + var lastRevokedInviteToken: String? = null var sendPresenceCount = 0 var leavePresenceCount = 0 data class Invite(val documentId: String, val userId: String, val role: CollaboratorRole) data class RoleChange(val documentId: String, val userId: String, val role: CollaboratorRole) + data class EmailInvite(val documentId: String, val email: String, val role: InviteRole) var refreshTreeCount = 0 var seedTemplatesCount = 0 @@ -290,6 +301,27 @@ class FakeDocumentsRepository : DocumentsRepository { return removeCollaboratorResult } + override suspend fun getInvites(documentId: String): ApiResult> = invitesResult + + override suspend fun sendInvite( + documentId: String, + email: String, + role: InviteRole, + ): ApiResult { + sendInviteCount++ + val address = InviteEmail.normalize(email) + lastSentInvite = EmailInvite(documentId, address, role) + return sendInviteResult ?: ApiResult.Success( + DocumentInvite(address, "token-$address", role, null, null, false, null, null), + ) + } + + override suspend fun revokeInvite(documentId: String, token: String): ApiResult { + revokeInviteCount++ + lastRevokedInviteToken = token + return revokeInviteResult + } + override suspend fun sendPresence(documentId: String): ApiResult> { sendPresenceCount++ return sendPresenceResult diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentInvitesViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentInvitesViewModelTest.kt new file mode 100644 index 0000000..370002d --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentInvitesViewModelTest.kt @@ -0,0 +1,225 @@ +package com.interlinedlist.android.feature.documents.ui.collaborators + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.documents.domain.DocumentInvite +import com.interlinedlist.android.feature.documents.domain.InviteRole +import com.interlinedlist.android.feature.documents.domain.InviteStatus +import com.interlinedlist.android.feature.documents.ui.FakeDocumentsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.time.Instant + +/** The email-invite section of the Manage-access sheet. */ +@OptIn(ExperimentalCoroutinesApi::class) +class DocumentInvitesViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private val now: Instant = Instant.parse("2026-06-01T12:00:00Z") + + private fun invite( + email: String, + token: String = "tok-$email", + role: InviteRole = InviteRole.VIEWER, + expiresAt: String? = null, + accepted: Boolean = false, + ) = DocumentInvite(email, token, role, expiresAt, null, accepted, null, null) + + private fun viewModel(repo: FakeDocumentsRepository) = + DocumentCollaboratorsViewModel( + repo, SavedStateHandle(mapOf(COLLABORATORS_DOCUMENT_ID_ARG to "D1")), + ) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads pending invites alongside collaborators`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + invitesResult = ApiResult.Success(listOf(invite("a@x.io"), invite("b@x.io"))) + } + val vm = viewModel(repo) + advanceUntilIdle() + + val state = vm.uiState.value.invites + assertThat(state.isLoading).isFalse() + assertThat(state.invites.map { it.email }).containsExactly("a@x.io", "b@x.io").inOrder() + } + + @Test + fun `renders role status and expiry for pending accepted and expired invites`() = + runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + invitesResult = ApiResult.Success( + listOf( + invite("pending@x.io", role = InviteRole.EDITOR, expiresAt = "2026-07-01T00:00:00Z"), + invite("accepted@x.io", role = InviteRole.ADMIN, accepted = true), + invite("expired@x.io", expiresAt = "2026-05-01T00:00:00Z"), + ), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + val invites = vm.uiState.value.invites.invites + assertThat(invites[0].role).isEqualTo(InviteRole.EDITOR) + assertThat(invites[0].statusAt(now)).isEqualTo(InviteStatus.PENDING) + assertThat(invites[1].statusAt(now)).isEqualTo(InviteStatus.ACCEPTED) + assertThat(invites[2].statusAt(now)).isEqualTo(InviteStatus.EXPIRED) + } + + @Test + fun `an invalid email is rejected without calling the repository`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository() + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onInviteEmailChange("not-an-email") + vm.sendInvite() + advanceUntilIdle() + + assertThat(repo.sendInviteCount).isEqualTo(0) + assertThat(vm.uiState.value.invites.emailError).isEqualTo("Enter a valid email address.") + assertThat(vm.uiState.value.invites.canSend).isFalse() + } + + @Test + fun `sendInvite passes the address and role and adds the new invite`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository() + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onInviteEmailChange(" Friend@Example.com ") + vm.selectInviteRole(InviteRole.ADMIN) + assertThat(vm.uiState.value.invites.canSend).isTrue() + vm.sendInvite() + advanceUntilIdle() + + assertThat(repo.lastSentInvite?.documentId).isEqualTo("D1") + assertThat(repo.lastSentInvite?.email).isEqualTo("friend@example.com") + assertThat(repo.lastSentInvite?.role).isEqualTo(InviteRole.ADMIN) + + val state = vm.uiState.value.invites + assertThat(state.invites.map { it.email }).containsExactly("friend@example.com") + // The field is cleared so the owner can invite the next person. + assertThat(state.email).isEmpty() + assertThat(state.isSending).isFalse() + } + + @Test + fun `re-inviting the same address replaces the existing row`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + invitesResult = ApiResult.Success(listOf(invite("friend@example.com", token = "old"))) + sendInviteResult = ApiResult.Success( + invite("friend@example.com", token = "fresh", role = InviteRole.EDITOR), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onInviteEmailChange("friend@example.com") + vm.sendInvite() + advanceUntilIdle() + + val invites = vm.uiState.value.invites.invites + assertThat(invites).hasSize(1) + assertThat(invites.single().token).isEqualTo("fresh") + } + + @Test + fun `a free account cannot send and is shown the subscription gate`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + sendInviteResult = ApiResult.Failure( + AppError.SubscriptionRequired("Subscribe to invite people to documents."), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onInviteEmailChange("friend@example.com") + vm.sendInvite() + advanceUntilIdle() + + val state = vm.uiState.value.invites + assertThat(state.subscriptionRequired).isTrue() + assertThat(state.errorMessage).isEqualTo("Subscribe to invite people to documents.") + assertThat(state.invites).isEmpty() + } + + @Test + fun `a server rejection is surfaced verbatim rather than generically`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + sendInviteResult = ApiResult.Failure(AppError.Conflict("That person is already a collaborator")) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onInviteEmailChange("friend@example.com") + vm.sendInvite() + advanceUntilIdle() + + val state = vm.uiState.value.invites + assertThat(state.errorMessage).isEqualTo("That person is already a collaborator") + assertThat(state.subscriptionRequired).isFalse() + } + + @Test + fun `a free account can still revoke`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + invitesResult = ApiResult.Success(listOf(invite("a@x.io", token = "t1"), invite("b@x.io", token = "t2"))) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.revokeInvite("t2") + // Applied immediately (optimistic). + assertThat(vm.uiState.value.invites.invites.map { it.token }).containsExactly("t1") + + advanceUntilIdle() + assertThat(repo.revokeInviteCount).isEqualTo(1) + assertThat(repo.lastRevokedInviteToken).isEqualTo("t2") + assertThat(vm.uiState.value.invites.invites.map { it.token }).containsExactly("t1") + } + + @Test + fun `a failed revoke restores the invite and reports why`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + invitesResult = ApiResult.Success(listOf(invite("a@x.io", token = "t1"))) + revokeInviteResult = ApiResult.Failure(AppError.NotFound(null)) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.revokeInvite("t1") + advanceUntilIdle() + + val state = vm.uiState.value.invites + assertThat(state.invites.map { it.token }).containsExactly("t1") + assertThat(state.errorMessage).isEqualTo("That invite is no longer available.") + } + + @Test + fun `a failed load surfaces an error without breaking the collaborator list`() = + runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + invitesResult = ApiResult.Failure(AppError.Network(null)) + } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.invites.errorMessage) + .isEqualTo("No connection. Check your network and try again.") + assertThat(vm.uiState.value.isLoading).isFalse() + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/common/InviteLabelsTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/common/InviteLabelsTest.kt new file mode 100644 index 0000000..0c00fc8 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/common/InviteLabelsTest.kt @@ -0,0 +1,37 @@ +package com.interlinedlist.android.feature.documents.ui.common + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.time.Instant +import java.time.ZoneOffset +import java.util.Locale + +/** Expiry labels rendered against a fixed clock, zone and locale. */ +class InviteLabelsTest { + + private val now: Instant = Instant.parse("2026-06-01T12:00:00Z") + + private fun label(iso: String?) = + inviteExpiryLabel(iso, now = now, zone = ZoneOffset.UTC, locale = Locale.US) + + @Test + fun `a missing expiry reads as no expiry`() { + assertThat(label(null)).isEqualTo("No expiry") + assertThat(label(" ")).isEqualTo("No expiry") + } + + @Test + fun `a future expiry reads as expires`() { + assertThat(label("2026-06-12T09:00:00Z")).isEqualTo("Expires Jun 12, 2026") + } + + @Test + fun `a past expiry reads as expired`() { + assertThat(label("2026-01-02T09:00:00Z")).isEqualTo("Expired Jan 2, 2026") + } + + @Test + fun `an unparseable expiry falls back to the raw value`() { + assertThat(label("soon")).isEqualTo("Expires soon") + } +}