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,6 +1,7 @@
package com.interlinedlist.android.feature.integrations.ui

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
Expand Down Expand Up @@ -98,6 +99,121 @@ class IntegrationsScreensTest {
.assertIsDisplayed()
}

@Test
fun accounts_staleConnection_showsBadgeAndActions() {
val stale = ConnectedAccount(
provider = ConnectedAccount.Provider.LINKEDIN,
isConnected = true,
handle = "Adron Hall",
identityProvider = "linkedin",
connectedAt = "2020-01-01T00:00:00Z",
lastVerifiedAt = "2020-02-01T00:00:00Z",
)
composeRule.setContent {
InterlinedListTheme {
ConnectedAccountsScreen(
state = ConnectedAccountsUiState(isLoading = false, accounts = listOf(stale)),
onBack = {},
)
}
}

composeRule.onNodeWithTag(ConnectedAccountsTestTags.badge("linkedin")).assertIsDisplayed()
composeRule.onNodeWithTag(ConnectedAccountsTestTags.health("linkedin")).assertIsDisplayed()
composeRule.onNodeWithTag(ConnectedAccountsTestTags.verify("linkedin")).assertIsDisplayed()
composeRule.onNodeWithTag(ConnectedAccountsTestTags.unlink("linkedin")).assertIsDisplayed()
}

@Test
fun accounts_unlink_asksForConfirmationStatingTheConsequence() {
val linked = ConnectedAccount(
provider = ConnectedAccount.Provider.LINKEDIN,
isConnected = true,
handle = "Adron Hall",
identityProvider = "linkedin",
lastVerifiedAt = "2020-02-01T00:00:00Z",
)
var requested: ConnectedAccount? = null
var confirmed = false
composeRule.setContent {
InterlinedListTheme {
ConnectedAccountsScreen(
state = ConnectedAccountsUiState(
isLoading = false,
accounts = listOf(linked),
unlinkCandidate = null,
),
onBack = {},
onRequestUnlink = { requested = it },
onConfirmUnlink = { confirmed = true },
)
}
}

// Tapping Unlink only asks; nothing is unlinked yet.
composeRule.onNodeWithTag(ConnectedAccountsTestTags.unlink("linkedin")).performClick()
assert(requested == linked)
assert(!confirmed)
composeRule.onNodeWithTag(ConnectedAccountsTestTags.UNLINK_DIALOG).assertDoesNotExist()
}

@Test
fun accounts_unlinkDialog_statesTheCrossPostConsequenceAndConfirms() {
val linked = ConnectedAccount(
provider = ConnectedAccount.Provider.LINKEDIN,
isConnected = true,
identityProvider = "linkedin",
lastVerifiedAt = "2020-02-01T00:00:00Z",
)
var confirmed = false
composeRule.setContent {
InterlinedListTheme {
ConnectedAccountsScreen(
state = ConnectedAccountsUiState(
isLoading = false,
accounts = listOf(linked),
unlinkCandidate = linked,
),
onBack = {},
onConfirmUnlink = { confirmed = true },
)
}
}

composeRule.onNodeWithText("Unlink LinkedIn?").assertIsDisplayed()
composeRule.onNodeWithText(
"This stops cross-posting to LinkedIn.",
substring = true,
).assertIsDisplayed()

composeRule.onNodeWithTag(ConnectedAccountsTestTags.UNLINK_CONFIRM).performClick()
assert(confirmed)
}

@Test
fun accounts_verify_invokesTheAction() {
val linked = ConnectedAccount(
provider = ConnectedAccount.Provider.BLUESKY,
isConnected = true,
identityProvider = "bluesky",
lastVerifiedAt = "2020-02-01T00:00:00Z",
)
var verified: ConnectedAccount? = null
composeRule.setContent {
InterlinedListTheme {
ConnectedAccountsScreen(
state = ConnectedAccountsUiState(isLoading = false, accounts = listOf(linked)),
onBack = {},
onVerify = { verified = it },
)
}
}

composeRule.onNodeWithTag(ConnectedAccountsTestTags.verify("bluesky")).performClick()

assert(verified == linked)
}

@Test
fun github_repos_renderAndSelect() {
var selected: GitHubRepo? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider
import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.common.result.map
import com.interlinedlist.android.core.network.error.safeApiCall
import com.interlinedlist.android.feature.integrations.data.mapper.toAccounts
import com.interlinedlist.android.feature.integrations.data.mapper.toDomain
import com.interlinedlist.android.feature.integrations.data.mapper.toDomainOrNull
import com.interlinedlist.android.feature.integrations.data.remote.IntegrationsApi
import com.interlinedlist.android.feature.integrations.data.remote.dto.CreateCommentRequest
import com.interlinedlist.android.feature.integrations.data.remote.dto.CreateIssueRequest
import com.interlinedlist.android.feature.integrations.data.remote.dto.GitHubIssueDto
import com.interlinedlist.android.feature.integrations.data.remote.dto.VerifyIdentityRequest
import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount
import com.interlinedlist.android.feature.integrations.domain.ExportType
import com.interlinedlist.android.feature.integrations.domain.GitHubAssignee
Expand Down Expand Up @@ -46,23 +48,37 @@ class DefaultIntegrationsRepository @Inject constructor(

override suspend fun getConnectedAccounts(): List<ConnectedAccount> =
withContext(dispatchers.io) {
// Identities come first: they carry the unlink/verify key and the
// connected/verified timestamps behind the health badge. If that read
// fails the screen degrades to status-only rows rather than going blank.
val identities = when (val result = safeApiCall(json) { api.getIdentities() }) {
is ApiResult.Success -> result.data.identitiesOrEmpty
is ApiResult.Failure -> emptyList()
}
// Statuses are independent; one provider failing shouldn't hide the
// rest, so a failed lookup is treated as "not connected".
ConnectedAccount.Provider.entries.map { provider ->
when (val result = safeApiCall(json) { api.getConnectionStatus(provider.statusPath) }) {
is ApiResult.Success -> ConnectedAccount(
provider = provider,
isConnected = result.data.isConnected,
handle = result.data.bestHandle,
)
is ApiResult.Failure -> ConnectedAccount(
provider = provider,
isConnected = false,
)
ConnectedAccount.Provider.entries.flatMap { provider ->
val status = when (
val result = safeApiCall(json) { api.getConnectionStatus(provider.statusPath) }
) {
is ApiResult.Success -> result.data
is ApiResult.Failure -> null
}
provider.toAccounts(status, identities)
}
}

override suspend fun unlinkIdentity(identityProvider: String): ApiResult<Unit> =
withContext(dispatchers.io) {
safeApiCall(json) { api.unlinkIdentity(identityProvider) }.map { it.close() }
}

override suspend fun verifyIdentity(identityProvider: String): ApiResult<Unit> =
withContext(dispatchers.io) {
safeApiCall(json) { api.verifyIdentity(VerifyIdentityRequest(identityProvider)) }
.map { it.close() }
}

override suspend fun getLimits(): ApiResult<PlanLimits> =
withContext(dispatchers.io) {
safeApiCall(json) { api.getLimits() }.map { it.toDomain() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,28 @@ interface IntegrationsRepository {
*/
suspend fun downloadExport(type: ExportType): ApiResult<File>

/** Fetches connection status for every supported provider. */
/**
* Fetches one row per provider, merged with the user's linked identities so each
* linked row carries the key unlink/verify need plus its connected/verified
* timestamps. A provider backing several identities (Mastodon instances) yields
* one row each.
*/
suspend fun getConnectedAccounts(): List<ConnectedAccount>

/**
* Unlinks the identity whose `provider` string is [identityProvider]
* (`ConnectedAccount.identityProvider`). This stops cross-posting to that network,
* so callers must confirm first. Failures carry the server's message.
*/
suspend fun unlinkIdentity(identityProvider: String): ApiResult<Unit>

/**
* Re-verifies the identity whose `provider` string is [identityProvider], refreshing
* its `lastVerifiedAt` server-side. Callers should re-read [getConnectedAccounts]
* afterwards rather than trust the (unspecified) response body.
*/
suspend fun verifyIdentity(identityProvider: String): ApiResult<Unit>

/** Reads plan limits/usage, or a failure the UI can render inline. */
suspend fun getLimits(): ApiResult<PlanLimits>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.interlinedlist.android.feature.integrations.data.mapper

import com.interlinedlist.android.feature.integrations.data.remote.dto.ConnectionStatusDto
import com.interlinedlist.android.feature.integrations.data.remote.dto.LinkedIdentityDto
import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount

/**
* Rows to show for one provider.
*
* Two sources are combined. `/api/auth/<provider>/status` answers "is this connected"
* for every provider the screen lists, including the ones with nothing linked;
* `/api/user/identities` adds the identity key that unlink/verify need plus the
* `connectedAt`/`lastVerifiedAt` that drive connection health.
*
* A provider can own more than one identity — Mastodon returns one per instance — so
* each identity becomes its own actionable row, and a provider with none falls back
* to a single status-only row.
*/
internal fun ConnectedAccount.Provider.toAccounts(
status: ConnectionStatusDto?,
identities: List<LinkedIdentityDto>,
): List<ConnectedAccount> {
val mine = identities.filter { ConnectedAccount.Provider.fromIdentityProvider(it.provider) == this }
if (mine.isEmpty()) {
return listOf(
ConnectedAccount(
provider = this,
isConnected = status?.isConnected ?: false,
handle = status?.bestHandle,
),
)
}
return mine.map { identity ->
ConnectedAccount(
// An identity record exists, so the account is linked whatever the status
// endpoint says — a lapsed authorization is reported as health, not absence.
provider = this,
isConnected = true,
handle = identity.providerUsername?.takeIf { it.isNotBlank() } ?: status?.bestHandle,
identityProvider = identity.provider.takeIf { it.isNotBlank() },
connectedAt = identity.connectedAt,
lastVerifiedAt = identity.lastVerifiedAt,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ import com.interlinedlist.android.feature.integrations.data.remote.dto.GitHubAss
import com.interlinedlist.android.feature.integrations.data.remote.dto.GitHubIssueDto
import com.interlinedlist.android.feature.integrations.data.remote.dto.GitHubLabelDto
import com.interlinedlist.android.feature.integrations.data.remote.dto.GitHubRepoDto
import com.interlinedlist.android.feature.integrations.data.remote.dto.IdentitiesResponse
import com.interlinedlist.android.feature.integrations.data.remote.dto.LimitsDto
import com.interlinedlist.android.feature.integrations.data.remote.dto.VerifyIdentityRequest
import okhttp3.ResponseBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
Expand Down Expand Up @@ -42,6 +45,33 @@ interface IntegrationsApi {
@GET("api/limits")
suspend fun getLimits(): LimitsDto

// --- Linked identities ---
//
// Linking is a browser OAuth redirect and stays on the web; these three are the
// parts a native client can do. Both mutations key on the identity's raw
// `provider` string — DELETE takes it as a QUERY PARAMETER (confirmed in the
// OpenAPI spec: `provider`, `in: query`) while verify takes it in a JSON BODY
// (its requestBody schema has a single `provider` property).

/** The current user's linked social identities, with connectedAt/lastVerifiedAt. */
@GET("api/user/identities")
suspend fun getIdentities(): IdentitiesResponse

/**
* Unlinks one identity. Returns a raw [ResponseBody] because the success body is
* unspecified — the caller re-reads the list rather than trusting it.
*/
@DELETE("api/user/identities")
suspend fun unlinkIdentity(@Query("provider") provider: String): ResponseBody

/**
* Re-checks that a linked identity's authorization still works, refreshing its
* `lastVerifiedAt`. The response body is unspecified (201 + bare `object`), so it
* is read as a raw [ResponseBody] and discarded; the refreshed list is the truth.
*/
@POST("api/user/identities/verify")
suspend fun verifyIdentity(@Body request: VerifyIdentityRequest): ResponseBody

// --- GitHub ---
//
// The bearer is auto-injected. When GitHub isn't linked these return HTTP 400
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.interlinedlist.android.feature.integrations.data.remote.dto

import kotlinx.serialization.Serializable

/**
* `GET /api/user/identities` → `{ "identities": [ ... ] }`. The OpenAPI spec types
* the body as a bare `object`, so this mirrors the live shape already verified by
* `:feature:profile` and `:feature:messages`; the generic `data` envelope is
* tolerated as well in case the server ever switches keys.
*/
@Serializable
data class IdentitiesResponse(
val identities: List<LinkedIdentityDto>? = null,
val data: List<LinkedIdentityDto>? = null,
) {
val identitiesOrEmpty: List<LinkedIdentityDto> get() = identities ?: data ?: emptyList()
}

/**
* A single linked social identity. [provider] is the key both
* `DELETE /api/user/identities?provider=` and `POST /api/user/identities/verify`
* expect, and for Mastodon it carries the instance (`mastodon:techhub.social`).
*
* [lastVerifiedAt] is what makes a stale connection visible before a cross-post
* silently fails; the API omits it until the connection has been checked once.
*/
@Serializable
data class LinkedIdentityDto(
val id: String = "",
val provider: String = "",
val providerUsername: String? = null,
val profileUrl: String? = null,
val avatarUrl: String? = null,
val connectedAt: String? = null,
val lastVerifiedAt: String? = null,
)

/** Body for `POST /api/user/identities/verify`; the spec models exactly one field. */
@Serializable
data class VerifyIdentityRequest(val provider: String)
Loading
Loading