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:
Trey T
2026-04-18 13:36:59 -05:00
parent 10b57aabaa
commit 485f70dfa1
5 changed files with 227 additions and 28 deletions

View File

@@ -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 ----------------

View File

@@ -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 }

View File

@@ -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) {

View File

@@ -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/") {

View File

@@ -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()
}
}