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 @@ -607,6 +607,8 @@ private fun MainShell(
OrganizationDetailRoute(
onBack = { tabNav.popBackStack() },
onDeleted = { tabNav.popBackStack() },
// Leaving drops access to the org, so return to the index.
onLeft = { tabNav.popBackStack() },
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ class OrganizationDetailScreenTest {
state: OrganizationDetailUiState,
onRemoveMember: (OrgMember) -> Unit = {},
onDelete: () -> Unit = {},
onJoin: () -> Unit = {},
onLeave: () -> Unit = {},
) {
composeRule.setContent {
InterlinedListTheme {
Expand All @@ -40,6 +42,8 @@ class OrganizationDetailScreenTest {
onRemoveMember = onRemoveMember,
onSaveEdit = { _, _, _ -> },
onDelete = onDelete,
onJoin = onJoin,
onLeave = onLeave,
)
}
}
Expand Down Expand Up @@ -97,4 +101,105 @@ class OrganizationDetailScreenTest {

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

@Test
fun nonMember_seesJoinPrompt_andNoMemberTools() {
setScreen(
state = OrganizationDetailUiState(
// No role: the API reports membership only for the caller's own orgs.
organization = Organization("o1", "Metals", null, null, true, 1, null, null),
isLoading = false,
),
)

composeRule.onNodeWithTag(OrganizationDetailTestTags.JOIN_PROMPT).assertIsDisplayed()
composeRule.onNodeWithTag(OrganizationDetailTestTags.JOIN).assertIsDisplayed()
composeRule.onNodeWithTag(OrganizationDetailTestTags.SEARCH).assertDoesNotExist()
}

@Test
fun nonMember_join_reportsTheAction() {
var joined = false
setScreen(
state = OrganizationDetailUiState(
organization = Organization("o1", "Metals", null, null, true, 1, null, null),
isLoading = false,
),
onJoin = { joined = true },
)

composeRule.onNodeWithTag(OrganizationDetailTestTags.JOIN).performClick()
assert(joined)
}

@Test
fun nonMemberOfPrivateOrg_isNotOfferedJoin() {
setScreen(
state = OrganizationDetailUiState(
organization = Organization("o1", "Acme", null, null, false, 2, null, null),
isLoading = false,
),
)

composeRule.onNodeWithTag(OrganizationDetailTestTags.JOIN_PROMPT).assertIsDisplayed()
composeRule.onNodeWithTag(OrganizationDetailTestTags.JOIN).assertDoesNotExist()
}

@Test
fun member_leaveFlow_confirmsBeforeLeaving() {
var left = false
setScreen(
state = OrganizationDetailUiState(
organization = Organization("o1", "Bikey Life", null, null, true, 3, OrgRole.MEMBER, null),
members = listOf(
OrgMember("u1", "ada", "Ada", null, OrgRole.OWNER, active = true),
OrgMember("me", "me", null, null, OrgRole.MEMBER, active = true),
),
isLoading = false,
),
onLeave = { left = true },
)

composeRule.onNodeWithTag(OrganizationDetailTestTags.OVERFLOW).performClick()
composeRule.onNodeWithTag(OrganizationDetailTestTags.LEAVE).performClick()
composeRule.onNodeWithTag(OrganizationDetailTestTags.LEAVE_DIALOG).assertIsDisplayed()
composeRule.onNodeWithTag(OrganizationDetailTestTags.LEAVE_CONFIRM).performClick()
assert(left)
}

@Test
fun soleOwner_isExplainedInsteadOfBeingAllowedToLeave() {
var left = false
setScreen(
state = OrganizationDetailUiState(
organization = Organization("o1", "Acme", null, null, false, 2, OrgRole.OWNER, null),
members = listOf(
OrgMember("me", "me", null, null, OrgRole.OWNER, active = true),
OrgMember("u2", "grace", null, null, OrgRole.MEMBER, active = true),
),
isLoading = false,
),
onLeave = { left = true },
)

composeRule.onNodeWithTag(OrganizationDetailTestTags.OVERFLOW).performClick()
composeRule.onNodeWithTag(OrganizationDetailTestTags.LEAVE).performClick()
// The dialog explains why, and offers no destructive confirm at all.
composeRule.onNodeWithTag(OrganizationDetailTestTags.LAST_OWNER_NOTICE).assertIsDisplayed()
composeRule.onNodeWithTag(OrganizationDetailTestTags.LEAVE_CONFIRM).assertDoesNotExist()
assert(!left)
}

@Test
fun nonMember_isNotOfferedLeave() {
setScreen(
state = OrganizationDetailUiState(
organization = Organization("o1", "Metals", null, null, true, 1, null, null),
isLoading = false,
),
)

composeRule.onNodeWithTag(OrganizationDetailTestTags.OVERFLOW).performClick()
composeRule.onNodeWithTag(OrganizationDetailTestTags.LEAVE).assertDoesNotExist()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.ui.test.onNodeWithTag
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.OrgRole
import com.interlinedlist.android.feature.organizations.domain.Organization
import org.junit.Rule
import org.junit.Test
Expand All @@ -25,6 +26,7 @@ class OrganizationsScreenTest {
private fun setScreen(
state: OrganizationsUiState,
onOpenOrg: (String) -> Unit = {},
onJoinOrg: (String) -> Unit = {},
) {
composeRule.setContent {
InterlinedListTheme {
Expand All @@ -33,6 +35,7 @@ class OrganizationsScreenTest {
onOpenOrg = onOpenOrg,
onBack = {},
onLoadMore = {},
onJoinOrg = onJoinOrg,
onCreateOrganization = { _, _, _ -> },
)
}
Expand Down Expand Up @@ -86,4 +89,51 @@ class OrganizationsScreenTest {
composeRule.onNodeWithTag(OrganizationsTestTags.CREATE_FAB).performClick()
composeRule.onNodeWithTag(OrganizationsTestTags.CREATE_NAME).assertIsDisplayed()
}

@Test
fun rendersMembershipState_joinForNonMembers_roleForMembers() {
setScreen(
state = OrganizationsUiState(
organizations = listOf(
// Public, no role -> not a member: offer Join.
Organization("1", "Metals", null, null, true, 1, null, null),
// A membership reports a role: show it, never offer Join.
Organization("2", "Bikey Life", null, null, true, 3, OrgRole.MEMBER, null),
),
isRefreshing = false,
),
)

composeRule.onNodeWithTag(OrganizationsTestTags.join("1")).assertIsDisplayed()
composeRule.onNodeWithTag(OrganizationsTestTags.membership("1")).assertDoesNotExist()
composeRule.onNodeWithTag(OrganizationsTestTags.membership("2")).assertIsDisplayed()
composeRule.onNodeWithTag(OrganizationsTestTags.join("2")).assertDoesNotExist()
}

@Test
fun joinButton_reportsTheOrgId() {
var joined: String? = null
setScreen(
state = OrganizationsUiState(
organizations = listOf(Organization("1", "Metals", null, null, true, 1, null, null)),
isRefreshing = false,
),
onJoinOrg = { joined = it },
)

composeRule.onNodeWithTag(OrganizationsTestTags.join("1")).performClick()
assert(joined == "1")
}

@Test
fun privateOrgsTheUserIsNotIn_offerNoJoin() {
setScreen(
state = OrganizationsUiState(
organizations = listOf(Organization("1", "Acme", null, null, false, 2, null, null)),
isRefreshing = false,
),
)

composeRule.onNodeWithTag(OrganizationsTestTags.join("1")).assertDoesNotExist()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.interlinedlist.android.feature.organizations.data

/**
* Supplies the signed-in user's id. Leaving an organization is expressed by the
* API as deleting your own membership row
* (`DELETE /api/organizations/{id}/members/{userId}`), so the repository needs it.
* Abstracted from `SessionStore` (which is Android-backed) so the repository stays
* unit-testable on the plain JVM.
*/
fun interface CurrentUserIdProvider {
fun currentUserId(): String?
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.interlinedlist.android.feature.organizations.data.local.OrganizationD
import com.interlinedlist.android.feature.organizations.data.remote.OrganizationsApi
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.OrganizationsResponse
import com.interlinedlist.android.feature.organizations.data.remote.dto.UpdateMemberRequest
import com.interlinedlist.android.feature.organizations.data.remote.dto.UpdateOrganizationRequest
Expand All @@ -32,6 +33,7 @@ class DefaultOrganizationsRepository @Inject constructor(
private val api: OrganizationsApi,
private val dao: OrganizationDao,
private val json: kotlinx.serialization.json.Json,
private val currentUserId: CurrentUserIdProvider,
private val dispatchers: DispatcherProvider,
) : OrganizationsRepository {

Expand Down Expand Up @@ -142,6 +144,47 @@ class DefaultOrganizationsRepository @Inject constructor(
}
}

override suspend fun joinOrganization(orgId: String): ApiResult<Unit> =
withContext(dispatchers.io) {
when (val result = safeApiCall(json) { api.joinOrganization(JoinOrganizationRequest(orgId)) }) {
// Re-read the org so the cached row carries the new role and member
// count; the index then renders "Member" instead of "Join".
is ApiResult.Success -> {
getOrganization(orgId)
ApiResult.Success(Unit)
}
is ApiResult.Failure -> result
}
}

override suspend fun leaveOrganization(orgId: String): ApiResult<Unit> =
withContext(dispatchers.io) {
val userId = currentUserId.currentUserId()?.takeIf { it.isNotBlank() }
?: return@withContext ApiResult.Failure(
AppError.Unauthorized("We couldn't confirm who you're signed in as. Sign in again and retry."),
)
when (val result = safeApiCall(json) { api.removeMember(orgId, userId) }) {
is ApiResult.Success -> {
refreshAfterLeaving(orgId)
ApiResult.Success(Unit)
}
is ApiResult.Failure -> result
}
}

/**
* Re-reads an org just left so the cache drops the membership. A private org is
* invisible to a non-member, so a 403/404 means it should leave the cache too.
*/
private suspend fun refreshAfterLeaving(orgId: String) {
val refreshed = getOrganization(orgId)
if (refreshed is ApiResult.Failure &&
(refreshed.error is AppError.Forbidden || refreshed.error is AppError.NotFound)
) {
dao.deleteById(orgId)
}
}

override suspend fun getMembers(orgId: String, limit: Int): ApiResult<List<OrgMember>> =
withContext(dispatchers.io) {
safeApiCall(json) { api.getMembers(orgId, limit = limit, offset = 0) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ object OrganizationMapper {
avatarUrl = dto.resolvedAvatar,
isPublic = dto.resolvedPublic,
memberCount = dto.resolvedMemberCount,
role = dto.role?.let(OrgRole::fromApi),
role = dto.resolvedRole?.let(OrgRole::fromApi),
updatedAt = dto.updatedAt,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ interface OrganizationsRepository {
/** Deletes an organization and evicts it from the cache. */
suspend fun deleteOrganization(id: String): ApiResult<Unit>

/**
* Joins a public organization (`POST /api/user/organizations`). On success the
* cached row is re-read so it carries the new role and member count.
*/
suspend fun joinOrganization(orgId: String): ApiResult<Unit>

/**
* Leaves an organization by removing the signed-in user's own membership. The
* server refuses to orphan an organization: the last owner gets a 400
* ("Cannot remove the last owner"), which is reported as a failure.
*/
suspend fun leaveOrganization(orgId: String): ApiResult<Unit>

/** Members of an organization (users granted access), with their roles. */
suspend fun getMembers(orgId: String, limit: Int = DEFAULT_PAGE_SIZE): ApiResult<List<OrgMember>>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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.MembersResponse
import com.interlinedlist.android.feature.organizations.data.remote.dto.OrgUsersResponse
import com.interlinedlist.android.feature.organizations.data.remote.dto.OrganizationEnvelope
Expand Down Expand Up @@ -33,6 +34,14 @@ interface OrganizationsApi {
@GET("api/user/organizations")
suspend fun getUserOrganizations(): OrganizationsResponse

/**
* Joins a public organization. Confirmed live: the body key is
* `organizationId`, the response is 201 `{ message, membership }`, a private
* org answers 403 and an existing membership answers 409.
*/
@POST("api/user/organizations")
suspend fun joinOrganization(@Body body: JoinOrganizationRequest)

@POST("api/organizations")
suspend fun createOrganization(@Body body: CreateOrganizationRequest): OrganizationEnvelope

Expand Down Expand Up @@ -68,6 +77,10 @@ interface OrganizationsApi {
@Body body: UpdateMemberRequest,
)

/**
* Removes a membership. Used both for removing someone else and for *leaving*
* (passing the signed-in user's own id) — the API has no separate leave route.
*/
@DELETE("api/organizations/{id}/members/{userId}")
suspend fun removeMember(
@Path("id") id: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ data class OrganizationDto(
val memberCount: Int? = null,
val membersCount: Int? = null,
val members: Int? = null,
// The current user's role in this org, when the endpoint includes it.
// The current user's role in this org, when the endpoint includes it. The
// index reports it as `role`, the detail endpoint as `userRole` (explicitly
// null for a non-member), so both names are read.
val role: String? = null,
val userRole: String? = null,
val updatedAt: String? = null,
) {
/** The avatar URL under whichever field name the API used. */
Expand All @@ -40,6 +43,12 @@ data class OrganizationDto(
val resolvedPublic: Boolean get() = isPublic ?: public ?: false
/** Member count under whichever name the API used, defaulting to zero. */
val resolvedMemberCount: Int get() = memberCount ?: membersCount ?: members ?: 0

/**
* The caller's role under whichever name the endpoint used. `null` means the
* caller is not a member — that is how the API signals non-membership.
*/
val resolvedRole: String? get() = role ?: userRole
}

/** Pagination block shared by list endpoints. */
Expand Down Expand Up @@ -84,6 +93,16 @@ data class CreateOrganizationRequest(
val isPublic: String? = null,
)

/**
* Body for `POST /api/user/organizations` — joining a public organization.
* The server requires the key `organizationId` (it 400s with
* `{"error":"Organization ID is required"}` otherwise).
*/
@Serializable
data class JoinOrganizationRequest(
val organizationId: String,
)

/** Body for `PUT /api/organizations/{id}` — partial metadata updates. */
@Serializable
data class UpdateOrganizationRequest(
Expand Down
Loading
Loading