From 93d5587ae20302d8ec27b40262886a20130b6c9e Mon Sep 17 00:00:00 2001 From: Matthias Urhahn <matthias.urhahn@sap.com> Date: Fri, 30 Apr 2021 08:17:29 +0200 Subject: [PATCH] Adjust test polling behavior 21 days after registration (EXPOSUREAPP-6882) (#3016) * Map HTTP 400 errors to *_REDEEMED after 21 days (server has deleted data set). Stop test polling after 21 days if we are in a final state (*_REDEEMED). * LINTs * Improve comments. --- .../coronatest/type/CoronaTestExtensions.kt | 8 +++ .../coronatest/type/pcr/PCRProcessor.kt | 40 ++++++++++---- .../rapidantigen/RapidAntigenProcessor.kt | 38 +++++++++---- .../type/CoronaTestExtensionsTest.kt | 34 ++++++++++++ .../coronatest/type/pcr/PCRProcessorTest.kt | 52 ++++++++++++++++++ .../rapidantigen/RapidAntigenProcessorTest.kt | 54 +++++++++++++++++++ 6 files changed, 207 insertions(+), 19 deletions(-) create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/CoronaTestExtensions.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/type/CoronaTestExtensionsTest.kt diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/CoronaTestExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/CoronaTestExtensions.kt new file mode 100644 index 000000000..a666b118c --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/CoronaTestExtensions.kt @@ -0,0 +1,8 @@ +package de.rki.coronawarnapp.coronatest.type + +import org.joda.time.Duration +import org.joda.time.Instant + +fun CoronaTest.isOlderThan21Days(nowUTC: Instant): Boolean { + return Duration(registeredAt, nowUTC).standardDays > 21 +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/pcr/PCRProcessor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/pcr/PCRProcessor.kt index 9c01a64e9..a5d44f688 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/pcr/PCRProcessor.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/pcr/PCRProcessor.kt @@ -18,9 +18,11 @@ import de.rki.coronawarnapp.coronatest.tan.CoronaTestTAN import de.rki.coronawarnapp.coronatest.type.CoronaTest import de.rki.coronawarnapp.coronatest.type.CoronaTestProcessor import de.rki.coronawarnapp.coronatest.type.CoronaTestService +import de.rki.coronawarnapp.coronatest.type.isOlderThan21Days import de.rki.coronawarnapp.datadonation.analytics.modules.keysubmission.AnalyticsKeySubmissionCollector import de.rki.coronawarnapp.datadonation.analytics.modules.registeredtest.TestResultDataCollector import de.rki.coronawarnapp.exception.ExceptionCategory +import de.rki.coronawarnapp.exception.http.BadRequestException import de.rki.coronawarnapp.exception.http.CwaWebException import de.rki.coronawarnapp.exception.reporting.report import de.rki.coronawarnapp.util.TimeStamper @@ -103,15 +105,33 @@ class PCRProcessor @Inject constructor( test as PCRCoronaTest if (test.isSubmitted) { - Timber.tag(TAG).w("Not refreshing, we have already submitted.") + Timber.tag(TAG).w("Not polling, we have already submitted.") return test } - val newTestResult = submissionService.asyncRequestTestResult(test.registrationToken).let { - Timber.tag(TAG).d("Raw test result was %s", it) - testResultDataCollector.updatePendingTestResultReceivedTime(it) + val nowUTC = timeStamper.nowUTC + val isOlderThan21Days = test.isOlderThan21Days(nowUTC) - it.toValidatedResult() + if (isOlderThan21Days && test.testResult == PCR_REDEEMED) { + Timber.tag(TAG).w("Not polling, test is older than 21 days.") + return test + } + + val newTestResult = try { + submissionService.asyncRequestTestResult(test.registrationToken).let { + Timber.tag(TAG).d("Raw test result was %s", it) + testResultDataCollector.updatePendingTestResultReceivedTime(it) + + it.toValidatedResult() + } + } catch (e: BadRequestException) { + if (isOlderThan21Days) { + Timber.tag(TAG).w("HTTP 400 error after 21 days, remapping to PCR_REDEEMED.") + PCR_REDEEMED + } else { + Timber.tag(TAG).v("Unexpected HTTP 400 error, rethrowing...") + throw e + } } if (newTestResult == PCR_POSITIVE) { @@ -119,9 +139,9 @@ class PCRProcessor @Inject constructor( } test.copy( - testResult = check60PlusDays(test, newTestResult), + testResult = check21PlusDays(test, newTestResult), testResultReceivedAt = determineReceivedDate(test, newTestResult), - lastUpdatedAt = timeStamper.nowUTC, + lastUpdatedAt = nowUTC, lastError = null ) } catch (e: Exception) { @@ -133,10 +153,10 @@ class PCRProcessor @Inject constructor( } } - // After 60 days, the previously EXPIRED test is deleted from the server, and it will return pending again. - private fun check60PlusDays(test: CoronaTest, newResult: CoronaTestResult): CoronaTestResult { + // After 21 days, the previously EXPIRED test is deleted from the server, and it may return pending again. + private fun check21PlusDays(test: CoronaTest, newResult: CoronaTestResult): CoronaTestResult { val calculateDays = Duration(test.registeredAt, timeStamper.nowUTC).standardDays - Timber.tag(TAG).d("Calculated test age: %d days", calculateDays) + Timber.tag(TAG).d("Calculated test age: %d days, newResult=%s", calculateDays, newResult) return if (newResult == PCR_OR_RAT_PENDING && calculateDays >= BackgroundConstants.POLLING_VALIDITY_MAX_DAYS) { Timber.tag(TAG).d("$calculateDays is exceeding the maximum polling duration") diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessor.kt index 2a9d412af..964bb01bd 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessor.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessor.kt @@ -17,7 +17,9 @@ import de.rki.coronawarnapp.coronatest.tan.CoronaTestTAN import de.rki.coronawarnapp.coronatest.type.CoronaTest import de.rki.coronawarnapp.coronatest.type.CoronaTestProcessor import de.rki.coronawarnapp.coronatest.type.CoronaTestService +import de.rki.coronawarnapp.coronatest.type.isOlderThan21Days import de.rki.coronawarnapp.exception.ExceptionCategory +import de.rki.coronawarnapp.exception.http.BadRequestException import de.rki.coronawarnapp.exception.http.CwaWebException import de.rki.coronawarnapp.exception.reporting.report import de.rki.coronawarnapp.util.TimeStamper @@ -82,19 +84,37 @@ class RapidAntigenProcessor @Inject constructor( test as RACoronaTest if (test.isSubmitted) { - Timber.tag(TAG).w("Not refreshing, we have already submitted.") + Timber.tag(TAG).w("Not polling, we have already submitted.") return test } - val newTestResult = submissionService.asyncRequestTestResult(test.registrationToken).let { - Timber.tag(TAG).v("Raw test result was %s", it) - it.toValidatedResult() + val nowUTC = timeStamper.nowUTC + val isOlderThan21Days = test.isOlderThan21Days(nowUTC) + + if (isOlderThan21Days && test.testResult == RAT_REDEEMED) { + Timber.tag(TAG).w("Not polling, test is older than 21 days.") + return test + } + + val newTestResult = try { + submissionService.asyncRequestTestResult(test.registrationToken).let { + Timber.tag(TAG).v("Raw test result was %s", it) + it.toValidatedResult() + } + } catch (e: BadRequestException) { + if (isOlderThan21Days) { + Timber.tag(TAG).w("HTTP 400 error after 21 days, remapping to RAT_REDEEMED.") + RAT_REDEEMED + } else { + Timber.tag(TAG).v("Unexpected HTTP 400 error, rethrowing...") + throw e + } } test.copy( - testResult = check60PlusDays(test, newTestResult), + testResult = check21PlusDays(test, newTestResult), testResultReceivedAt = determineReceivedDate(test, newTestResult), - lastUpdatedAt = timeStamper.nowUTC, + lastUpdatedAt = nowUTC, lastError = null ) } catch (e: Exception) { @@ -106,10 +126,10 @@ class RapidAntigenProcessor @Inject constructor( } } - // After 60 days, the previously EXPIRED test is deleted from the server, and it will return pending again. - private fun check60PlusDays(test: CoronaTest, newResult: CoronaTestResult): CoronaTestResult { + // After 21 days, the previously EXPIRED test is deleted from the server, and it may return pending again. + private fun check21PlusDays(test: CoronaTest, newResult: CoronaTestResult): CoronaTestResult { val calculateDays = Duration(test.registeredAt, timeStamper.nowUTC).standardDays - Timber.tag(TAG).d("Calculated test age: %d days", calculateDays) + Timber.tag(TAG).d("Calculated test age: %d days, newResult=%s", calculateDays, newResult) return if ( (newResult == PCR_OR_RAT_PENDING || newResult == RAT_PENDING) && diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/type/CoronaTestExtensionsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/type/CoronaTestExtensionsTest.kt new file mode 100644 index 000000000..aba14190d --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/type/CoronaTestExtensionsTest.kt @@ -0,0 +1,34 @@ +package de.rki.coronawarnapp.coronatest.type + +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.joda.time.Duration +import org.joda.time.Instant +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class CoronaTestExtensionsTest : BaseTest() { + + @Test + fun `is test older than 21 days - time changes`() { + val test = mockk<CoronaTest>().apply { + every { registeredAt } returns Instant.EPOCH + } + + test.isOlderThan21Days(Instant.EPOCH.plus(Duration.standardDays(21))) shouldBe false + test.isOlderThan21Days(Instant.EPOCH.plus(Duration.standardDays(22))) shouldBe true + } + + @Test + fun `is test older than 21 days - test changes`() { + val nowUTC = Instant.EPOCH.plus(Duration.standardDays(22)) + mockk<CoronaTest>().apply { + every { registeredAt } returns Instant.EPOCH + }.isOlderThan21Days(nowUTC) shouldBe true + + mockk<CoronaTest>().apply { + every { registeredAt } returns Instant.EPOCH.plus(Duration.standardDays(1)) + }.isOlderThan21Days(nowUTC) shouldBe false + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/type/pcr/PCRProcessorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/type/pcr/PCRProcessorTest.kt index 7a2c51d7d..ab6c4b178 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/type/pcr/PCRProcessorTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/type/pcr/PCRProcessorTest.kt @@ -17,6 +17,7 @@ import de.rki.coronawarnapp.coronatest.tan.CoronaTestTAN import de.rki.coronawarnapp.coronatest.type.CoronaTestService import de.rki.coronawarnapp.datadonation.analytics.modules.keysubmission.AnalyticsKeySubmissionCollector import de.rki.coronawarnapp.datadonation.analytics.modules.registeredtest.TestResultDataCollector +import de.rki.coronawarnapp.exception.http.BadRequestException import de.rki.coronawarnapp.util.TimeStamper import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations @@ -181,4 +182,55 @@ class PCRProcessorTest : BaseTest() { isResultAvailableNotificationSent shouldBe false } } + + @Test + fun `polling is skipped if test is older than 21 days and state was already REDEEMED`() = runBlockingTest { + coEvery { submissionService.asyncRequestTestResult(any()) } answers { PCR_POSITIVE } + + val instance = createInstance() + + val pcrTest = PCRCoronaTest( + identifier = "identifier", + lastUpdatedAt = Instant.EPOCH, + registeredAt = nowUTC.minus(Duration.standardDays(22)), + registrationToken = "regtoken", + testResult = PCR_REDEEMED + ) + + // Older than 21 days and already redeemed + instance.pollServer(pcrTest) shouldBe pcrTest + + // Older than 21 days but not in final state, we take value from server + instance.pollServer( + pcrTest.copy(testResult = PCR_NEGATIVE) + ).testResult shouldBe PCR_POSITIVE + } + + @Test + fun `http 400 errors map to REDEEMED (EXPIRED) state after 21 days`() = runBlockingTest { + val ourBadRequest = BadRequestException("Who?") + coEvery { submissionService.asyncRequestTestResult(any()) } throws ourBadRequest + + val instance = createInstance() + + val pcrTest = PCRCoronaTest( + identifier = "identifier", + lastUpdatedAt = Instant.EPOCH, + registeredAt = nowUTC, + registrationToken = "regtoken", + testResult = PCR_POSITIVE + ) + + // Test is not older than 21 days, we want the error! + instance.pollServer(pcrTest).apply { + testResult shouldBe PCR_POSITIVE + lastError shouldBe ourBadRequest + } + + // Test IS older than 21 days, we expected the error, and map it to REDEEMED (expired) + instance.pollServer(pcrTest.copy(registeredAt = nowUTC.minus(Duration.standardDays(22)))).apply { + testResult shouldBe PCR_REDEEMED + lastError shouldBe null + } + } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessorTest.kt index 76573d88b..3cd6913d9 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessorTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessorTest.kt @@ -14,6 +14,7 @@ import de.rki.coronawarnapp.coronatest.server.CoronaTestResult.RAT_POSITIVE import de.rki.coronawarnapp.coronatest.server.CoronaTestResult.RAT_REDEEMED import de.rki.coronawarnapp.coronatest.server.CoronaTestResult.values import de.rki.coronawarnapp.coronatest.type.CoronaTestService +import de.rki.coronawarnapp.exception.http.BadRequestException import de.rki.coronawarnapp.util.TimeStamper import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations @@ -146,4 +147,57 @@ class RapidAntigenProcessorTest : BaseTest() { } } } + + @Test + fun `polling is skipped if test is older than 21 days and state was already REDEEMED`() = runBlockingTest { + coEvery { submissionService.asyncRequestTestResult(any()) } answers { RAT_POSITIVE } + + val instance = createInstance() + + val raTest = RACoronaTest( + identifier = "identifier", + lastUpdatedAt = Instant.EPOCH, + registeredAt = nowUTC.minus(Duration.standardDays(22)), + registrationToken = "regtoken", + testResult = RAT_REDEEMED, + testedAt = Instant.EPOCH, + ) + + // Older than 21 days and already redeemed + instance.pollServer(raTest) shouldBe raTest + + // Older than 21 days but not in final state, we take value from server + instance.pollServer( + raTest.copy(testResult = RAT_NEGATIVE) + ).testResult shouldBe RAT_POSITIVE + } + + @Test + fun `http 400 errors map to REDEEMED (EXPIRED) state after 21 days`() = runBlockingTest { + val ourBadRequest = BadRequestException("Who?") + coEvery { submissionService.asyncRequestTestResult(any()) } throws ourBadRequest + + val instance = createInstance() + + val raTest = RACoronaTest( + identifier = "identifier", + lastUpdatedAt = Instant.EPOCH, + registeredAt = nowUTC, + registrationToken = "regtoken", + testResult = RAT_POSITIVE, + testedAt = Instant.EPOCH, + ) + + // Test is not older than 21 days, we want the error! + instance.pollServer(raTest).apply { + testResult shouldBe RAT_POSITIVE + lastError shouldBe ourBadRequest + } + + // Test IS older than 21 days, we expected the error, and map it to REDEEMED (expired) + instance.pollServer(raTest.copy(registeredAt = nowUTC.minus(Duration.standardDays(22)))).apply { + testResult shouldBe RAT_REDEEMED + lastError shouldBe null + } + } } -- GitLab