diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/database/converter/ReminderInfoConverterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/database/converter/ReminderInfoConverterTest.kt new file mode 100644 index 00000000000..5efa6d1c21c --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/database/converter/ReminderInfoConverterTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.internal.offline.repository.database.converter + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.getstream.chat.android.client.internal.offline.repository.database.converter.internal.ReminderInfoConverter +import io.getstream.chat.android.client.internal.offline.repository.domain.message.internal.ReminderInfoEntity +import io.getstream.chat.android.randomDate +import io.getstream.chat.android.randomDateOrNull +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Date + +/** + * Runs under Robolectric because the dates are serialised by the API 26+ formatter, which is stricter about the year + * than the [java.text.SimpleDateFormat] fallback used below it. + */ +@RunWith(AndroidJUnit4::class) +internal class ReminderInfoConverterTest { + + private val converter = ReminderInfoConverter() + + @Test + fun `a reminder built from the date fixtures survives the round trip`() { + repeat(ROUNDS) { + val reminder = ReminderInfoEntity( + remindAt = randomDateOrNull(), + createdAt = randomDate(), + updatedAt = randomDate(), + ) + + converter.stringToReminderInfo(converter.reminderInfoToString(reminder)) shouldBeEqualTo reminder + } + } + + @Test + fun `a reminder at the last serializable instant survives the round trip`() { + // Guards the upper bound the date fixtures generate up to: past it the year no longer fits the wire format, + // and a reminder comes back holding a different date, or fails to be read back at all. + val lastSerializable = Date(LAST_SERIALIZABLE_DATE_MILLIS) + val reminder = ReminderInfoEntity( + remindAt = lastSerializable, + createdAt = lastSerializable, + updatedAt = lastSerializable, + ) + + converter.stringToReminderInfo(converter.reminderInfoToString(reminder)) shouldBeEqualTo reminder + } + + private companion object { + // Enough draws that a fixture generating unserializable dates again fails here, rather than + // intermittently somewhere downstream. + const val ROUNDS = 5_000 + const val LAST_SERIALIZABLE_DATE_MILLIS = 253_402_300_799_999L // 9999-12-31T23:59:59.999Z + } +} diff --git a/stream-chat-android-core/src/test/java/io/getstream/chat/android/RandomDateTest.kt b/stream-chat-android-core/src/test/java/io/getstream/chat/android/RandomDateTest.kt new file mode 100644 index 00000000000..0747d8789be --- /dev/null +++ b/stream-chat-android-core/src/test/java/io/getstream/chat/android/RandomDateTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android + +import org.amshove.kluent.shouldBeNull +import org.junit.jupiter.api.Test +import java.util.Date + +/** + * The two properties the rest of the suite leans on. Both have broken before, and because the generator is random, + * breaking either shows up as an unrelated test failing once in a few hundred CI runs rather than as a failure here. + */ +internal class RandomDateTest { + + @Test + fun `a generated date is always in the future`() { + // Production code that keeps only future dates, live locations for one, drops a fixture date that is already + // in the past, leaving the test asserting on an empty result. + val now = Date() + + val past = List(ROUNDS) { randomDate() }.filter { it.before(now) } + + past.firstOrNull().shouldBeNull() + } + + @Test + fun `a generated date is always serializable`() { + // The ISO-8601 formatter writes a four digit year, so a later date is written as a different date, or as one + // that cannot be read back at all. + val lastSerializable = Date(LAST_SERIALIZABLE_DATE_MILLIS) + + val unserializable = List(ROUNDS) { randomDate() }.filter { it.after(lastSerializable) } + + unserializable.firstOrNull().shouldBeNull() + } + + @Test + fun `a date generated after another one is serializable too`() { + val lastSerializable = Date(LAST_SERIALIZABLE_DATE_MILLIS) + + val unserializable = List(ROUNDS) { randomDateAfter(randomDate()) }.filter { it.after(lastSerializable) } + + unserializable.firstOrNull().shouldBeNull() + } + + private companion object { + const val ROUNDS = 100_000 + const val LAST_SERIALIZABLE_DATE_MILLIS = 253_402_300_799_999L // 9999-12-31T23:59:59.999Z + } +} diff --git a/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt b/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt index d534e58a6fb..3725e51c452 100644 --- a/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt +++ b/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt @@ -773,10 +773,23 @@ public fun randomFiles( creationFunction: (Int) -> File = { randomFile() }, ): List = (1..size).map(creationFunction) +/** + * The window generated dates are drawn from. + * + * The upper bound is the last instant the SDK can serialise: the ISO-8601 formatter writes a four digit year, so + * anything past year 9999 is written as a different date, or as one that cannot be read back at all. + * + * The lower bound keeps generated dates in the future, which is what callers already relied on. Drawing from the whole + * positive `Long` range put every date millions of years ahead, so production code that keeps only future dates, live + * locations for one, never saw a generated date fall behind the clock. Use [randomDateBefore] for a date in the past. + */ +private const val MIN_GENERATED_DATE_MILLIS: Long = 32_503_680_000_000L // 3000-01-01T00:00:00.000Z +private const val MAX_GENERATED_DATE_MILLIS: Long = 253_402_300_799_999L // 9999-12-31T23:59:59.999Z + public fun randomDateOrNull(): Date? = randomDate().takeIf { randomBoolean() } -public fun randomDate(): Date = Date(positiveRandomLong()) +public fun randomDate(): Date = Date(randomLongBetween(MIN_GENERATED_DATE_MILLIS, MAX_GENERATED_DATE_MILLIS)) public fun randomDateBefore(date: Date): Date = Date(date.time - positiveRandomInt()) -public fun randomDateAfter(date: Date): Date = Date(randomLongBetween(date.time)) +public fun randomDateAfter(date: Date): Date = Date(randomLongBetween(date.time, MAX_GENERATED_DATE_MILLIS)) public fun createDate( year: Int = positiveRandomInt(),