tauri-plugin-background-service 1.0.1

Background service lifecycle plugin for Tauri v2 — run long-lived tasks on Android, iOS, and desktop
Documentation
package app.tauri.backgroundservice

import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

/**
 * NTF-04 (Step 7b): Robolectric tests for [handleNotificationActionResult] — the
 * re-present-vs-cancel APPLY layer. The Core result is injected via
 * [decideNotificationOutcome] (NO JNI: HeadlessBridge.performNotificationAction
 * is System.loadLibrary(the native core) and cannot load under Robolectric).
 *
 * AC3 (load-bearing): the re-presented notification MUST carry the original
 * replyText in its body. Anti-loop: a synthetic permanent code CANCELs (a
 * sentinel notification is removed), it does NOT re-present.
 */
@RunWith(RobolectricTestRunner::class)
class NotificationActionRePresentTest {
    private lateinit var context: Context
    private val notificationId = 44010

    @Before
    fun setup() {
        context = ApplicationProvider.getApplicationContext()
    }

    private fun shadowNotification(id: Int): Notification? {
        val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        // Tag-agnostic retrieval. Step 9b posts under (chatTag, id), and Robolectric's
        // ShadowNotificationManager.getNotification(int) delegates to getNotification(null, id)
        // — TAG-SENSITIVE — so it returns null for a tag-keyed post. Query activeNotifications
        // by id so a cancel assertion is meaningful (not vacuously null).
        return nm.activeNotifications.firstOrNull { it.id == id }?.notification
    }

    private fun postSentinel() {
        ActionableMessageNotifier.showMessageNotification(
            context = context,
            notificationId = notificationId,
            chatId = "chat-sentinel",
            messageId = "msg-sentinel",
            title = "sentinel",
            body = "sentinel-body",
            routeUri = "bg-service://chat?chat_id=chat-sentinel&message_id=msg-sentinel",
            smallIcon = android.R.drawable.sym_def_app_icon,
            launchIntent = null,
        )
        assertNotNull("sentinel must post before a cancel assertion", shadowNotification(notificationId))
    }

    @Test
    @Config(sdk = [34])
    fun recoverableRustFailure_rePresentsNotificationPreservingReplyText() {
        val result = HeadlessBridgeResult(
            ok = false,
            state = "failed",
            message = "core not running",
            recoverable = true,
            rawJson = """{"ok":false,"state":"failed","recoverable":true,"message":"core not running"}""",
        )
        val outcome = decideNotificationOutcome(result, replyText = "typed reply")

        handleNotificationActionResult(context, outcome, "chat-7", "msg-7", notificationId)

        // RE-POSTED (cancel did NOT fire): the notification is present. If the
        // apply layer had cancelled instead, the notification would be absent.
        val posted = shadowNotification(notificationId)
        assertNotNull("recoverable failure must RE-PRESENT, not cancel", posted)
        // AC3 (load-bearing): the re-presented notification carries replyText.
        assertEquals(
            "typed reply",
            posted!!.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString(),
        )
    }

    @Test
    @Config(sdk = [34])
    fun success_cancels() {
        postSentinel()
        val result = HeadlessBridgeResult(
            ok = true,
            state = "running",
            message = null,
            recoverable = false,
            rawJson = """{"ok":true,"state":"running"}""",
        )

        handleNotificationActionResult(context, decideNotificationOutcome(result, "x"), "chat-sentinel", "m", notificationId)

        assertNull("success must CANCEL (dismiss the notification)", shadowNotification(notificationId))
    }

    @Test
    @Config(sdk = [34])
    fun permanentSyntheticCode_cancels_antiLoop() {
        postSentinel()
        // failure() hardcodes recoverable=true for a PERMANENT env failure.
        val result = HeadlessBridgeResult.failure("native_library_load_failed", "load err")

        handleNotificationActionResult(context, decideNotificationOutcome(result, "x"), "chat-sentinel", "m", notificationId)

        assertNull("synthetic permanent failure must CANCEL (anti-loop), not re-present", shadowNotification(notificationId))
    }

    @Test
    @Config(sdk = [34])
    fun permanentRustNotRecoverable_cancels() {
        postSentinel()
        // Rust empty-reply verdict: recoverable == false (headless_core.rs:313).
        val result = HeadlessBridgeResult(
            ok = false,
            state = "failed",
            message = "empty notification reply",
            recoverable = false,
            rawJson = """{"ok":false,"state":"failed","recoverable":false,"message":"empty notification reply"}""",
        )

        handleNotificationActionResult(context, decideNotificationOutcome(result, ""), "chat-sentinel", "m", notificationId)

        assertNull("Rust-permanent (not recoverable) must CANCEL", shadowNotification(notificationId))
    }
}