diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/WebRequestBuilder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/WebRequestBuilder.kt index b6dc003eb623b8e2a9bdf2a5a0119498d1dfbc2e..906b6b44c1dc7bbaa96e76d87349e68fd9e67411 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/WebRequestBuilder.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/WebRequestBuilder.kt @@ -102,9 +102,12 @@ class WebRequestBuilder( ): File = withContext(Dispatchers.IO) { val fileName = "${UUID.nameUUIDFromBytes(url.toByteArray())}.zip" val file = File(FileStorageHelper.keyExportDirectory, fileName) - file.outputStream().use { + if (file.exists()) file.delete() + file.outputStream().use { fos -> Timber.v("Added $url to queue.") - distributionService.getKeyFiles(url).byteStream().copyTo(it, DEFAULT_BUFFER_SIZE) + distributionService.getKeyFiles(url).byteStream().use { + it.copyTo(fos, DEFAULT_BUFFER_SIZE) + } Timber.v("key file request successful.") } return@withContext file diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt index 889ab566942df188a9780255f3301afafc45daa3..48bc37e409fea1553cc046a816338afa94529ee2 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt @@ -6,6 +6,7 @@ import de.rki.coronawarnapp.CoronaWarnApplication import de.rki.coronawarnapp.R import de.rki.coronawarnapp.risk.RiskLevel import de.rki.coronawarnapp.util.security.SecurityHelper.globalEncryptedSharedPreferencesInstance +import org.joda.time.Instant import java.util.Date /** @@ -18,6 +19,11 @@ object LocalData { private val TAG: String? = LocalData::class.simpleName + private const val PREFERENCE_NEXT_TIME_RATE_LIMITING_UNLOCKS = + "preference_next_time_rate_limiting_unlocks" + private const val PREFERENCE_GOOGLE_API_PROVIDE_DIAGNOSIS_KEYS_CALL_COUNT = + "preference_google_api_provide_diagnosis_keys_call_count" + /**************************************************** * ONBOARDING DATA ****************************************************/ @@ -390,6 +396,40 @@ object LocalData { } } + var nextTimeRateLimitingUnlocks: Instant + get() { + return Instant.ofEpochMilli( + getSharedPreferenceInstance().getLong( + PREFERENCE_NEXT_TIME_RATE_LIMITING_UNLOCKS, + 0L + ) + ) + } + set(value) { + getSharedPreferenceInstance().edit(true) { + putLong( + PREFERENCE_NEXT_TIME_RATE_LIMITING_UNLOCKS, + value.millis + ) + } + } + + var googleAPIProvideDiagnosisKeysCallCount: Int + get() { + return getSharedPreferenceInstance().getInt( + PREFERENCE_GOOGLE_API_PROVIDE_DIAGNOSIS_KEYS_CALL_COUNT, + 0 + ) + } + set(value) { + getSharedPreferenceInstance().edit(true) { + putInt( + PREFERENCE_GOOGLE_API_PROVIDE_DIAGNOSIS_KEYS_CALL_COUNT, + value + ) + } + } + /** * Gets the last time of successful risk level calculation as long * from the EncryptedSharedPrefs diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/keycache/KeyCacheDao.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/keycache/KeyCacheDao.kt index ec812964a597e8b6102eaa38a3d8b0bd2d3f3fda..0e6cbeadb1b6f5e2f6b5c06b6668ad3a80fab963 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/keycache/KeyCacheDao.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/keycache/KeyCacheDao.kt @@ -35,6 +35,9 @@ interface KeyCacheDao { @Query("SELECT * FROM date") suspend fun getAllEntries(): List<KeyCacheEntity> + @Query("SELECT * FROM date WHERE id IN (:idList)") + suspend fun getAllEntries(idList: List<String>): List<KeyCacheEntity> + @Query("DELETE FROM date") suspend fun clear() @@ -44,6 +47,9 @@ interface KeyCacheDao { @Delete suspend fun deleteEntry(entity: KeyCacheEntity) + @Delete + suspend fun deleteEntries(entities: List<KeyCacheEntity>) + @Insert suspend fun insertEntry(keyCacheEntity: KeyCacheEntity): Long } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/keycache/KeyCacheRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/keycache/KeyCacheRepository.kt index 9e3ce000310077d2827eefe94fb3ee4e5b2f4ecf..dd6b6425d39da8b125aac2b520d3e9f5f3ee7d5b 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/keycache/KeyCacheRepository.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/keycache/KeyCacheRepository.kt @@ -88,6 +88,14 @@ class KeyCacheRepository(private val keyCacheDao: KeyCacheDao) { keyCacheDao.clear() } + suspend fun clear(idList: List<String>) { + if (idList.isNotEmpty()) { + val entries = keyCacheDao.getAllEntries(idList) + entries.forEach { deleteFileForEntry(it) } + keyCacheDao.deleteEntries(entries) + } + } + suspend fun getFilesFromEntries() = keyCacheDao .getAllEntries() .map { File(it.path) } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt index 37f736283fd6be6574b05560cf7dab98c6646e65..20f3a38eec5d70f161842cef456a43390e7a2ac3 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt @@ -20,26 +20,29 @@ package de.rki.coronawarnapp.transaction import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration -import de.rki.coronawarnapp.CoronaWarnApplication import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService import de.rki.coronawarnapp.storage.FileStorageHelper import de.rki.coronawarnapp.storage.LocalData -import de.rki.coronawarnapp.storage.keycache.KeyCacheRepository import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.API_SUBMISSION import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.CLOSE import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.FETCH_DATE_UPDATE import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.FILES_FROM_WEB_REQUESTS +import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.QUOTA_CALCULATION import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.RETRIEVE_RISK_SCORE_PARAMS import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.SETUP import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.TOKEN import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.rollback import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.start import de.rki.coronawarnapp.util.CachedKeyFileHolder +import de.rki.coronawarnapp.util.GoogleQuotaCalculator +import de.rki.coronawarnapp.util.QuotaCalculator import de.rki.coronawarnapp.worker.BackgroundWorkHelper import org.joda.time.DateTime import org.joda.time.DateTimeZone +import org.joda.time.Duration import org.joda.time.Instant +import org.joda.time.chrono.GJChronology import timber.log.Timber import java.io.File import java.util.Date @@ -90,6 +93,9 @@ object RetrieveDiagnosisKeysTransaction : Transaction() { /** Initial Setup of the Transaction and Transaction ID Generation and Date Lock */ SETUP, + /** calculates the Quota so that the rate limiting is caught gracefully*/ + QUOTA_CALCULATION, + /** Initialisation of the identifying token used during the entire transaction */ TOKEN, @@ -118,12 +124,25 @@ object RetrieveDiagnosisKeysTransaction : Transaction() { /** atomic reference for the rollback value for created files during the transaction */ private val exportFilesForRollback = AtomicReference<List<File>>() + private val progressTowardsQuotaForRollback = AtomicReference<Int>() + + private const val QUOTA_RESET_PERIOD_IN_HOURS = 24 + + private val quotaCalculator: QuotaCalculator<Int> = GoogleQuotaCalculator( + incrementByAmount = 14, + quotaLimit = 20, + quotaResetPeriod = Duration.standardHours(QUOTA_RESET_PERIOD_IN_HOURS.toLong()), + quotaTimeZone = DateTimeZone.UTC, + quotaChronology = GJChronology.getInstanceUTC() + ) + suspend fun startWithConstraints() { val currentDate = DateTime(Instant.now(), DateTimeZone.UTC) val lastFetch = DateTime( LocalData.lastTimeDiagnosisKeysFromServerFetch(), DateTimeZone.UTC ) + if (LocalData.lastTimeDiagnosisKeysFromServerFetch() == null || currentDate.withTimeAtStartOfDay() != lastFetch.withTimeAtStartOfDay() ) { @@ -152,6 +171,18 @@ object RetrieveDiagnosisKeysTransaction : Transaction() { ****************************************************/ val currentDate = executeSetup() + /**************************************************** + * CALCULATE QUOTA FOR PROVIDE DIAGNOSIS KEYS + ****************************************************/ + val hasExceededQuota = executeQuotaCalculation() + + // When we are above the Quote, cancel the execution entirely + if (hasExceededQuota) { + Timber.tag(TAG).w("above quota, skipping RetrieveDiagnosisKeys") + executeClose() + return@lockAndExecuteUnique + } + /**************************************************** * RETRIEVE TOKEN ****************************************************/ @@ -194,8 +225,9 @@ object RetrieveDiagnosisKeysTransaction : Transaction() { if (TOKEN.isInStateStack()) { rollbackToken() } - if (FILES_FROM_WEB_REQUESTS.isInStateStack()) { - rollbackFilesFromWebRequests() + // we reset the quota only if the submission has not happened yet + if (QUOTA_CALCULATION.isInStateStack() && !API_SUBMISSION.isInStateStack()) { + rollbackProgressTowardsQuota() } } catch (e: Exception) { // We handle every exception through a RollbackException to make sure that a single EntryPoint @@ -214,10 +246,9 @@ object RetrieveDiagnosisKeysTransaction : Transaction() { LocalData.googleApiToken(googleAPITokenForRollback.get()) } - private suspend fun rollbackFilesFromWebRequests() { - Timber.v("rollback $FILES_FROM_WEB_REQUESTS") - KeyCacheRepository.getDateRepository(CoronaWarnApplication.getAppContext()) - .clear() + private fun rollbackProgressTowardsQuota() { + Timber.v("rollback $QUOTA_CALCULATION") + quotaCalculator.resetProgressTowardsQuota(progressTowardsQuotaForRollback.get()) } /** @@ -230,6 +261,16 @@ object RetrieveDiagnosisKeysTransaction : Transaction() { currentDate } + /** + * Executes the QUOTA_CALCULATION Transaction State + */ + private suspend fun executeQuotaCalculation() = executeState( + QUOTA_CALCULATION + ) { + progressTowardsQuotaForRollback.set(quotaCalculator.getProgressTowardsQuota()) + quotaCalculator.calculateQuota() + } + /** * Executes the TOKEN Transaction State */ diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CachedKeyFileHolder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CachedKeyFileHolder.kt index 15437cf475839ca2d6fb7f8588fb58c37eac474e..97998f348d1a8ba6fc35b1a4d2dde5b53f0536a3 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CachedKeyFileHolder.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CachedKeyFileHolder.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File +import java.util.Collections import java.util.Date import java.util.UUID @@ -97,22 +98,30 @@ object CachedKeyFileHolder { val deferredQueries: MutableCollection<Deferred<Any>> = mutableListOf() keyCache.deleteOutdatedEntries(uuidListFromServer) val missingDays = getMissingDaysFromDiff(serverDates) + val failedEntryCacheKeys = Collections.synchronizedList(mutableListOf<String>()) if (missingDays.isNotEmpty()) { // we have a date difference deferredQueries.addAll( missingDays .map { getURLForDay(it) } - .map { url -> async { url.createDayEntryForUrl() } } + .map { url -> + val cacheKey = url.generateCacheKeyFromString() + async { + try { + url.createDayEntryForUrl(cacheKey) + } catch (e: Exception) { + Timber.v("failed entry: $cacheKey") + failedEntryCacheKeys.add(cacheKey) + } + } + } ) } // execute the query plan - try { - deferredQueries.awaitAll() - } catch (e: Exception) { - // For an error we clear the cache to try again - keyCache.clear() - throw e - } + deferredQueries.awaitAll() + Timber.v("${failedEntryCacheKeys.size} failed entries ") + // For an error we clear the cache to try again + keyCache.clear(failedEntryCacheKeys) keyCache.getFilesFromEntries() .also { it.forEach { file -> Timber.v("cached file:${file.path}") } } } @@ -161,8 +170,8 @@ object CachedKeyFileHolder { * Creates a date entry in the Key Cache for a given String with a unique Key Name derived from the URL * and the URI of the downloaded File for that given key */ - private suspend fun String.createDayEntryForUrl() = keyCache.createEntry( - this.generateCacheKeyFromString(), + private suspend fun String.createDayEntryForUrl(cacheKey: String) = keyCache.createEntry( + cacheKey, WebRequestBuilder.getInstance().asyncGetKeyFilesFromServer(this).toURI(), DAY ) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/GoogleQuotaCalculator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/GoogleQuotaCalculator.kt new file mode 100644 index 0000000000000000000000000000000000000000..e7aace190ed9a6217487d3f76b6356c07c6aa267 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/GoogleQuotaCalculator.kt @@ -0,0 +1,62 @@ +package de.rki.coronawarnapp.util + +import de.rki.coronawarnapp.storage.LocalData +import org.joda.time.Chronology +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import org.joda.time.Duration +import org.joda.time.Instant + +/** + * This Calculator class takes multiple parameters to check if the Google API + * can be called or the Rate Limit has been reached. The Quota is expected to reset at + * the start of the day in the given timeZone and Chronology + * + * @property incrementByAmount The amount of Quota Calls to increment per Call + * @property quotaLimit The maximum amount of Quota Calls allowed before Rate Limiting + * @property quotaResetPeriod The Period after which the Quota Resets + * @property quotaTimeZone The Timezone to work in + * @property quotaChronology The Chronology to work in + */ +class GoogleQuotaCalculator( + val incrementByAmount: Int, + val quotaLimit: Int, + val quotaResetPeriod: Duration, + val quotaTimeZone: DateTimeZone, + val quotaChronology: Chronology +) : QuotaCalculator<Int> { + override var hasExceededQuota: Boolean = false + + override fun calculateQuota(): Boolean { + if (Instant.now().isAfter(LocalData.nextTimeRateLimitingUnlocks)) { + LocalData.nextTimeRateLimitingUnlocks = DateTime + .now(quotaTimeZone) + .withChronology(quotaChronology) + .plus(quotaResetPeriod) + .withTimeAtStartOfDay() + .toInstant() + LocalData.googleAPIProvideDiagnosisKeysCallCount = 0 + } + + if (LocalData.googleAPIProvideDiagnosisKeysCallCount <= quotaLimit) { + LocalData.googleAPIProvideDiagnosisKeysCallCount += incrementByAmount + } + + hasExceededQuota = LocalData.googleAPIProvideDiagnosisKeysCallCount > quotaLimit + + return hasExceededQuota + } + + override fun resetProgressTowardsQuota(newProgress: Int) { + if (newProgress > quotaLimit) { + throw IllegalArgumentException("cannot reset progress to a value higher than the quota limit") + } + if (newProgress % incrementByAmount != 0) { + throw IllegalArgumentException("supplied progress is no multiple of $incrementByAmount") + } + LocalData.googleAPIProvideDiagnosisKeysCallCount = newProgress + hasExceededQuota = false + } + + override fun getProgressTowardsQuota(): Int = LocalData.googleAPIProvideDiagnosisKeysCallCount +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/QuotaCalculator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/QuotaCalculator.kt new file mode 100644 index 0000000000000000000000000000000000000000..682f4a6002b06ef548e04da1c68012372f3c6743 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/QuotaCalculator.kt @@ -0,0 +1,29 @@ +package de.rki.coronawarnapp.util + +/** + * Class to check if a Quota has been reached based on the calculation done inside + * the Calculator + * + */ +interface QuotaCalculator<T> { + val hasExceededQuota: Boolean + + /** + * This function is called to recalculate an old quota score + */ + fun calculateQuota(): Boolean + + /** + * Reset the quota progress + * + * @param newProgress new progress towards the quota + */ + fun resetProgressTowardsQuota(newProgress: T) + + /** + * Retrieve the current progress towards the quota + * + * @return current progress count + */ + fun getProgressTowardsQuota(): T +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt index e8c59395e8583acf1d6313379823f3b75d1edb94..929b0fcf82b875d4f6441ff31333b76ec2a4aaf6 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt @@ -13,6 +13,7 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll import kotlinx.coroutines.runBlocking +import org.joda.time.Instant import org.junit.After import org.junit.Before import org.junit.Test @@ -46,6 +47,10 @@ class RetrieveDiagnosisKeysTransactionTest { every { LocalData.lastTimeDiagnosisKeysFromServerFetch() } returns Date() every { LocalData.lastTimeDiagnosisKeysFromServerFetch(any()) } just Runs every { LocalData.googleApiToken() } returns UUID.randomUUID().toString() + every { LocalData.googleAPIProvideDiagnosisKeysCallCount = any() } just Runs + every { LocalData.googleAPIProvideDiagnosisKeysCallCount } returns 0 + every { LocalData.nextTimeRateLimitingUnlocks = any() } just Runs + every { LocalData.nextTimeRateLimitingUnlocks } returns Instant.now() } @Test @@ -57,6 +62,7 @@ class RetrieveDiagnosisKeysTransactionTest { coVerifyOrder { RetrieveDiagnosisKeysTransaction["executeSetup"]() + RetrieveDiagnosisKeysTransaction["executeQuotaCalculation"]() RetrieveDiagnosisKeysTransaction["executeRetrieveRiskScoreParams"]() RetrieveDiagnosisKeysTransaction["executeFetchKeyFilesFromServer"](any<Date>()) RetrieveDiagnosisKeysTransaction["executeFetchDateUpdate"](any<Date>()) @@ -77,6 +83,7 @@ class RetrieveDiagnosisKeysTransactionTest { coVerifyOrder { RetrieveDiagnosisKeysTransaction["executeSetup"]() + RetrieveDiagnosisKeysTransaction["executeQuotaCalculation"]() RetrieveDiagnosisKeysTransaction["executeRetrieveRiskScoreParams"]() RetrieveDiagnosisKeysTransaction["executeFetchKeyFilesFromServer"](any<Date>()) RetrieveDiagnosisKeysTransaction["executeAPISubmission"]( diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/CachedKeyFileHolderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/CachedKeyFileHolderTest.kt index b03e57dd79478303134648a0bd26d9d8638d2b04..1d6e897947f32d9ffd2b948a821691e78cf1f184 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/CachedKeyFileHolderTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/CachedKeyFileHolderTest.kt @@ -38,6 +38,7 @@ class CachedKeyFileHolderTest { every { KeyCacheRepository.getDateRepository(any()) } returns keyCacheRepository mockkObject(CachedKeyFileHolder) coEvery { keyCacheRepository.deleteOutdatedEntries(any()) } just Runs + coEvery { keyCacheRepository.clear(any()) } just Runs } /** @@ -53,6 +54,7 @@ class CachedKeyFileHolderTest { every { CachedKeyFileHolder["checkForFreeSpace"]() } returns Unit every { CachedKeyFileHolder["getDatesFromServer"]() } returns arrayListOf<String>() + runBlocking { CachedKeyFileHolder.asyncFetchFiles(date) @@ -63,6 +65,7 @@ class CachedKeyFileHolderTest { keyCacheRepository.deleteOutdatedEntries(any()) CachedKeyFileHolder["getMissingDaysFromDiff"](arrayListOf<String>()) keyCacheRepository.getDates() + keyCacheRepository.clear(emptyList()) keyCacheRepository.getFilesFromEntries() } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/GoogleQuotaCalculatorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/GoogleQuotaCalculatorTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..7428be1353049ec3c94a2e6c1215b5b76e108089 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/GoogleQuotaCalculatorTest.kt @@ -0,0 +1,295 @@ +package de.rki.coronawarnapp.util + +import de.rki.coronawarnapp.storage.LocalData +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.joda.time.DateTime +import org.joda.time.DateTimeUtils +import org.joda.time.DateTimeZone +import org.joda.time.Duration +import org.joda.time.Instant +import org.joda.time.chrono.GJChronology +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import testhelpers.BaseTest +import timber.log.Timber +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong + +internal class GoogleQuotaCalculatorTest : BaseTest() { + + private val timeInTest = DateTimeUtils.currentTimeMillis() + + private lateinit var classUnderTest: GoogleQuotaCalculator + private val nextTimeRateLimitingUnlocksInTesting = AtomicLong() + private val googleAPIProvideDiagnosisKeysCallCount = AtomicInteger() + + private val defaultIncrementByAmountInTest = 14 + private val defaultQuotaLimitInTest = 20 + + @BeforeEach + fun setUpClassUnderTest() { + classUnderTest = GoogleQuotaCalculator( + incrementByAmount = defaultIncrementByAmountInTest, + quotaLimit = defaultQuotaLimitInTest, + quotaResetPeriod = Duration.standardHours(24), + quotaTimeZone = DateTimeZone.UTC, + quotaChronology = GJChronology.getInstanceUTC() + ) + DateTimeUtils.setCurrentMillisFixed(timeInTest) + + // Since LocalData is simple to mock + mockkObject(LocalData) + every { LocalData.nextTimeRateLimitingUnlocks = any() } answers { + nextTimeRateLimitingUnlocksInTesting.set((this.arg(0) as Instant).millis) + } + every { LocalData.nextTimeRateLimitingUnlocks } answers { + Instant.ofEpochMilli(nextTimeRateLimitingUnlocksInTesting.get()) + } + every { LocalData.googleAPIProvideDiagnosisKeysCallCount = any() } answers { + googleAPIProvideDiagnosisKeysCallCount.set(this.arg(0)) + } + every { LocalData.googleAPIProvideDiagnosisKeysCallCount } answers { + googleAPIProvideDiagnosisKeysCallCount.get() + } + + } + + @Test + fun `isAboveQuota false if called initially`() { + assertEquals(classUnderTest.hasExceededQuota, false) + } + + @Test + fun `isAboveQuota true if called above quota limit when calling with amount bigger than one`() { + for (callNumber in 1..5) { + classUnderTest.calculateQuota() + val aboveQuota = classUnderTest.hasExceededQuota + Timber.v("call number $callNumber above quota: $aboveQuota") + if (callNumber > 1) { + assertEquals(true, aboveQuota) + } else { + assertEquals(false, aboveQuota) + } + } + } + + @Test + fun `getProgressTowardsQuota increases with calls to isAboveQuota but is stopped once increased above the quota`() { + var latestCallNumberWithoutLimiting = 1 + for (callNumber in 1..5) { + classUnderTest.calculateQuota() + val aboveQuota = classUnderTest.hasExceededQuota + Timber.v("call number $callNumber above quota: $aboveQuota") + val expectedIncrement = callNumber * defaultIncrementByAmountInTest + if (expectedIncrement >= defaultQuotaLimitInTest) { + assertEquals( + (latestCallNumberWithoutLimiting + 1) * defaultIncrementByAmountInTest, + classUnderTest.getProgressTowardsQuota() + ) + } else { + assertEquals( + callNumber * defaultIncrementByAmountInTest, + classUnderTest.getProgressTowardsQuota() + ) + latestCallNumberWithoutLimiting = callNumber + } + } + } + + @Test + fun `getProgressTowardsQuota is reset and the quota is not recalculated but isAboveQuota should still be false`() { + var latestCallNumberWithoutLimiting = 1 + for (callNumber in 1..5) { + classUnderTest.calculateQuota() + val aboveQuota = classUnderTest.hasExceededQuota + Timber.v("call number $callNumber above quota: $aboveQuota") + val expectedIncrement = callNumber * defaultIncrementByAmountInTest + if (expectedIncrement >= defaultQuotaLimitInTest) { + assertEquals( + (latestCallNumberWithoutLimiting + 1) * defaultIncrementByAmountInTest, + classUnderTest.getProgressTowardsQuota() + ) + } else { + assertEquals( + callNumber * defaultIncrementByAmountInTest, + classUnderTest.getProgressTowardsQuota() + ) + latestCallNumberWithoutLimiting = callNumber + } + } + + classUnderTest.resetProgressTowardsQuota(0) + assertEquals(false, classUnderTest.hasExceededQuota) + } + + @Test + fun `getProgressTowardsQuota is reset but the reset value is no multiple of incrementByAmount`() { + var latestCallNumberWithoutLimiting = 1 + for (callNumber in 1..5) { + classUnderTest.calculateQuota() + val aboveQuota = classUnderTest.hasExceededQuota + Timber.v("call number $callNumber above quota: $aboveQuota") + val expectedIncrement = callNumber * defaultIncrementByAmountInTest + if (expectedIncrement >= defaultQuotaLimitInTest) { + assertEquals( + (latestCallNumberWithoutLimiting + 1) * defaultIncrementByAmountInTest, + classUnderTest.getProgressTowardsQuota() + ) + } else { + assertEquals( + callNumber * defaultIncrementByAmountInTest, + classUnderTest.getProgressTowardsQuota() + ) + latestCallNumberWithoutLimiting = callNumber + } + } + + assertThrows<IllegalArgumentException> { + classUnderTest.resetProgressTowardsQuota(defaultIncrementByAmountInTest + 1) + } + } + + @Test + fun `getProgressTowardsQuota is reset and the quota is not recalculated and the progress should update`() { + var latestCallNumberWithoutLimiting = 1 + for (callNumber in 1..5) { + classUnderTest.calculateQuota() + val aboveQuota = classUnderTest.hasExceededQuota + Timber.v("call number $callNumber above quota: $aboveQuota") + val expectedIncrement = callNumber * defaultIncrementByAmountInTest + if (expectedIncrement >= defaultQuotaLimitInTest) { + assertEquals( + (latestCallNumberWithoutLimiting + 1) * defaultIncrementByAmountInTest, + classUnderTest.getProgressTowardsQuota() + ) + } else { + assertEquals( + callNumber * defaultIncrementByAmountInTest, + classUnderTest.getProgressTowardsQuota() + ) + latestCallNumberWithoutLimiting = callNumber + } + } + + val newProgressAfterReset = 14 + classUnderTest.resetProgressTowardsQuota(newProgressAfterReset) + assertEquals(false, classUnderTest.hasExceededQuota) + assertEquals(newProgressAfterReset, classUnderTest.getProgressTowardsQuota()) + } + + @Test + fun `getProgressTowardsQuota is reset and the quota is not recalculated and the progress throws an error because of too high newProgress`() { + var latestCallNumberWithoutLimiting = 1 + var progressBeforeReset: Int? = null + for (callNumber in 1..5) { + classUnderTest.calculateQuota() + val aboveQuota = classUnderTest.hasExceededQuota + Timber.v("call number $callNumber above quota: $aboveQuota") + val expectedIncrement = callNumber * defaultIncrementByAmountInTest + if (expectedIncrement >= defaultQuotaLimitInTest) { + progressBeforeReset = + (latestCallNumberWithoutLimiting + 1) * defaultIncrementByAmountInTest + assertEquals( + (latestCallNumberWithoutLimiting + 1) * defaultIncrementByAmountInTest, + classUnderTest.getProgressTowardsQuota() + ) + } else { + assertEquals( + callNumber * defaultIncrementByAmountInTest, + classUnderTest.getProgressTowardsQuota() + ) + latestCallNumberWithoutLimiting = callNumber + } + } + + val newProgressAfterReset = defaultQuotaLimitInTest + 1 + assertThrows<IllegalArgumentException> { + classUnderTest.resetProgressTowardsQuota(newProgressAfterReset) + } + assertEquals(true, classUnderTest.hasExceededQuota) + assertEquals( + (progressBeforeReset + ?: throw IllegalStateException("progressBeforeReset was not set during test")), + classUnderTest.getProgressTowardsQuota() + ) + } + + @Test + fun `isAboveQuota true if called above quota limit when calling with amount one`() { + classUnderTest = GoogleQuotaCalculator( + incrementByAmount = 1, + quotaLimit = 3, + quotaResetPeriod = Duration.standardHours(24), + quotaTimeZone = DateTimeZone.UTC, + quotaChronology = GJChronology.getInstanceUTC() + ) + for (callNumber in 1..15) { + classUnderTest.calculateQuota() + val aboveQuota = classUnderTest.hasExceededQuota + Timber.v("call number $callNumber above quota: $aboveQuota") + if (callNumber > 3) { + assertEquals(true, aboveQuota) + } else { + assertEquals(false, aboveQuota) + } + } + } + + @Test + fun `isAboveQuota false if called above quota limit but next day resets quota`() { + for (callNumber in 1..5) { + classUnderTest.calculateQuota() + val aboveQuota = classUnderTest.hasExceededQuota + Timber.v("call number $callNumber above quota: $aboveQuota") + if (callNumber > 1) { + assertEquals(true, aboveQuota) + } else { + assertEquals(false, aboveQuota) + } + } + + // Day Change + val timeInTestAdvancedByADay = timeInTest + Duration.standardDays(1).millis + DateTimeUtils.setCurrentMillisFixed(timeInTestAdvancedByADay) + classUnderTest.calculateQuota() + val aboveQuotaAfterDayAdvance = classUnderTest.hasExceededQuota + Timber.v("above quota after day advance: $aboveQuotaAfterDayAdvance") + + assertEquals(false, aboveQuotaAfterDayAdvance) + } + + @Test + fun `test if isAfter is affected by Timezone to make sure we do not run into Shifting Errors`() { + val testTimeUTC = DateTime( + timeInTest, + DateTimeZone.UTC + ).withChronology(GJChronology.getInstanceUTC()) + val testTimeGMT = DateTime( + timeInTest, + DateTimeZone.forID("Etc/GMT+2") + ).withChronology(GJChronology.getInstanceUTC()) + + assertEquals(testTimeGMT, testTimeUTC) + assertEquals(testTimeGMT.millis, testTimeUTC.millis) + + val testTimeUTCAfterGMT = testTimeUTC.plusMinutes(1) + + assertEquals(true, testTimeUTCAfterGMT.isAfter(testTimeGMT)) + + val testTimeGMTAfterUTC = testTimeGMT.plusMinutes(1) + + assertEquals(true, testTimeGMTAfterUTC.isAfter(testTimeUTC)) + + } + + @AfterEach + fun cleanup() { + DateTimeUtils.setCurrentMillisSystem() + unmockkObject(LocalData) + } +} \ No newline at end of file