Integration: residence invite accept/decline APIs + wire notification actions
Adds acceptResidenceInvite / declineResidenceInvite to ResidenceApi
(POST /api/residences/{id}/invite/{accept|decline}) and exposes them via
APILayer. On accept success, myResidences is force-refreshed so the
newly-joined residence appears without a manual pull.
Wires NotificationActionReceiver's ACCEPT_INVITE / DECLINE_INVITE
handlers to the new APILayer calls, replacing the log-only TODOs left
behind by P4 Stream O. Notifications are now cleared only on API
success so a failed accept stays actionable.
Tests:
- ResidenceApiInviteTest covers correct HTTP method/path + error surfacing.
- NotificationActionReceiverTest invite cases updated to assert the new
APILayer calls (were previously asserting the log-only path).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -212,39 +212,39 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
||||
|
||||
// ---------------- Accept / Decline invite ----------------
|
||||
|
||||
private fun handleAcceptInvite(context: Context, residenceId: Long?, notificationId: Int) {
|
||||
private suspend fun handleAcceptInvite(context: Context, residenceId: Long?, notificationId: Int) {
|
||||
if (residenceId == null) {
|
||||
Log.w(TAG, "ACCEPT_INVITE without residence_id — no-op")
|
||||
return
|
||||
}
|
||||
// APILayer.acceptResidenceInvite does not yet exist — see TODO below.
|
||||
// composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
|
||||
// (add after createTaskCompletion at ~line 790). Backend endpoint
|
||||
// intended at POST /api/residences/{id}/invite/accept.
|
||||
Log.w(
|
||||
TAG,
|
||||
"TODO: APILayer.acceptResidenceInvite($residenceId) — implement in " +
|
||||
"composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt " +
|
||||
"(follow createTaskCompletion pattern; POST /api/residences/{id}/invite/accept)"
|
||||
)
|
||||
// Best-effort UX: cancel the notification so the user isn't stuck
|
||||
// on a button that does nothing visible. The invite will be picked
|
||||
// up on next app-open from /api/residences/pending.
|
||||
cancelNotification(context, notificationId)
|
||||
when (val result = APILayer.acceptResidenceInvite(residenceId.toInt())) {
|
||||
is ApiResult.Success -> {
|
||||
Log.d(TAG, "Residence invite $residenceId accepted")
|
||||
cancelNotification(context, notificationId)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
// Leave the notification so the user can retry from the app.
|
||||
Log.e(TAG, "Accept invite failed: ${result.message}")
|
||||
}
|
||||
else -> Log.w(TAG, "Unexpected ApiResult from acceptResidenceInvite")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeclineInvite(context: Context, residenceId: Long?, notificationId: Int) {
|
||||
private suspend fun handleDeclineInvite(context: Context, residenceId: Long?, notificationId: Int) {
|
||||
if (residenceId == null) {
|
||||
Log.w(TAG, "DECLINE_INVITE without residence_id — no-op")
|
||||
return
|
||||
}
|
||||
Log.w(
|
||||
TAG,
|
||||
"TODO: APILayer.declineResidenceInvite($residenceId) — implement in " +
|
||||
"composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt " +
|
||||
"(follow createTaskCompletion pattern; POST /api/residences/{id}/invite/decline)"
|
||||
)
|
||||
cancelNotification(context, notificationId)
|
||||
when (val result = APILayer.declineResidenceInvite(residenceId.toInt())) {
|
||||
is ApiResult.Success -> {
|
||||
Log.d(TAG, "Residence invite $residenceId declined")
|
||||
cancelNotification(context, notificationId)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Log.e(TAG, "Decline invite failed: ${result.message}")
|
||||
}
|
||||
else -> Log.w(TAG, "Unexpected ApiResult from declineResidenceInvite")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- helpers ----------------
|
||||
|
||||
@@ -243,12 +243,14 @@ class NotificationActionReceiverTest {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// ---------- 5. ACCEPT_INVITE: clears notification (TODO: API call) ----------
|
||||
// ---------- 5. ACCEPT_INVITE: calls APILayer + clears notification ----------
|
||||
|
||||
@Test
|
||||
fun acceptInvite_withResidenceId_cancelsNotification() = runTest {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
|
||||
coEvery { APILayer.acceptResidenceInvite(any()) } returns ApiResult.Success(Unit)
|
||||
|
||||
val notifId = 9005
|
||||
postDummyNotification(notifId)
|
||||
|
||||
@@ -259,10 +261,7 @@ class NotificationActionReceiverTest {
|
||||
}
|
||||
receiverFor(scope).onReceive(context, intent)
|
||||
|
||||
// API method does not yet exist — see TODO in receiver. Expectation is
|
||||
// that the notification is still cleared (best-effort UX) and we did
|
||||
// NOT crash. APILayer.createTaskCompletion should NOT have been called.
|
||||
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
|
||||
coVerify(exactly = 1) { APILayer.acceptResidenceInvite(101) }
|
||||
assertFalse(
|
||||
"invite notification should be cleared on accept",
|
||||
notificationManager.activeNotifications.any { it.id == notifId }
|
||||
@@ -343,6 +342,8 @@ class NotificationActionReceiverTest {
|
||||
fun declineInvite_withResidenceId_cancelsNotification() = runTest {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
|
||||
coEvery { APILayer.declineResidenceInvite(any()) } returns ApiResult.Success(Unit)
|
||||
|
||||
val notifId = 9009
|
||||
postDummyNotification(notifId)
|
||||
|
||||
@@ -353,6 +354,7 @@ class NotificationActionReceiverTest {
|
||||
}
|
||||
receiverFor(scope).onReceive(context, intent)
|
||||
|
||||
coVerify(exactly = 1) { APILayer.declineResidenceInvite(77) }
|
||||
assertFalse(
|
||||
"invite notification should be cleared on decline",
|
||||
notificationManager.activeNotifications.any { it.id == notifId }
|
||||
|
||||
@@ -415,6 +415,32 @@ object APILayer {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a residence invite (push-notification action button).
|
||||
* Refreshes myResidences on success so the new residence appears in the
|
||||
* cache without requiring a manual pull-to-refresh.
|
||||
*/
|
||||
suspend fun acceptResidenceInvite(residenceId: Int): ApiResult<Unit> {
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
val result = residenceApi.acceptResidenceInvite(token, residenceId)
|
||||
if (result is ApiResult.Success) {
|
||||
// Residence list may have changed — force a refresh so any
|
||||
// newly-joined residence shows up in the home screen.
|
||||
getMyResidences(forceRefresh = true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline a residence invite (push-notification action button).
|
||||
* Does not require cache refresh — pending-invites are not cached
|
||||
* client-side; the next app-open will re-query the server anyway.
|
||||
*/
|
||||
suspend fun declineResidenceInvite(residenceId: Int): ApiResult<Unit> {
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return residenceApi.declineResidenceInvite(token, residenceId)
|
||||
}
|
||||
|
||||
suspend fun getResidence(id: Int, forceRefresh: Boolean = false): ApiResult<ResidenceResponse> {
|
||||
// Check DataManager caches first - return cached if valid and not forcing refresh
|
||||
if (!forceRefresh) {
|
||||
|
||||
@@ -177,6 +177,50 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a residence invite (push-notification action button).
|
||||
*
|
||||
* Backend: `POST /api/residences/{id}/invite/accept`. No body.
|
||||
* Parity: `NotificationCategories.swift` ACCEPT_INVITE on iOS.
|
||||
*/
|
||||
suspend fun acceptResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/residences/$residenceId/invite/accept/") {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(Unit)
|
||||
} else {
|
||||
val errorMessage = ErrorParser.parseError(response)
|
||||
ApiResult.Error(errorMessage, response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline a residence invite. See [acceptResidenceInvite] for endpoint
|
||||
* pattern. Backend: `POST /api/residences/{id}/invite/decline`.
|
||||
*/
|
||||
suspend fun declineResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/residences/$residenceId/invite/decline/") {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(Unit)
|
||||
} else {
|
||||
val errorMessage = ErrorParser.parseError(response)
|
||||
ApiResult.Error(errorMessage, response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun joinWithCode(token: String, code: String): ApiResult<JoinResidenceResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/residences/join-with-code/") {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.mock.MockEngine
|
||||
import io.ktor.client.engine.mock.respond
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.headersOf
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Verifies the residence-invite accept/decline endpoints hit the correct
|
||||
* paths. Wired from [com.tt.honeyDue.notifications.NotificationActionReceiver]
|
||||
* when the user taps the Accept/Decline button on a `residence_invite`
|
||||
* push notification (iOS parity with `NotificationCategories.swift`).
|
||||
*/
|
||||
class ResidenceApiInviteTest {
|
||||
|
||||
private fun mockClient(onRequest: (String) -> Unit): HttpClient =
|
||||
HttpClient(MockEngine) {
|
||||
engine {
|
||||
addHandler { req ->
|
||||
onRequest(req.url.encodedPath)
|
||||
respond(
|
||||
content = "",
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "text/plain")
|
||||
)
|
||||
}
|
||||
}
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptResidenceInvite_hits_correct_endpoint() = runTest {
|
||||
var method: HttpMethod? = null
|
||||
var path: String? = null
|
||||
val client = HttpClient(MockEngine) {
|
||||
engine {
|
||||
addHandler { req ->
|
||||
method = req.method
|
||||
path = req.url.encodedPath
|
||||
respond(
|
||||
content = "",
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "text/plain")
|
||||
)
|
||||
}
|
||||
}
|
||||
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
|
||||
}
|
||||
val api = ResidenceApi(client)
|
||||
|
||||
val result = api.acceptResidenceInvite("test-token", residenceId = 42)
|
||||
|
||||
assertTrue(result is ApiResult.Success, "expected Success, got $result")
|
||||
assertEquals(HttpMethod.Post, method)
|
||||
assertTrue(
|
||||
path?.endsWith("/residences/42/invite/accept/") == true,
|
||||
"unexpected path: $path"
|
||||
)
|
||||
client.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun declineResidenceInvite_hits_correct_endpoint() = runTest {
|
||||
var method: HttpMethod? = null
|
||||
var path: String? = null
|
||||
val client = HttpClient(MockEngine) {
|
||||
engine {
|
||||
addHandler { req ->
|
||||
method = req.method
|
||||
path = req.url.encodedPath
|
||||
respond(
|
||||
content = "",
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "text/plain")
|
||||
)
|
||||
}
|
||||
}
|
||||
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
|
||||
}
|
||||
val api = ResidenceApi(client)
|
||||
|
||||
val result = api.declineResidenceInvite("test-token", residenceId = 99)
|
||||
|
||||
assertTrue(result is ApiResult.Success, "expected Success, got $result")
|
||||
assertEquals(HttpMethod.Post, method)
|
||||
assertTrue(
|
||||
path?.endsWith("/residences/99/invite/decline/") == true,
|
||||
"unexpected path: $path"
|
||||
)
|
||||
client.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptResidenceInvite_surfaces_server_error() = runTest {
|
||||
val client = HttpClient(MockEngine) {
|
||||
engine {
|
||||
addHandler {
|
||||
respond(
|
||||
content = """{"detail":"Invite not found"}""",
|
||||
status = HttpStatusCode.NotFound,
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/json")
|
||||
)
|
||||
}
|
||||
}
|
||||
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
|
||||
}
|
||||
val api = ResidenceApi(client)
|
||||
|
||||
val result = api.acceptResidenceInvite("test-token", residenceId = 1)
|
||||
|
||||
assertTrue(result is ApiResult.Error, "expected Error, got $result")
|
||||
assertEquals(404, result.code)
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user