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,13 +1,18 @@
package com.interlinedlist.android.feature.documents.ui.collaborators

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
import com.interlinedlist.android.feature.documents.domain.Collaborator
import com.interlinedlist.android.feature.documents.domain.CollaboratorRole
import com.interlinedlist.android.feature.documents.domain.DocumentInvite
import com.interlinedlist.android.feature.documents.domain.InviteRole
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
Expand All @@ -23,6 +28,7 @@ class DocumentCollaboratorsScreenTest {
state: DocumentCollaboratorsUiState,
onChangeRole: (String, CollaboratorRole) -> Unit = { _, _ -> },
onRevoke: (String) -> Unit = {},
onRevokeInvite: (String) -> Unit = {},
) {
composeRule.setContent {
InterlinedListTheme {
Expand All @@ -34,11 +40,23 @@ class DocumentCollaboratorsScreenTest {
onInvite = {},
onChangeRole = onChangeRole,
onRevoke = onRevoke,
onInviteEmailChange = {},
onSelectInviteEmailRole = {},
onSendInvite = {},
onRevokeInvite = onRevokeInvite,
)
}
}
}

private fun invite(
email: String,
token: String,
role: InviteRole = InviteRole.VIEWER,
expiresAt: String? = null,
accepted: Boolean = false,
) = DocumentInvite(email, token, role, expiresAt, null, accepted, null, null)

@Test
fun rendersCollaborators_withRoleControls() {
setContent(
Expand Down Expand Up @@ -98,4 +116,89 @@ class DocumentCollaboratorsScreenTest {
setContent(DocumentCollaboratorsUiState(collaborators = emptyList(), isLoading = false))
composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.EMPTY).assertIsDisplayed()
}

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

@Test
fun pendingInvites_renderRoleStatusAndExpiry() {
setContent(
DocumentCollaboratorsUiState(
isLoading = false,
invites = DocumentInvitesUiState(
isLoading = false,
invites = listOf(
invite("friend@example.com", "tok-a", InviteRole.EDITOR),
invite("late@example.com", "tok-b", expiresAt = "2020-01-02T00:00:00Z"),
invite("done@example.com", "tok-c", InviteRole.ADMIN, accepted = true),
),
),
),
)

composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.INVITE_LIST).assertIsDisplayed()
composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.inviteRow("tok-a")).assertIsDisplayed()
composeRule.onNodeWithText("friend@example.com").assertIsDisplayed()
composeRule.onNodeWithText("Editor · No expiry").assertIsDisplayed()
composeRule.onNodeWithText("Pending").assertIsDisplayed()
// An invite whose expiry has passed reads as expired, not pending.
composeRule.onNodeWithText("Expired").assertIsDisplayed()
composeRule.onNodeWithText("Accepted").assertIsDisplayed()
}

@Test
fun revokeInvite_invokesCallback() {
var revoked: String? = null
setContent(
DocumentCollaboratorsUiState(
isLoading = false,
invites = DocumentInvitesUiState(
isLoading = false,
invites = listOf(invite("friend@example.com", "tok-a")),
),
),
onRevokeInvite = { revoked = it },
)

composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.inviteRevoke("tok-a")).performClick()
assert(revoked == "tok-a")
}

@Test
fun sendInvite_isDisabled_forAnIncompleteEmail() {
setContent(
DocumentCollaboratorsUiState(
isLoading = false,
invites = DocumentInvitesUiState(isLoading = false, email = "friend@"),
),
)
composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.INVITE_SEND).assertIsNotEnabled()
}

@Test
fun sendInvite_isEnabled_forAValidEmail() {
setContent(
DocumentCollaboratorsUiState(
isLoading = false,
invites = DocumentInvitesUiState(isLoading = false, email = "friend@example.com"),
),
)
composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.INVITE_SEND).assertIsEnabled()
}

@Test
fun subscriptionGate_isShown_whenSendingIsRefused() {
setContent(
DocumentCollaboratorsUiState(
isLoading = false,
invites = DocumentInvitesUiState(
isLoading = false,
subscriptionRequired = true,
errorMessage = "Subscribe to invite people to documents.",
),
),
)

composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.INVITE_GATE).assertIsDisplayed()
composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.INVITE_EMPTY).assertIsDisplayed()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider
import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.common.result.AppError
import com.interlinedlist.android.core.common.result.map
import com.interlinedlist.android.core.network.api.InterlinedListApi
import com.interlinedlist.android.core.network.dto.toDomain as userDtoToDomain
import com.interlinedlist.android.core.network.error.safeApiCall
import com.interlinedlist.android.feature.documents.data.local.DocumentDao
import com.interlinedlist.android.feature.documents.data.local.FolderDao
Expand All @@ -21,6 +23,7 @@ import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi
import com.interlinedlist.android.feature.documents.data.remote.dto.CreateDocumentRequest
import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderDocumentRequest
import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderRequest
import com.interlinedlist.android.feature.documents.data.remote.dto.CreateInviteRequest
import com.interlinedlist.android.feature.documents.data.remote.dto.CreateShareLinkRequest
import com.interlinedlist.android.feature.documents.data.remote.dto.FromTemplateRequest
import com.interlinedlist.android.feature.documents.data.remote.dto.InviteCollaboratorRequest
Expand All @@ -34,11 +37,14 @@ import com.interlinedlist.android.feature.documents.domain.CollaboratorCandidate
import com.interlinedlist.android.feature.documents.domain.CollaboratorRole
import com.interlinedlist.android.feature.documents.domain.Document
import com.interlinedlist.android.feature.documents.domain.DocumentFolder
import com.interlinedlist.android.feature.documents.domain.DocumentInvite
import com.interlinedlist.android.feature.documents.domain.DocumentTemplate
import com.interlinedlist.android.feature.documents.domain.FolderContents
import com.interlinedlist.android.feature.documents.domain.FolderNode
import com.interlinedlist.android.feature.documents.domain.FolderSummary
import com.interlinedlist.android.feature.documents.domain.FolderTree
import com.interlinedlist.android.feature.documents.domain.InviteEmail
import com.interlinedlist.android.feature.documents.domain.InviteRole
import com.interlinedlist.android.feature.documents.domain.Presence
import com.interlinedlist.android.feature.documents.domain.ShareLink
import com.interlinedlist.android.feature.documents.domain.ShareRole
Expand All @@ -62,6 +68,8 @@ import javax.inject.Inject
*/
class DefaultDocumentsRepository @Inject constructor(
private val api: DocumentsApi,
/** Shared current-user endpoint, used only for the subscriber gate on sending invites. */
private val userApi: InterlinedListApi,
private val documentDao: DocumentDao,
private val folderDao: FolderDao,
private val pendingOpDao: PendingOpDao,
Expand Down Expand Up @@ -479,6 +487,69 @@ class DefaultDocumentsRepository @Inject constructor(
}
}

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

override suspend fun getInvites(documentId: String): ApiResult<List<DocumentInvite>> =
withContext(dispatchers.io) {
safeApiCall(json) { api.getInvites(documentId) }
.map { response -> response.items.map { it.toDomain() } }
}

override suspend fun sendInvite(
documentId: String,
email: String,
role: InviteRole,
): ApiResult<DocumentInvite> = withContext(dispatchers.io) {
val address = InviteEmail.normalize(email)
if (!InviteEmail.isValid(address)) {
return@withContext ApiResult.Failure(AppError.Unknown(InviteEmail.INVALID_MESSAGE))
}
// Sending is subscriber-only (the server 403s a free owner). Check first so a
// free account never issues the write at all. Fail OPEN when the status cannot
// be read — a flaky /api/user must not block a paying subscriber; the server
// remains the authority and answers with the same SubscriptionRequired error.
if (currentUserIsSubscriber() == false) {
return@withContext ApiResult.Failure(AppError.SubscriptionRequired(NOT_SUBSCRIBED_MESSAGE))
}
when (
val result = safeApiCall(json) {
api.createInvite(documentId, CreateInviteRequest(email = address, role = role.apiValue))
}
) {
is ApiResult.Success -> {
val dto = result.data.inviteOrSelf
ApiResult.Success(
dto?.toDomain() ?: DocumentInvite(
email = address,
token = "",
role = role,
expiresAt = null,
createdAt = null,
accepted = false,
revokedAt = null,
url = null,
),
)
}
is ApiResult.Failure -> result
}
}

override suspend fun revokeInvite(documentId: String, token: String): ApiResult<Unit> =
withContext(dispatchers.io) {
safeApiCall(json) { api.revokeInvite(documentId, token) }.map { }
}

/**
* The signed-in account's subscription tier, or null when it cannot be read.
* Mirrors the `customerStatus` check the rest of the app gates premium features on.
*/
private suspend fun currentUserIsSubscriber(): Boolean? =
when (val result = safeApiCall(json) { userApi.getCurrentUser().user }) {
is ApiResult.Success -> result.data.userDtoToDomain().customerStatus.isSubscriber
is ApiResult.Failure -> null
}

// --- Delta sync --------------------------------------------------------

override suspend fun pullDelta(): ApiResult<Unit> = withContext(dispatchers.io) {
Expand Down Expand Up @@ -660,4 +731,9 @@ class DefaultDocumentsRepository @Inject constructor(

/** Treats the synthetic root id as "no parent" for API calls. */
private fun String.realOrNull(): String? = takeUnless { it == FolderNode.ROOT_ID }

private companion object {
/** Matches the server's own copy for the 403 a free owner receives. */
const val NOT_SUBSCRIBED_MESSAGE = "Subscribe to invite people to documents."
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import com.interlinedlist.android.feature.documents.domain.CollaboratorCandidate
import com.interlinedlist.android.feature.documents.domain.CollaboratorRole
import com.interlinedlist.android.feature.documents.domain.Document
import com.interlinedlist.android.feature.documents.domain.DocumentFolder
import com.interlinedlist.android.feature.documents.domain.DocumentInvite
import com.interlinedlist.android.feature.documents.domain.DocumentTemplate
import com.interlinedlist.android.feature.documents.domain.FolderContents
import com.interlinedlist.android.feature.documents.domain.FolderSummary
import com.interlinedlist.android.feature.documents.domain.InviteRole
import com.interlinedlist.android.feature.documents.domain.Presence
import com.interlinedlist.android.feature.documents.domain.ShareLink
import com.interlinedlist.android.feature.documents.domain.ShareRole
Expand Down Expand Up @@ -154,6 +156,29 @@ interface DocumentsRepository {
/** Claims edit/admin access to a shared document via its token. */
suspend fun claimSharedDocument(token: String): ApiResult<Unit>

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

/**
* Pending email invites for a document. Free for any owner — a lapsed
* subscription must never hide invites the owner still needs to revoke.
*/
suspend fun getInvites(documentId: String): ApiResult<List<DocumentInvite>>

/**
* Invites [email] at [role]. Refuses locally — issuing no request at all — when
* the address is not a valid one, or when the signed-in account is known not to
* be a subscriber (sending is a subscriber feature), in which case the failure is
* [com.interlinedlist.android.core.common.result.AppError.SubscriptionRequired].
*/
suspend fun sendInvite(
documentId: String,
email: String,
role: InviteRole,
): ApiResult<DocumentInvite>

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

// --- Delta sync --------------------------------------------------------

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.interlinedlist.android.feature.documents.data.mapper

import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentInviteDto
import com.interlinedlist.android.feature.documents.domain.DocumentInvite
import com.interlinedlist.android.feature.documents.domain.InviteRole

/**
* Maps an invite wire model into the domain [DocumentInvite].
*
* The documented create response omits `token` and only returns the landing `url`,
* so the token is recovered from the URL's last path segment — without it the owner
* could not revoke an invite they had just sent.
*/
fun DocumentInviteDto.toDomain(): DocumentInvite = DocumentInvite(
email = email,
token = token.ifBlank { tokenFromInviteUrl(url) },
role = InviteRole.fromApi(role),
expiresAt = expiresAt,
createdAt = createdAt,
accepted = accepted || acceptedAt != null,
revokedAt = revokedAt,
url = url,
)

/** Last path segment of an invite landing URL (`.../documents/invite/<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 @@ -6,8 +6,11 @@ import com.interlinedlist.android.feature.documents.data.remote.dto.Collaborator
import com.interlinedlist.android.feature.documents.data.remote.dto.CreateDocumentRequest
import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderDocumentRequest
import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderRequest
import com.interlinedlist.android.feature.documents.data.remote.dto.CreateInviteRequest
import com.interlinedlist.android.feature.documents.data.remote.dto.CreateShareLinkRequest
import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentListResponse
import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentInviteEnvelope
import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentInvitesResponse
import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentResponse
import com.interlinedlist.android.feature.documents.data.remote.dto.FolderListResponse
import com.interlinedlist.android.feature.documents.data.remote.dto.FolderResponse
Expand Down Expand Up @@ -165,6 +168,29 @@ interface DocumentsApi {
@POST("api/documents/shared/{token}")
suspend fun claimSharedDocument(@Path("token") token: String)

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

/** Pending email invites for a document (owner only; free — not subscriber-gated). */
@GET("api/documents/{id}/invites")
suspend fun getInvites(@Path("id") id: String): DocumentInvitesResponse

/**
* Invites an email address (which need not belong to an account yet) at a role.
* Owner **and subscriber** only: a free owner is rejected with 403.
*/
@POST("api/documents/{id}/invites")
suspend fun createInvite(
@Path("id") id: String,
@Body body: CreateInviteRequest,
): DocumentInviteEnvelope

/** Revokes a pending invite by its token, killing the link immediately. Always free. */
@DELETE("api/documents/{id}/invites/{token}")
suspend fun revokeInvite(
@Path("id") id: String,
@Path("token") token: String,
)

// --- Delta sync --------------------------------------------------------

/**
Expand Down
Loading
Loading