Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -586,6 +595,72 @@ class DefaultListsRepository @Inject constructor(
safeApiCall(json) { api.claimSharedList(token) }.map { }
}

// --- Email invites -----------------------------------------------------

override suspend fun getInvites(listId: String): ApiResult<List<ListInvite>> =
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<ListInvite> = 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<Unit> =
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<String, String>.toJsonData(): Map<String, JsonElement> =
filterValues { it.isNotBlank() }
Expand All @@ -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."
Expand Down
Original file line number Diff line number Diff line change
@@ -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/<token>`). */
private fun tokenFromInviteUrl(url: String?): String =
url?.substringBefore('?')?.substringBefore('#')?.trimEnd('/')?.substringAfterLast('/').orEmpty()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -273,6 +275,29 @@ interface ListsRepository {
/** Claims edit/admin access to a shared list via its token. */
suspend fun claimSharedList(token: String): ApiResult<Unit>

// --- 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<List<ListInvite>>

/**
* 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<ListInvite>

/** Revokes a pending invite, killing its link immediately. Never subscriber-gated. */
suspend fun revokeInvite(listId: String, token: String): ApiResult<Unit>

companion object {
const val DEFAULT_PAGE_SIZE = 20
}
Expand Down
Loading
Loading