From 5d067a96c6d5ff98a67617e5d9506dc6cedfff3b Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:40:52 -0700 Subject: [PATCH] feat(integrations): unlink, re-verify and health for connected accounts The connected-accounts screen was read-only: DELETE /api/user/identities was declared but unused and POST /api/user/identities/verify was never called, so a connection could quietly lapse and take cross-posting with it. Contracts (confirmed against the OpenAPI spec before wiring): - DELETE /api/user/identities takes the identity's `provider` as a QUERY PARAMETER (`?provider=mastodon%3Atechhub.social`), not a body or path segment. - POST /api/user/identities/verify takes `{ "provider": "..." }` in a JSON BODY. Both success bodies are unspecified, so they are read as raw ResponseBody and discarded; the refreshed list is the source of truth. getConnectedAccounts() now merges GET /api/user/identities into the per-provider status rows, which supplies the unlink/verify key plus connectedAt and lastVerifiedAt. One identity becomes one row, so Mastodon instances stay distinct and unlinking one cannot take out another. A failed identities read degrades to status-only rows rather than blanking the screen. Connection health uses a 30-day staleness threshold: the help centre (/help/cross-posting -> "Keeping your connections active") documents platform authorizations expiring on their own, LinkedIn's "after a couple of months", so 30 days leaves roughly a month of warning before the earliest point a cross-post could start failing. Three states render distinctly - verified recently, verified too long ago ("Check connection"), and never verified - and the copy names the consequence ("posts may stop reaching LinkedIn without an error") instead of printing a raw timestamp. Unlink is confirmed first, and the dialog states the consequence plainly: it stops cross-posting to that network. Neither mutation is applied optimistically - on success the list is re-read so the screen can never show a state that is no longer true, and on failure the connection stays exactly where it was with the server's own message surfaced. Linking itself is untouched: it still needs a browser OAuth redirect (#39). Tests: connection-health states and the 30-day boundary; the three rendering cases; unlink round-trip with list refresh; failed unlink keeps the row and surfaces the server message; verify round-trip with refresh; identity merge, query-parameter and request-body shapes against MockWebServer; plus Compose coverage of the confirmation dialog and the stale badge. Closes #40 --- .../ui/IntegrationsScreensTest.kt | 116 +++++++++ .../data/DefaultIntegrationsRepository.kt | 38 ++- .../data/IntegrationsRepository.kt | 21 +- .../data/mapper/IdentityMapper.kt | 45 ++++ .../data/remote/IntegrationsApi.kt | 30 +++ .../data/remote/dto/IdentityDto.kt | 40 +++ .../integrations/domain/ConnectedAccount.kt | 84 ++++++- .../integrations/domain/ConnectionHealth.kt | 40 +++ .../ui/accounts/ConnectedAccountsScreen.kt | 229 +++++++++++++++--- .../ui/accounts/ConnectedAccountsViewModel.kt | 85 ++++++- .../ui/accounts/ConnectionHealthCopy.kt | 104 ++++++++ .../data/DefaultIntegrationsRepositoryTest.kt | 164 +++++++++++-- .../domain/ConnectedAccountTest.kt | 105 ++++++++ .../ui/FakeIntegrationsRepository.kt | 21 +- .../ConnectedAccountsViewModelTest.kt | 181 +++++++++++++- .../ui/accounts/ConnectionHealthCopyTest.kt | 122 ++++++++++ 16 files changed, 1353 insertions(+), 72 deletions(-) create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/mapper/IdentityMapper.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/IdentityDto.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectionHealth.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectionHealthCopy.kt create mode 100644 feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectedAccountTest.kt create mode 100644 feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectionHealthCopyTest.kt diff --git a/feature/integrations/src/androidTest/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsScreensTest.kt b/feature/integrations/src/androidTest/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsScreensTest.kt index 0a8dac6..e25f884 100644 --- a/feature/integrations/src/androidTest/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsScreensTest.kt +++ b/feature/integrations/src/androidTest/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsScreensTest.kt @@ -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 @@ -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 diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepository.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepository.kt index 203d165..905fad8 100644 --- a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepository.kt +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepository.kt @@ -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 @@ -46,23 +48,37 @@ class DefaultIntegrationsRepository @Inject constructor( override suspend fun getConnectedAccounts(): List = 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 = + withContext(dispatchers.io) { + safeApiCall(json) { api.unlinkIdentity(identityProvider) }.map { it.close() } + } + + override suspend fun verifyIdentity(identityProvider: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.verifyIdentity(VerifyIdentityRequest(identityProvider)) } + .map { it.close() } + } + override suspend fun getLimits(): ApiResult = withContext(dispatchers.io) { safeApiCall(json) { api.getLimits() }.map { it.toDomain() } diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/IntegrationsRepository.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/IntegrationsRepository.kt index c274f5b..4107a4a 100644 --- a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/IntegrationsRepository.kt +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/IntegrationsRepository.kt @@ -23,9 +23,28 @@ interface IntegrationsRepository { */ suspend fun downloadExport(type: ExportType): ApiResult - /** 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 + /** + * 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 + + /** + * 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 + /** Reads plan limits/usage, or a failure the UI can render inline. */ suspend fun getLimits(): ApiResult diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/mapper/IdentityMapper.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/mapper/IdentityMapper.kt new file mode 100644 index 0000000..fac27bb --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/mapper/IdentityMapper.kt @@ -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//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, +): List { + 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, + ) + } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/IntegrationsApi.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/IntegrationsApi.kt index cc92947..52a563e 100644 --- a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/IntegrationsApi.kt +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/IntegrationsApi.kt @@ -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 @@ -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 diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/IdentityDto.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/IdentityDto.kt new file mode 100644 index 0000000..6edc39c --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/IdentityDto.kt @@ -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? = null, + val data: List? = null, +) { + val identitiesOrEmpty: List 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) diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectedAccount.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectedAccount.kt index de99b07..1763847 100644 --- a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectedAccount.kt +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectedAccount.kt @@ -1,22 +1,86 @@ package com.interlinedlist.android.feature.integrations.domain +import java.time.Duration +import java.time.Instant + /** - * A social/identity provider the user can connect on the web, and whether the - * current account is connected. This module is read-only: the OAuth "connect" - * dance needs a browser redirect and is deferred to the web app, so the UI only - * surfaces status plus a "manage on the web" note. + * A social/identity provider the user can cross-post to and, when the account is + * actually linked, the identity record standing behind it. + * + * *Linking* still needs a browser OAuth redirect and stays on the web. *Unlinking* + * and *re-verifying* an already-linked account are plain API calls, both keyed on + * [identityProvider] — the raw `provider` string the identities payload returns + * (`github`, `linkedin`, `mastodon:techhub.social`, …), which + * `DELETE /api/user/identities?provider=` and `POST /api/user/identities/verify` + * both expect verbatim. A null [identityProvider] means nothing is linked for this + * provider, so there is nothing to unlink or verify. + * + * Timestamps stay as the API's raw ISO-8601 strings (the repo convention); the + * derived state comes from [healthAt], which takes [Instant] so rendering stays + * deterministic in tests. */ data class ConnectedAccount( val provider: Provider, val isConnected: Boolean, /** Provider-supplied handle/username when connected, e.g. "@you". */ val handle: String? = null, + /** The identity's `provider` string, which unlink/verify key on; null when unlinked. */ + val identityProvider: String? = null, + /** ISO-8601 instant the account was linked, when the API reports one. */ + val connectedAt: String? = null, + /** ISO-8601 instant the connection was last confirmed to work, when the API reports one. */ + val lastVerifiedAt: String? = null, ) { - enum class Provider(val statusPath: String, val label: String) { - GITHUB("api/auth/github/status", "GitHub"), - LINKEDIN("api/auth/linkedin/status", "LinkedIn"), - BLUESKY("api/auth/bluesky/status", "Bluesky"), - MASTODON("api/auth/mastodon/status", "Mastodon"), - TWITTER("api/auth/twitter/status", "X (Twitter)"), + /** True when there is an identity record behind this row, i.e. unlink/verify are possible. */ + val isLinked: Boolean get() = !identityProvider.isNullOrBlank() + + /** + * Stable list key. One provider can back several identities — Mastodon returns + * one per instance (`mastodon:techhub.social`) — so the identity string, not the + * provider, identifies a row. + */ + val key: String get() = identityProvider?.takeIf { it.isNotBlank() } ?: provider.name + + /** + * Connection health at [now], or null when nothing is linked (health is a property + * of an authorization, and an unlinked provider has none). + * + * An unparseable `lastVerifiedAt` is treated as never verified rather than fresh: + * the point of the badge is to fail loud rather than let a syndication fail quiet. + */ + fun healthAt(now: Instant = Instant.now()): ConnectionHealth? { + if (!isLinked) return null + val verified = lastVerifiedAt?.let { runCatching { Instant.parse(it) }.getOrNull() } + ?: return ConnectionHealth.NEVER_VERIFIED + val age = Duration.between(verified, now) + return if (age >= ConnectionHealth.STALE_AFTER) ConnectionHealth.STALE else ConnectionHealth.FRESH + } + + enum class Provider( + val statusPath: String, + val label: String, + /** The token the identities API uses; a Mastodon identity prefixes it to `:host`. */ + val apiToken: String, + /** True when unlinking this account stops cross-posting to a social network. */ + val isCrossPostTarget: Boolean, + ) { + GITHUB("api/auth/github/status", "GitHub", "github", isCrossPostTarget = false), + LINKEDIN("api/auth/linkedin/status", "LinkedIn", "linkedin", isCrossPostTarget = true), + BLUESKY("api/auth/bluesky/status", "Bluesky", "bluesky", isCrossPostTarget = true), + MASTODON("api/auth/mastodon/status", "Mastodon", "mastodon", isCrossPostTarget = true), + TWITTER("api/auth/twitter/status", "X (Twitter)", "twitter", isCrossPostTarget = true), + ; + + companion object { + /** + * The provider an identity's `provider` string belongs to, or null when it is + * one this screen does not model. Mastodon encodes the instance after a colon, + * so only the leading token is matched. + */ + fun fromIdentityProvider(raw: String?): Provider? { + val token = raw?.substringBefore(':')?.trim()?.lowercase().orEmpty() + return entries.firstOrNull { it.apiToken == token } + } + } } } diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectionHealth.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectionHealth.kt new file mode 100644 index 0000000..0439421 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectionHealth.kt @@ -0,0 +1,40 @@ +package com.interlinedlist.android.feature.integrations.domain + +import java.time.Duration + +/** + * How trustworthy a linked account's authorization looks right now. + * + * The help centre (`/help/cross-posting` → *Keeping your connections active*) + * explains that each platform authorization expires on its own schedule and has to + * be renewed — LinkedIn's, for example, "generally expire after a couple of months + * and can't be refreshed automatically". When one lapses, the next cross-post is + * dropped by that platform without the composer ever saying so. + * + * The identities payload reports `lastVerifiedAt`, so the app can flag a connection + * that has not been checked recently *before* a post silently fails to syndicate. + */ +enum class ConnectionHealth { + /** Verified within [STALE_AFTER]; nothing to do. */ + FRESH, + + /** Verified, but longer ago than [STALE_AFTER] — it may already have lapsed. */ + STALE, + + /** Linked but never verified, so there is no evidence the authorization still works. */ + NEVER_VERIFIED, + + ; + + companion object { + /** + * A connection counts as [STALE] once it has gone unverified for this long. + * + * 30 days is half of the shortest documented expiry ("a couple of months" for + * LinkedIn), which leaves roughly a month of warning before the earliest point + * at which a cross-post could start failing, while staying quiet for anyone who + * verifies or posts regularly. + */ + val STALE_AFTER: Duration = Duration.ofDays(30) + } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsScreen.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsScreen.kt index 0048de7..706bccf 100644 --- a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsScreen.kt +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsScreen.kt @@ -15,7 +15,11 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material.icons.outlined.Circle +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -23,10 +27,15 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -41,11 +50,24 @@ import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount object ConnectedAccountsTestTags { const val LIST = "accountsList" const val PROGRESS = "accountsProgress" - fun row(provider: ConnectedAccount.Provider) = "account_${provider.name}" - fun status(provider: ConnectedAccount.Provider) = "accountStatus_${provider.name}" + const val SNACKBAR = "accountsSnackbar" + const val UNLINK_DIALOG = "accountsUnlinkDialog" + const val UNLINK_CONFIRM = "accountsUnlinkConfirm" + const val UNLINK_CANCEL = "accountsUnlinkCancel" + + /** Rows key on the identity string, since one provider can back several identities. */ + fun row(key: String) = "account_$key" + fun status(key: String) = "accountStatus_$key" + fun health(key: String) = "accountHealth_$key" + fun badge(key: String) = "accountBadge_$key" + fun verify(key: String) = "accountVerify_$key" + fun unlink(key: String) = "accountUnlink_$key" + + fun row(provider: ConnectedAccount.Provider) = row(provider.name) + fun status(provider: ConnectedAccount.Provider) = status(provider.name) } -/** Hilt-wired entry point for the read-only "Connected accounts" screen. */ +/** Hilt-wired entry point for the "Connected accounts" screen. */ @Composable fun ConnectedAccountsRoute( onBack: () -> Unit, @@ -53,7 +75,17 @@ fun ConnectedAccountsRoute( viewModel: ConnectedAccountsViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - ConnectedAccountsScreen(state = state, onBack = onBack, modifier = modifier) + ConnectedAccountsScreen( + state = state, + onBack = onBack, + onVerify = viewModel::verify, + onRequestUnlink = viewModel::requestUnlink, + onDismissUnlink = viewModel::dismissUnlinkRequest, + onConfirmUnlink = viewModel::confirmUnlink, + onMessageShown = viewModel::clearMessage, + onErrorShown = viewModel::clearError, + modifier = modifier, + ) } /** Stateless "Connected accounts" UI — easy to preview and to drive from Compose tests. */ @@ -62,8 +94,36 @@ fun ConnectedAccountsRoute( fun ConnectedAccountsScreen( state: ConnectedAccountsUiState, onBack: () -> Unit, + onVerify: (ConnectedAccount) -> Unit = {}, + onRequestUnlink: (ConnectedAccount) -> Unit = {}, + onDismissUnlink: () -> Unit = {}, + onConfirmUnlink: () -> Unit = {}, + onMessageShown: () -> Unit = {}, + onErrorShown: () -> Unit = {}, modifier: Modifier = Modifier, ) { + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(state.message) { + state.message?.let { + snackbarHostState.showSnackbar(it) + onMessageShown() + } + } + LaunchedEffect(state.errorMessage) { + state.errorMessage?.let { + snackbarHostState.showSnackbar(it) + onErrorShown() + } + } + + state.unlinkCandidate?.let { candidate -> + UnlinkConfirmationDialog( + account = candidate, + onConfirm = onConfirmUnlink, + onDismiss = onDismissUnlink, + ) + } + Scaffold( modifier = modifier.fillMaxSize(), topBar = { @@ -76,6 +136,9 @@ fun ConnectedAccountsScreen( }, ) }, + snackbarHost = { + SnackbarHost(snackbarHostState, modifier = Modifier.testTag(ConnectedAccountsTestTags.SNACKBAR)) + }, ) { padding -> if (state.isLoading && state.accounts.isEmpty()) { Box( @@ -96,12 +159,20 @@ fun ConnectedAccountsScreen( verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(vertical = 16.dp), ) { - items(state.accounts, key = { it.provider.name }) { account -> AccountRow(account) } + items(state.accounts, key = { it.key }) { account -> + AccountRow( + account = account, + isBusy = account.key in state.pendingKeys, + onVerify = { onVerify(account) }, + onUnlink = { onRequestUnlink(account) }, + ) + } item { Spacer(Modifier.size(4.dp)) Text( - text = "Connecting or disconnecting accounts happens on the InterlinedList " + - "website — this app shows their current status.", + text = "Connecting a new account happens on the InterlinedList website. " + + "Verifying keeps an existing connection alive so your cross-posts keep " + + "going through.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -111,37 +182,120 @@ fun ConnectedAccountsScreen( } @Composable -private fun AccountRow(account: ConnectedAccount) { - Card(modifier = Modifier.fillMaxWidth().testTag(ConnectedAccountsTestTags.row(account.provider))) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val (icon, tint) = if (account.isConnected) { - Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary - } else { - Icons.Outlined.Circle to MaterialTheme.colorScheme.onSurfaceVariant - } - Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) - Spacer(Modifier.size(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(account.provider.label, style = MaterialTheme.typography.titleMedium) - val subtitle = when { - account.isConnected && account.handle != null -> "Connected · ${account.handle}" - account.isConnected -> "Connected" - else -> "Not connected" +private fun AccountRow( + account: ConnectedAccount, + isBusy: Boolean, + onVerify: () -> Unit, + onUnlink: () -> Unit, +) { + val badge = account.healthBadge() + Card(modifier = Modifier.fillMaxWidth().testTag(ConnectedAccountsTestTags.row(account.key))) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val (icon, tint) = when { + badge != null -> Icons.Default.WarningAmber to MaterialTheme.colorScheme.error + account.isConnected -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary + else -> Icons.Outlined.Circle to MaterialTheme.colorScheme.onSurfaceVariant } + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) + Spacer(Modifier.size(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(account.provider.label, style = MaterialTheme.typography.titleMedium) + val subtitle = when { + account.isConnected && account.handle != null -> "Connected · ${account.handle}" + account.isConnected -> "Connected" + else -> "Not connected" + } + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = if (account.isConnected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.testTag(ConnectedAccountsTestTags.status(account.key)), + ) + } + if (badge != null) { + AssistChip( + onClick = onVerify, + enabled = !isBusy, + label = { Text(badge) }, + colors = AssistChipDefaults.assistChipColors( + labelColor = MaterialTheme.colorScheme.error, + ), + modifier = Modifier.testTag(ConnectedAccountsTestTags.badge(account.key)), + ) + } + } + + account.healthLine()?.let { line -> + Spacer(Modifier.size(8.dp)) Text( - text = subtitle, + text = line, style = MaterialTheme.typography.bodySmall, - color = if (account.isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.testTag(ConnectedAccountsTestTags.status(account.provider)), + color = if (badge != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.testTag(ConnectedAccountsTestTags.health(account.key)), ) } + + if (account.isLinked) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + if (isBusy) { + CircularProgressIndicator(Modifier.size(20.dp)) + Spacer(Modifier.size(12.dp)) + } + TextButton( + onClick = onVerify, + enabled = !isBusy, + modifier = Modifier.testTag(ConnectedAccountsTestTags.verify(account.key)), + ) { Text("Verify") } + TextButton( + onClick = onUnlink, + enabled = !isBusy, + modifier = Modifier.testTag(ConnectedAccountsTestTags.unlink(account.key)), + ) { Text("Unlink") } + } + } } } } +/** States the consequence before the account goes away, not after. */ +@Composable +private fun UnlinkConfirmationDialog( + account: ConnectedAccount, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(ConnectedAccountsTestTags.UNLINK_DIALOG), + title = { Text(account.unlinkTitle()) }, + text = { Text(account.unlinkMessage()) }, + confirmButton = { + TextButton( + onClick = onConfirm, + modifier = Modifier.testTag(ConnectedAccountsTestTags.UNLINK_CONFIRM), + ) { Text("Unlink") } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + modifier = Modifier.testTag(ConnectedAccountsTestTags.UNLINK_CANCEL), + ) { Text("Keep connected") } + }, + ) +} + @Preview(showBackground = true) @Composable private fun ConnectedAccountsScreenPreview() { @@ -150,7 +304,22 @@ private fun ConnectedAccountsScreenPreview() { state = ConnectedAccountsUiState( isLoading = false, accounts = listOf( - ConnectedAccount(ConnectedAccount.Provider.GITHUB, isConnected = true, handle = "@adron"), + ConnectedAccount( + provider = ConnectedAccount.Provider.GITHUB, + isConnected = true, + handle = "@adron", + identityProvider = "github", + connectedAt = "2026-08-01T00:00:00Z", + lastVerifiedAt = "2026-09-14T00:00:00Z", + ), + ConnectedAccount( + provider = ConnectedAccount.Provider.LINKEDIN, + isConnected = true, + handle = "Adron Hall", + identityProvider = "linkedin", + connectedAt = "2026-05-01T00:00:00Z", + lastVerifiedAt = "2026-06-01T00:00:00Z", + ), ConnectedAccount(ConnectedAccount.Provider.BLUESKY, isConnected = false), ), ), diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModel.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModel.kt index c83c116..6cf1299 100644 --- a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModel.kt +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModel.kt @@ -2,8 +2,10 @@ package com.interlinedlist.android.feature.integrations.ui.accounts import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.integrations.data.IntegrationsRepository import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount +import com.interlinedlist.android.feature.integrations.ui.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -12,12 +14,29 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject -/** UI state for the read-only "Connected accounts" screen. */ +/** UI state for the "Connected accounts" screen. */ data class ConnectedAccountsUiState( val isLoading: Boolean = true, val accounts: List = emptyList(), + /** Keys of rows with an unlink or verify request in flight. */ + val pendingKeys: Set = emptySet(), + /** The account whose unlink confirmation is on screen, if any. */ + val unlinkCandidate: ConnectedAccount? = null, + /** Transient success text for the snackbar. */ + val message: String? = null, + /** Transient failure text for the snackbar — the server's message where it gave one. */ + val errorMessage: String? = null, ) +/** + * Drives the "Connected accounts" screen. + * + * Linking still happens on the web, but unlinking and re-verifying an already-linked + * identity are ordinary API calls, so both are offered here. Neither is applied + * optimistically: the list is re-read from the server after a successful call so the + * screen can never show a state that is no longer true, and a failed unlink leaves the + * connection exactly where it was with the server's own message surfaced. + */ @HiltViewModel class ConnectedAccountsViewModel @Inject constructor( private val repository: IntegrationsRepository, @@ -30,9 +49,69 @@ class ConnectedAccountsViewModel @Inject constructor( fun refresh() { _uiState.update { it.copy(isLoading = true) } + viewModelScope.launch { reload() } + } + + /** Opens the unlink confirmation; unlinking never happens straight off a tap. */ + fun requestUnlink(account: ConnectedAccount) { + if (!account.isLinked) return + _uiState.update { it.copy(unlinkCandidate = account) } + } + + fun dismissUnlinkRequest() = _uiState.update { it.copy(unlinkCandidate = null) } + + /** Performs the unlink the user just confirmed, then re-reads the list. */ + fun confirmUnlink() { + val account = _uiState.value.unlinkCandidate ?: return + _uiState.update { it.copy(unlinkCandidate = null) } + mutate(account) { provider -> repository.unlinkIdentity(provider) to account.unlinkedMessage() } + } + + /** Re-verifies a linked connection, then re-reads the list so the health badge updates. */ + fun verify(account: ConnectedAccount) { + mutate(account) { provider -> repository.verifyIdentity(provider) to account.verifiedMessage() } + } + + fun clearMessage() = _uiState.update { it.copy(message = null) } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } + + /** + * Shared shape of both mutations: mark the row busy, call, and on success announce + * it and re-read the list; on failure surface the server's message and change + * nothing else. In-flight taps on the same row are deduped. + */ + private fun mutate( + account: ConnectedAccount, + action: suspend (identityProvider: String) -> Pair, String>, + ) { + val identityProvider = account.identityProvider ?: return + if (account.key in _uiState.value.pendingKeys) return + + _uiState.update { + it.copy(pendingKeys = it.pendingKeys + account.key, errorMessage = null, message = null) + } viewModelScope.launch { - val accounts = repository.getConnectedAccounts() - _uiState.update { it.copy(isLoading = false, accounts = accounts) } + val (result, successMessage) = action(identityProvider) + when (result) { + is ApiResult.Success -> { + _uiState.update { + it.copy(pendingKeys = it.pendingKeys - account.key, message = successMessage) + } + reload() + } + is ApiResult.Failure -> _uiState.update { + it.copy( + pendingKeys = it.pendingKeys - account.key, + errorMessage = result.error.toUserMessage(), + ) + } + } } } + + private suspend fun reload() { + val accounts = repository.getConnectedAccounts() + _uiState.update { it.copy(isLoading = false, accounts = accounts) } + } } diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectionHealthCopy.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectionHealthCopy.kt new file mode 100644 index 0000000..a1c771f --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectionHealthCopy.kt @@ -0,0 +1,104 @@ +package com.interlinedlist.android.feature.integrations.ui.accounts + +import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount +import com.interlinedlist.android.feature.integrations.domain.ConnectionHealth +import java.time.Instant +import java.time.temporal.ChronoUnit + +/** + * Wording for the connected-accounts rows. Pure functions of an account and [now], + * so the three health states can be asserted in plain unit tests and rendered + * identically by the Composable. + * + * The rule throughout: say what the state *means* for the user, never just print a + * timestamp. A raw "lastVerifiedAt: 2026-06-01" tells nobody that their next + * LinkedIn cross-post is about to vanish. + */ + +/** Short badge for a connection that needs attention, or null when there is nothing to flag. */ +fun ConnectedAccount.healthBadge(now: Instant = Instant.now()): String? = + when (healthAt(now)) { + ConnectionHealth.STALE -> "Check connection" + ConnectionHealth.NEVER_VERIFIED -> "Never verified" + ConnectionHealth.FRESH, null -> null + } + +/** + * The explanatory line under a linked account, or null when nothing is linked (the + * row then just reads "Not connected"). + */ +fun ConnectedAccount.healthLine(now: Instant = Instant.now()): String? = + when (healthAt(now)) { + null -> null + ConnectionHealth.FRESH -> + "Verified ${agoLabel(lastVerifiedAt, now) ?: "recently"} — this connection is working." + ConnectionHealth.STALE -> { + val age = agoLabel(lastVerifiedAt, now) ?: "over 30 days ago" + "Last verified $age. It may have expired — ${atRiskClause()}" + } + ConnectionHealth.NEVER_VERIFIED -> { + val connected = agoLabel(connectedAt, now) + val prefix = if (connected == null) "Never verified." else "Connected $connected, never verified." + "$prefix There is no sign it still works — ${atRiskClause()}" + } + } + +/** + * What goes wrong if this connection has lapsed. Cross-post targets fail *silently* + * — the post publishes on InterlinedList and simply never reaches the network — which + * is the whole reason the badge exists. + */ +private fun ConnectedAccount.atRiskClause(): String = + if (provider.isCrossPostTarget) { + "posts may stop reaching ${provider.label} without an error. Tap Verify to check it." + } else { + "${provider.label} features may stop working. Tap Verify to check it." + } + +/** Dialog title for the unlink confirmation. */ +fun ConnectedAccount.unlinkTitle(): String = "Unlink ${provider.label}?" + +/** + * The consequence, stated plainly: unlinking a cross-post target turns syndication to + * that network off. Losing that by accident is silent otherwise, so the dialog says it + * before the tap, not after. + */ +fun ConnectedAccount.unlinkMessage(): String = + if (provider.isCrossPostTarget) { + "This stops cross-posting to ${provider.label}. New posts will no longer be sent " + + "there. Anything already cross-posted stays where it is, and you can reconnect " + + "on the InterlinedList website." + } else { + "This disconnects ${provider.label}. Its data will no longer be available in the " + + "app until you reconnect on the InterlinedList website." + } + +/** Snackbar text confirming a completed unlink. */ +fun ConnectedAccount.unlinkedMessage(): String = + if (provider.isCrossPostTarget) { + "${provider.label} unlinked. Cross-posting to ${provider.label} is off." + } else { + "${provider.label} unlinked." + } + +/** Snackbar text confirming a completed re-verification. */ +fun ConnectedAccount.verifiedMessage(): String = "${provider.label} connection verified." + +/** + * A coarse "3 days ago" label for an ISO-8601 instant, or null when it is missing or + * unparseable so callers can fall back to wording that doesn't pretend to know. + */ +private fun agoLabel(isoTimestamp: String?, now: Instant): String? { + val then = isoTimestamp?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return null + val days = ChronoUnit.DAYS.between(then, now).coerceAtLeast(0) + return when { + days == 0L -> "today" + days == 1L -> "yesterday" + days < 30L -> "$days days ago" + days < 365L -> pluralize(days / 30, "month") + else -> pluralize(days / 365, "year") + } +} + +private fun pluralize(value: Long, unit: String): String = + "$value ${if (value == 1L) unit else "${unit}s"} ago" diff --git a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepositoryTest.kt b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepositoryTest.kt index 766ca52..e6c002a 100644 --- a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepositoryTest.kt +++ b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepositoryTest.kt @@ -6,6 +6,7 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.integrations.data.remote.IntegrationsApi import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount +import com.interlinedlist.android.feature.integrations.domain.ConnectionHealth import com.interlinedlist.android.feature.integrations.domain.ExportType import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import kotlinx.coroutines.CoroutineDispatcher @@ -22,6 +23,7 @@ import org.junit.Test import retrofit2.Retrofit import java.io.File import java.nio.file.Files +import java.time.Instant /** * Repository behaviour against a real HTTP stack (Retrofit + OkHttp) driven by @@ -105,7 +107,9 @@ class DefaultIntegrationsRepositoryTest { @Test fun `getConnectedAccounts maps each provider status and degrades failures to not-connected`() = runTest(dispatcher) { - // Providers are queried in enum order: github, linkedin, bluesky, mastodon, twitter. + // Identities are read first, then providers in enum order: + // github, linkedin, bluesky, mastodon, twitter. + server.enqueue(MockResponse().setBody("""{ "identities": [] }""")) server.enqueue(MockResponse().setBody("""{ "connected": true, "handle": "@adron" }""")) server.enqueue(MockResponse().setBody("""{ "connected": false }""")) server.enqueue(MockResponse().setBody("""{ "username": "adron.bsky.social" }""")) @@ -129,23 +133,155 @@ class DefaultIntegrationsRepositoryTest { } @Test - fun `getConnectedAccounts hits each provider status path`() = runTest(dispatcher) { - repeat(ConnectedAccount.Provider.entries.size) { - server.enqueue(MockResponse().setBody("""{ "connected": false }""")) + fun `getConnectedAccounts reads the identities payload then each provider status path`() = + runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "identities": [] }""")) + repeat(ConnectedAccount.Provider.entries.size) { + server.enqueue(MockResponse().setBody("""{ "connected": false }""")) + } + + repository.getConnectedAccounts() + + val paths = buildList { + repeat(ConnectedAccount.Provider.entries.size + 1) { add(server.takeRequest().path) } + } + assertThat(paths).containsExactly( + "/api/user/identities", + "/api/auth/github/status", + "/api/auth/linkedin/status", + "/api/auth/bluesky/status", + "/api/auth/mastodon/status", + "/api/auth/twitter/status", + ).inOrder() + } + + @Test + fun `getConnectedAccounts merges identities so linked rows carry health and an unlink key`() = + runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { "identities": [ + { + "id": "i1", + "provider": "linkedin", + "providerUsername": "Adron Hall", + "connectedAt": "2026-05-01T12:00:00.000Z", + "lastVerifiedAt": "2026-07-01T12:00:00.000Z" + }, + { + "id": "i2", + "provider": "mastodon:techhub.social", + "providerUsername": "adron@techhub.social", + "connectedAt": "2026-06-01T12:00:00.000Z" + }, + { + "id": "i3", + "provider": "mastodon:mastodon.social", + "providerUsername": "adron@mastodon.social", + "connectedAt": "2026-06-02T12:00:00.000Z" + } + ] } + """.trimIndent(), + ), + ) + repeat(ConnectedAccount.Provider.entries.size) { + server.enqueue(MockResponse().setBody("""{ "connected": false }""")) + } + + val accounts = repository.getConnectedAccounts() + + val linkedIn = accounts.single { it.provider == ConnectedAccount.Provider.LINKEDIN } + // An identity record means linked, whatever the status endpoint claims — a + // lapsed authorization shows up as health, not as "Not connected". + assertThat(linkedIn.isConnected).isTrue() + assertThat(linkedIn.identityProvider).isEqualTo("linkedin") + assertThat(linkedIn.handle).isEqualTo("Adron Hall") + assertThat(linkedIn.lastVerifiedAt).isEqualTo("2026-07-01T12:00:00.000Z") + assertThat(linkedIn.healthAt(Instant.parse("2026-09-16T12:00:00Z"))) + .isEqualTo(ConnectionHealth.STALE) + + // Each Mastodon instance is its own row, so unlinking one can't take out the other. + val mastodon = accounts.filter { it.provider == ConnectedAccount.Provider.MASTODON } + assertThat(mastodon.map { it.identityProvider }) + .containsExactly("mastodon:techhub.social", "mastodon:mastodon.social") + assertThat(mastodon.map { it.key }).containsNoDuplicates() + assertThat(mastodon.first().healthAt(Instant.parse("2026-09-16T12:00:00Z"))) + .isEqualTo(ConnectionHealth.NEVER_VERIFIED) + + // Providers with no identity stay as status-only rows with nothing to unlink. + val bluesky = accounts.single { it.provider == ConnectedAccount.Provider.BLUESKY } + assertThat(bluesky.isLinked).isFalse() } - repository.getConnectedAccounts() + @Test + fun `getConnectedAccounts degrades to status-only rows when the identities read fails`() = + runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(500).setBody("boom")) + repeat(ConnectedAccount.Provider.entries.size) { + server.enqueue(MockResponse().setBody("""{ "connected": true, "handle": "@adron" }""")) + } - val paths = buildList { - repeat(ConnectedAccount.Provider.entries.size) { add(server.takeRequest().path) } + val accounts = repository.getConnectedAccounts() + + assertThat(accounts).hasSize(ConnectedAccount.Provider.entries.size) + assertThat(accounts.none { it.isLinked }).isTrue() + assertThat(accounts.all { it.isConnected }).isTrue() } - assertThat(paths).containsExactly( - "/api/auth/github/status", - "/api/auth/linkedin/status", - "/api/auth/bluesky/status", - "/api/auth/mastodon/status", - "/api/auth/twitter/status", - ).inOrder() + + @Test + fun `unlinkIdentity deletes with the provider as a query parameter`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "success": true }""")) + + val result = repository.unlinkIdentity("mastodon:techhub.social") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("DELETE") + // The spec declares `provider` as a query parameter, not a body or path segment. + assertThat(recorded.path).isEqualTo("/api/user/identities?provider=mastodon%3Atechhub.social") + assertThat(recorded.body.size).isEqualTo(0) + } + + @Test + fun `a failed unlink returns the server's message`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(404) + .setBody("""{ "error": "Identity not found", "code": "not_found" }"""), + ) + + val result = repository.unlinkIdentity("linkedin") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + val error = (result as ApiResult.Failure).error + assertThat(error).isInstanceOf(AppError.NotFound::class.java) + assertThat(error.message).isEqualTo("Identity not found") + } + + @Test + fun `verifyIdentity posts the provider in the body`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(201).setBody("""{ "verified": true }""")) + + val result = repository.verifyIdentity("linkedin") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/user/identities/verify") + assertThat(recorded.body.readUtf8()).isEqualTo("""{"provider":"linkedin"}""") + } + + @Test + fun `a failed verify returns the server's message`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(400) + .setBody("""{ "error": "LinkedIn account not linked", "code": "not_linked" }"""), + ) + + val result = repository.verifyIdentity("linkedin") + + assertThat((result as ApiResult.Failure).error.message) + .isEqualTo("LinkedIn account not linked") } @Test diff --git a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectedAccountTest.kt b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectedAccountTest.kt new file mode 100644 index 0000000..8a7d916 --- /dev/null +++ b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectedAccountTest.kt @@ -0,0 +1,105 @@ +package com.interlinedlist.android.feature.integrations.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.time.Instant + +/** + * Connection health is what makes a lapsed cross-post destination visible before a + * post silently fails to syndicate, so the three states and the boundary between + * them are pinned here rather than left to the UI. + */ +class ConnectedAccountTest { + + private val now: Instant = Instant.parse("2026-09-16T12:00:00Z") + + private fun linked(lastVerifiedAt: String?, connectedAt: String? = "2026-01-01T00:00:00Z") = + ConnectedAccount( + provider = ConnectedAccount.Provider.LINKEDIN, + isConnected = true, + handle = "Adron Hall", + identityProvider = "linkedin", + connectedAt = connectedAt, + lastVerifiedAt = lastVerifiedAt, + ) + + @Test + fun `a recently verified connection is fresh`() { + val account = linked(lastVerifiedAt = "2026-09-14T12:00:00Z") // 2 days ago + + assertThat(account.healthAt(now)).isEqualTo(ConnectionHealth.FRESH) + } + + @Test + fun `a connection unverified for longer than the threshold is stale`() { + val account = linked(lastVerifiedAt = "2026-07-01T12:00:00Z") // 77 days ago + + assertThat(account.healthAt(now)).isEqualTo(ConnectionHealth.STALE) + } + + @Test + fun `a linked connection with no lastVerifiedAt is never-verified`() { + val account = linked(lastVerifiedAt = null) + + assertThat(account.healthAt(now)).isEqualTo(ConnectionHealth.NEVER_VERIFIED) + } + + @Test + fun `the staleness threshold is 30 days, inclusive`() { + // One second inside the window is still fresh; exactly 30 days old is stale. + val justInside = linked(lastVerifiedAt = "2026-08-17T12:00:01Z") + val exactlyThirtyDays = linked(lastVerifiedAt = "2026-08-17T12:00:00Z") + + assertThat(ConnectionHealth.STALE_AFTER.toDays()).isEqualTo(30) + assertThat(justInside.healthAt(now)).isEqualTo(ConnectionHealth.FRESH) + assertThat(exactlyThirtyDays.healthAt(now)).isEqualTo(ConnectionHealth.STALE) + } + + @Test + fun `an unparseable timestamp fails loud rather than claiming freshness`() { + val account = linked(lastVerifiedAt = "not-a-timestamp") + + assertThat(account.healthAt(now)).isEqualTo(ConnectionHealth.NEVER_VERIFIED) + } + + @Test + fun `an unlinked provider has no health and no identity key`() { + val account = ConnectedAccount(ConnectedAccount.Provider.BLUESKY, isConnected = false) + + assertThat(account.healthAt(now)).isNull() + assertThat(account.isLinked).isFalse() + assertThat(account.key).isEqualTo("BLUESKY") + } + + @Test + fun `a linked row keys on the identity provider string so instances stay distinct`() { + val techhub = linked(null).copy( + provider = ConnectedAccount.Provider.MASTODON, + identityProvider = "mastodon:techhub.social", + ) + val social = techhub.copy(identityProvider = "mastodon:mastodon.social") + + assertThat(techhub.key).isEqualTo("mastodon:techhub.social") + assertThat(social.key).isNotEqualTo(techhub.key) + } + + @Test + fun `identity provider strings map back to their provider`() { + val from = ConnectedAccount.Provider::fromIdentityProvider + + assertThat(from("github")).isEqualTo(ConnectedAccount.Provider.GITHUB) + assertThat(from("LinkedIn")).isEqualTo(ConnectedAccount.Provider.LINKEDIN) + assertThat(from("mastodon:techhub.social")).isEqualTo(ConnectedAccount.Provider.MASTODON) + assertThat(from("twitter")).isEqualTo(ConnectedAccount.Provider.TWITTER) + assertThat(from("someothernetwork")).isNull() + assertThat(from(null)).isNull() + } + + @Test + fun `only the social networks count as cross-post targets`() { + assertThat(ConnectedAccount.Provider.GITHUB.isCrossPostTarget).isFalse() + assertThat( + ConnectedAccount.Provider.entries.filter { it.isCrossPostTarget }.map { it.apiToken }, + ).containsExactly("linkedin", "bluesky", "mastodon", "twitter") + } +} diff --git a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/FakeIntegrationsRepository.kt b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/FakeIntegrationsRepository.kt index 67e7ba5..92a2ba5 100644 --- a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/FakeIntegrationsRepository.kt +++ b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/FakeIntegrationsRepository.kt @@ -21,6 +21,15 @@ class FakeIntegrationsRepository : IntegrationsRepository { var accounts: List = emptyList() var accountsCalls = 0 + /** Queued account lists; each getConnectedAccounts() consumes one, then falls back to [accounts]. */ + val queuedAccounts = ArrayDeque>() + + var unlinkResult: ApiResult = ApiResult.Success(Unit) + val unlinkedProviders = mutableListOf() + + var verifyResult: ApiResult = ApiResult.Success(Unit) + val verifiedProviders = mutableListOf() + var limitsResult: ApiResult = ApiResult.Failure(AppError.Unknown("not set")) // --- GitHub --- @@ -58,7 +67,17 @@ class FakeIntegrationsRepository : IntegrationsRepository { override suspend fun getConnectedAccounts(): List { accountsCalls++ - return accounts + return queuedAccounts.removeFirstOrNull() ?: accounts + } + + override suspend fun unlinkIdentity(identityProvider: String): ApiResult { + unlinkedProviders.add(identityProvider) + return unlinkResult + } + + override suspend fun verifyIdentity(identityProvider: String): ApiResult { + verifiedProviders.add(identityProvider) + return verifyResult } override suspend fun getLimits(): ApiResult = limitsResult diff --git a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModelTest.kt b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModelTest.kt index bc99b45..8c896bc 100644 --- a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModelTest.kt +++ b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModelTest.kt @@ -1,6 +1,8 @@ package com.interlinedlist.android.feature.integrations.ui.accounts 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.integrations.domain.ConnectedAccount import com.interlinedlist.android.feature.integrations.ui.FakeIntegrationsRepository import kotlinx.coroutines.Dispatchers @@ -20,6 +22,16 @@ class ConnectedAccountsViewModelTest { private val dispatcher = StandardTestDispatcher() private lateinit var repo: FakeIntegrationsRepository + private val linkedIn = ConnectedAccount( + provider = ConnectedAccount.Provider.LINKEDIN, + isConnected = true, + handle = "Adron Hall", + identityProvider = "linkedin", + connectedAt = "2026-05-01T12:00:00Z", + lastVerifiedAt = "2026-07-01T12:00:00Z", + ) + private val bluesky = ConnectedAccount(ConnectedAccount.Provider.BLUESKY, isConnected = false) + @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -32,8 +44,12 @@ class ConnectedAccountsViewModelTest { @Test fun `loads accounts on init and clears loading`() = runTest(dispatcher) { repo.accounts = listOf( - ConnectedAccount(ConnectedAccount.Provider.GITHUB, isConnected = true, handle = "@adron"), - ConnectedAccount(ConnectedAccount.Provider.BLUESKY, isConnected = false), + ConnectedAccount( + ConnectedAccount.Provider.GITHUB, + isConnected = true, + handle = "@adron", + ), + bluesky, ) val vm = ConnectedAccountsViewModel(repo) @@ -56,4 +72,165 @@ class ConnectedAccountsViewModelTest { assertThat(repo.accountsCalls).isEqualTo(2) } + + // --- unlink --- + + @Test + fun `unlink is confirmed before anything is sent`() = runTest(dispatcher) { + repo.accounts = listOf(linkedIn) + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + vm.requestUnlink(linkedIn) + advanceUntilIdle() + + // The confirmation is up, but nothing has been unlinked yet. + assertThat(vm.uiState.value.unlinkCandidate).isEqualTo(linkedIn) + assertThat(repo.unlinkedProviders).isEmpty() + assertThat(vm.uiState.value.accounts).containsExactly(linkedIn) + } + + @Test + fun `dismissing the confirmation leaves the connection alone`() = runTest(dispatcher) { + repo.accounts = listOf(linkedIn) + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + vm.requestUnlink(linkedIn) + vm.dismissUnlinkRequest() + advanceUntilIdle() + + assertThat(vm.uiState.value.unlinkCandidate).isNull() + assertThat(repo.unlinkedProviders).isEmpty() + assertThat(vm.uiState.value.accounts).containsExactly(linkedIn) + } + + @Test + fun `a confirmed unlink sends the identity provider and refreshes the list`() = runTest(dispatcher) { + // First load has LinkedIn linked; the post-unlink reload no longer does. + repo.queuedAccounts.addLast(listOf(linkedIn, bluesky)) + repo.queuedAccounts.addLast( + listOf(ConnectedAccount(ConnectedAccount.Provider.LINKEDIN, isConnected = false), bluesky), + ) + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + vm.requestUnlink(linkedIn) + vm.confirmUnlink() + advanceUntilIdle() + + assertThat(repo.unlinkedProviders).containsExactly("linkedin") + // The screen re-read the list rather than guessing at the new state. + assertThat(repo.accountsCalls).isEqualTo(2) + assertThat(vm.uiState.value.accounts.single { it.provider == ConnectedAccount.Provider.LINKEDIN }.isLinked) + .isFalse() + assertThat(vm.uiState.value.unlinkCandidate).isNull() + assertThat(vm.uiState.value.pendingKeys).isEmpty() + assertThat(vm.uiState.value.message) + .isEqualTo("LinkedIn unlinked. Cross-posting to LinkedIn is off.") + assertThat(vm.uiState.value.errorMessage).isNull() + } + + @Test + fun `a failed unlink keeps the connection and surfaces the server message`() = runTest(dispatcher) { + repo.accounts = listOf(linkedIn, bluesky) + repo.unlinkResult = ApiResult.Failure(AppError.Unknown("Identity not found")) + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + vm.requestUnlink(linkedIn) + vm.confirmUnlink() + advanceUntilIdle() + + assertThat(repo.unlinkedProviders).containsExactly("linkedin") + assertThat(vm.uiState.value.errorMessage).isEqualTo("Identity not found") + // The row is still there, and no pointless refresh was issued. + assertThat(vm.uiState.value.accounts).contains(linkedIn) + assertThat(repo.accountsCalls).isEqualTo(1) + assertThat(vm.uiState.value.pendingKeys).isEmpty() + assertThat(vm.uiState.value.message).isNull() + } + + @Test + fun `unlink is ignored for a provider with nothing linked`() = runTest(dispatcher) { + repo.accounts = listOf(bluesky) + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + vm.requestUnlink(bluesky) + vm.confirmUnlink() + advanceUntilIdle() + + assertThat(vm.uiState.value.unlinkCandidate).isNull() + assertThat(repo.unlinkedProviders).isEmpty() + } + + // --- verify --- + + @Test + fun `verify sends the identity provider and refreshes the list`() = runTest(dispatcher) { + val verified = linkedIn.copy(lastVerifiedAt = "2026-09-16T12:00:00Z") + repo.queuedAccounts.addLast(listOf(linkedIn)) + repo.queuedAccounts.addLast(listOf(verified)) + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + vm.verify(linkedIn) + advanceUntilIdle() + + assertThat(repo.verifiedProviders).containsExactly("linkedin") + assertThat(repo.accountsCalls).isEqualTo(2) + assertThat(vm.uiState.value.accounts.single().lastVerifiedAt).isEqualTo("2026-09-16T12:00:00Z") + assertThat(vm.uiState.value.message).isEqualTo("LinkedIn connection verified.") + assertThat(vm.uiState.value.pendingKeys).isEmpty() + } + + @Test + fun `a failed verify surfaces the server message and leaves the row untouched`() = runTest(dispatcher) { + repo.accounts = listOf(linkedIn) + repo.verifyResult = ApiResult.Failure(AppError.Unknown("LinkedIn account not linked")) + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + vm.verify(linkedIn) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("LinkedIn account not linked") + assertThat(vm.uiState.value.accounts).containsExactly(linkedIn) + assertThat(repo.accountsCalls).isEqualTo(1) + } + + @Test + fun `a second tap while a verify is in flight is ignored`() = runTest(dispatcher) { + repo.accounts = listOf(linkedIn) + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + vm.verify(linkedIn) + vm.verify(linkedIn) + advanceUntilIdle() + + assertThat(repo.verifiedProviders).containsExactly("linkedin") + } + + @Test + fun `snackbar text is cleared once shown`() = runTest(dispatcher) { + repo.accounts = listOf(linkedIn) + repo.verifyResult = ApiResult.Failure(AppError.Unknown("boom")) + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + vm.verify(linkedIn) + advanceUntilIdle() + vm.clearError() + + assertThat(vm.uiState.value.errorMessage).isNull() + + repo.verifyResult = ApiResult.Success(Unit) + vm.verify(linkedIn) + advanceUntilIdle() + vm.clearMessage() + + assertThat(vm.uiState.value.message).isNull() + } } diff --git a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectionHealthCopyTest.kt b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectionHealthCopyTest.kt new file mode 100644 index 0000000..c85be95 --- /dev/null +++ b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectionHealthCopyTest.kt @@ -0,0 +1,122 @@ +package com.interlinedlist.android.feature.integrations.ui.accounts + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount +import org.junit.Test +import java.time.Instant + +/** + * What the row actually renders for each connection-health state. The three cases are + * distinct on purpose: "verified recently", "verified too long ago" and "never verified" + * carry different risks, and the copy has to name the consequence rather than print a + * timestamp and leave the user to work it out. + */ +class ConnectionHealthCopyTest { + + private val now: Instant = Instant.parse("2026-09-16T12:00:00Z") + + private fun linkedIn(lastVerifiedAt: String?, connectedAt: String? = "2026-05-01T12:00:00Z") = + ConnectedAccount( + provider = ConnectedAccount.Provider.LINKEDIN, + isConnected = true, + handle = "Adron Hall", + identityProvider = "linkedin", + connectedAt = connectedAt, + lastVerifiedAt = lastVerifiedAt, + ) + + // --- fresh --- + + @Test + fun `a fresh connection shows no badge and a reassuring line`() { + val account = linkedIn(lastVerifiedAt = "2026-09-14T12:00:00Z") + + assertThat(account.healthBadge(now)).isNull() + assertThat(account.healthLine(now)).isEqualTo("Verified 2 days ago — this connection is working.") + } + + @Test + fun `a connection verified today reads as today, not as a timestamp`() { + val account = linkedIn(lastVerifiedAt = "2026-09-16T08:00:00Z") + + assertThat(account.healthLine(now)).isEqualTo("Verified today — this connection is working.") + } + + // --- stale --- + + @Test + fun `a stale connection is badged and names the silent-failure risk`() { + val account = linkedIn(lastVerifiedAt = "2026-07-01T12:00:00Z") + + assertThat(account.healthBadge(now)).isEqualTo("Check connection") + assertThat(account.healthLine(now)).isEqualTo( + "Last verified 2 months ago. It may have expired — posts may stop reaching " + + "LinkedIn without an error. Tap Verify to check it.", + ) + } + + @Test + fun `a stale non-cross-post connection describes its own consequence`() { + val account = linkedIn(lastVerifiedAt = "2026-07-01T12:00:00Z").copy( + provider = ConnectedAccount.Provider.GITHUB, + identityProvider = "github", + ) + + assertThat(account.healthBadge(now)).isEqualTo("Check connection") + assertThat(account.healthLine(now)).contains("GitHub features may stop working") + assertThat(account.healthLine(now)).doesNotContain("cross-post") + } + + // --- never verified --- + + @Test + fun `a never-verified connection is called out separately from a stale one`() { + val account = linkedIn(lastVerifiedAt = null) + + assertThat(account.healthBadge(now)).isEqualTo("Never verified") + assertThat(account.healthLine(now)).isEqualTo( + "Connected 4 months ago, never verified. There is no sign it still works — " + + "posts may stop reaching LinkedIn without an error. Tap Verify to check it.", + ) + } + + @Test + fun `a never-verified connection with no connectedAt still explains itself`() { + val account = linkedIn(lastVerifiedAt = null, connectedAt = null) + + assertThat(account.healthLine(now)).startsWith("Never verified. There is no sign it still works") + } + + // --- not linked --- + + @Test + fun `an unlinked provider has no health copy at all`() { + val account = ConnectedAccount(ConnectedAccount.Provider.BLUESKY, isConnected = false) + + assertThat(account.healthBadge(now)).isNull() + assertThat(account.healthLine(now)).isNull() + } + + // --- unlink confirmation --- + + @Test + fun `the unlink confirmation states the cross-posting consequence plainly`() { + val account = linkedIn(lastVerifiedAt = "2026-09-14T12:00:00Z") + + assertThat(account.unlinkTitle()).isEqualTo("Unlink LinkedIn?") + assertThat(account.unlinkMessage()).startsWith("This stops cross-posting to LinkedIn.") + assertThat(account.unlinkMessage()).contains("reconnect on the InterlinedList website") + assertThat(account.unlinkedMessage()).isEqualTo("LinkedIn unlinked. Cross-posting to LinkedIn is off.") + } + + @Test + fun `a non-cross-post provider does not claim cross-posting stops`() { + val account = linkedIn(lastVerifiedAt = "2026-09-14T12:00:00Z").copy( + provider = ConnectedAccount.Provider.GITHUB, + identityProvider = "github", + ) + + assertThat(account.unlinkMessage()).doesNotContain("cross-post") + assertThat(account.unlinkedMessage()).isEqualTo("GitHub unlinked.") + } +}