diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b09ec99f..a23ca32d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,10 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/test/java/com/fabledsword/minstrel/events/ReconnectBackoffTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/events/ReconnectBackoffTest.kt new file mode 100644 index 00000000..f21eebac --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/events/ReconnectBackoffTest.kt @@ -0,0 +1,24 @@ +package com.fabledsword.minstrel.events + +import org.junit.jupiter.api.Test +import kotlin.random.Random +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ReconnectBackoffTest { + @Test + fun `doubles from two seconds to a five minute cap`() { + val ladder = generateSequence(ReconnectBackoff.BASE_MS) { ReconnectBackoff.next(it) }.take(10).toList() + assertEquals(listOf(2_000L, 4_000L, 8_000L, 16_000L, 32_000L, 64_000L, 128_000L, 256_000L), ladder.take(8)) + assertEquals(300_000L, ladder[8]) + assertEquals(300_000L, ladder[9]) + } + + @Test + fun `jitter stays within a quarter either way and does spread`() { + val random = Random(42) + val waits = List(1_000) { ReconnectBackoff.jittered(100_000L, random) } + assertTrue(waits.all { it in 75_000L..125_000L }, "out of bounds: ${waits.minOrNull()}..${waits.maxOrNull()}") + assertTrue(waits.toSet().size > 100, "jitter should spread reconnects") + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/notifications/delivery/DeliveryPlanTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/notifications/delivery/DeliveryPlanTest.kt new file mode 100644 index 00000000..e4e2c3a7 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/notifications/delivery/DeliveryPlanTest.kt @@ -0,0 +1,128 @@ +package com.fabledsword.minstrel.notifications.delivery + +import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire +import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire +import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DeliveryPlanTest { + private val t0 = Instant.parse("2026-10-08T12:00:00Z") + + private fun notice( + id: String, + minutes: Long, + kind: String = "request_approved", + read: Boolean = false, + ) = CachedNotificationEntity( + id = id, + kind = kind, + title = "t-$id", + body = "b-$id", + link = "/requests", + createdAt = Instant.fromEpochMilliseconds(t0.toEpochMilliseconds() + minutes * 60_000), + readAt = if (read) t0 else null, + ) + + private val allOn: (String) -> Boolean = { true } + + @Test + fun `a first look sets the mark to the newest and announces nothing`() { + val page = listOf(notice("b", 5), notice("a", 1)) + val plan = planCatchUp(page, mark = null, phoneOn = allOn) + assertTrue(plan.announce.isEmpty()) + assertEquals(page[0].createdAt, plan.upTo) + } + + @Test + fun `a first look at an empty inbox still lets the first notice through`() { + val first = planCatchUp(emptyList(), mark = null, phoneOn = allOn) + assertEquals(Instant.DISTANT_PAST, first.upTo) + val next = planCatchUp(listOf(notice("a", 1)), mark = first.upTo, phoneOn = allOn) + assertEquals(listOf("a"), next.announce.map { it.id }) + } + + @Test + fun `announces unread notices newer than the mark, oldest first, and moves the mark`() { + val page = listOf(notice("c", 9), notice("b", 6), notice("old", 2), notice("read", 7, read = true)) + val plan = planCatchUp(page, mark = notice("m", 3).createdAt, phoneOn = allOn) + assertEquals(listOf("b", "c"), plan.announce.map { it.id }) + assertEquals(page[0].createdAt, plan.upTo) + } + + @Test + fun `nothing new leaves the mark where it was`() { + val mark = notice("m", 10).createdAt + val plan = planCatchUp(listOf(notice("a", 1)), mark = mark, phoneOn = allOn) + assertTrue(plan.announce.isEmpty()) + assertEquals(mark, plan.upTo) + } + + @Test + fun `a kind with the phone off is passed over, and not announced later either`() { + val page = listOf(notice("h", 5, kind = "tracks_missing"), notice("r", 4)) + val plan = planCatchUp(page, mark = t0, phoneOn = { it != "tracks_missing" }) + assertEquals(listOf("r"), plan.announce.map { it.id }) + assertEquals(page[0].createdAt, plan.upTo, "the mark passes it") + } + + @Test + fun `phone prefs come from the cached settings, with every kind on when none are cached`() { + val settings = NotificationSettingsWire( + kinds = listOf( + NotificationKindSettingWire("request_approved", false, inbox = true, phone = false, email = true), + NotificationKindSettingWire("request_completed", false, inbox = false, phone = true, email = true), + NotificationKindSettingWire("request_rejected", false, inbox = true, phone = true, email = true), + ), + emailAvailable = true, + ) + val phoneOn = phoneOnFor(settings) + assertFalse(phoneOn("request_approved")) + assertFalse(phoneOn("request_completed"), "phone rides on the inbox") + assertTrue(phoneOn("request_rejected")) + assertTrue(phoneOnFor(null)("tracks_missing")) + } + + @Test + fun `three get a line each, more become one line with the count`() { + assertNull(announcementFor(emptyList())) + val three = (1..3).map { notice("n$it", it.toLong()) } + assertEquals(Announcement.Each(three), announcementFor(three)) + + val five = (1..5).map { notice("n$it", it.toLong()) } + val pile = assertIs(announcementFor(five)) + assertEquals(5, pile.count) + assertEquals(ShadeChannel.YOUR_REQUESTS, pile.channel) + assertEquals("5 new notifications", pileText(pile.count)) + } + + @Test + fun `a pile of admin notices goes to library health, a mixed one does not`() { + val admin = (1..4).map { notice("a$it", it.toLong(), kind = "tracks_missing") } + assertEquals(ShadeChannel.LIBRARY_HEALTH, assertIs(announcementFor(admin)).channel) + val mixed = admin + notice("r", 9) + assertEquals(ShadeChannel.YOUR_REQUESTS, assertIs(announcementFor(mixed)).channel) + } + + @Test + fun `listener kinds are their requests, the rest is library health`() { + listOf("request_approved", "request_rejected", "request_completed").forEach { + assertEquals(ShadeChannel.YOUR_REQUESTS, shadeChannelFor(it)) + } + listOf("request_pending", "quarantine_flagged", "scan_failed", "tracks_missing").forEach { + assertEquals(ShadeChannel.LIBRARY_HEALTH, shadeChannelFor(it)) + } + } + + @Test + fun `the service runs only signed in with background delivery on`() { + assertTrue(deliveryWanted(signedIn = true, enabled = true)) + assertFalse(deliveryWanted(signedIn = true, enabled = false)) + assertFalse(deliveryWanted(signedIn = false, enabled = true)) + } +}