From 9ebe5c18c5e26ad720e9661bc6677e01ca0b3c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20M=C3=B6ller?= <jakob.moeller@sap.com> Date: Mon, 7 Sep 2020 16:03:28 +0200 Subject: [PATCH] Remove Cache Clearing to reduce CDN load on failing devices (EXPOSUREAPP-2405) (#1108) * Remove Cache Clearing to reduce CDN load on failing devices. This ideally needs to be accompanied by a way to clear the cache manually and a way to identify more root causes of Transaction Failures. Signed-off-by: d067928 <jakob.moeller@sap.com> * Remove Files that failed for Key Retrieval Only Remove Files from Cache that failed to download, instead of every file. This is accompanied ideally by no rollback in the Key Retrieval. We only delete the cache ref, the file will not be deleted as it is considered not present Signed-off-by: d067928 <jakob.moeller@sap.com> * Introduce dedicated QuotaCalculator for Unit Testing Signed-off-by: d067928 <jakob.moeller@sap.com> * Refactor QuotaCalculator for LocalData Property Access and Write Tests Signed-off-by: d067928 <jakob.moeller@sap.com> * Use Instant on the Device Read since this is not timezone specific Signed-off-by: d067928 <jakob.moeller@sap.com> * Add specific state for the quota calculation and dedicated rollback Signed-off-by: d067928 <jakob.moeller@sap.com> * PR Comments Signed-off-by: d067928 <jakob.moeller@sap.com> --- .../coronawarnapp/http/WebRequestBuilder.kt | 7 +- .../de/rki/coronawarnapp/storage/LocalData.kt | 40 +++ .../storage/keycache/KeyCacheDao.kt | 6 + .../storage/keycache/KeyCacheRepository.kt | 8 + .../RetrieveDiagnosisKeysTransaction.kt | 57 +++- .../coronawarnapp/util/CachedKeyFileHolder.kt | 29 +- .../util/GoogleQuotaCalculator.kt | 62 ++++ .../rki/coronawarnapp/util/QuotaCalculator.kt | 29 ++ .../RetrieveDiagnosisKeysTransactionTest.kt | 7 + .../util/CachedKeyFileHolderTest.kt | 3 + .../util/GoogleQuotaCalculatorTest.kt | 295 ++++++++++++++++++ 11 files changed, 523 insertions(+), 20 deletions(-) create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/GoogleQuotaCalculator.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/QuotaCalculator.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/GoogleQuotaCalculatorTest.kt 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 b6dc003eb..906b6b44c 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 889ab5669..48bc37e40 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 ec812964a..0e6cbeadb 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 9e3ce0003..dd6b6425d 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 37f736283..20f3a38ee 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 15437cf47..97998f348 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 000000000..e7aace190 --- /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 000000000..682f4a600 --- /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 e8c59395e..929b0fcf8 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 b03e57dd7..1d6e89794 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 000000000..7428be135 --- /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 -- GitLab