From da8eab207a693d819f8646f619a8213259a95079 Mon Sep 17 00:00:00 2001 From: rasputin2525 Date: Mon, 27 Jul 2026 12:00:26 +0100 Subject: [PATCH] test: add unit and instrumented tests for tasks #97-#100 #97 - CheckInSyncWorkerTest: dead-man's-switch invariant coverage - Empty queue, all-success, all-network-unavailable - NON_RETRYABLE_ERROR_CODES (400/404/410) deletes only those items - Retryable codes (401/500) keep item and return retry - Mixed scenario coverage - CheckInSyncWorkerFactory helper for mock injection #98 - AuthViewModelTest, TwoFactorViewModelTest, AcceptanceViewModelTest - Success, error, and network-unavailable branches for every public method - Placeholder test for #87 token requirement #99 - Instrumented screen tests (AuthScreen, BeneficiaryAcceptanceScreen, TwoFactorSetupScreen, VaultDeepLinkScreen) - Stub error assertions for #66/#67 as deliberate change markers - android-ci.yml: add emulator step for connectedDebugAndroidTest #100 - VaultWidgetUpdateWorkerTest: RemoteViews binding and vault selection - TTL formatting, saveVaultData/refreshAll invocation assertions - firstOrNull() selection documented as #79 deliberate-change marker Deps added: work-testing, hilt-android-testing --- .github/workflows/android-ci.yml | 25 + android/app/build.gradle.kts | 3 + .../ethosprotocol/ScreenInstrumentedTests.kt | 535 ++++++++++++++++++ .../AuthTwoFactorAcceptanceViewModelTest.kt | 438 ++++++++++++++ .../ethosprotocol/CheckInSyncWorkerFactory.kt | 33 ++ .../ethosprotocol/CheckInSyncWorkerTest.kt | 239 ++++++++ .../VaultWidgetUpdateWorkerTest.kt | 266 +++++++++ android/gradle/libs.versions.toml | 5 + 8 files changed, 1544 insertions(+) create mode 100644 android/app/src/androidTest/java/com/ethosprotocol/ScreenInstrumentedTests.kt create mode 100644 android/app/src/test/java/com/ethosprotocol/AuthTwoFactorAcceptanceViewModelTest.kt create mode 100644 android/app/src/test/java/com/ethosprotocol/CheckInSyncWorkerFactory.kt create mode 100644 android/app/src/test/java/com/ethosprotocol/CheckInSyncWorkerTest.kt create mode 100644 android/app/src/test/java/com/ethosprotocol/VaultWidgetUpdateWorkerTest.kt diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 24687a5..bf66dc2 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -37,6 +37,31 @@ jobs: - name: Run unit tests run: ./gradlew testDebugUnitTest + - name: Enable KVM for hardware acceleration + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | \ + sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run instrumented tests on emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: Nexus 6 + working-directory: android + script: ./gradlew connectedDebugAndroidTest + + - name: Upload instrumented test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-instrumented-test-reports + path: android/app/build/reports/androidTests/connected + if-no-files-found: ignore + - name: Assemble release APK (R8/shrinking smoke check) run: ./gradlew assembleRelease diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 720dbee..beb9d05 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -139,6 +139,9 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) + testImplementation(libs.work.testing) + androidTestImplementation(libs.hilt.android.testing) + kspAndroidTest(libs.hilt.android.compiler.testing) androidTestImplementation(libs.androidx.test.ext) androidTestImplementation(libs.espresso.core) androidTestImplementation(platform(libs.compose.bom)) diff --git a/android/app/src/androidTest/java/com/ethosprotocol/ScreenInstrumentedTests.kt b/android/app/src/androidTest/java/com/ethosprotocol/ScreenInstrumentedTests.kt new file mode 100644 index 0000000..9b1bb67 --- /dev/null +++ b/android/app/src/androidTest/java/com/ethosprotocol/ScreenInstrumentedTests.kt @@ -0,0 +1,535 @@ +package com.ethosprotocol + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import com.ethosprotocol.api.ApiClient +import com.ethosprotocol.api.ApiResult +import com.ethosprotocol.api.TokenProvider +import com.ethosprotocol.models.* +import com.ethosprotocol.services.PasskeyService +import com.ethosprotocol.ui.AcceptanceViewModel +import com.ethosprotocol.ui.AuthViewModel +import com.ethosprotocol.ui.TwoFactorViewModel +import com.ethosprotocol.ui.VaultViewModel +import com.ethosprotocol.ui.screens.* +import com.ethosprotocol.services.NotificationHelper +import com.ethosprotocol.services.PendingCheckInDao +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.ethosprotocol.services.CheckInSyncWorker + +// ============================================================================ +// AuthScreenTest +// ============================================================================ + +/** + * Instrumented Compose tests for [AuthScreen]. + * + * Uses [createComposeRule] with a manually constructed [AuthViewModel] so the screen + * can be exercised in isolation without a running Hilt component. + */ +@HiltAndroidTest +class AuthScreenTest { + + @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) + @get:Rule(order = 1) val composeRule = createComposeRule() + + private val passkeyService: PasskeyService = mockk(relaxed = true) + private val tokenProvider: TokenProvider = mockk(relaxed = true) + private lateinit var vm: AuthViewModel + + @Before + fun setUp() { + hiltRule.inject() + every { tokenProvider.token } returns null + vm = AuthViewModel(passkeyService, tokenProvider) + } + + @Test + fun authScreen_showsSignInButton() { + composeRule.setContent { AuthScreen(vm = vm) } + + composeRule.onNodeWithText("Sign in with Passkey").assertIsDisplayed() + } + + @Test + fun authScreen_showsCreateAccountButton() { + composeRule.setContent { AuthScreen(vm = vm) } + + composeRule.onNodeWithText("Create account").assertIsDisplayed() + } + + @Test + fun authScreen_showsAppTitle() { + composeRule.setContent { AuthScreen(vm = vm) } + + composeRule.onNodeWithText("Ethos-Protocol").assertIsDisplayed() + } + + @Test + fun authScreen_tapCreateAccount_showsRegisterDialog() { + composeRule.setContent { AuthScreen(vm = vm) } + + composeRule.onNodeWithText("Create account").performClick() + + // The register dialog should appear with username field + composeRule.onNodeWithText("Create Account").assertIsDisplayed() + composeRule.onNodeWithText("Username").assertIsDisplayed() + } + + @Test + fun authScreen_registerDialog_registerButtonDisabledWhenUsernameBlank() { + composeRule.setContent { AuthScreen(vm = vm) } + + composeRule.onNodeWithText("Create account").performClick() + // Register button should be disabled with no username entered + composeRule.onNodeWithText("Register").assertIsNotEnabled() + } + + @Test + fun authScreen_registerDialog_registerButtonEnabledAfterUsernameEntered() { + composeRule.setContent { AuthScreen(vm = vm) } + + composeRule.onNodeWithText("Create account").performClick() + composeRule.onNodeWithText("Username").performTextInput("alice") + + composeRule.onNodeWithText("Register").assertIsEnabled() + } + + @Test + fun authScreen_registerDialog_cancelDismissesDialog() { + composeRule.setContent { AuthScreen(vm = vm) } + + composeRule.onNodeWithText("Create account").performClick() + composeRule.onNodeWithText("Create Account").assertIsDisplayed() + + composeRule.onNodeWithText("Cancel").performClick() + + composeRule.onNodeWithText("Sign in with Passkey").assertIsDisplayed() + } + + /** + * Stub error message test for #66: a sign-in failure from the passkey service + * must surface a human-readable error string in the UI. The current implementation + * shows [Exception.message] directly. Once #66 is implemented with a proper user- + * facing copy, this test should be updated to assert the exact string — removing this + * comment is the deliberate, visible test change. + */ + @Test + fun authScreen_signInError_isDisplayed() { + coEvery { passkeyService.authenticate(any()) } returns + Result.failure(RuntimeException("Passkey error #66")) + + composeRule.setContent { AuthScreen(vm = vm) } + + // Trigger sign-in (no real Activity so passkey will fail) + composeRule.onNodeWithText("Sign in with Passkey").performClick() + + // The error text should appear somewhere in the UI + composeRule.onNodeWithText("Passkey error #66", substring = true).assertIsDisplayed() + } +} + +// ============================================================================ +// BeneficiaryAcceptanceScreenTest +// ============================================================================ + +@HiltAndroidTest +class BeneficiaryAcceptanceScreenTest { + + @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) + @get:Rule(order = 1) val composeRule = createComposeRule() + + private val apiClient: ApiClient = mockk() + private lateinit var vm: AcceptanceViewModel + + @Before + fun setUp() { + hiltRule.inject() + vm = AcceptanceViewModel(apiClient) + } + + @Test + fun acceptanceScreen_showsVaultId() { + composeRule.setContent { + BeneficiaryAcceptanceScreen( + vaultId = "VAULT-ABC-123", + onAccepted = {}, + onDecline = {}, + vm = vm + ) + } + + composeRule.onNodeWithText("VAULT-ABC-123").assertIsDisplayed() + } + + @Test + fun acceptanceScreen_showsAcceptAndDeclineButtons() { + composeRule.setContent { + BeneficiaryAcceptanceScreen( + vaultId = "v1", + onAccepted = {}, + onDecline = {}, + vm = vm + ) + } + + composeRule.onNodeWithText("Accept").assertIsDisplayed() + composeRule.onNodeWithText("Decline").assertIsDisplayed() + } + + @Test + fun acceptanceScreen_declineButton_callsOnDeclineCallback() { + var declined = false + composeRule.setContent { + BeneficiaryAcceptanceScreen( + vaultId = "v1", + onAccepted = {}, + onDecline = { declined = true }, + vm = vm + ) + } + + composeRule.onNodeWithText("Decline").performClick() + + assert(declined) { "onDecline callback was not invoked" } + } + + /** + * Stub error for #67: network-unavailable during acceptance must show a user-facing + * error message. The exact copy ("No network. Please try again.") is what the + * ViewModel currently emits. Removing/changing this string after #67 is resolved + * is a deliberate, visible test change. + */ + @Test + fun acceptanceScreen_networkError_isDisplayed() { + coEvery { apiClient.acceptBeneficiary(any()) } returns ApiResult.NetworkUnavailable + + composeRule.setContent { + BeneficiaryAcceptanceScreen( + vaultId = "v1", + onAccepted = {}, + onDecline = {}, + vm = vm + ) + } + + composeRule.onNodeWithText("Accept").performClick() + + composeRule.onNodeWithText("No network. Please try again.", substring = true) + .assertIsDisplayed() + } + + @Test + fun acceptanceScreen_apiError_isDisplayed() { + coEvery { apiClient.acceptBeneficiary(any()) } returns + ApiResult.Error("Vault not found", 404) + + composeRule.setContent { + BeneficiaryAcceptanceScreen( + vaultId = "v1", + onAccepted = {}, + onDecline = {}, + vm = vm + ) + } + + composeRule.onNodeWithText("Accept").performClick() + + composeRule.onNodeWithText("Vault not found", substring = true).assertIsDisplayed() + } + + @Test + fun acceptanceScreen_acceptSuccess_invokesOnAcceptedCallback() { + var accepted = false + coEvery { apiClient.acceptBeneficiary("v1") } returns ApiResult.Success(Unit) + + composeRule.setContent { + BeneficiaryAcceptanceScreen( + vaultId = "v1", + onAccepted = { accepted = true }, + onDecline = {}, + vm = vm + ) + } + + composeRule.onNodeWithText("Accept").performClick() + composeRule.waitForIdle() + + assert(accepted) { "onAccepted callback was not invoked" } + } +} + +// ============================================================================ +// TwoFactorSetupScreenTest +// ============================================================================ + +@HiltAndroidTest +class TwoFactorSetupScreenTest { + + @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) + @get:Rule(order = 1) val composeRule = createComposeRule() + + private val apiClient: ApiClient = mockk(relaxed = true) + private lateinit var vm: TwoFactorViewModel + + @Before + fun setUp() { + hiltRule.inject() + vm = TwoFactorViewModel(apiClient) + } + + @Test + fun twoFactorSetupScreen_showsMethodOptions() { + composeRule.setContent { + TwoFactorSetupScreen(vaultId = "v1", onComplete = {}, onDismiss = {}) + } + + composeRule.onNodeWithText("Authenticator App (TOTP)").assertIsDisplayed() + composeRule.onNodeWithText("SMS Code").assertIsDisplayed() + composeRule.onNodeWithText("Email Code").assertIsDisplayed() + } + + @Test + fun twoFactorSetupScreen_showsContinueAndCancelButtons() { + composeRule.setContent { + TwoFactorSetupScreen(vaultId = "v1", onComplete = {}, onDismiss = {}) + } + + composeRule.onNodeWithText("Continue").assertIsDisplayed() + composeRule.onNodeWithText("Cancel").assertIsDisplayed() + } + + @Test + fun twoFactorSetupScreen_selectSms_showsPhoneField() { + composeRule.setContent { + TwoFactorSetupScreen(vaultId = "v1", onComplete = {}, onDismiss = {}) + } + + composeRule.onNodeWithText("SMS Code").performClick() + + composeRule.onNodeWithText("Phone number").assertIsDisplayed() + } + + @Test + fun twoFactorSetupScreen_selectEmail_showsEmailField() { + composeRule.setContent { + TwoFactorSetupScreen(vaultId = "v1", onComplete = {}, onDismiss = {}) + } + + composeRule.onNodeWithText("Email Code").performClick() + + composeRule.onNodeWithText("Email address").assertIsDisplayed() + } + + @Test + fun twoFactorSetupScreen_smsContinueDisabledWithoutPhone() { + composeRule.setContent { + TwoFactorSetupScreen(vaultId = "v1", onComplete = {}, onDismiss = {}) + } + + composeRule.onNodeWithText("SMS Code").performClick() + // Phone field is blank → Continue must be disabled + composeRule.onNodeWithText("Continue").assertIsNotEnabled() + } + + @Test + fun twoFactorSetupScreen_smsContinueEnabledAfterPhoneEntered() { + composeRule.setContent { + TwoFactorSetupScreen(vaultId = "v1", onComplete = {}, onDismiss = {}) + } + + composeRule.onNodeWithText("SMS Code").performClick() + composeRule.onNodeWithText("Phone number").performTextInput("+15551234567") + + composeRule.onNodeWithText("Continue").assertIsEnabled() + } + + @Test + fun twoFactorSetupScreen_cancelInvokesOnDismiss() { + var dismissed = false + composeRule.setContent { + TwoFactorSetupScreen(vaultId = "v1", onComplete = {}, onDismiss = { dismissed = true }) + } + + composeRule.onNodeWithText("Cancel").performClick() + + assert(dismissed) { "onDismiss callback was not invoked" } + } + + /** + * Stub error test for #66/#67: a server error during enable2FA must surface + * an error string in the setup dialog. Update the exact copy once the final + * error strings are decided. + */ + @Test + fun twoFactorSetupScreen_enable2FAError_isDisplayed() { + coEvery { apiClient.enable2FA(any(), any()) } returns + ApiResult.Error("Server error #66", 500) + + composeRule.setContent { + TwoFactorSetupScreen(vaultId = "v1", onComplete = {}, onDismiss = {}) + } + + // TOTP is selected by default so Continue is enabled + composeRule.onNodeWithText("Continue").performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithText("Server error #66", substring = true).assertIsDisplayed() + } +} + +// ============================================================================ +// VaultDeepLinkScreenTest +// ============================================================================ + +@HiltAndroidTest +class VaultDeepLinkScreenTest { + + @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) + @get:Rule(order = 1) val composeRule = createComposeRule() + + private val apiClient: ApiClient = mockk() + private val notificationHelper: NotificationHelper = mockk(relaxed = true) + private val pendingCheckInDao: PendingCheckInDao = mockk(relaxed = true) + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var vm: VaultViewModel + + @Before + fun setUp() { + hiltRule.inject() + mockkObject(CheckInSyncWorker.Companion) + every { CheckInSyncWorker.schedule(any()) } just Runs + vm = VaultViewModel(apiClient, notificationHelper, pendingCheckInDao, context) + } + + private fun makeVault(id: String) = Vault( + id = id, owner = "GABC", beneficiary = "GXYZ", + balance = 10_000_000L, checkInInterval = 2_592_000L, + lastCheckIn = "2026-04-01T00:00:00Z", ttlRemaining = 172_800L, + status = VaultStatus.active + ) + + @Test + fun deepLinkScreen_checkInAction_showsCheckInTitle() { + coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(makeVault("v1"))) + + composeRule.setContent { + VaultDeepLinkScreen( + vaultId = "v1", + actionPath = "checkin", + onDone = {}, + vm = vm + ) + } + composeRule.waitForIdle() + + composeRule.onNodeWithText("Check In").assertIsDisplayed() + } + + @Test + fun deepLinkScreen_withdrawAction_showsWithdrawNotAvailableError() { + coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(makeVault("v1"))) + + composeRule.setContent { + VaultDeepLinkScreen( + vaultId = "v1", + actionPath = "withdraw", + onDone = {}, + vm = vm + ) + } + + composeRule.onNodeWithText("Withdraw").performClick() + composeRule.waitForIdle() + + /** + * Stub error for #66: this asserts the current "not yet available" message. + * Once withdrawal is implemented, remove this assertion and replace with the + * real flow test — that removal is the deliberate, visible test change. + */ + composeRule.onNodeWithText("not yet available", substring = true).assertIsDisplayed() + } + + @Test + fun deepLinkScreen_manageBeneficiaryAction_showsNotAvailableError() { + coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(makeVault("v1"))) + + composeRule.setContent { + VaultDeepLinkScreen( + vaultId = "v1", + actionPath = "manage_beneficiary", + onDone = {}, + vm = vm + ) + } + + composeRule.onNodeWithText("Manage Beneficiary").performClick() + composeRule.waitForIdle() + + /** + * Stub error for #67: same deliberate-change contract as the withdraw test above. + */ + composeRule.onNodeWithText("not yet available", substring = true).assertIsDisplayed() + } + + @Test + fun deepLinkScreen_unknownAction_showsDoneButton() { + coEvery { apiClient.listVaults() } returns ApiResult.Success(emptyList()) + + composeRule.setContent { + VaultDeepLinkScreen( + vaultId = "v1", + actionPath = "unknown_action", + onDone = {}, + vm = vm + ) + } + + composeRule.onNodeWithText("Done").assertIsDisplayed() + } + + @Test + fun deepLinkScreen_doneButton_invokesCallback() { + coEvery { apiClient.listVaults() } returns ApiResult.Success(emptyList()) + var done = false + + composeRule.setContent { + VaultDeepLinkScreen( + vaultId = "v1", + actionPath = "checkin", + onDone = { done = true }, + vm = vm + ) + } + + composeRule.onAllNodesWithText("Done").onFirst().performClick() + composeRule.waitForIdle() + + assert(done) { "onDone callback was not invoked" } + } + + @Test + fun deepLinkScreen_viewDetailsWithVault_showsVaultCard() { + coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(makeVault("v1"))) + + composeRule.setContent { + VaultDeepLinkScreen( + vaultId = "v1", + actionPath = "view_details", + onDone = {}, + vm = vm + ) + } + composeRule.waitForIdle() + + // VaultCard renders the truncated vault id + composeRule.onNodeWithText("v1", substring = true).assertIsDisplayed() + } +} diff --git a/android/app/src/test/java/com/ethosprotocol/AuthTwoFactorAcceptanceViewModelTest.kt b/android/app/src/test/java/com/ethosprotocol/AuthTwoFactorAcceptanceViewModelTest.kt new file mode 100644 index 0000000..0ad5dcc --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/AuthTwoFactorAcceptanceViewModelTest.kt @@ -0,0 +1,438 @@ +package com.ethosprotocol + +import android.app.Activity +import com.ethosprotocol.api.ApiClient +import com.ethosprotocol.api.ApiResult +import com.ethosprotocol.api.TokenProvider +import com.ethosprotocol.models.* +import com.ethosprotocol.services.PasskeyService +import com.ethosprotocol.ui.AcceptanceViewModel +import com.ethosprotocol.ui.AuthViewModel +import com.ethosprotocol.ui.TwoFactorViewModel +import io.mockk.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +// ============================================================================ +// AuthViewModelTest +// ============================================================================ + +@OptIn(ExperimentalCoroutinesApi::class) +class AuthViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val passkeyService: PasskeyService = mockk() + private val tokenProvider: TokenProvider = mockk(relaxed = true) + private val activity: Activity = mockk(relaxed = true) + private lateinit var vm: AuthViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + clearAllMocks() + } + + // ----------------------------------------------------------------------- + // Initial state + // ----------------------------------------------------------------------- + + @Test + fun `initial state isAuthenticated true when token present`() = runTest { + every { tokenProvider.token } returns "some-jwt" + vm = AuthViewModel(passkeyService, tokenProvider) + + assertTrue(vm.state.value.isAuthenticated) + assertFalse(vm.state.value.isLoading) + assertNull(vm.state.value.error) + } + + @Test + fun `initial state isAuthenticated false when token absent`() = runTest { + every { tokenProvider.token } returns null + vm = AuthViewModel(passkeyService, tokenProvider) + + assertFalse(vm.state.value.isAuthenticated) + } + + // ----------------------------------------------------------------------- + // signIn + // ----------------------------------------------------------------------- + + @Test + fun `signIn success sets isAuthenticated true`() = runTest { + every { tokenProvider.token } returns null + vm = AuthViewModel(passkeyService, tokenProvider) + coEvery { passkeyService.authenticate(activity) } returns Result.success(Unit) + + vm.signIn(activity) + + assertTrue(vm.state.value.isAuthenticated) + assertFalse(vm.state.value.isLoading) + assertNull(vm.state.value.error) + } + + @Test + fun `signIn failure sets error message`() = runTest { + every { tokenProvider.token } returns null + vm = AuthViewModel(passkeyService, tokenProvider) + coEvery { passkeyService.authenticate(activity) } returns + Result.failure(RuntimeException("Passkey cancelled")) + + vm.signIn(activity) + + assertFalse(vm.state.value.isAuthenticated) + assertFalse(vm.state.value.isLoading) + assertEquals("Passkey cancelled", vm.state.value.error) + } + + @Test + fun `signIn clears previous error before attempt`() = runTest { + every { tokenProvider.token } returns null + vm = AuthViewModel(passkeyService, tokenProvider) + // First call fails to put error in state + coEvery { passkeyService.authenticate(activity) } returns + Result.failure(RuntimeException("first error")) + vm.signIn(activity) + assertEquals("first error", vm.state.value.error) + + // Second call succeeds — error must be cleared + coEvery { passkeyService.authenticate(activity) } returns Result.success(Unit) + vm.signIn(activity) + + assertNull(vm.state.value.error) + assertTrue(vm.state.value.isAuthenticated) + } + + // ----------------------------------------------------------------------- + // register + // ----------------------------------------------------------------------- + + @Test + fun `register success triggers signIn and sets isAuthenticated`() = runTest { + every { tokenProvider.token } returns null + vm = AuthViewModel(passkeyService, tokenProvider) + coEvery { passkeyService.register(activity, "alice") } returns Result.success(Unit) + coEvery { passkeyService.authenticate(activity) } returns Result.success(Unit) + + vm.register(activity, "alice") + + assertTrue(vm.state.value.isAuthenticated) + assertFalse(vm.state.value.isLoading) + } + + @Test + fun `register failure sets error message`() = runTest { + every { tokenProvider.token } returns null + vm = AuthViewModel(passkeyService, tokenProvider) + coEvery { passkeyService.register(activity, "alice") } returns + Result.failure(RuntimeException("Username taken")) + + vm.register(activity, "alice") + + assertFalse(vm.state.value.isAuthenticated) + assertEquals("Username taken", vm.state.value.error) + } + + // ----------------------------------------------------------------------- + // signOut + // ----------------------------------------------------------------------- + + @Test + fun `signOut clears token and sets isAuthenticated false`() = runTest { + every { tokenProvider.token } returns "jwt" + vm = AuthViewModel(passkeyService, tokenProvider) + + vm.signOut() + + verify { tokenProvider.clear() } + assertFalse(vm.state.value.isAuthenticated) + } +} + +// ============================================================================ +// TwoFactorViewModelTest +// ============================================================================ + +@OptIn(ExperimentalCoroutinesApi::class) +class TwoFactorViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val apiClient: ApiClient = mockk() + private lateinit var vm: TwoFactorViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + vm = TwoFactorViewModel(apiClient) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + clearAllMocks() + } + + private fun status(enabled: Boolean) = TwoFactorStatus( + vaultId = "v1", enabled = enabled, method = TwoFactorMethod.totp + ) + + private fun enableResponse() = Enable2FAResponse( + vaultId = "v1", method = TwoFactorMethod.totp, + secret = "BASE32SECRET", provisioningUri = "otpauth://totp/…" + ) + + // ----------------------------------------------------------------------- + // loadStatus + // ----------------------------------------------------------------------- + + @Test + fun `loadStatus success updates status state`() = runTest { + val s = status(enabled = true) + coEvery { apiClient.get2FAStatus("v1") } returns ApiResult.Success(s) + + vm.loadStatus("v1") + + assertEquals(s, vm.state.value.status) + assertFalse(vm.state.value.isLoading) + assertNull(vm.state.value.error) + } + + @Test + fun `loadStatus error sets error message`() = runTest { + coEvery { apiClient.get2FAStatus("v1") } returns ApiResult.Error("Not found", 404) + + vm.loadStatus("v1") + + assertNull(vm.state.value.status) + assertEquals("Not found", vm.state.value.error) + } + + @Test + fun `loadStatus network unavailable sets no network error`() = runTest { + coEvery { apiClient.get2FAStatus("v1") } returns ApiResult.NetworkUnavailable + + vm.loadStatus("v1") + + assertEquals("No network", vm.state.value.error) + assertFalse(vm.state.value.isLoading) + } + + // ----------------------------------------------------------------------- + // enable2FA + // ----------------------------------------------------------------------- + + @Test + fun `enable2FA success stores setupResponse`() = runTest { + val resp = enableResponse() + coEvery { + apiClient.enable2FA("v1", Enable2FARequest(TwoFactorMethod.totp, null, null)) + } returns ApiResult.Success(resp) + + vm.enable2FA("v1", TwoFactorMethod.totp, null, null) + + assertEquals(resp, vm.state.value.setupResponse) + assertFalse(vm.state.value.isLoading) + } + + @Test + fun `enable2FA error sets error message`() = runTest { + coEvery { + apiClient.enable2FA("v1", any()) + } returns ApiResult.Error("Server error", 500) + + vm.enable2FA("v1", TwoFactorMethod.totp, null, null) + + assertNull(vm.state.value.setupResponse) + assertEquals("Server error", vm.state.value.error) + } + + @Test + fun `enable2FA network unavailable sets no network error`() = runTest { + coEvery { apiClient.enable2FA("v1", any()) } returns ApiResult.NetworkUnavailable + + vm.enable2FA("v1", TwoFactorMethod.totp, null, null) + + assertEquals("No network", vm.state.value.error) + } + + // ----------------------------------------------------------------------- + // verify2FA + // ----------------------------------------------------------------------- + + @Test + fun `verify2FA success sets verified true`() = runTest { + coEvery { apiClient.verify2FA("v1", Verify2FARequest("123456")) } returns + ApiResult.Success(Unit) + + vm.verify2FA("v1", "123456") + + assertTrue(vm.state.value.verified) + assertFalse(vm.state.value.isLoading) + } + + @Test + fun `verify2FA error sets error message`() = runTest { + coEvery { apiClient.verify2FA("v1", any()) } returns ApiResult.Error("Wrong code", 422) + + vm.verify2FA("v1", "000000") + + assertFalse(vm.state.value.verified) + assertEquals("Wrong code", vm.state.value.error) + } + + @Test + fun `verify2FA network unavailable sets no network error`() = runTest { + coEvery { apiClient.verify2FA("v1", any()) } returns ApiResult.NetworkUnavailable + + vm.verify2FA("v1", "123456") + + assertFalse(vm.state.value.verified) + assertEquals("No network", vm.state.value.error) + } + + // ----------------------------------------------------------------------- + // disable2FA + // ----------------------------------------------------------------------- + + @Test + fun `disable2FA success clears status`() = runTest { + // Prime state with a status first + coEvery { apiClient.get2FAStatus("v1") } returns ApiResult.Success(status(true)) + vm.loadStatus("v1") + assertNotNull(vm.state.value.status) + + coEvery { apiClient.disable2FA("v1") } returns ApiResult.Success(Unit) + vm.disable2FA("v1") + + assertNull(vm.state.value.status) + assertFalse(vm.state.value.isLoading) + } + + @Test + fun `disable2FA error sets error message`() = runTest { + coEvery { apiClient.disable2FA("v1") } returns ApiResult.Error("Forbidden", 403) + + vm.disable2FA("v1") + + assertEquals("Forbidden", vm.state.value.error) + } + + @Test + fun `disable2FA network unavailable sets no network error`() = runTest { + coEvery { apiClient.disable2FA("v1") } returns ApiResult.NetworkUnavailable + + vm.disable2FA("v1") + + assertEquals("No network", vm.state.value.error) + } +} + +// ============================================================================ +// AcceptanceViewModelTest +// ============================================================================ + +@OptIn(ExperimentalCoroutinesApi::class) +class AcceptanceViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val apiClient: ApiClient = mockk() + private lateinit var vm: AcceptanceViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + vm = AcceptanceViewModel(apiClient) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + clearAllMocks() + } + + // ----------------------------------------------------------------------- + // accept + // ----------------------------------------------------------------------- + + @Test + fun `accept success sets isAccepted true`() = runTest { + coEvery { apiClient.acceptBeneficiary("v1") } returns ApiResult.Success(Unit) + + vm.accept("v1") + + assertTrue(vm.state.value.isAccepted) + assertFalse(vm.state.value.isLoading) + assertNull(vm.state.value.error) + } + + @Test + fun `accept error sets error message`() = runTest { + coEvery { apiClient.acceptBeneficiary("v1") } returns ApiResult.Error("Not found", 404) + + vm.accept("v1") + + assertFalse(vm.state.value.isAccepted) + assertEquals("Not found", vm.state.value.error) + assertFalse(vm.state.value.isLoading) + } + + @Test + fun `accept network unavailable sets offline error message`() = runTest { + coEvery { apiClient.acceptBeneficiary("v1") } returns ApiResult.NetworkUnavailable + + vm.accept("v1") + + assertFalse(vm.state.value.isAccepted) + assertEquals("No network. Please try again.", vm.state.value.error) + assertFalse(vm.state.value.isLoading) + } + + /** + * Regression test for #87: accept() must supply the beneficiary token when + * the token requirement is implemented. This test documents the expected state + * transition (success → isAccepted) so that dropping the token from the call + * becomes a deliberate, visible test failure once #87 lands. + */ + @Test + fun `accept token requirement placeholder – isAccepted true on success`() = runTest { + // TODO(#87): update this test to pass a beneficiary acceptance token once + // the API contract requires it (acceptBeneficiary(vaultId, token)). + coEvery { apiClient.acceptBeneficiary("v1") } returns ApiResult.Success(Unit) + + vm.accept("v1") + + assertTrue( + "AcceptanceViewModel must set isAccepted=true on a successful accept() call. " + + "If this fails after #87 lands, ensure the token is being forwarded to the API.", + vm.state.value.isAccepted + ) + } + + @Test + fun `loading flag is true during accept and false after`() = runTest { + // Use a dispatcher that does NOT auto-advance so we can observe isLoading=true + val pausingDispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(pausingDispatcher) + val pausingVm = AcceptanceViewModel(apiClient) + coEvery { apiClient.acceptBeneficiary("v1") } returns ApiResult.Success(Unit) + + pausingVm.accept("v1") + // coroutine started but not yet resumed + assertTrue(pausingVm.state.value.isLoading) + + // advance until idle to let the coroutine complete + advanceUntilIdle() + assertFalse(pausingVm.state.value.isLoading) + assertTrue(pausingVm.state.value.isAccepted) + } +} diff --git a/android/app/src/test/java/com/ethosprotocol/CheckInSyncWorkerFactory.kt b/android/app/src/test/java/com/ethosprotocol/CheckInSyncWorkerFactory.kt new file mode 100644 index 0000000..44cddde --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/CheckInSyncWorkerFactory.kt @@ -0,0 +1,33 @@ +package com.ethosprotocol + +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import com.ethosprotocol.api.ApiClient +import com.ethosprotocol.services.CheckInSyncWorker +import com.ethosprotocol.services.NotificationHelper +import com.ethosprotocol.services.PendingCheckInDao + +/** + * A [WorkerFactory] that injects test doubles (mocks) into [CheckInSyncWorker], + * bypassing the Hilt/AssistedInject wiring that is not available in plain JVM unit tests. + */ +class CheckInSyncWorkerFactory( + private val apiClient: ApiClient, + private val dao: PendingCheckInDao, + private val notificationHelper: NotificationHelper +) : WorkerFactory() { + + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker? { + return if (workerClassName == CheckInSyncWorker::class.java.name) { + CheckInSyncWorker(appContext, workerParameters, apiClient, dao, notificationHelper) + } else { + null + } + } +} diff --git a/android/app/src/test/java/com/ethosprotocol/CheckInSyncWorkerTest.kt b/android/app/src/test/java/com/ethosprotocol/CheckInSyncWorkerTest.kt new file mode 100644 index 0000000..5453977 --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/CheckInSyncWorkerTest.kt @@ -0,0 +1,239 @@ +package com.ethosprotocol + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.ListenableWorker +import androidx.work.testing.TestListenableWorkerBuilder +import com.ethosprotocol.api.ApiClient +import com.ethosprotocol.api.ApiResult +import com.ethosprotocol.services.CheckInSyncWorker +import com.ethosprotocol.services.NotificationHelper +import com.ethosprotocol.services.PendingCheckIn +import com.ethosprotocol.services.PendingCheckInDao +import io.mockk.* +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [CheckInSyncWorker] using [TestListenableWorkerBuilder]. + * + * The worker enforces a dead-man's-switch invariant: a pending check-in must never + * be silently dropped unless the server has definitively rejected the request with a + * non-retryable status code ({400, 404, 410}). All other failure modes (network + * unavailable, 5xx, 401, …) must leave the item in the queue and return + * [ListenableWorker.Result.retry]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class CheckInSyncWorkerTest { + + private val apiClient: ApiClient = mockk() + private val dao: PendingCheckInDao = mockk(relaxed = true) + private val notificationHelper: NotificationHelper = mockk(relaxed = true) + + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + @After + fun tearDown() { + clearAllMocks() + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private fun buildWorker(): CheckInSyncWorker = + TestListenableWorkerBuilder(context) + .setWorkerFactory( + CheckInSyncWorkerFactory(apiClient, dao, notificationHelper) + ) + .build() + + private fun item(id: String) = PendingCheckIn(vaultId = id, queuedAt = 1_000L) + + // --------------------------------------------------------------------------- + // 1. Empty queue → success, nothing deleted, notification not cancelled + // --------------------------------------------------------------------------- + + @Test + fun `empty queue returns success without touching the DAO or notification`() = runBlocking { + coEvery { dao.getAll() } returns emptyList() + + val result = buildWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + // No items to process so delete should never be called + coVerify(exactly = 0) { dao.delete(any()) } + // Queue is empty from the start so cancelQueuedCheckIn should not be called + verify(exactly = 0) { notificationHelper.cancelQueuedCheckIn() } + } + + // --------------------------------------------------------------------------- + // 2. All successes → all deleted, notification cancelled, returns success + // --------------------------------------------------------------------------- + + @Test + fun `all items succeed are deleted and notification is cancelled`() = runBlocking { + val items = listOf(item("v1"), item("v2"), item("v3")) + // First call returns the full list; subsequent calls (after deletes) return empty + coEvery { dao.getAll() } returnsMany listOf(items, emptyList()) + coEvery { apiClient.checkIn(any()) } returns ApiResult.Success(Unit) + + val result = buildWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify(exactly = 1) { dao.delete(item("v1")) } + coVerify(exactly = 1) { dao.delete(item("v2")) } + coVerify(exactly = 1) { dao.delete(item("v3")) } + verify(exactly = 1) { notificationHelper.cancelQueuedCheckIn() } + } + + // --------------------------------------------------------------------------- + // 3. All network-unavailable → nothing deleted, returns retry + // --------------------------------------------------------------------------- + + @Test + fun `all network-unavailable leaves queue intact and returns retry`() = runBlocking { + val items = listOf(item("v1"), item("v2")) + coEvery { dao.getAll() } returnsMany listOf(items, items) // second call: still full + coEvery { apiClient.checkIn(any()) } returns ApiResult.NetworkUnavailable + + val result = buildWorker().doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + coVerify(exactly = 0) { dao.delete(any()) } + verify(exactly = 0) { notificationHelper.cancelQueuedCheckIn() } + } + + // --------------------------------------------------------------------------- + // 4. Non-retryable error codes (400, 404, 410) → only those items are deleted + // --------------------------------------------------------------------------- + + @Test + fun `non-retryable error code 400 deletes item`() = runBlocking { + val items = listOf(item("v1")) + coEvery { dao.getAll() } returnsMany listOf(items, emptyList()) + coEvery { apiClient.checkIn("v1") } returns ApiResult.Error("Bad Request", 400) + + val result = buildWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify(exactly = 1) { dao.delete(item("v1")) } + verify(exactly = 1) { notificationHelper.cancelQueuedCheckIn() } + } + + @Test + fun `non-retryable error code 404 deletes item`() = runBlocking { + val items = listOf(item("v1")) + coEvery { dao.getAll() } returnsMany listOf(items, emptyList()) + coEvery { apiClient.checkIn("v1") } returns ApiResult.Error("Not Found", 404) + + val result = buildWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify(exactly = 1) { dao.delete(item("v1")) } + verify(exactly = 1) { notificationHelper.cancelQueuedCheckIn() } + } + + @Test + fun `non-retryable error code 410 deletes item`() = runBlocking { + val items = listOf(item("v1")) + coEvery { dao.getAll() } returnsMany listOf(items, emptyList()) + coEvery { apiClient.checkIn("v1") } returns ApiResult.Error("Gone", 410) + + val result = buildWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify(exactly = 1) { dao.delete(item("v1")) } + verify(exactly = 1) { notificationHelper.cancelQueuedCheckIn() } + } + + // --------------------------------------------------------------------------- + // 5. Retryable error codes (e.g. 500, 401) → item NOT deleted, returns retry + // --------------------------------------------------------------------------- + + @Test + fun `retryable error code 500 does not delete item and returns retry`() = runBlocking { + val items = listOf(item("v1")) + coEvery { dao.getAll() } returnsMany listOf(items, items) + coEvery { apiClient.checkIn("v1") } returns ApiResult.Error("Server Error", 500) + + val result = buildWorker().doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + coVerify(exactly = 0) { dao.delete(any()) } + verify(exactly = 0) { notificationHelper.cancelQueuedCheckIn() } + } + + @Test + fun `retryable error code 401 does not delete item and returns retry`() = runBlocking { + val items = listOf(item("v1")) + coEvery { dao.getAll() } returnsMany listOf(items, items) + coEvery { apiClient.checkIn("v1") } returns ApiResult.Error("Unauthorized", 401) + + val result = buildWorker().doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + coVerify(exactly = 0) { dao.delete(any()) } + } + + // --------------------------------------------------------------------------- + // 6. Mixed: one non-retryable, one network-unavailable, one success + // → non-retryable and success deleted; network-unavailable kept; returns retry + // --------------------------------------------------------------------------- + + @Test + fun `mixed results: non-retryable and success deleted, network error retained, returns retry`() = + runBlocking { + val vGone = item("gone") // 410 → delete + val vOffline = item("offline") // NetworkUnavailable → keep + val vOk = item("ok") // Success → delete + val allItems = listOf(vGone, vOffline, vOk) + + // After deleting vGone and vOk, one item remains + coEvery { dao.getAll() } returnsMany listOf(allItems, listOf(vOffline)) + coEvery { apiClient.checkIn("gone") } returns ApiResult.Error("Gone", 410) + coEvery { apiClient.checkIn("offline") } returns ApiResult.NetworkUnavailable + coEvery { apiClient.checkIn("ok") } returns ApiResult.Success(Unit) + + val result = buildWorker().doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + coVerify(exactly = 1) { dao.delete(vGone) } + coVerify(exactly = 1) { dao.delete(vOk) } + coVerify(exactly = 0) { dao.delete(vOffline) } + // Queue is not empty (vOffline remains) → do NOT cancel notification + verify(exactly = 0) { notificationHelper.cancelQueuedCheckIn() } + } + + // --------------------------------------------------------------------------- + // 7. Mixed: all non-retryable → all deleted, queue empty, returns success + // --------------------------------------------------------------------------- + + @Test + fun `all non-retryable: all deleted, queue drained, notification cancelled, returns success`() = + runBlocking { + val items = listOf(item("a"), item("b")) + coEvery { dao.getAll() } returnsMany listOf(items, emptyList()) + coEvery { apiClient.checkIn("a") } returns ApiResult.Error("Not Found", 404) + coEvery { apiClient.checkIn("b") } returns ApiResult.Error("Gone", 410) + + val result = buildWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify(exactly = 1) { dao.delete(item("a")) } + coVerify(exactly = 1) { dao.delete(item("b")) } + verify(exactly = 1) { notificationHelper.cancelQueuedCheckIn() } + } +} diff --git a/android/app/src/test/java/com/ethosprotocol/VaultWidgetUpdateWorkerTest.kt b/android/app/src/test/java/com/ethosprotocol/VaultWidgetUpdateWorkerTest.kt new file mode 100644 index 0000000..7f0c3dd --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/VaultWidgetUpdateWorkerTest.kt @@ -0,0 +1,266 @@ +package com.ethosprotocol + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.TestListenableWorkerBuilder +import com.ethosprotocol.api.ApiClient +import com.ethosprotocol.api.ApiResult +import com.ethosprotocol.models.Vault +import com.ethosprotocol.models.VaultStatus +import com.ethosprotocol.widget.VaultStatusWidget +import com.ethosprotocol.widget.VaultWidgetUpdateWorker +import io.mockk.* +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [VaultWidgetUpdateWorker] using [TestListenableWorkerBuilder]. + * + * Covers: + * - Network unavailable / API error → returns success without touching the widget + * - Empty vault list → returns success without touching the widget + * - Single vault → saveVaultData and refreshAll called with correct values (#79 fix) + * - Multiple vaults → firstOrNull() selects the first vault in the list (#79 selection logic) + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class VaultWidgetUpdateWorkerTest { + + private val apiClient: ApiClient = mockk() + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + mockkObject(VaultStatusWidget) + every { VaultStatusWidget.saveVaultData(any(), any(), any(), any()) } just Runs + every { VaultStatusWidget.refreshAll(any()) } just Runs + } + + @After + fun tearDown() { + clearAllMocks() + unmockkObject(VaultStatusWidget) + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private fun buildWorker(): VaultWidgetUpdateWorker = + TestListenableWorkerBuilder(context) + .setWorkerFactory(VaultWidgetUpdateWorkerFactory(apiClient)) + .build() + + private fun vault( + id: String, + ttlRemaining: Long? = 172_800L, // 2 days + lastCheckIn: String = "2026-04-01T00:00:00Z" + ) = Vault( + id = id, + owner = "GABC", + beneficiary = "GXYZ", + balance = 10_000_000L, + checkInInterval = 2_592_000L, + lastCheckIn = lastCheckIn, + ttlRemaining = ttlRemaining, + status = VaultStatus.active + ) + + // --------------------------------------------------------------------------- + // 1. NetworkUnavailable → success, widget not updated + // --------------------------------------------------------------------------- + + @Test + fun `network unavailable returns success and does not update widget`() = runBlocking { + coEvery { apiClient.listVaults() } returns ApiResult.NetworkUnavailable + + val result = buildWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + verify(exactly = 0) { VaultStatusWidget.saveVaultData(any(), any(), any(), any()) } + verify(exactly = 0) { VaultStatusWidget.refreshAll(any()) } + } + + // --------------------------------------------------------------------------- + // 2. API error → success, widget not updated + // --------------------------------------------------------------------------- + + @Test + fun `api error returns success and does not update widget`() = runBlocking { + coEvery { apiClient.listVaults() } returns ApiResult.Error("Server error", 500) + + val result = buildWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + verify(exactly = 0) { VaultStatusWidget.saveVaultData(any(), any(), any(), any()) } + verify(exactly = 0) { VaultStatusWidget.refreshAll(any()) } + } + + // --------------------------------------------------------------------------- + // 3. Empty vault list → success, widget not updated + // --------------------------------------------------------------------------- + + @Test + fun `empty vault list returns success and does not update widget`() = runBlocking { + coEvery { apiClient.listVaults() } returns ApiResult.Success(emptyList()) + + val result = buildWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + verify(exactly = 0) { VaultStatusWidget.saveVaultData(any(), any(), any(), any()) } + verify(exactly = 0) { VaultStatusWidget.refreshAll(any()) } + } + + // --------------------------------------------------------------------------- + // 4. Single vault → saveVaultData called with correct formatted values + // --------------------------------------------------------------------------- + + @Test + fun `single vault calls saveVaultData with correct vault name`() = runBlocking { + val v = vault("GABCDEFGHIJKLMNOP", ttlRemaining = 172_800L) + coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(v)) + + buildWorker().doWork() + + // id.take(12) + "…" + val expectedName = "GABCDEFGHIJKL" // take(12) = "GABCDEFGHIJK" wait — 12 chars + // "GABCDEFGHIJKLMNOP".take(12) = "GABCDEFGHIJK" (12 chars) + "…" + verify { + VaultStatusWidget.saveVaultData( + any(), + vaultName = "GABCDEFGHIJK…", + ttlRemaining = "2d 0h", + lastCheckIn = v.lastCheckIn + ) + } + } + + @Test + fun `single vault calls saveVaultData with correctly formatted ttl days and hours`() = runBlocking { + // 90061 seconds = 1 day 1 hour 1 minute 1 second → formatTtl → "1d 1h" + val v = vault("v1", ttlRemaining = 90_061L) + coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(v)) + + buildWorker().doWork() + + verify { + VaultStatusWidget.saveVaultData(any(), any(), ttlRemaining = "1d 1h", any()) + } + } + + @Test + fun `single vault with ttl under one day formatted as hours only`() = runBlocking { + // 3600 seconds = 1 hour, no days + val v = vault("v1", ttlRemaining = 3_600L) + coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(v)) + + buildWorker().doWork() + + verify { + VaultStatusWidget.saveVaultData(any(), any(), ttlRemaining = "1h", any()) + } + } + + @Test + fun `single vault with null ttl formatted as Unknown`() = runBlocking { + val v = vault("v1", ttlRemaining = null) + coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(v)) + + buildWorker().doWork() + + verify { + VaultStatusWidget.saveVaultData(any(), any(), ttlRemaining = "Unknown", any()) + } + } + + @Test + fun `single vault calls refreshAll after saveVaultData`() = runBlocking { + val v = vault("v1") + coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(v)) + + val result = buildWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + verifyOrder { + VaultStatusWidget.saveVaultData(any(), any(), any(), any()) + VaultStatusWidget.refreshAll(any()) + } + } + + // --------------------------------------------------------------------------- + // 5. Multiple vaults → firstOrNull() selects the first vault (#79 fix) + // + // The current implementation uses result.data.firstOrNull(). #79 documents that + // this selection logic needs to be fixed (e.g. select the vault expiring soonest). + // These tests assert the CURRENT firstOrNull() behaviour so that once #79 is + // implemented the tests will fail visibly, making the change deliberate. + // --------------------------------------------------------------------------- + + @Test + fun `multiple vaults selects the first vault in the list (pre-79 behaviour)`() = runBlocking { + val first = vault("first-vault", ttlRemaining = 100_000L) + val second = vault("second-vault", ttlRemaining = 50_000L) + coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(first, second)) + + buildWorker().doWork() + + // Only the first vault's id should appear in the widget data (#79: update this + // once the "most urgent vault" selection logic replaces firstOrNull()). + verify { + VaultStatusWidget.saveVaultData( + any(), + vaultName = "first-vault…", + any(), + lastCheckIn = first.lastCheckIn + ) + } + verify(exactly = 0) { + VaultStatusWidget.saveVaultData(any(), vaultName = "second-vault…", any(), any()) + } + } + + @Test + fun `multiple vaults calls refreshAll exactly once`() = runBlocking { + val vaults = listOf(vault("v1"), vault("v2"), vault("v3")) + coEvery { apiClient.listVaults() } returns ApiResult.Success(vaults) + + buildWorker().doWork() + + verify(exactly = 1) { VaultStatusWidget.refreshAll(any()) } + } +} + +// ============================================================================ +// VaultWidgetUpdateWorkerFactory +// ============================================================================ + +/** + * [WorkerFactory] that injects a mock [ApiClient] into [VaultWidgetUpdateWorker], + * bypassing the Hilt/AssistedInject wiring not available in plain JVM unit tests. + */ +private class VaultWidgetUpdateWorkerFactory( + private val apiClient: ApiClient +) : WorkerFactory() { + + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker? { + return if (workerClassName == VaultWidgetUpdateWorker::class.java.name) { + VaultWidgetUpdateWorker(appContext, workerParameters, apiClient) + } else { + null + } + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 0981e8e..42204ce 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -17,6 +17,8 @@ mockk = "1.13.13" room = "2.6.1" work = "2.10.0" security-crypto = "1.1.0-alpha06" +hilt-testing = "2.53" +work-testing = "2.10.0" [libraries] compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } @@ -56,6 +58,9 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } androidx-test-ext = { group = "androidx.test.ext", name = "junit", version = "1.2.1" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.6.1" } security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" } +work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work-testing" } +hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt-testing" } +hilt-android-compiler-testing = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt-testing" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }