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
Expand Up @@ -8,9 +8,12 @@ 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.organizations.domain.OrgLinkedInPage
import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInStatus
import com.interlinedlist.android.feature.organizations.domain.OrgMember
import com.interlinedlist.android.feature.organizations.domain.OrgRole
import com.interlinedlist.android.feature.organizations.domain.Organization
import com.interlinedlist.android.feature.organizations.ui.LINKEDIN_DISCONNECT_CONSEQUENCE
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
Expand Down Expand Up @@ -38,6 +41,9 @@ class OrganizationDetailScreenTest {
onDelete: () -> Unit = {},
onJoin: () -> Unit = {},
onLeave: () -> Unit = {},
onAssignLinkedInPage: (OrgMember, String?) -> Unit = { _, _ -> },
onSyncLinkedInPages: () -> Unit = {},
onRemoveLinkedInCredential: () -> Unit = {},
) {
composeRule.setContent {
InterlinedListTheme {
Expand All @@ -52,6 +58,9 @@ class OrganizationDetailScreenTest {
onDelete = onDelete,
onJoin = onJoin,
onLeave = onLeave,
onAssignLinkedInPage = onAssignLinkedInPage,
onSyncLinkedInPages = onSyncLinkedInPages,
onRemoveLinkedInCredential = onRemoveLinkedInCredential,
)
}
}
Expand All @@ -62,6 +71,7 @@ class OrganizationDetailScreenTest {
members: List<OrgMember> = emptyList(),
isPublic: Boolean = false,
isSystem: Boolean = false,
linkedIn: OrgLinkedInStatus? = null,
) = OrganizationDetailUiState(
organization = Organization(
id = "o1",
Expand All @@ -76,6 +86,7 @@ class OrganizationDetailScreenTest {
),
members = members,
isLoading = false,
linkedIn = linkedIn,
)

private val ada = OrgMember("u1", "ada", "Ada", null, OrgRole.OWNER, active = true)
Expand Down Expand Up @@ -312,4 +323,139 @@ class OrganizationDetailScreenTest {

assert(saved?.third == true) { "expected the toggled visibility to reach the save, got $saved" }
}

// ---- LinkedIn company pages --------------------------------------------

private val acmePage = OrgLinkedInPage(id = "p1", linkedInPageId = "12345678", name = "Acme Corp")
private val labsPage = OrgLinkedInPage(id = "p2", linkedInPageId = "87654321", name = "Acme Labs")

private fun connected(assignments: Map<String, String> = emptyMap()) = OrgLinkedInStatus(
connected = true,
expiresAt = "2026-12-01T00:00:00.000Z",
pages = listOf(acmePage, labsPage),
assignments = assignments,
)

@Test
fun owner_seesTheLinkedInSection() {
setScreen(state = state(OrgRole.OWNER, listOf(ada, grace), linkedIn = connected()))

composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN).assertIsDisplayed()
composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_CONNECTED).assertIsDisplayed()
composeRule.onNodeWithTag(OrganizationDetailTestTags.linkedInPage("p1")).assertIsDisplayed()
}

@Test
fun admin_seesTheLinkedInSection() {
setScreen(state = state(OrgRole.ADMIN, listOf(ada, grace), linkedIn = connected()))

composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN).assertIsDisplayed()
}

@Test
fun member_isOfferedNoLinkedInSection() {
// The server refuses a member outright ("Admin or owner required"), so the
// section they could not use is not shown at all.
setScreen(state = state(OrgRole.MEMBER, listOf(ada, grace), linkedIn = connected()))

composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN).assertDoesNotExist()
composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_SYNC).assertDoesNotExist()
composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT).assertDoesNotExist()
}

@Test
fun notConnected_readsAsAState_notAnError() {
setScreen(
state = state(
OrgRole.OWNER,
listOf(ada, grace),
linkedIn = OrgLinkedInStatus.NOT_CONNECTED,
),
)

composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_NOT_CONNECTED).assertIsDisplayed()
composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_ERROR).assertDoesNotExist()
// Nothing to sync or disconnect without a credential.
composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_SYNC).assertDoesNotExist()
composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT).assertDoesNotExist()
}

@Test
fun disconnect_statesTheConsequenceAndOnlyActsOnConfirmation() {
var disconnected = false
setScreen(
state = state(OrgRole.OWNER, listOf(ada, grace), linkedIn = connected()),
onRemoveLinkedInCredential = { disconnected = true },
)

composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT).performClick()
composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT_DIALOG).assertIsDisplayed()
// The dialog names the cost before anything happens.
composeRule.onNodeWithText(LINKEDIN_DISCONNECT_CONSEQUENCE).assertIsDisplayed()
assert(!disconnected) { "the credential must not be removed before confirmation" }

composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT_CONFIRM).performClick()
assert(disconnected)
}

@Test
fun disconnect_cancelLeavesTheCredentialAlone() {
var disconnected = false
setScreen(
state = state(OrgRole.OWNER, listOf(ada, grace), linkedIn = connected()),
onRemoveLinkedInCredential = { disconnected = true },
)

composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_DISCONNECT).performClick()
composeRule.onNodeWithText("Cancel").performClick()

assert(!disconnected)
}

@Test
fun sync_refreshesThePageList() {
var synced = false
setScreen(
state = state(OrgRole.OWNER, listOf(ada, grace), linkedIn = connected()),
onSyncLinkedInPages = { synced = true },
)

composeRule.onNodeWithTag(OrganizationDetailTestTags.LINKEDIN_SYNC).performClick()
assert(synced)
}

@Test
fun assigningAPage_reportsTheMemberAndPage() {
var assigned: Pair<String, String?>? = null
setScreen(
state = state(OrgRole.OWNER, listOf(ada, grace), linkedIn = connected()),
onAssignLinkedInPage = { member, pageId -> assigned = member.userId to pageId },
)

composeRule.onNodeWithTag(OrganizationDetailTestTags.linkedInAssignment("u2")).performClick()
composeRule.onNodeWithTag(OrganizationDetailTestTags.linkedInPageOption("u2", "p2")).performClick()

assert(assigned == "u2" to "p2") { "expected grace to be assigned Acme Labs, got $assigned" }
}

@Test
fun clearingAnAssignment_sendsNoPage() {
var assigned: Pair<String, String?>? = null
setScreen(
state = state(
OrgRole.OWNER,
listOf(ada, grace),
linkedIn = connected(mapOf("u2" to "p1")),
),
onAssignLinkedInPage = { member, pageId -> assigned = member.userId to pageId },
)

// The current assignment is what the control reads.
composeRule.onNodeWithTag(OrganizationDetailTestTags.linkedInAssignment("u2"))
.assertTextContains("Acme Corp")
composeRule.onNodeWithTag(OrganizationDetailTestTags.linkedInAssignment("u2")).performClick()
composeRule.onNodeWithTag(OrganizationDetailTestTags.linkedInClearOption("u2")).performClick()

assert(assigned == "u2" to null) { "expected the assignment to be cleared, got $assigned" }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import com.interlinedlist.android.feature.organizations.data.remote.Organization
import com.interlinedlist.android.feature.organizations.data.remote.dto.AddMemberRequest
import com.interlinedlist.android.feature.organizations.data.remote.dto.CreateOrganizationRequest
import com.interlinedlist.android.feature.organizations.data.remote.dto.JoinOrganizationRequest
import com.interlinedlist.android.feature.organizations.data.remote.dto.LinkedInAssignmentRequest
import com.interlinedlist.android.feature.organizations.data.remote.dto.OrganizationsResponse
import com.interlinedlist.android.feature.organizations.data.remote.dto.UpdateMemberRequest
import com.interlinedlist.android.feature.organizations.data.remote.dto.UpdateOrganizationRequest
import com.interlinedlist.android.feature.organizations.domain.MemberCandidate
import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInStatus
import com.interlinedlist.android.feature.organizations.domain.OrgMember
import com.interlinedlist.android.feature.organizations.domain.OrgRole
import com.interlinedlist.android.feature.organizations.domain.Organization
Expand Down Expand Up @@ -219,6 +221,42 @@ class DefaultOrganizationsRepository @Inject constructor(
withContext(dispatchers.io) {
safeApiCall(json) { api.removeMember(orgId, userId) }.map { }
}

override suspend fun getLinkedInStatus(orgId: String): ApiResult<OrgLinkedInStatus> =
withContext(dispatchers.io) {
safeApiCall(json) { api.getLinkedInStatus(orgId) }.map(OrgLinkedInMapper::fromDto)
}

override suspend fun assignLinkedInPage(
orgId: String,
userId: String,
pageId: String?,
): ApiResult<Boolean> = withContext(dispatchers.io) {
safeApiCall(json) {
api.putLinkedInAssignment(orgId, LinkedInAssignmentRequest(userId = userId, pageId = pageId))
}.map { response ->
// The server answers {"assigned": …}; fall back to what we asked for.
response.assigned ?: (pageId != null)
}
}

override suspend fun removeLinkedInCredential(orgId: String): ApiResult<Unit> =
withContext(dispatchers.io) {
safeApiCall(json) { api.deleteLinkedInCredential(orgId) }.map { }
}

/**
* Syncs and then re-reads the status, because the sync response shape could
* not be observed (no reachable organization has a credential) while the
* status shape is confirmed. One extra GET buys a page list we can trust.
*/
override suspend fun syncLinkedInPages(orgId: String): ApiResult<OrgLinkedInStatus> =
withContext(dispatchers.io) {
when (val result = safeApiCall(json) { api.syncLinkedInPages(orgId) }) {
is ApiResult.Success -> getLinkedInStatus(orgId)
is ApiResult.Failure -> result
}
}
}

/** Builds a [Paged] from the response's pagination block, tolerating its absence. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.interlinedlist.android.feature.organizations.data

import com.interlinedlist.android.feature.organizations.data.remote.dto.OrgLinkedInPageDto
import com.interlinedlist.android.feature.organizations.data.remote.dto.OrgLinkedInStatusResponse
import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInPage
import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInStatus

/**
* DTO → domain mapping for the organization LinkedIn status.
*
* "Not connected" is the ordinary answer (`{"credential":null}` — verified live),
* so it maps to [OrgLinkedInStatus.NOT_CONNECTED] rather than being treated as a
* missing payload. The connected payload could not be observed, so pages and
* assignments are read from either the top level or from inside `credential`, and
* an assignment is accepted either as its own `{userId,pageId}` row or as an
* `assignedUserId` on the page.
*/
object OrgLinkedInMapper {

fun fromDto(dto: OrgLinkedInStatusResponse): OrgLinkedInStatus {
val credential = dto.credential
val connected = dto.connected ?: (credential != null)
val pageDtos = credential?.pages ?: dto.pages.orEmpty()
val pages = pageDtos.mapNotNull(::pageFromDto)
val assignmentDtos = credential?.assignments ?: dto.assignments.orEmpty()

val assignments = buildMap {
pageDtos.forEach { page ->
val pageId = page.resolvedId ?: return@forEach
page.assignedUserId?.let { put(it, pageId) }
}
assignmentDtos.forEach { assignment ->
val userId = assignment.userId ?: return@forEach
val pageId = assignment.pageId ?: return@forEach
put(userId, pageId)
}
}

return OrgLinkedInStatus(
connected = connected,
expiresAt = credential?.expiresAt ?: dto.expiresAt,
pages = pages,
// Assignments only mean anything against a page we know about.
assignments = assignments.filterValues { pageId -> pages.any { it.id == pageId } },
)
}

private fun pageFromDto(dto: OrgLinkedInPageDto): OrgLinkedInPage? {
val id = dto.resolvedId ?: return null
return OrgLinkedInPage(
id = id,
linkedInPageId = dto.linkedInPageId,
name = dto.resolvedName,
logoUrl = dto.resolvedLogo,
lastSyncedAt = dto.lastSyncedAt,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.interlinedlist.android.feature.organizations.data

import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.feature.organizations.domain.MemberCandidate
import com.interlinedlist.android.feature.organizations.domain.OrgLinkedInStatus
import com.interlinedlist.android.feature.organizations.domain.OrgMember
import com.interlinedlist.android.feature.organizations.domain.OrgRole
import com.interlinedlist.android.feature.organizations.domain.Organization
Expand Down Expand Up @@ -78,6 +79,29 @@ interface OrganizationsRepository {
/** Removes a user's membership from the organization. */
suspend fun removeMember(orgId: String, userId: String): ApiResult<Unit>

/**
* The organization's shared LinkedIn credential, its company pages and the
* per-member page assignments. An organization with no credential is a
* success carrying [OrgLinkedInStatus.NOT_CONNECTED], not a failure.
*/
suspend fun getLinkedInStatus(orgId: String): ApiResult<OrgLinkedInStatus>

/**
* Assigns [userId] to the company page [pageId], or clears their assignment
* when [pageId] is null. Returns whether the member ends up assigned, as the
* server reports it.
*/
suspend fun assignLinkedInPage(orgId: String, userId: String, pageId: String?): ApiResult<Boolean>

/**
* Disconnects the shared credential. The server clears the page assignments
* with it, so the organization can no longer post to its company pages.
*/
suspend fun removeLinkedInCredential(orgId: String): ApiResult<Unit>

/** Re-discovers the company pages and returns the refreshed status. */
suspend fun syncLinkedInPages(orgId: String): ApiResult<OrgLinkedInStatus>

companion object {
const val DEFAULT_PAGE_SIZE = 20
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package com.interlinedlist.android.feature.organizations.data.remote
import com.interlinedlist.android.feature.organizations.data.remote.dto.AddMemberRequest
import com.interlinedlist.android.feature.organizations.data.remote.dto.CreateOrganizationRequest
import com.interlinedlist.android.feature.organizations.data.remote.dto.JoinOrganizationRequest
import com.interlinedlist.android.feature.organizations.data.remote.dto.LinkedInAssignmentRequest
import com.interlinedlist.android.feature.organizations.data.remote.dto.LinkedInAssignmentResponse
import com.interlinedlist.android.feature.organizations.data.remote.dto.MembersResponse
import com.interlinedlist.android.feature.organizations.data.remote.dto.OrgLinkedInStatusResponse
import com.interlinedlist.android.feature.organizations.data.remote.dto.OrgUsersResponse
import com.interlinedlist.android.feature.organizations.data.remote.dto.OrganizationEnvelope
import com.interlinedlist.android.feature.organizations.data.remote.dto.OrganizationsResponse
Expand Down Expand Up @@ -96,4 +99,39 @@ interface OrganizationsApi {
@Query("limit") limit: Int,
@Query("offset") offset: Int,
): OrgUsersResponse

/**
* The organization's shared LinkedIn credential and the pages discovered for
* it. Members-only: an outsider gets
* `403 {"error":"Not a member of this organization"}` (verified live), and an
* organization with no credential answers `200 {"credential":null,"role":…}`.
*/
@GET("api/organizations/{id}/linkedin/status")
suspend fun getLinkedInStatus(@Path("id") id: String): OrgLinkedInStatusResponse

/**
* Assigns one member to one company page — or clears their assignment when the
* body carries no `pageId`. Owner/admin only (`403 "Admin or owner required"`).
*/
@PUT("api/organizations/{id}/linkedin/assignments")
suspend fun putLinkedInAssignment(
@Path("id") id: String,
@Body body: LinkedInAssignmentRequest,
): LinkedInAssignmentResponse

/**
* Disconnects the shared credential; the server clears the assignments with
* it. Answers `404 {"error":"No LinkedIn credential found"}` when there is
* none (verified live).
*/
@DELETE("api/organizations/{id}/linkedin/credential")
suspend fun deleteLinkedInCredential(@Path("id") id: String)

/**
* Re-discovers the organization's company pages. Answers
* `404 {"error":"No active LinkedIn credential for this organization"}` when
* the organization has not connected LinkedIn (verified live).
*/
@POST("api/organizations/{id}/linkedin/sync-pages")
suspend fun syncLinkedInPages(@Path("id") id: String)
}
Loading
Loading