From 485f70dfa105b4e3778d269979a62b84ea4539bb Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 13:36:59 -0500 Subject: [PATCH] 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) --- .../NotificationActionReceiver.kt | 46 +++---- .../NotificationActionReceiverTest.kt | 12 +- .../com/tt/honeyDue/network/APILayer.kt | 26 ++++ .../com/tt/honeyDue/network/ResidenceApi.kt | 44 ++++++ .../network/ResidenceApiInviteTest.kt | 127 ++++++++++++++++++ 5 files changed, 227 insertions(+), 28 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/ResidenceApiInviteTest.kt diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiver.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiver.kt index 716b7f4..ee121b5 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiver.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiver.kt @@ -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 ---------------- diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiverTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiverTest.kt index 4f35e60..f2636c3 100644 --- a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiverTest.kt +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiverTest.kt @@ -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 } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt index c3dfe39..5c81c83 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt @@ -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 { + 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 { + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) + return residenceApi.declineResidenceInvite(token, residenceId) + } + suspend fun getResidence(id: Int, forceRefresh: Boolean = false): ApiResult { // Check DataManager caches first - return cached if valid and not forcing refresh if (!forceRefresh) { diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ResidenceApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ResidenceApi.kt index d0261e4..f14ac12 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ResidenceApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ResidenceApi.kt @@ -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 { + 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 { + 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 { return try { val response = client.post("$baseUrl/residences/join-with-code/") { diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/ResidenceApiInviteTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/ResidenceApiInviteTest.kt new file mode 100644 index 0000000..e397078 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/ResidenceApiInviteTest.kt @@ -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() + } +}