From 25498f0bd37beb93531d5d1a6e5ce0d821bad1c5 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 14:06:09 -0700 Subject: [PATCH] feat(lists): send, list and revoke list email invites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the email-invite flow to the list access (watchers) screen, alongside the existing per-person watcher roles rather than replacing them, mirroring the document flow landed in #59: - POST/GET /api/lists/{id}/invites and DELETE .../invites/{token} plumbed through ListsRepository as sendInvite/getInvites/revokeInvite. - An "Invite by email" form (address + Read-only/Edit/Admin role) and a "Pending invites" list showing role, derived status (Pending / Accepted / Expired / Revoked) and expiry, each row revocable. - Sending is gated on CustomerStatus.isSubscriber via the shared GET /api/user check, so a free account issues no write at all; listing and revoking are never gated, matching the server — a lapsed owner can always shut off access they previously granted. - Addresses are validated client-side before any request, and server rejections are surfaced with the server's own message instead of a generic failure. Invite roles use the server's sharing vocabulary (watcher / collaborator / manager) behind this module's Read-only / Edit / Admin labels, and the create response's missing token is recovered from the returned landing URL so a just-sent invite can be revoked. Re-inviting an address replaces the existing row, because the server treats it as idempotent rather than an error. Accepting an invite (/api/lists/invite/{token}) is deliberately out of scope; it is covered by #60 for lists and documents together. Closes #58 Co-Authored-By: Claude Opus 5 --- .../lists/ui/watchers/WatchersScreenTest.kt | 125 ++++++- .../lists/data/DefaultListsRepository.kt | 78 +++++ .../feature/lists/data/InviteMapper.kt | 30 ++ .../feature/lists/data/ListsRepository.kt | 25 ++ .../feature/lists/data/remote/ListsApi.kt | 26 ++ .../lists/data/remote/dto/InviteDtos.kt | 92 +++++ .../feature/lists/domain/ListInvite.kt | 106 ++++++ .../android/feature/lists/ui/InviteLabels.kt | 30 ++ .../feature/lists/ui/ListsErrorMessages.kt | 18 + .../lists/ui/watchers/WatchersScreen.kt | 226 +++++++++++++ .../lists/ui/watchers/WatchersViewModel.kt | 109 ++++++ .../feature/lists/FakeListsRepository.kt | 36 ++ .../DefaultListsRepositoryCreationTest.kt | 10 +- .../data/DefaultListsRepositoryGithubTest.kt | 10 +- .../data/DefaultListsRepositoryInviteTest.kt | 319 ++++++++++++++++++ .../data/DefaultListsRepositoryPolishTest.kt | 10 +- .../data/DefaultListsRepositoryShareTest.kt | 10 +- .../lists/data/DefaultListsRepositoryTest.kt | 10 +- .../data/DefaultListsRepositoryViewsTest.kt | 10 +- .../lists/data/ListsRepositoryDeferredTest.kt | 10 +- .../feature/lists/domain/ListInviteTest.kt | 114 +++++++ .../feature/lists/ui/InviteLabelsTest.kt | 37 ++ .../ui/watchers/ListInvitesViewModelTest.kt | 230 +++++++++++++ 23 files changed, 1649 insertions(+), 22 deletions(-) create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/InviteMapper.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/InviteDtos.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListInvite.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/InviteLabels.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryInviteTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/domain/ListInviteTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/InviteLabelsTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/ListInvitesViewModelTest.kt diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreenTest.kt index b9ab4ac..3e87dc8 100644 --- a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreenTest.kt +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreenTest.kt @@ -1,11 +1,16 @@ package com.interlinedlist.android.feature.lists.ui.watchers 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.lists.domain.InviteRole +import com.interlinedlist.android.feature.lists.domain.ListInvite import com.interlinedlist.android.feature.lists.domain.Watcher import com.interlinedlist.android.feature.lists.domain.WatcherRole import org.junit.Rule @@ -19,7 +24,11 @@ class WatchersScreenTest { @get:Rule val composeRule = createComposeRule() - private fun setScreen(state: WatchersUiState) { + private fun setScreen( + state: WatchersUiState, + onRevokeInvite: (String) -> Unit = {}, + onSelectInviteEmailRole: (InviteRole) -> Unit = {}, + ) { composeRule.setContent { InterlinedListTheme { WatchersScreen( @@ -29,11 +38,23 @@ class WatchersScreenTest { onAddCandidate = {}, onChangeRole = { _, _ -> }, onRemoveWatcher = {}, + onInviteEmailChange = {}, + onSelectInviteEmailRole = onSelectInviteEmailRole, + onSendInvite = {}, + onRevokeInvite = onRevokeInvite, ) } } } + private fun invite( + email: String, + token: String, + role: InviteRole = InviteRole.VIEWER, + expiresAt: String? = null, + accepted: Boolean = false, + ) = ListInvite(email, token, role, expiresAt, null, accepted, null, null) + @Test fun rendersWatcherRows() { setScreen( @@ -56,4 +77,106 @@ class WatchersScreenTest { composeRule.onNodeWithTag(WatchersTestTags.EMPTY).assertIsDisplayed() } + + // --- Email invites ----------------------------------------------------- + + @Test + fun pendingInvites_renderRoleStatusAndExpiry() { + setScreen( + WatchersUiState( + isLoading = false, + invites = ListInvitesUiState( + 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(WatchersTestTags.INVITE_LIST).assertIsDisplayed() + composeRule.onNodeWithTag(WatchersTestTags.inviteRow("tok-a")).assertIsDisplayed() + composeRule.onNodeWithText("friend@example.com").assertIsDisplayed() + composeRule.onNodeWithText("Edit · 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 + setScreen( + WatchersUiState( + isLoading = false, + invites = ListInvitesUiState( + isLoading = false, + invites = listOf(invite("friend@example.com", "tok-a")), + ), + ), + onRevokeInvite = { revoked = it }, + ) + + composeRule.onNodeWithTag(WatchersTestTags.inviteRevoke("tok-a")).performClick() + assert(revoked == "tok-a") + } + + @Test + fun inviteRoleChips_areIndividuallySelectable() { + var picked: InviteRole? = null + setScreen( + WatchersUiState(isLoading = false, invites = ListInvitesUiState(isLoading = false)), + onSelectInviteEmailRole = { picked = it }, + ) + + composeRule.onNodeWithTag(WatchersTestTags.inviteEmailRole(InviteRole.ADMIN)).performClick() + assert(picked == InviteRole.ADMIN) + + composeRule.onNodeWithTag(WatchersTestTags.inviteEmailRole(InviteRole.VIEWER)).performClick() + assert(picked == InviteRole.VIEWER) + } + + @Test + fun sendInvite_isDisabled_forAnIncompleteEmail() { + setScreen( + WatchersUiState( + isLoading = false, + invites = ListInvitesUiState(isLoading = false, email = "friend@"), + ), + ) + composeRule.onNodeWithTag(WatchersTestTags.INVITE_SEND).assertIsNotEnabled() + } + + @Test + fun sendInvite_isEnabled_forAValidEmail() { + setScreen( + WatchersUiState( + isLoading = false, + invites = ListInvitesUiState(isLoading = false, email = "friend@example.com"), + ), + ) + composeRule.onNodeWithTag(WatchersTestTags.INVITE_SEND).assertIsEnabled() + } + + @Test + fun subscriptionGate_isShown_whenSendingIsRefused() { + setScreen( + WatchersUiState( + isLoading = false, + invites = ListInvitesUiState( + isLoading = false, + subscriptionRequired = true, + errorMessage = "Subscribe to invite people to lists.", + ), + ), + ) + + composeRule.onNodeWithTag(WatchersTestTags.INVITE_GATE).assertIsDisplayed() + composeRule.onNodeWithText("Subscribe to invite people to lists.").assertIsDisplayed() + // Revoking stays available even behind the gate. + composeRule.onNodeWithTag(WatchersTestTags.INVITE_EMPTY).assertIsDisplayed() + } } diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt index b1cfae4..0deb868 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt @@ -4,12 +4,16 @@ 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.model.CustomerStatus +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.lists.data.local.ListDao import com.interlinedlist.android.feature.lists.data.remote.ListsApi import com.interlinedlist.android.feature.lists.data.remote.dto.AddWatcherRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateConnectionRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateFolderRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.CreateInviteRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateListRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateShareLinkRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateViewRequest @@ -24,9 +28,12 @@ import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateViewReques import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateWatcherRoleRequest import com.interlinedlist.android.feature.lists.domain.Contributor import com.interlinedlist.android.feature.lists.domain.GITHUB_SOURCE_ISSUES +import com.interlinedlist.android.feature.lists.domain.InviteEmail +import com.interlinedlist.android.feature.lists.domain.InviteRole import com.interlinedlist.android.feature.lists.domain.ListConnection import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder +import com.interlinedlist.android.feature.lists.domain.ListInvite import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema import com.interlinedlist.android.feature.lists.domain.ListSource @@ -60,6 +67,8 @@ import javax.inject.Inject */ class DefaultListsRepository @Inject constructor( private val api: ListsApi, + /** Shared current-user endpoint, used only for the subscriber gate on sending invites. */ + private val userApi: InterlinedListApi, private val listDao: ListDao, private val json: kotlinx.serialization.json.Json, private val dispatchers: DispatcherProvider, @@ -586,6 +595,72 @@ class DefaultListsRepository @Inject constructor( safeApiCall(json) { api.claimSharedList(token) }.map { } } + // --- Email invites ----------------------------------------------------- + + override suspend fun getInvites(listId: String): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getInvites(listId) } + .map { response -> response.items.map(InviteMapper::fromDto) } + } + + override suspend fun sendInvite( + listId: 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(listId, CreateInviteRequest(email = address, role = role.apiValue)) + } + ) { + is ApiResult.Success -> { + val dto = result.data.inviteOrSelf + ApiResult.Success( + dto?.let(InviteMapper::fromDto) ?: ListInvite( + email = address, + token = "", + role = role, + expiresAt = null, + createdAt = null, + accepted = false, + revokedAt = null, + url = null, + ), + ) + } + is ApiResult.Failure -> result + } + } + + override suspend fun revokeInvite(listId: String, token: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.revokeInvite(listId, token) }.map { } + } + + /** + * The signed-in account's subscription tier, or null when it cannot be read — + * including a `customerStatus` this build does not recognise, which is "unknown", + * not "free", and so must not lock a paying owner out of their own invite form. + */ + private suspend fun currentUserIsSubscriber(): Boolean? = + when (val result = safeApiCall(json) { userApi.getCurrentUser().user }) { + is ApiResult.Success -> result.data.userDtoToDomain().customerStatus + .takeUnless { it == CustomerStatus.UNKNOWN } + ?.isSubscriber + is ApiResult.Failure -> null + } + /** Blank form fields are dropped so we don't overwrite server values with empty strings. */ private fun Map.toJsonData(): Map = filterValues { it.isNotBlank() } @@ -595,6 +670,9 @@ class DefaultListsRepository @Inject constructor( /** Safety net for a breadcrumb walk: deep nesting is not worth the requests. */ const val MAX_PARENT_CHAIN = 10 + /** Matches the server's own copy for the 403 a free owner receives. */ + const val NOT_SUBSCRIBED_MESSAGE = "Subscribe to invite people to lists." + const val MISSING_VIEW_NAME = "A view needs a name." const val MISSING_VIEW_SCOPE = "Choose whether the view is shared or personal." const val VIEW_NOT_RETURNED = "The view was saved but the server did not return it." diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/InviteMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/InviteMapper.kt new file mode 100644 index 0000000..dc36e8d --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/InviteMapper.kt @@ -0,0 +1,30 @@ +package com.interlinedlist.android.feature.lists.data + +import com.interlinedlist.android.feature.lists.data.remote.dto.ListInviteDto +import com.interlinedlist.android.feature.lists.domain.InviteRole +import com.interlinedlist.android.feature.lists.domain.ListInvite + +/** + * DTO → domain mapping for email invites. + * + * 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. + */ +object InviteMapper { + + fun fromDto(dto: ListInviteDto): ListInvite = ListInvite( + email = dto.email, + token = dto.token.ifBlank { tokenFromInviteUrl(dto.url) }, + role = InviteRole.fromApi(dto.role), + expiresAt = dto.expiresAt, + createdAt = dto.createdAt, + accepted = dto.accepted || dto.acceptedAt != null, + revokedAt = dto.revokedAt, + url = dto.url, + ) + + /** Last path segment of an invite landing URL (`.../lists/invite/`). */ + private fun tokenFromInviteUrl(url: String?): String = + url?.substringBefore('?')?.substringBefore('#')?.trimEnd('/')?.substringAfterLast('/').orEmpty() +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt index 1db045b..662c3eb 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt @@ -2,9 +2,11 @@ package com.interlinedlist.android.feature.lists.data import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.lists.domain.Contributor +import com.interlinedlist.android.feature.lists.domain.InviteRole import com.interlinedlist.android.feature.lists.domain.ListConnection import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder +import com.interlinedlist.android.feature.lists.domain.ListInvite import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema import com.interlinedlist.android.feature.lists.domain.ListSource @@ -273,6 +275,29 @@ interface ListsRepository { /** Claims edit/admin access to a shared list via its token. */ suspend fun claimSharedList(token: String): ApiResult + // --- Email invites ----------------------------------------------------- + + /** + * Pending email invites for a list. Free for any owner — a lapsed subscription + * must never hide invites the owner still needs to revoke. + */ + suspend fun getInvites(listId: 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( + listId: String, + email: String, + role: InviteRole, + ): ApiResult + + /** Revokes a pending invite, killing its link immediately. Never subscriber-gated. */ + suspend fun revokeInvite(listId: String, token: String): ApiResult + companion object { const val DEFAULT_PAGE_SIZE = 20 } diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt index 4efba67..5ef0189 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt @@ -6,6 +6,7 @@ import com.interlinedlist.android.feature.lists.data.remote.dto.ConnectionsRespo import com.interlinedlist.android.feature.lists.data.remote.dto.ContributorsResponse import com.interlinedlist.android.feature.lists.data.remote.dto.CreateConnectionRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateFolderRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.CreateInviteRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateListRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateViewRequest import com.interlinedlist.android.feature.lists.data.remote.dto.DeleteViewResponse @@ -13,6 +14,8 @@ import com.interlinedlist.android.feature.lists.data.remote.dto.FolderDto import com.interlinedlist.android.feature.lists.data.remote.dto.FolderEnvelope import com.interlinedlist.android.feature.lists.data.remote.dto.FoldersResponse import com.interlinedlist.android.feature.lists.data.remote.dto.ListEnvelope +import com.interlinedlist.android.feature.lists.data.remote.dto.ListInviteEnvelope +import com.interlinedlist.android.feature.lists.data.remote.dto.ListInvitesResponse import com.interlinedlist.android.feature.lists.data.remote.dto.ListViewEnvelope import com.interlinedlist.android.feature.lists.data.remote.dto.ListViewsResponse import com.interlinedlist.android.feature.lists.data.remote.dto.ListsResponse @@ -263,4 +266,27 @@ interface ListsApi { /** Claims edit/admin access to a shared list as the logged-in user. */ @POST("api/lists/shared/{token}") suspend fun claimSharedList(@Path("token") token: String) + + // --- Email invites ----------------------------------------------------- + + /** Pending email invites for a list (owner only; free — not subscriber-gated). */ + @GET("api/lists/{id}/invites") + suspend fun getInvites(@Path("id") id: String): ListInvitesResponse + + /** + * 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/lists/{id}/invites") + suspend fun createInvite( + @Path("id") id: String, + @Body body: CreateInviteRequest, + ): ListInviteEnvelope + + /** Revokes a pending invite by its token, killing the link immediately. Always free. */ + @DELETE("api/lists/{id}/invites/{token}") + suspend fun revokeInvite( + @Path("id") id: String, + @Path("token") token: String, + ) } diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/InviteDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/InviteDtos.kt new file mode 100644 index 0000000..b443d49 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/InviteDtos.kt @@ -0,0 +1,92 @@ +package com.interlinedlist.android.feature.lists.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Wire models for the list 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 share-invite schema additionally declares `id`, + * `listId`, `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 ListInviteDto( + val id: String = "", + val listId: 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/lists/{id}/invites` — documented as `{ "invites": [...] }`. */ +@Serializable +data class ListInvitesResponse( + val invites: List? = null, + val data: List? = null, +) { + val items: List get() = invites ?: data ?: emptyList() +} + +/** + * `POST /api/lists/{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 ListInviteEnvelope( + val invite: ListInviteDto? = null, + val data: ListInviteDto? = 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: ListInviteDto? + get() = invite ?: data ?: (email ?: url)?.let { + ListInviteDto( + 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/lists/{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/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListInvite.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListInvite.kt new file mode 100644 index 0000000..00dfbe6 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListInvite.kt @@ -0,0 +1,106 @@ +package com.interlinedlist.android.feature.lists.domain + +import java.time.Instant + +/** + * An email invite to a list: a role granted to an *email address* rather than to an + * account, so the invitee need not have signed up yet and the list stays private + * throughout. Unlike a [ShareLink] (a bearer capability anyone holding it can use), + * 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 ListInvite( + 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('/')}/lists/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 [ListInvite.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 this + * module's labels (Read-only / Edit / Admin). Unknown values map to [VIEWER] so an + * invite is never over-privileged. + */ +enum class InviteRole(val apiValue: String, val label: String) { + VIEWER("watcher", "Read-only"), + EDITOR("collaborator", "Edit"), + 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 + * with `A valid email address is required`). 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/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/InviteLabels.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/InviteLabels.kt new file mode 100644 index 0000000..638ecee --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/InviteLabels.kt @@ -0,0 +1,30 @@ +package com.interlinedlist.android.feature.lists.ui + +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/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/ListsErrorMessages.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/ListsErrorMessages.kt index 101111e..2d9698d 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/ListsErrorMessages.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/ListsErrorMessages.kt @@ -15,3 +15,21 @@ fun AppError.toUserMessage(): String = when (this) { /** True when 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 list) is gone, not the list. + */ +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 lists." + is AppError.Forbidden -> message ?: "Only the list 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 list." + 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/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreen.kt index 9fa2f71..1a6963a 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreen.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreen.kt @@ -10,30 +10,38 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.MailOutline import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button import androidx.compose.material3.Card 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 import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar 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 @@ -41,9 +49,13 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.lists.domain.Contributor +import com.interlinedlist.android.feature.lists.domain.InviteRole +import com.interlinedlist.android.feature.lists.domain.InviteStatus +import com.interlinedlist.android.feature.lists.domain.ListInvite import com.interlinedlist.android.feature.lists.domain.Watcher import com.interlinedlist.android.feature.lists.domain.WatcherCandidate import com.interlinedlist.android.feature.lists.domain.WatcherRole +import com.interlinedlist.android.feature.lists.ui.inviteExpiryLabel /** Stable test tags for the watchers screen. */ object WatchersTestTags { @@ -57,6 +69,17 @@ object WatchersTestTags { fun remove(userId: String) = "watcherRemove_$userId" fun candidate(userId: String) = "watcherCandidate_$userId" fun contributor(userId: String) = "contributor_$userId" + + // 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" } /** @@ -77,6 +100,10 @@ fun WatchersRoute( onAddCandidate = { viewModel.addWatcher(it) }, onChangeRole = viewModel::changeRole, onRemoveWatcher = viewModel::removeWatcher, + onInviteEmailChange = viewModel::onInviteEmailChange, + onSelectInviteEmailRole = viewModel::selectInviteRole, + onSendInvite = viewModel::sendInvite, + onRevokeInvite = viewModel::revokeInvite, modifier = modifier, ) } @@ -91,6 +118,10 @@ fun WatchersScreen( onAddCandidate: (WatcherCandidate) -> Unit, onChangeRole: (Watcher, WatcherRole) -> Unit, onRemoveWatcher: (Watcher) -> Unit, + onInviteEmailChange: (String) -> Unit, + onSelectInviteEmailRole: (InviteRole) -> Unit, + onSendInvite: () -> Unit, + onRevokeInvite: (String) -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -165,6 +196,19 @@ fun WatchersScreen( } } + item { + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + InviteByEmailSection( + state = state.invites, + onEmailChange = onInviteEmailChange, + onSelectRole = onSelectInviteEmailRole, + onSend = onSendInvite, + onRevoke = onRevokeInvite, + ) + } + if (state.contributors.isNotEmpty()) { item { Text( @@ -185,6 +229,169 @@ fun WatchersScreen( } } +/** + * "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: ListInvitesUiState, + 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 list 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(WatchersTestTags.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(WatchersTestTags.inviteEmailRole(role)), + ) + } + } + + Spacer(Modifier.height(8.dp)) + Button( + onClick = onSend, + enabled = state.canSend, + modifier = Modifier.testTag(WatchersTestTags.INVITE_SEND), + ) { Text(if (state.isSending) "Sending…" else "Send invite") } + + if (state.subscriptionRequired) { + Spacer(Modifier.height(8.dp)) + Column(Modifier.testTag(WatchersTestTags.INVITE_GATE)) { + Text( + text = "Subscriber feature", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = state.errorMessage ?: "Subscribe to invite people to lists.", + 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(WatchersTestTags.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(WatchersTestTags.INVITE_EMPTY), + ) + + // A short, owner-managed list — a plain Column keeps it renderable inside + // the screen's LazyColumn without nesting a second lazy list. + else -> Column( + modifier = Modifier + .fillMaxWidth() + .testTag(WatchersTestTags.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: ListInvite, onRevoke: () -> Unit) { + val status = invite.statusAt() + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(WatchersTestTags.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(WatchersTestTags.inviteRevoke(invite.token)), + ) { Text("Revoke") } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun WatcherRow( @@ -331,12 +538,31 @@ private fun WatchersScreenPreview() { Watcher("u2", "grace", null, null, WatcherRole.VIEWER), ), isLoading = false, + invites = ListInvitesUiState( + invites = listOf( + ListInvite( + email = "friend@example.com", + token = "tok-a", + role = InviteRole.EDITOR, + expiresAt = null, + createdAt = null, + accepted = false, + revokedAt = null, + url = null, + ), + ), + isLoading = false, + ), ), onBack = {}, onSearchQueryChange = {}, onAddCandidate = {}, onChangeRole = { _, _ -> }, onRemoveWatcher = {}, + onInviteEmailChange = {}, + onSelectInviteEmailRole = {}, + onSendInvite = {}, + onRevokeInvite = {}, ) } } diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModel.kt index af84385..3a2aba9 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModel.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModel.kt @@ -6,9 +6,14 @@ import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.lists.data.ListsRepository import com.interlinedlist.android.feature.lists.domain.Contributor +import com.interlinedlist.android.feature.lists.domain.InviteEmail +import com.interlinedlist.android.feature.lists.domain.InviteRole +import com.interlinedlist.android.feature.lists.domain.ListInvite import com.interlinedlist.android.feature.lists.domain.Watcher import com.interlinedlist.android.feature.lists.domain.WatcherCandidate import com.interlinedlist.android.feature.lists.domain.WatcherRole +import com.interlinedlist.android.feature.lists.ui.isSubscriptionGate +import com.interlinedlist.android.feature.lists.ui.toInviteMessage import com.interlinedlist.android.feature.lists.ui.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -31,10 +36,34 @@ data class WatchersUiState( val searchQuery: String = "", val candidates: List = emptyList(), val isSearching: Boolean = false, + /** The "Invite by email" section, which sits alongside the per-person roles. */ + val invites: ListInvitesUiState = ListInvitesUiState(), ) { val isEmpty: Boolean get() = watchers.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. + */ +data class ListInvitesUiState( + 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 +} + @HiltViewModel class WatchersViewModel @Inject constructor( private val repository: ListsRepository, @@ -50,6 +79,7 @@ class WatchersViewModel @Inject constructor( init { load() + loadInvites() } fun load() { @@ -132,4 +162,83 @@ class WatchersViewModel @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(listId)) { + 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(listId, 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(listId, token)) { + is ApiResult.Success -> Unit + is ApiResult.Failure -> updateInvites { + it.copy(invites = previous, errorMessage = result.error.toInviteMessage()) + } + } + } + } + + private fun updateInvites(transform: (ListInvitesUiState) -> ListInvitesUiState) = + _uiState.update { it.copy(invites = transform(it.invites)) } } diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt index f8634b9..8d3c25c 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt @@ -5,9 +5,12 @@ import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.lists.data.ListsRepository import com.interlinedlist.android.feature.lists.domain.Contributor import com.interlinedlist.android.feature.lists.domain.GITHUB_SOURCE_ISSUES +import com.interlinedlist.android.feature.lists.domain.InviteEmail +import com.interlinedlist.android.feature.lists.domain.InviteRole import com.interlinedlist.android.feature.lists.domain.ListConnection import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder +import com.interlinedlist.android.feature.lists.domain.ListInvite import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema import com.interlinedlist.android.feature.lists.domain.ListSource @@ -87,6 +90,18 @@ class FakeListsRepository : ListsRepository { var resolveSharedResult: ApiResult? = null var claimSharedResult: ApiResult = ApiResult.Success(Unit) + // Email invites. + var invitesResult: ApiResult> = ApiResult.Success(emptyList()) + var sendInviteResult: ApiResult? = null + var revokeInviteResult: ApiResult = ApiResult.Success(Unit) + var sendInviteCount = 0 + var revokeInviteCount = 0 + var lastSentInvite: EmailInvite? = null + var lastRevokedInviteToken: String? = null + + /** What [sendInvite] was last asked to do, so tests can assert it verbatim. */ + data class EmailInvite(val listId: String, val email: String, val role: InviteRole) + var refreshCount = 0 var loadMoreCount = 0 var parentChainCount = 0 @@ -434,6 +449,27 @@ class FakeListsRepository : ListsRepository { return claimSharedResult } + override suspend fun getInvites(listId: String): ApiResult> = invitesResult + + override suspend fun sendInvite( + listId: String, + email: String, + role: InviteRole, + ): ApiResult { + sendInviteCount++ + val address = InviteEmail.normalize(email) + lastSentInvite = EmailInvite(listId, address, role) + return sendInviteResult ?: ApiResult.Success( + ListInvite(address, "token-$address", role, null, null, false, null, null), + ) + } + + override suspend fun revokeInvite(listId: String, token: String): ApiResult { + revokeInviteCount++ + lastRevokedInviteToken = token + return revokeInviteResult + } + companion object { fun subscriptionFailure(): ApiResult.Failure = ApiResult.Failure(AppError.SubscriptionRequired("Lists require an active subscription")) diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryCreationTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryCreationTest.kt index 8d8c308..eb48e2f 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryCreationTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryCreationTest.kt @@ -3,6 +3,7 @@ package com.interlinedlist.android.feature.lists.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.lists.data.local.CachedListEntity import com.interlinedlist.android.feature.lists.data.local.ListDao import com.interlinedlist.android.feature.lists.data.remote.ListsApi @@ -39,7 +40,9 @@ import retrofit2.Retrofit class DefaultListsRepositoryCreationTest { private lateinit var server: MockWebServer + private lateinit var retrofit: Retrofit private lateinit var api: ListsApi + private lateinit var userApi: InterlinedListApi private lateinit var dao: FakeCreationDao private lateinit var repository: DefaultListsRepository @@ -55,13 +58,14 @@ class DefaultListsRepositoryCreationTest { @Before fun setUp() { server = MockWebServer().also { it.start() } - api = Retrofit.Builder() + retrofit = Retrofit.Builder() .baseUrl(server.url("/")) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() - .create(ListsApi::class.java) + api = retrofit.create(ListsApi::class.java) + userApi = retrofit.create(InterlinedListApi::class.java) dao = FakeCreationDao() - repository = DefaultListsRepository(api, dao, json, testDispatchers) + repository = DefaultListsRepository(api, userApi, dao, json, testDispatchers) } @After diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryGithubTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryGithubTest.kt index f53e743..ea0cd27 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryGithubTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryGithubTest.kt @@ -3,6 +3,7 @@ package com.interlinedlist.android.feature.lists.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.lists.data.local.CachedListEntity import com.interlinedlist.android.feature.lists.data.local.ListDao import com.interlinedlist.android.feature.lists.data.remote.ListsApi @@ -33,7 +34,9 @@ import retrofit2.Retrofit class DefaultListsRepositoryGithubTest { private lateinit var server: MockWebServer + private lateinit var retrofit: Retrofit private lateinit var api: ListsApi + private lateinit var userApi: InterlinedListApi private lateinit var dao: FakeGithubDao private lateinit var repository: DefaultListsRepository @@ -49,13 +52,14 @@ class DefaultListsRepositoryGithubTest { @Before fun setUp() { server = MockWebServer().also { it.start() } - api = Retrofit.Builder() + retrofit = Retrofit.Builder() .baseUrl(server.url("/")) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() - .create(ListsApi::class.java) + api = retrofit.create(ListsApi::class.java) + userApi = retrofit.create(InterlinedListApi::class.java) dao = FakeGithubDao() - repository = DefaultListsRepository(api, dao, json, testDispatchers) + repository = DefaultListsRepository(api, userApi, dao, json, testDispatchers) } @After diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryInviteTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryInviteTest.kt new file mode 100644 index 0000000..7698b13 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryInviteTest.kt @@ -0,0 +1,319 @@ +package com.interlinedlist.android.feature.lists.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.lists.data.local.CachedListEntity +import com.interlinedlist.android.feature.lists.data.local.ListDao +import com.interlinedlist.android.feature.lists.data.remote.ListsApi +import com.interlinedlist.android.feature.lists.domain.InviteRole +import com.interlinedlist.android.feature.lists.domain.InviteStatus +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +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 list email-invite endpoints: send, list and revoke, + * the subscriber gate on sending (and its deliberate absence on revoking), and the + * client-side email guard. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultListsRepositoryInviteTest { + + private lateinit var server: MockWebServer + private lateinit var repository: DefaultListsRepository + + // Mirrors the app's shared Json (explicit nulls off, coerce defaults on). + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + private val dispatcher = StandardTestDispatcher() + + private val testDispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + repository = DefaultListsRepository( + retrofit.create(ListsApi::class.java), + retrofit.create(InterlinedListApi::class.java), + FakeInviteDao(), + json, + testDispatchers, + ) + } + + @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(dispatcher) { + 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("L1") + + 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/lists/L1/invites") + } + + @Test + fun `getInvites derives pending accepted expired and revoked from the row`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "invites": [ + { "email": "pending@x.io", "role": "watcher", "accepted": false, "token": "t1" }, + { "email": "done@x.io", "role": "watcher", "accepted": true, "token": "t2" }, + { "email": "late@x.io", "role": "watcher", "accepted": false, + "expiresAt": "2026-01-02T00:00:00.000Z", "token": "t3" }, + { "email": "dead@x.io", "role": "watcher", "accepted": false, + "revokedAt": "2026-02-02T00:00:00.000Z", "token": "t4" } + ] + } + """.trimIndent(), + ), + ) + + val now = Instant.parse("2026-06-01T00:00:00Z") + val invites = (repository.getInvites("L1") as ApiResult.Success).data + + assertThat(invites.map { it.statusAt(now) }).containsExactly( + InviteStatus.PENDING, + InviteStatus.ACCEPTED, + InviteStatus.EXPIRED, + InviteStatus.REVOKED, + ).inOrder() + // An expiry still in the future is pending, not expired. + assertThat(invites[2].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(dispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "invites": [] }""")) + + assertThat(repository.getInvites("L1")).isInstanceOf(ApiResult.Success::class.java) + + assertThat(server.requestCount).isEqualTo(1) + assertThat(server.takeRequest().path).isEqualTo("/api/lists/L1/invites") + } + + // --- Sending ----------------------------------------------------------- + + @Test + fun `sendInvite posts the normalised email and role, and recovers the token from the url`() = + runTest(dispatcher) { + enqueueUser("subscriber") + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """ + { + "email": "friend@example.com", "role": "collaborator", "expiresAt": null, + "url": "https://interlinedlist.com/lists/invite/xN3v9Qk" + } + """.trimIndent(), + ), + ) + + val result = repository.sendInvite("L1", " 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/lists/L1/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(dispatcher) { + enqueueUser("subscriber:annual") + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """{ "invite": { "email": "a@b.io", "role": "manager", "token": "tok-w" } }""", + ), + ) + + val result = repository.sendInvite("L1", "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(dispatcher) { + enqueueUser("free") + + val result = repository.sendInvite("L1", "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 lists.") + + // 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(dispatcher) { + val result = repository.sendInvite("L1", "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(dispatcher) { + enqueueUser("subscriber") + server.enqueue( + MockResponse().setResponseCode(400) + .setBody("""{ "error": "A valid email address is required", "code": "bad_request" }"""), + ) + + val result = repository.sendInvite("L1", "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(dispatcher) { + // 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("L1", "friend@example.com", InviteRole.VIEWER) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(server.requestCount).isEqualTo(2) + } + + @Test + fun `an unrecognised customer status is treated as unknown, not free`() = runTest(dispatcher) { + enqueueUser("subscriber:lifetime") + server.enqueue( + MockResponse().setResponseCode(201) + .setBody("""{ "email": "friend@example.com", "role": "watcher", "token": "tok-ok" }"""), + ) + + val result = repository.sendInvite("L1", "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(dispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "revoked": true }""")) + + val result = repository.revokeInvite("L1", "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/lists/L1/invites/tok-gone") + } + + @Test + fun `revokeInvite maps an unknown token to NotFound`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(404) + .setBody("""{ "error": "Invite not found", "code": "not_found" }"""), + ) + + val result = repository.revokeInvite("L1", "nope") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) + } + +} + +/** The invite endpoints never touch the cache, so the DAO is inert. */ +private class FakeInviteDao : ListDao { + private val state = MutableStateFlow>(emptyList()) + override fun observeLists(): Flow> = state + override suspend fun upsertAll(lists: List) {} + override suspend fun upsert(list: CachedListEntity) {} + override suspend fun deleteById(id: String) {} + override suspend fun clear() {} +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryPolishTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryPolishTest.kt index 76ca859..a53d3d4 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryPolishTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryPolishTest.kt @@ -4,6 +4,7 @@ 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.lists.data.local.CachedListEntity import com.interlinedlist.android.feature.lists.data.local.ListDao import com.interlinedlist.android.feature.lists.data.remote.ListsApi @@ -37,7 +38,9 @@ import retrofit2.Retrofit class DefaultListsRepositoryPolishTest { private lateinit var server: MockWebServer + private lateinit var retrofit: Retrofit private lateinit var api: ListsApi + private lateinit var userApi: InterlinedListApi private lateinit var dao: FakePolishDao private lateinit var repository: DefaultListsRepository @@ -54,13 +57,14 @@ class DefaultListsRepositoryPolishTest { @Before fun setUp() { server = MockWebServer().also { it.start() } - api = Retrofit.Builder() + retrofit = Retrofit.Builder() .baseUrl(server.url("/")) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() - .create(ListsApi::class.java) + api = retrofit.create(ListsApi::class.java) + userApi = retrofit.create(InterlinedListApi::class.java) dao = FakePolishDao() - repository = DefaultListsRepository(api, dao, json, testDispatchers) + repository = DefaultListsRepository(api, userApi, dao, json, testDispatchers) } @After diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryShareTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryShareTest.kt index 1871b30..f1e914a 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryShareTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryShareTest.kt @@ -4,6 +4,7 @@ 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.lists.data.local.CachedListEntity import com.interlinedlist.android.feature.lists.data.local.ListDao import com.interlinedlist.android.feature.lists.data.remote.ListsApi @@ -34,7 +35,9 @@ import retrofit2.Retrofit class DefaultListsRepositoryShareTest { private lateinit var server: MockWebServer + private lateinit var retrofit: Retrofit private lateinit var api: ListsApi + private lateinit var userApi: InterlinedListApi private lateinit var repository: DefaultListsRepository // Mirrors the app's shared Json (explicit nulls off, coerce defaults on). @@ -50,12 +53,13 @@ class DefaultListsRepositoryShareTest { @Before fun setUp() { server = MockWebServer().also { it.start() } - api = Retrofit.Builder() + retrofit = Retrofit.Builder() .baseUrl(server.url("/")) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() - .create(ListsApi::class.java) - repository = DefaultListsRepository(api, FakeShareDao(), json, testDispatchers) + api = retrofit.create(ListsApi::class.java) + userApi = retrofit.create(InterlinedListApi::class.java) + repository = DefaultListsRepository(api, userApi, FakeShareDao(), json, testDispatchers) } @After diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryTest.kt index df89d7a..e3ace8b 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryTest.kt @@ -4,6 +4,7 @@ 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.lists.data.local.CachedListEntity import com.interlinedlist.android.feature.lists.data.local.ListDao import com.interlinedlist.android.feature.lists.data.remote.ListsApi @@ -34,7 +35,9 @@ import retrofit2.Retrofit class DefaultListsRepositoryTest { private lateinit var server: MockWebServer + private lateinit var retrofit: Retrofit private lateinit var api: ListsApi + private lateinit var userApi: InterlinedListApi private lateinit var dao: FakeListDao private lateinit var repository: DefaultListsRepository @@ -50,13 +53,14 @@ class DefaultListsRepositoryTest { @Before fun setUp() { server = MockWebServer().also { it.start() } - api = Retrofit.Builder() + retrofit = Retrofit.Builder() .baseUrl(server.url("/")) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() - .create(ListsApi::class.java) + api = retrofit.create(ListsApi::class.java) + userApi = retrofit.create(InterlinedListApi::class.java) dao = FakeListDao() - repository = DefaultListsRepository(api, dao, json, testDispatchers) + repository = DefaultListsRepository(api, userApi, dao, json, testDispatchers) } @After diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryViewsTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryViewsTest.kt index 9a9ddcd..a822714 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryViewsTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryViewsTest.kt @@ -4,6 +4,7 @@ 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.lists.data.local.CachedListEntity import com.interlinedlist.android.feature.lists.data.local.ListDao import com.interlinedlist.android.feature.lists.data.remote.ListsApi @@ -39,7 +40,9 @@ import retrofit2.Retrofit class DefaultListsRepositoryViewsTest { private lateinit var server: MockWebServer + private lateinit var retrofit: Retrofit private lateinit var api: ListsApi + private lateinit var userApi: InterlinedListApi private lateinit var repository: DefaultListsRepository // Mirrors the app's shared Json (explicit nulls off, coerce defaults on). @@ -55,12 +58,13 @@ class DefaultListsRepositoryViewsTest { @Before fun setUp() { server = MockWebServer().also { it.start() } - api = Retrofit.Builder() + retrofit = Retrofit.Builder() .baseUrl(server.url("/")) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() - .create(ListsApi::class.java) - repository = DefaultListsRepository(api, FakeViewsDao(), json, testDispatchers) + api = retrofit.create(ListsApi::class.java) + userApi = retrofit.create(InterlinedListApi::class.java) + repository = DefaultListsRepository(api, userApi, FakeViewsDao(), json, testDispatchers) } @After diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepositoryDeferredTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepositoryDeferredTest.kt index e58d5db..918e2ba 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepositoryDeferredTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepositoryDeferredTest.kt @@ -4,6 +4,7 @@ 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.lists.data.local.CachedListEntity import com.interlinedlist.android.feature.lists.data.local.ListDao import com.interlinedlist.android.feature.lists.data.remote.ListsApi @@ -37,7 +38,9 @@ import retrofit2.Retrofit class ListsRepositoryDeferredTest { private lateinit var server: MockWebServer + private lateinit var retrofit: Retrofit private lateinit var api: ListsApi + private lateinit var userApi: InterlinedListApi private lateinit var repository: DefaultListsRepository private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } @@ -52,12 +55,13 @@ class ListsRepositoryDeferredTest { @Before fun setUp() { server = MockWebServer().also { it.start() } - api = Retrofit.Builder() + retrofit = Retrofit.Builder() .baseUrl(server.url("/")) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() - .create(ListsApi::class.java) - repository = DefaultListsRepository(api, FakeDao(), json, testDispatchers) + api = retrofit.create(ListsApi::class.java) + userApi = retrofit.create(InterlinedListApi::class.java) + repository = DefaultListsRepository(api, userApi, FakeDao(), json, testDispatchers) } @After diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/domain/ListInviteTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/domain/ListInviteTest.kt new file mode 100644 index 0000000..890dd73 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/domain/ListInviteTest.kt @@ -0,0 +1,114 @@ +package com.interlinedlist.android.feature.lists.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 ListInviteTest { + + 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, + ) = ListInvite( + 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) + assertThat(revoked.statusAt(now).label).isEqualTo("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/lists/invite/abc").inviteUrl()) + .isEqualTo("https://example.test/lists/invite/abc") + assertThat(invite().inviteUrl()) + .isEqualTo("https://interlinedlist.com/lists/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 `invite roles carry the labels the list access UI shows`() { + assertThat(InviteRole.VIEWER.label).isEqualTo("Read-only") + assertThat(InviteRole.EDITOR.label).isEqualTo("Edit") + assertThat(InviteRole.ADMIN.label).isEqualTo("Admin") + } + + @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/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/InviteLabelsTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/InviteLabelsTest.kt new file mode 100644 index 0000000..79cb2d4 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/InviteLabelsTest.kt @@ -0,0 +1,37 @@ +package com.interlinedlist.android.feature.lists.ui + +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") + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/ListInvitesViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/ListInvitesViewModelTest.kt new file mode 100644 index 0000000..bdcb030 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/ListInvitesViewModelTest.kt @@ -0,0 +1,230 @@ +package com.interlinedlist.android.feature.lists.ui.watchers + +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.lists.FakeListsRepository +import com.interlinedlist.android.feature.lists.domain.InviteRole +import com.interlinedlist.android.feature.lists.domain.InviteStatus +import com.interlinedlist.android.feature.lists.domain.ListInvite +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 list access (watchers) screen. */ +@OptIn(ExperimentalCoroutinesApi::class) +class ListInvitesViewModelTest { + + 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, + revokedAt: String? = null, + ) = ListInvite(email, token, role, expiresAt, null, accepted, revokedAt, null) + + private fun viewModel(repo: FakeListsRepository) = + WatchersViewModel(repo, SavedStateHandle(mapOf(WATCHERS_LIST_ID_ARG to "L1"))) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads pending invites alongside watchers`() = runTest(dispatcher) { + val repo = FakeListsRepository().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 and status for pending accepted expired and revoked invites`() = runTest(dispatcher) { + val repo = FakeListsRepository().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"), + invite("revoked@x.io", revokedAt = "2026-05-02T00:00:00Z"), + ), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + val invites = vm.uiState.value.invites.invites + assertThat(invites[0].role).isEqualTo(InviteRole.EDITOR) + assertThat(invites.map { it.statusAt(now) }).containsExactly( + InviteStatus.PENDING, + InviteStatus.ACCEPTED, + InviteStatus.EXPIRED, + InviteStatus.REVOKED, + ).inOrder() + } + + @Test + fun `an invalid email is rejected without calling the repository`() = runTest(dispatcher) { + val repo = FakeListsRepository() + 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 = FakeListsRepository() + 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?.listId).isEqualTo("L1") + 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 = FakeListsRepository().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 = FakeListsRepository().apply { + sendInviteResult = ApiResult.Failure( + AppError.SubscriptionRequired("Subscribe to invite people to lists."), + ) + } + 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 lists.") + assertThat(state.invites).isEmpty() + } + + @Test + fun `a server rejection is surfaced verbatim rather than generically`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + sendInviteResult = ApiResult.Failure(AppError.Conflict("That person already watches this list")) + } + 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 already watches this list") + assertThat(state.subscriptionRequired).isFalse() + } + + @Test + fun `a free account can still revoke`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + invitesResult = ApiResult.Success(listOf(invite("a@x.io", token = "t1"), invite("b@x.io", token = "t2"))) + // Sending is refused for this account, but revoking must still go through. + sendInviteResult = ApiResult.Failure( + AppError.SubscriptionRequired("Subscribe to invite people to lists."), + ) + } + 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 = FakeListsRepository().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 invite load surfaces an error without breaking the watcher list`() = runTest(dispatcher) { + val repo = FakeListsRepository().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() + } +}