tauri-plugin-background-service 0.7.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.Activity
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config

/**
 * Unit tests for BackgroundServicePlugin SharedPreferences logic.
 *
 * Tests the actual SharedPreferences behavior that the @Command methods
 * rely on, rather than mocking SharedPreferences itself.
 *
 * Note: Full @Command method tests require Tauri Invoke objects which
 * need the Tauri Android framework. These tests verify the underlying
 * persistence logic.
 */
@RunWith(RobolectricTestRunner::class)
class BackgroundServicePluginTest {

    /** Concrete Activity for Robolectric's ActivityScenario. */
    class TestActivity : Activity()

    private lateinit var context: Context
    private lateinit var prefs: android.content.SharedPreferences

    @Before
    fun setup() {
        context = ApplicationProvider.getApplicationContext()
        prefs = context.getSharedPreferences("bg_service", Context.MODE_PRIVATE)
    }

    // ── startKeepalive: persists label and service type ────────────────

    @Test
    fun startKeepalivePersistsLabelAndType() {
        prefs.edit()
            .putString("bg_service_label", "Syncing")
            .putString("bg_service_type", "dataSync")
            .apply()

        assertEquals("Syncing", prefs.getString("bg_service_label", null))
        assertEquals("dataSync", prefs.getString("bg_service_type", null))
    }

    @Test
    fun startKeepaliveWithSpecialUsePersistsType() {
        prefs.edit()
            .putString("bg_service_label", "Background Sync")
            .putString("bg_service_type", "specialUse")
            .apply()

        assertEquals("Background Sync", prefs.getString("bg_service_label", null))
        assertEquals("specialUse", prefs.getString("bg_service_type", null))
    }

    // ── stopKeepalive: clears all keys ──────────────────────────────────

    @Test
    fun stopKeepaliveClearsAllKeys() {
        // Set up initial state
        prefs.edit()
            .putString("bg_service_label", "Syncing")
            .putString("bg_service_type", "dataSync")
            .putBoolean("bg_auto_start_pending", true)
            .putString("bg_auto_start_label", "Syncing")
            .putString("bg_auto_start_type", "dataSync")
            .apply()

        // Simulate stopKeepalive
        prefs.edit()
            .remove("bg_service_label")
            .remove("bg_service_type")
            .remove("bg_auto_start_pending")
            .remove("bg_auto_start_label")
            .remove("bg_auto_start_type")
            .apply()

        assertNull(prefs.getString("bg_service_label", null))
        assertNull(prefs.getString("bg_service_type", null))
        assertFalse(prefs.getBoolean("bg_auto_start_pending", false))
        assertNull(prefs.getString("bg_auto_start_label", null))
        assertNull(prefs.getString("bg_auto_start_type", null))
    }

    // ── getAutoStartConfig: reads pending state ─────────────────────────

    @Test
    fun getAutoStartConfigReturnsPendingTrue() {
        prefs.edit()
            .putBoolean("bg_auto_start_pending", true)
            .putString("bg_auto_start_label", "Syncing")
            .putString("bg_auto_start_type", "dataSync")
            .apply()

        assertTrue(prefs.getBoolean("bg_auto_start_pending", false))
        assertEquals("Syncing", prefs.getString("bg_auto_start_label", null))
        assertEquals("dataSync", prefs.getString("bg_auto_start_type", null))
    }

    @Test
    fun getAutoStartConfigReturnsNotPending() {
        assertFalse(prefs.getBoolean("bg_auto_start_pending", false))
    }

    @Test
    fun getAutoStartConfigPendingWithNoLabel() {
        prefs.edit()
            .putBoolean("bg_auto_start_pending", true)
            .apply()

        // Pending is true but no label → incomplete config
        assertTrue(prefs.getBoolean("bg_auto_start_pending", false))
        assertNull(prefs.getString("bg_auto_start_label", null))
    }

    // ── clearAutoStartConfig: clears only auto-start keys ───────────────

    @Test
    fun clearAutoStartConfigClearsOnlyAutoStartKeys() {
        prefs.edit()
            .putString("bg_service_label", "Active")
            .putString("bg_service_type", "dataSync")
            .putBoolean("bg_auto_start_pending", true)
            .putString("bg_auto_start_label", "Active")
            .putString("bg_auto_start_type", "dataSync")
            .apply()

        // Simulate clearAutoStartConfig
        prefs.edit()
            .remove("bg_auto_start_pending")
            .remove("bg_auto_start_label")
            .remove("bg_auto_start_type")
            .apply()

        // Auto-start keys cleared
        assertFalse(prefs.getBoolean("bg_auto_start_pending", false))
        assertNull(prefs.getString("bg_auto_start_label", null))
        assertNull(prefs.getString("bg_auto_start_type", null))

        // Service keys preserved
        assertEquals("Active", prefs.getString("bg_service_label", null))
        assertEquals("dataSync", prefs.getString("bg_service_type", null))
    }

    // ── load(): POST_NOTIFICATIONS permission request ──────────────────

    @Test
    @Config(sdk = [32]) // Below TIRAMISU (33) — no permission request
    fun loadDoesNotRequestPermissionsBelowApi33() {
        // On API < 33, POST_NOTIFICATIONS permission doesn't exist.
        // The load() method should skip the request entirely.
        // Verify by checking no permission request is pending.
        val activity = androidx.test.core.app.ActivityScenario.launch(
            TestActivity::class.java
        )
        activity.onActivity { act ->
            val shadowActivity = shadowOf(act)
            // No permissions should have been requested
            assertNull(shadowActivity.lastRequestedPermission)
        }
    }

    @Test
    @Config(sdk = [33]) // TIRAMISU — should request permission if not granted
    fun loadRequestsPermissionsOnApi33WhenNotGranted() {
        val activity = androidx.test.core.app.ActivityScenario.launch(
            TestActivity::class.java
        )
        activity.onActivity { act ->
            // Deny the permission first
            val shadowActivity = shadowOf(act)
            shadowActivity.denyPermissions(android.Manifest.permission.POST_NOTIFICATIONS)

            // After calling load(), the plugin would request the permission.
            // Since we can't construct the plugin without Tauri framework,
            // verify the permission check logic directly.
            assertFalse(
                act.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
                    == android.content.pm.PackageManager.PERMISSION_GRANTED
            )
        }
    }

    // ── Preflight FGS type validation ────────────────────────────────────

    @Test
    fun validateFgsTypeAllowedTypeReturnsNull() {
        val allowedTypes = listOf("dataSync", "specialUse")
        val result = BackgroundServicePlugin.validateForegroundServiceType(
            "dataSync", allowedTypes, true
        )
        assertNull(result)
    }

    @Test
    fun validateFgsTypeUndeclaredTypeReturnsError() {
        val allowedTypes = listOf("dataSync")
        val result = BackgroundServicePlugin.validateForegroundServiceType(
            "location", allowedTypes, true
        )
        assertNotNull(result)
        val json = org.json.JSONObject(result!!)
        assertEquals("fgs_type_not_allowed", json.getString("code"))
        assertEquals("location", json.getString("invalidType"))
    }

    @Test
    fun validateFgsTypeSkippedWhenValidationDisabled() {
        val allowedTypes = listOf("dataSync")
        val result = BackgroundServicePlugin.validateForegroundServiceType(
            "location", allowedTypes, false
        )
        assertNull(result)
    }

    @Test
    fun validateFgsTypeMultipleAllowedTypes() {
        val allowedTypes = listOf("dataSync", "location", "specialUse")
        assertNull(
            BackgroundServicePlugin.validateForegroundServiceType(
                "location", allowedTypes, true
            )
        )
        assertNull(
            BackgroundServicePlugin.validateForegroundServiceType(
                "specialUse", allowedTypes, true
            )
        )
        assertNotNull(
            BackgroundServicePlugin.validateForegroundServiceType(
                "mediaPlayback", allowedTypes, true
            )
        )
    }

    @Test
    fun validateFgsTypeEmptyAllowlistRejectsAll() {
        val result = BackgroundServicePlugin.validateForegroundServiceType(
            "dataSync", emptyList(), true
        )
        assertNotNull(result)
    }

    // ── Structured FGS validation error format ────────────────────────────

    @Test
    fun validateFgsType_structuredError_hasCodeField() {
        val result = BackgroundServicePlugin.validateForegroundServiceType(
            "location", listOf("dataSync"), true
        )
        assertNotNull(result)
        val json = org.json.JSONObject(result!!)
        assertEquals("fgs_type_not_allowed", json.getString("code"))
    }

    @Test
    fun validateFgsType_structuredError_hasMessageField() {
        val result = BackgroundServicePlugin.validateForegroundServiceType(
            "location", listOf("dataSync"), true
        )
        assertNotNull(result)
        val json = org.json.JSONObject(result!!)
        val message = json.getString("message")
        assertTrue("Message should mention the type", message.contains("location"))
        assertTrue("Message should mention config key", message.contains("androidForegroundServiceTypes"))
    }

    @Test
    fun validateFgsType_structuredError_hasInvalidTypeField() {
        val result = BackgroundServicePlugin.validateForegroundServiceType(
            "mediaPlayback", listOf("dataSync", "specialUse"), true
        )
        assertNotNull(result)
        val json = org.json.JSONObject(result!!)
        assertEquals("mediaPlayback", json.getString("invalidType"))
    }

    @Test
    fun validateFgsType_structuredError_hasValidOptionsArray() {
        val allowed = listOf("dataSync", "specialUse", "location")
        val result = BackgroundServicePlugin.validateForegroundServiceType(
            "camera", allowed, true
        )
        assertNotNull(result)
        val json = org.json.JSONObject(result!!)
        val options = json.getJSONArray("validOptions")
        val actual = (0 until options.length()).map { options.getString(it) }
        assertEquals(allowed, actual)
    }

    // ── stopKeepalive clears DurableState ─────────────────────────────────

    @Test
    fun stopKeepaliveClearsDurableState() {
        // Simulate service was running with DurableState persisted
        val durableState = DurableState(
            desiredRunning = true,
            lastServiceLabel = "Syncing",
            lastServiceType = "dataSync",
            lastStartEpochMs = 1000L,
        )
        DurableState.save(context, durableState)
        assertTrue("Precondition: DurableState should be saved",
            DurableState.load(context).desiredRunning)

        // Simulate stopKeepalive clearing DurableState
        DurableState.clear(context)

        val loaded = DurableState.load(context)
        assertFalse("desiredRunning should be false after clear", loaded.desiredRunning)
        assertEquals("", loaded.lastServiceLabel)
    }

    // ── computePermissionStatus ────────────────────────────────────────────

    @Test
    fun computePermissionStatus_granted_returnsGranted() {
        assertEquals("granted",
            BackgroundServicePlugin.computePermissionStatus(true, false))
    }

    @Test
    fun computePermissionStatus_grantedWithRationale_returnsGranted() {
        // granted takes precedence over rationale
        assertEquals("granted",
            BackgroundServicePlugin.computePermissionStatus(true, true))
    }

    @Test
    fun computePermissionStatus_notGranted_withRationale_returnsNotDetermined() {
        assertEquals("notDetermined",
            BackgroundServicePlugin.computePermissionStatus(false, true))
    }

    @Test
    fun computePermissionStatus_notGranted_withoutRationale_returnsDenied() {
        assertEquals("denied",
            BackgroundServicePlugin.computePermissionStatus(false, false))
    }

    // ── loadConfig: requestNotificationPermissionOnLoad ────────────────────

    @Test
    fun loadConfig_requestNotificationPermissionOnLoad_defaultsToTrue() {
        val json = org.json.JSONObject()
        // No androidRequestNotificationPermissionOnLoad key — should default to true
        assertTrue(json.optBoolean("androidRequestNotificationPermissionOnLoad", true))
    }

    @Test
    fun loadConfig_requestNotificationPermissionOnLoad_explicitFalse() {
        val json = org.json.JSONObject().apply {
            put("androidRequestNotificationPermissionOnLoad", false)
        }
        assertFalse(json.optBoolean("androidRequestNotificationPermissionOnLoad", true))
    }

    @Test
    fun loadConfig_requestNotificationPermissionOnLoad_explicitTrue() {
        val json = org.json.JSONObject().apply {
            put("androidRequestNotificationPermissionOnLoad", true)
        }
        assertTrue(json.optBoolean("androidRequestNotificationPermissionOnLoad", true))
    }
}