M12f: :app invite links dropped while paired, push-token retry, private notifications, en/sv only (D-112, D-116)
- AppViewModel.onLink drops an invite link while paired (AC-2.6, D-084), so it never prefills S-1 later. - PushTokens: a failed E-17 PUT (network, 429, 5xx) is kept by PushTokenRetry and sent again after each successful sync until accepted; a newer FID replaces it (AC-13.7). - Notifications are VISIBILITY_PRIVATE with a generic public version (notif_generic_title, notif_generic_body): no description, amount, account or name on a secure lock screen (security review L-7). - androidResources.localeFilters en, sv (SPEC G-8, AC-19.1). - Tests: AppViewModelTest (links while paired and unpaired, AC-2.9 permission after the first finished successful sync), PushTokenRetryTest, NotificationPrivacyTest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
package se.hagstedt.kvitt
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.FakeScenarios
|
||||
import se.hagstedt.kvitt.link.AppLink
|
||||
import se.hagstedt.kvitt.model.SessionState
|
||||
|
||||
/** The shell's link and permission rules: AC-2.6 (D-084) and AC-2.9. A plain JVM test over the fakes. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AppViewModelTest {
|
||||
@Before
|
||||
fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
|
||||
@After
|
||||
fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
private fun vm(fakes: FakeRepositories) = AppViewModel(fakes.session, fakes.sync, fakes.accounts)
|
||||
|
||||
private val link = AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCD2345EFGH", "Jacob")
|
||||
|
||||
@Test
|
||||
fun anInviteLinkWhilePaired_isDropped_andNeverPrefillsALaterPairing() {
|
||||
val fakes = FakeRepositories(FakeScenarios.empty)
|
||||
val vm = vm(fakes)
|
||||
assertTrue(vm.session.value is SessionState.Paired)
|
||||
vm.onLink(link)
|
||||
assertNull(vm.invite.value)
|
||||
fakes.store.edit { it.copy(session = SessionState.Revoked) }
|
||||
assertEquals(SessionState.Revoked, vm.session.value)
|
||||
assertNull("a link from the paired time does not come back", vm.invite.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInviteLinkWhileUnpaired_prefillsThePairingScreen() {
|
||||
val vm = vm(FakeRepositories(FakeScenarios.empty.copy(session = SessionState.Unpaired)))
|
||||
vm.onLink(link)
|
||||
assertEquals(link, vm.invite.value)
|
||||
vm.onPaired()
|
||||
assertNull("consumed by the pairing", vm.invite.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationPermission_isAskedOnlyAfterTheFirstSuccessfulSyncAfterPairing() {
|
||||
val first: FakeScenario = FakeScenarios.empty.copy(lastSuccessAt = null, syncRunning = true)
|
||||
val fakes = FakeRepositories(first)
|
||||
val vm = vm(fakes)
|
||||
assertFalse(vm.askNotifications.value)
|
||||
vm.onPaired()
|
||||
assertFalse("the first sync is still running", vm.askNotifications.value)
|
||||
fakes.store.edit { it.copy(lastSuccessAt = "2026-10-17T12:00:00.000Z") }
|
||||
assertFalse("success recorded but the cycle still runs", vm.askNotifications.value)
|
||||
fakes.store.edit { it.copy(syncRunning = false) }
|
||||
assertTrue(vm.askNotifications.value)
|
||||
vm.notificationsAsked()
|
||||
assertFalse(vm.askNotifications.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package se.hagstedt.kvitt.push
|
||||
|
||||
import android.app.Application
|
||||
import android.app.Notification
|
||||
import androidx.core.app.NotificationCompat
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import se.hagstedt.kvitt.link.OpenTarget
|
||||
|
||||
/** Security review L-7 (D-116): the lock screen shows only the generic public version, never the expense. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [36], application = Application::class)
|
||||
class NotificationPrivacyTest {
|
||||
private val context = RuntimeEnvironment.getApplication()
|
||||
|
||||
@Test
|
||||
fun notificationsArePrivate_withAGenericPublicVersion() {
|
||||
val content = NotificationContent("Anna added an expense", "ICA Maxi · SEK 1,234.50 · Vardag", OpenTarget.Expense("e-1"))
|
||||
val n = Notifications.build(context, content, null)
|
||||
assertEquals(NotificationCompat.VISIBILITY_PRIVATE, n.visibility)
|
||||
assertEquals("Anna added an expense", n.extras.getCharSequence(Notification.EXTRA_TITLE).toString())
|
||||
val public = requireNotNull(n.publicVersion)
|
||||
val title = public.extras.getCharSequence(Notification.EXTRA_TITLE).toString()
|
||||
val text = public.extras.getCharSequence(Notification.EXTRA_TEXT).toString()
|
||||
assertEquals("Kvitt", title)
|
||||
assertEquals("Open Kvitt to see it", text)
|
||||
listOf("ICA", "1,234.50", "Vardag", "Anna").forEach { assertFalse(it, (title + text).contains(it)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package se.hagstedt.kvitt.push
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import se.hagstedt.kvitt.push.PushTokenRetry.Outcome
|
||||
|
||||
/** Audit AC-13.7: a failed E-17 send is retried after the next successful sync, until accepted. */
|
||||
class PushTokenRetryTest {
|
||||
@Test
|
||||
fun aRetryableFailure_isSentAgainOnRetry_untilAccepted() = runTest {
|
||||
val sent = mutableListOf<String>()
|
||||
val answers = ArrayDeque(listOf(Outcome.Retry, Outcome.Retry, Outcome.Done))
|
||||
val retry = PushTokenRetry { sent += it; answers.removeFirst() }
|
||||
retry.send("fid-1")
|
||||
assertEquals("fid-1", retry.pending.value)
|
||||
retry.retry()
|
||||
assertEquals("fid-1", retry.pending.value)
|
||||
retry.retry()
|
||||
assertNull(retry.pending.value)
|
||||
retry.retry()
|
||||
assertEquals(listOf("fid-1", "fid-1", "fid-1"), sent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDefinitiveAnswer_isNotRetried_andANewerTokenReplacesTheOldOne() = runTest {
|
||||
val sent = mutableListOf<String>()
|
||||
val retry = PushTokenRetry { sent += it; if (it == "old") Outcome.Retry else Outcome.Done }
|
||||
retry.send("old")
|
||||
retry.send("new")
|
||||
assertNull(retry.pending.value)
|
||||
retry.retry()
|
||||
assertEquals(listOf("old", "new"), sent)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user