Skip to content
Snippets Groups Projects
Unverified Commit 869085ca authored by Matthias Urhahn's avatar Matthias Urhahn Committed by GitHub
Browse files

Automatic vaccination data updates (EXPOSUREAPP-6733) (#3119)


* Vaccination data updates, draft 1: Skeletons.

* Vaccination data updates, draft 2: Wiring calls to refresh and condition checks.

* Vaccination data updates, draft 3: Unit tests.

* Introduce unit test that checks application setup.

* Improve logging.

* LINTs

* Change mockk init.

* Add missing task factory registration.

Co-authored-by: default avatarMohamed <mohamed.metwalli@sap.com>
parent c5f53e86
No related branches found
No related tags found
No related merge requests found
Showing
with 841 additions and 6 deletions
......@@ -40,6 +40,7 @@ import de.rki.coronawarnapp.util.device.ForegroundState
import de.rki.coronawarnapp.util.di.AppInjector
import de.rki.coronawarnapp.util.di.ApplicationComponent
import de.rki.coronawarnapp.util.hasAPILevel
import de.rki.coronawarnapp.vaccination.core.execution.VaccinationUpdateScheduler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
......@@ -78,6 +79,7 @@ class CoronaWarnApplication : Application(), HasAndroidInjector {
@Inject lateinit var raTestResultScheduler: RAResultScheduler
@Inject lateinit var pcrTestResultAvailableNotificationService: PCRTestResultAvailableNotificationService
@Inject lateinit var raTestResultAvailableNotificationService: RATTestResultAvailableNotificationService
@Inject lateinit var vaccinationUpdateScheduler: VaccinationUpdateScheduler
@LogHistoryTree @Inject lateinit var rollingLogHistory: Timber.Tree
......@@ -131,6 +133,9 @@ class CoronaWarnApplication : Application(), HasAndroidInjector {
pcrTestResultAvailableNotificationService.setup()
raTestResultAvailableNotificationService.setup()
Timber.v("Setting up vaccination data update scheduler.")
vaccinationUpdateScheduler.setup()
deviceTimeHandler.launch()
configChangeDetector.launch()
riskLevelChangeDetector.launch()
......
......@@ -18,6 +18,7 @@ import de.rki.coronawarnapp.presencetracing.checkins.checkout.auto.AutoCheckOutW
import de.rki.coronawarnapp.presencetracing.risk.execution.PresenceTracingWarningWorker
import de.rki.coronawarnapp.presencetracing.storage.retention.TraceLocationDbCleanUpPeriodicWorker
import de.rki.coronawarnapp.submission.auto.SubmissionWorker
import de.rki.coronawarnapp.vaccination.core.execution.worker.VaccinationUpdateWorker
@Module
abstract class WorkerBinder {
......@@ -119,4 +120,11 @@ abstract class WorkerBinder {
abstract fun traceWarningWorker(
factory: PresenceTracingWarningWorker.Factory
): InjectedWorkerFactory<out ListenableWorker>
@Binds
@IntoMap
@WorkerKey(VaccinationUpdateWorker::class)
abstract fun vaccinationUpdateWorker(
factory: VaccinationUpdateWorker.Factory
): InjectedWorkerFactory<out ListenableWorker>
}
......@@ -2,6 +2,7 @@ package de.rki.coronawarnapp.vaccination.core
import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinatedPersonData
import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationValueSet
import org.joda.time.Instant
import org.joda.time.LocalDate
data class VaccinatedPerson(
......@@ -44,6 +45,12 @@ data class VaccinatedPerson(
val isEligbleForProofCertificate: Boolean
get() = data.isEligbleForProofCertificate
val isProofCertificateCheckPending: Boolean
get() = data.isPCRunPending
val lastProofCheckAt: Instant
get() = data.lastSuccessfulPCRunAt
enum class Status {
INCOMPLETE,
COMPLETE
......
package de.rki.coronawarnapp.vaccination.core
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import de.rki.coronawarnapp.task.Task
import de.rki.coronawarnapp.task.TaskFactory
import de.rki.coronawarnapp.task.TaskTypeKey
import de.rki.coronawarnapp.vaccination.core.execution.task.VaccinationUpdateTask
import de.rki.coronawarnapp.vaccination.core.server.VaccinationServerModule
@Module(
......@@ -8,4 +14,11 @@ import de.rki.coronawarnapp.vaccination.core.server.VaccinationServerModule
VaccinationServerModule::class
]
)
abstract class VaccinationModule
abstract class VaccinationModule {
@Binds
@IntoMap
@TaskTypeKey(VaccinationUpdateTask::class)
abstract fun vaccinationUpdateTaskFactory(
factory: VaccinationUpdateTask.Factory
): TaskFactory<out Task.Progress, out Task.Result>
}
package de.rki.coronawarnapp.vaccination.core.execution
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import de.rki.coronawarnapp.task.TaskController
import de.rki.coronawarnapp.task.TaskFactory
import de.rki.coronawarnapp.task.common.DefaultTaskRequest
import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDateUtc
import de.rki.coronawarnapp.util.TimeStamper
import de.rki.coronawarnapp.util.coroutine.AppScope
import de.rki.coronawarnapp.util.coroutine.await
import de.rki.coronawarnapp.util.device.ForegroundState
import de.rki.coronawarnapp.vaccination.core.execution.task.VaccinationUpdateTask
import de.rki.coronawarnapp.vaccination.core.execution.worker.VaccinationUpdateWorkerRequestBuilder
import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VaccinationUpdateScheduler @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val taskController: TaskController,
private val vaccinationRepository: VaccinationRepository,
private val foregroundState: ForegroundState,
private val workManager: WorkManager,
private val workerRequestBuilder: VaccinationUpdateWorkerRequestBuilder,
private val timeStamper: TimeStamper,
) {
fun setup() {
Timber.tag(TAG).d("setup()")
// If there is a pending check, we can perform it in the background.
// We basically consume all "pending check flags" in the background until there are none.
vaccinationRepository.vaccinationInfos
.map { vaccinatedPersons ->
vaccinatedPersons.any { it.isProofCertificateCheckPending }
}
.distinctUntilChanged()
.onEach { hasProofCheckPending ->
val alreadyScheduled = isScheduled()
Timber.tag(TAG).d("Enable worker? hasPending=$hasProofCheckPending, scheduled=$alreadyScheduled")
setPeriodicUpdatesEnabled(hasProofCheckPending)
}
.catch { Timber.tag(TAG).e(it, "Failed to monitor for pending proof checks.") }
.launchIn(appScope)
// If there is a pending check or we have stale data, we refresh immediately when opening the app
combine(
// Pending checks?
vaccinationRepository.vaccinationInfos.map { persons ->
persons.any { it.isProofCertificateCheckPending }
}.distinctUntilChanged(),
// Stale data?
vaccinationRepository.vaccinationInfos.map { persons ->
val nowUTC = timeStamper.nowUTC
persons.any {
it.lastProofCheckAt.toLocalDateUtc() != nowUTC.toLocalDateUtc() && it.isEligbleForProofCertificate
}
}.distinctUntilChanged(),
foregroundState.isInForeground
) { hasPending, staleData, isForeground ->
Timber.tag(TAG).v("Run now? pending=$hasPending, staleData=$staleData, isForeground=$isForeground")
if (isForeground && (hasPending || staleData)) {
Timber.tag(TAG).d("App moved to foreground, with pending checks, initiating refresh.")
DefaultTaskRequest(
type = VaccinationUpdateTask::class,
arguments = VaccinationUpdateTask.Arguments,
errorHandling = TaskFactory.Config.ErrorHandling.SILENT,
originTag = TAG,
).run { taskController.submit(this) }
}
}
.catch { Timber.tag(TAG).e(it, "Failed to monitor foreground state changes.") }
.launchIn(appScope)
}
private fun setPeriodicUpdatesEnabled(enabled: Boolean) {
Timber.tag(TAG).i("setPeriodicUpdatesEnabled(enabled=$enabled)")
if (enabled) {
val request = workerRequestBuilder.createPeriodicWorkRequest()
Timber.tag(TAG).d("queueWorker(request=%s)", request)
workManager.enqueueUniquePeriodicWork(
WORKER_ID_VACCINATION_UPDATE,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
} else {
Timber.tag(TAG).d("cancelWorker()")
workManager.cancelUniqueWork(WORKER_ID_VACCINATION_UPDATE)
}
}
private suspend fun isScheduled(): Boolean = workManager
.getWorkInfosForUniqueWork(WORKER_ID_VACCINATION_UPDATE)
.await()
.any { it.isScheduled }
internal val WorkInfo.isScheduled: Boolean
get() = state == WorkInfo.State.RUNNING || state == WorkInfo.State.ENQUEUED
companion object {
private val TAG: String = VaccinationUpdateScheduler::class.java.simpleName
private const val WORKER_ID_VACCINATION_UPDATE = "VaccinationUpdateWorker"
}
}
package de.rki.coronawarnapp.vaccination.core.execution.task
import de.rki.coronawarnapp.appconfig.AppConfigProvider
import de.rki.coronawarnapp.bugreporting.reportProblem
import de.rki.coronawarnapp.task.Task
import de.rki.coronawarnapp.task.TaskFactory
import de.rki.coronawarnapp.task.common.DefaultProgress
import de.rki.coronawarnapp.util.TimeStamper
import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import org.joda.time.Duration
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Provider
class VaccinationUpdateTask @Inject constructor(
private val timeStamper: TimeStamper,
private val vaccinationRepository: VaccinationRepository,
) : Task<DefaultProgress, VaccinationUpdateTask.Result> {
private val internalProgress = ConflatedBroadcastChannel<DefaultProgress>()
override val progress: Flow<DefaultProgress> = internalProgress.asFlow()
private var isCanceled = false
override suspend fun run(arguments: Task.Arguments): Result = try {
Timber.d("Running with arguments=%s", arguments)
doWork()
} catch (error: Exception) {
Timber.tag(TAG).e(error)
error.reportProblem(TAG, "Vaccination update task failed.")
throw error
} finally {
Timber.i("Finished (isCanceled=$isCanceled).")
internalProgress.close()
}
private suspend fun doWork(): Result {
Timber.tag(TAG).d("Refreshing vaccination data.")
vaccinationRepository.refresh()
Timber.tag(TAG).d("Vaccination data refreshed.")
return Result
}
override suspend fun cancel() {
Timber.w("cancel() called.")
isCanceled = true
}
object Arguments : Task.Arguments
object Result : Task.Result
data class Config(
override val executionTimeout: Duration = Duration.standardMinutes(9),
override val collisionBehavior: TaskFactory.Config.CollisionBehavior =
TaskFactory.Config.CollisionBehavior.SKIP_IF_SIBLING_RUNNING
) : TaskFactory.Config
class Factory @Inject constructor(
private val taskByDagger: Provider<VaccinationUpdateTask>,
private val appConfigProvider: AppConfigProvider
) : TaskFactory<DefaultProgress, Result> {
override suspend fun createConfig(): TaskFactory.Config = Config(
executionTimeout = appConfigProvider.getAppConfig().overallDownloadTimeout
)
override val taskProvider: () -> Task<DefaultProgress, Result> = {
taskByDagger.get()
}
}
companion object {
private const val TAG = "VaccinationUpdateTask"
}
}
package de.rki.coronawarnapp.vaccination.core.execution.worker
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import de.rki.coronawarnapp.bugreporting.reportProblem
import de.rki.coronawarnapp.task.TaskController
import de.rki.coronawarnapp.task.TaskFactory
import de.rki.coronawarnapp.task.common.DefaultTaskRequest
import de.rki.coronawarnapp.task.submitBlocking
import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
import de.rki.coronawarnapp.vaccination.core.execution.task.VaccinationUpdateTask
import timber.log.Timber
class VaccinationUpdateWorker @AssistedInject constructor(
@Assisted val context: Context,
@Assisted workerParams: WorkerParameters,
private val taskController: TaskController
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result = try {
Timber.tag(TAG).v("$id: doWork() started. Run attempt: $runAttemptCount")
Timber.tag(TAG).i("Running vaccination data update task.")
val taskState = taskController.submitBlocking(
DefaultTaskRequest(
VaccinationUpdateTask::class,
arguments = VaccinationUpdateTask.Arguments,
errorHandling = TaskFactory.Config.ErrorHandling.SILENT,
originTag = TAG,
)
)
when {
taskState.isSuccessful -> {
Timber.tag(TAG).d("$id: VaccinationUpdateTask finished successfully.")
Result.success()
}
else -> {
taskState.error?.let {
Timber.tag(TAG).w(it, "$id: Error during VaccinationUpdateTask.")
}
Result.retry()
}
}
} catch (e: Exception) {
e.reportProblem(TAG, "VaccinationUpdateTask failed exceptionally, will retry.")
Result.retry()
}
@AssistedFactory
interface Factory : InjectedWorkerFactory<VaccinationUpdateWorker>
companion object {
private val TAG = VaccinationUpdateWorker::class.java.simpleName
}
}
package de.rki.coronawarnapp.vaccination.core.execution.worker
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequest
import androidx.work.PeriodicWorkRequestBuilder
import dagger.Reusable
import de.rki.coronawarnapp.presencetracing.risk.execution.PresenceTracingWarningWorker
import de.rki.coronawarnapp.worker.BackgroundConstants
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@Reusable
class VaccinationUpdateWorkerRequestBuilder @Inject constructor() {
fun createPeriodicWorkRequest(): PeriodicWorkRequest =
PeriodicWorkRequestBuilder<PresenceTracingWarningWorker>(
24,
TimeUnit.HOURS
)
.setInitialDelay(
BackgroundConstants.KIND_DELAY,
TimeUnit.MINUTES
)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
BackgroundConstants.BACKOFF_INITIAL_DELAY,
TimeUnit.MINUTES
)
.setConstraints(buildConstraints())
.build()
private fun buildConstraints() =
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
}
......@@ -14,6 +14,7 @@ import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateQRCode
import de.rki.coronawarnapp.vaccination.core.repository.errors.VaccinatedPersonNotFoundException
import de.rki.coronawarnapp.vaccination.core.repository.errors.VaccinationCertificateNotFoundException
import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinatedPersonData
import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinationContainer
import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinationStorage
import de.rki.coronawarnapp.vaccination.core.repository.storage.toProofContainer
import de.rki.coronawarnapp.vaccination.core.repository.storage.toVaccinationContainer
......@@ -122,9 +123,7 @@ class VaccinationRepository @Inject constructor(
if (updatedPerson.isEligbleForProofCertificate) {
Timber.tag(TAG).i("%s is eligble for proof certificate, launching async check.", updatedPerson.identifier)
appScope.launch {
refresh(updatedPerson.identifier)
}
appScope.launch { refresh(updatedPerson.identifier) }
}
return updatedPerson.vaccinationCertificates.single {
......@@ -132,7 +131,7 @@ class VaccinationRepository @Inject constructor(
}
}
suspend fun checkForProof(personIdentifier: VaccinatedPersonIdentifier?) {
private suspend fun checkForProof(personIdentifier: VaccinatedPersonIdentifier?) {
Timber.tag(TAG).i("checkForProof(personIdentifier=%s)", personIdentifier)
withContext(appScope.coroutineContext) {
internalData.updateBlocking {
......@@ -166,7 +165,10 @@ class VaccinationRepository @Inject constructor(
throw NotImplementedError()
}
suspend fun refresh(personIdentifier: VaccinatedPersonIdentifier?) {
/**
* Passing null as identifier will refresh all available data, if within constraints.
*/
suspend fun refresh(personIdentifier: VaccinatedPersonIdentifier? = null) {
Timber.tag(TAG).d("refresh(personIdentifier=%s)", personIdentifier)
// TODO
}
......@@ -181,6 +183,8 @@ class VaccinationRepository @Inject constructor(
suspend fun deleteVaccinationCertificate(vaccinationCertificateId: String) {
Timber.tag(TAG).w("deleteVaccinationCertificate(certificateId=%s)", vaccinationCertificateId)
var deletedVaccination: VaccinationContainer? = null
internalData.updateBlocking {
val target = this.find { person ->
person.vaccinationCertificates.any { it.certificateId == vaccinationCertificateId }
......@@ -188,6 +192,10 @@ class VaccinationRepository @Inject constructor(
"No vaccination certificate found for $vaccinationCertificateId"
)
deletedVaccination = target.data.vaccinations.single {
it.certificateId != vaccinationCertificateId
}
val newTarget = target.copy(
data = target.data.copy(
vaccinations = target.data.vaccinations.filter {
......@@ -200,6 +208,13 @@ class VaccinationRepository @Inject constructor(
if (it != target) newTarget else it
}.toSet()
}
deletedVaccination?.let {
if (!it.isEligbleForProofCertificate) return
Timber.tag(TAG).i("Deleted vaccination was eligble for proof, refreshing: %s", deletedVaccination)
appScope.launch { refresh(it.personIdentifier) }
}
}
companion object {
......
package de.rki.coronawarnapp
import androidx.work.WorkManager
import dagger.android.DispatchingAndroidInjector
import de.rki.coronawarnapp.appconfig.ConfigChangeDetector
import de.rki.coronawarnapp.appconfig.devicetime.DeviceTimeHandler
import de.rki.coronawarnapp.contactdiary.retention.ContactDiaryWorkScheduler
import de.rki.coronawarnapp.coronatest.CoronaTestRepository
import de.rki.coronawarnapp.coronatest.notification.ShareTestResultNotificationService
import de.rki.coronawarnapp.coronatest.type.pcr.execution.PCRResultScheduler
import de.rki.coronawarnapp.coronatest.type.pcr.notification.PCRTestResultAvailableNotificationService
import de.rki.coronawarnapp.coronatest.type.rapidantigen.execution.RAResultScheduler
import de.rki.coronawarnapp.coronatest.type.rapidantigen.notification.RATTestResultAvailableNotificationService
import de.rki.coronawarnapp.datadonation.analytics.worker.DataDonationAnalyticsScheduler
import de.rki.coronawarnapp.deadman.DeadmanNotificationScheduler
import de.rki.coronawarnapp.notification.GeneralNotifications
import de.rki.coronawarnapp.presencetracing.checkins.checkout.auto.AutoCheckOut
import de.rki.coronawarnapp.presencetracing.risk.execution.PresenceTracingRiskWorkScheduler
import de.rki.coronawarnapp.presencetracing.storage.retention.TraceLocationDbCleanUpScheduler
import de.rki.coronawarnapp.risk.RiskLevelChangeDetector
import de.rki.coronawarnapp.risk.execution.ExposureWindowRiskWorkScheduler
import de.rki.coronawarnapp.submission.auto.AutoSubmission
import de.rki.coronawarnapp.task.TaskController
import de.rki.coronawarnapp.util.CWADebug
import de.rki.coronawarnapp.util.WatchdogService
import de.rki.coronawarnapp.util.device.ForegroundState
import de.rki.coronawarnapp.util.di.AppInjector
import de.rki.coronawarnapp.util.di.ApplicationComponent
import de.rki.coronawarnapp.vaccination.core.execution.VaccinationUpdateScheduler
import io.mockk.MockKAnnotations
import io.mockk.Runs
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.verifySequence
import org.conscrypt.Conscrypt
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import timber.log.Timber
import java.security.Security
class CoronaWarnApplicationTest : BaseTest() {
@MockK lateinit var applicationComponent: ApplicationComponent
@MockK lateinit var androidInjector: DispatchingAndroidInjector<Any>
@MockK lateinit var watchdogService: WatchdogService
@MockK lateinit var taskController: TaskController
@MockK lateinit var foregroundState: ForegroundState
@MockK lateinit var workManager: WorkManager
@MockK lateinit var configChangeDetector: ConfigChangeDetector
@MockK lateinit var riskLevelChangeDetector: RiskLevelChangeDetector
@MockK lateinit var deadmanNotificationScheduler: DeadmanNotificationScheduler
@MockK lateinit var contactDiaryWorkScheduler: ContactDiaryWorkScheduler
@MockK lateinit var dataDonationAnalyticsScheduler: DataDonationAnalyticsScheduler
@MockK lateinit var notificationHelper: GeneralNotifications
@MockK lateinit var deviceTimeHandler: DeviceTimeHandler
@MockK lateinit var autoSubmission: AutoSubmission
@MockK lateinit var coronaTestRepository: CoronaTestRepository
@MockK lateinit var autoCheckOut: AutoCheckOut
@MockK lateinit var traceLocationDbCleanupScheduler: TraceLocationDbCleanUpScheduler
@MockK lateinit var shareTestResultNotificationService: ShareTestResultNotificationService
@MockK lateinit var exposureWindowRiskWorkScheduler: ExposureWindowRiskWorkScheduler
@MockK lateinit var presenceTracingRiskWorkScheduler: PresenceTracingRiskWorkScheduler
@MockK lateinit var pcrTestResultScheduler: PCRResultScheduler
@MockK lateinit var raTestResultScheduler: RAResultScheduler
@MockK lateinit var pcrTestResultAvailableNotificationService: PCRTestResultAvailableNotificationService
@MockK lateinit var raTestResultAvailableNotificationService: RATTestResultAvailableNotificationService
@MockK lateinit var vaccinationUpdateScheduler: VaccinationUpdateScheduler
@MockK lateinit var rollingLogHistory: Timber.Tree
@BeforeEach
fun setup() {
MockKAnnotations.init(this, relaxed = true)
mockkStatic(Conscrypt::class)
every { Conscrypt.newProvider() } returns mockk()
mockkStatic(Security::class)
every { Security.insertProviderAt(any(), any()) } returns 0
mockkObject(CWADebug)
CWADebug.apply {
every { init(any()) } just Runs
every { initAfterInjection(any()) } just Runs
}
mockkObject(AppInjector)
AppInjector.apply {
every { init(any()) } returns applicationComponent
}
applicationComponent.apply {
every { inject(any<CoronaWarnApplication>()) } answers {
val app = arg<CoronaWarnApplication>(0)
app.component = applicationComponent
app.androidInjector = androidInjector
app.watchdogService = watchdogService
app.taskController = taskController
app.foregroundState = foregroundState
app.workManager = workManager
app.configChangeDetector = configChangeDetector
app.riskLevelChangeDetector = riskLevelChangeDetector
app.deadmanNotificationScheduler = deadmanNotificationScheduler
app.contactDiaryWorkScheduler = contactDiaryWorkScheduler
app.dataDonationAnalyticsScheduler = dataDonationAnalyticsScheduler
app.notificationHelper = notificationHelper
app.deviceTimeHandler = deviceTimeHandler
app.autoSubmission = autoSubmission
app.coronaTestRepository = coronaTestRepository
app.autoCheckOut = autoCheckOut
app.traceLocationDbCleanupScheduler = traceLocationDbCleanupScheduler
app.shareTestResultNotificationService = shareTestResultNotificationService
app.exposureWindowRiskWorkScheduler = exposureWindowRiskWorkScheduler
app.presenceTracingRiskWorkScheduler = presenceTracingRiskWorkScheduler
app.pcrTestResultScheduler = pcrTestResultScheduler
app.raTestResultScheduler = raTestResultScheduler
app.pcrTestResultAvailableNotificationService = pcrTestResultAvailableNotificationService
app.raTestResultAvailableNotificationService = raTestResultAvailableNotificationService
app.vaccinationUpdateScheduler = vaccinationUpdateScheduler
app.rollingLogHistory = object : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
// NOOP
}
}
}
}
}
private fun createInstance() = CoronaWarnApplication()
@Test
fun `all setups are called`() {
createInstance().onCreate()
verifySequence {
watchdogService.launch()
contactDiaryWorkScheduler.setup()
deadmanNotificationScheduler.setup()
exposureWindowRiskWorkScheduler.setup()
presenceTracingRiskWorkScheduler.setup()
pcrTestResultScheduler.setup()
raTestResultScheduler.setup()
pcrTestResultAvailableNotificationService.setup()
raTestResultAvailableNotificationService.setup()
vaccinationUpdateScheduler.setup()
deviceTimeHandler.launch()
configChangeDetector.launch()
riskLevelChangeDetector.launch()
autoSubmission.setup()
autoCheckOut.setupMonitor()
traceLocationDbCleanupScheduler.scheduleDaily()
shareTestResultNotificationService.setup()
}
}
}
......@@ -28,6 +28,7 @@ import de.rki.coronawarnapp.submission.SubmissionRepository
import de.rki.coronawarnapp.task.TaskController
import de.rki.coronawarnapp.util.di.AppContext
import de.rki.coronawarnapp.util.serialization.BaseGson
import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository
import io.github.classgraph.ClassGraph
import io.kotest.matchers.collections.shouldContainAll
import io.mockk.mockk
......@@ -167,4 +168,7 @@ class MockProvider {
@Provides
fun ratResultScheduler(): RAResultScheduler = mockk()
@Provides
fun vaccinationRepository(): VaccinationRepository = mockk()
}
package de.rki.coronawarnapp.vaccination.core.execution
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkInfo
import androidx.work.WorkManager
import de.rki.coronawarnapp.task.TaskController
import de.rki.coronawarnapp.task.TaskFactory
import de.rki.coronawarnapp.task.TaskRequest
import de.rki.coronawarnapp.util.TimeStamper
import de.rki.coronawarnapp.util.device.ForegroundState
import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson
import de.rki.coronawarnapp.vaccination.core.execution.task.VaccinationUpdateTask
import de.rki.coronawarnapp.vaccination.core.execution.worker.VaccinationUpdateWorkerRequestBuilder
import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository
import io.kotest.matchers.shouldBe
import io.mockk.MockKAnnotations
import io.mockk.Runs
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import org.joda.time.Instant
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.coroutines.runBlockingTest2
import testhelpers.gms.MockListenableFuture
class VaccinationUpdateSchedulerTest : BaseTest() {
@MockK lateinit var taskController: TaskController
@MockK lateinit var vaccinationRepository: VaccinationRepository
@MockK lateinit var foregroundState: ForegroundState
@MockK lateinit var workManager: WorkManager
@MockK lateinit var workerRequestBuilder: VaccinationUpdateWorkerRequestBuilder
@MockK lateinit var workInfo: WorkInfo
@MockK lateinit var periodicWorkRequest: PeriodicWorkRequest
@MockK lateinit var timeStamper: TimeStamper
private val nowUTC = Instant.ofEpochSecond(1611764225)
private val vaccinationInfosFlow = MutableStateFlow(emptySet<VaccinatedPerson>())
private val foregroundStateFlow = MutableStateFlow(false)
private val taskRequestSlot = slot<TaskRequest>()
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
// Happy path is no proofs, no executions / workers
every { taskController.submit(any()) } just Runs
every { timeStamper.nowUTC } returns nowUTC
every { workerRequestBuilder.createPeriodicWorkRequest() } returns periodicWorkRequest
workManager.apply {
every { enqueueUniquePeriodicWork(any(), any(), periodicWorkRequest) } returns mockk()
every { cancelUniqueWork(any()) } returns mockk()
every { getWorkInfosForUniqueWork(any()) } returns MockListenableFuture.forResult(listOf(workInfo))
}
every { workInfo.state } returns WorkInfo.State.SUCCEEDED
vaccinationRepository.apply {
every { vaccinationInfos } returns vaccinationInfosFlow
}
every { foregroundState.isInForeground } returns foregroundStateFlow
every { taskController.submit(any()) } just Runs
}
private fun createInstance(scope: CoroutineScope) = VaccinationUpdateScheduler(
appScope = scope,
taskController = taskController,
vaccinationRepository = vaccinationRepository,
foregroundState = foregroundState,
workManager = workManager,
workerRequestBuilder = workerRequestBuilder,
timeStamper = timeStamper,
)
private fun mockPerson(
isEligbleForPC: Boolean,
hasPendingCheck: Boolean = false,
lastProofCheckTime: Instant = Instant.now()
): VaccinatedPerson = mockk<VaccinatedPerson>().apply {
every { isEligbleForProofCertificate } returns isEligbleForPC
every { isProofCertificateCheckPending } returns hasPendingCheck
every { lastProofCheckAt } returns lastProofCheckTime
}
@Test
fun `the worker is canceled if there is no elligble vaccination certificate`() =
runBlockingTest2(ignoreActive = true) {
val instance = createInstance(scope = this)
instance.setup()
verify {
workManager.cancelUniqueWork("VaccinationUpdateWorker")
}
}
@Test
fun `any pending proofs cause the worker to be scheduled`() = runBlockingTest2(ignoreActive = true) {
vaccinationInfosFlow.value = setOf(mockPerson(hasPendingCheck = true, isEligbleForPC = true))
createInstance(scope = this).setup()
verify {
workManager.enqueueUniquePeriodicWork(
"VaccinationUpdateWorker",
ExistingPeriodicWorkPolicy.KEEP,
periodicWorkRequest
)
}
}
@Test
fun `reaching foreground state with pending proofs causes immediate refresh`() =
runBlockingTest2(ignoreActive = true) {
vaccinationInfosFlow.value = setOf(mockPerson(hasPendingCheck = true, isEligbleForPC = true))
createInstance(scope = this).setup()
verify(exactly = 0) { taskController.submit(any()) }
foregroundStateFlow.value = true
advanceUntilIdle()
verify { taskController.submit(capture(taskRequestSlot)) }
taskRequestSlot.captured.apply {
type shouldBe VaccinationUpdateTask::class
errorHandling shouldBe TaskFactory.Config.ErrorHandling.SILENT
}
}
@Test
fun `reaching foreground state with stale proof data causes immediate refresh`() =
runBlockingTest2(ignoreActive = true) {
vaccinationInfosFlow.value = setOf(mockPerson(isEligbleForPC = true, lastProofCheckTime = Instant.EPOCH))
createInstance(scope = this).setup()
verify(exactly = 0) { taskController.submit(any()) }
foregroundStateFlow.value = true
advanceUntilIdle()
verify { taskController.submit(capture(taskRequestSlot)) }
taskRequestSlot.captured.apply {
type shouldBe VaccinationUpdateTask::class
errorHandling shouldBe TaskFactory.Config.ErrorHandling.SILENT
}
}
}
package de.rki.coronawarnapp.vaccination.core.execution.task
import de.rki.coronawarnapp.util.TimeStamper
import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository
import io.mockk.MockKAnnotations
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.just
import kotlinx.coroutines.test.runBlockingTest
import org.joda.time.Instant
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class VaccinationUpdateTaskTest : BaseTest() {
@MockK lateinit var timeStamper: TimeStamper
@MockK lateinit var vaccinationRepository: VaccinationRepository
private val currentInstant = Instant.ofEpochSecond(1611764225)
@BeforeEach
fun setUp() {
MockKAnnotations.init(this)
every { timeStamper.nowUTC } returns currentInstant
coEvery { vaccinationRepository.refresh(any()) } just Runs
}
private fun createInstance() = VaccinationUpdateTask(
timeStamper = timeStamper,
vaccinationRepository = vaccinationRepository
)
@Test
fun `task calls generic refresh`() = runBlockingTest {
val task = createInstance()
task.run(VaccinationUpdateTask.Arguments)
coVerify { vaccinationRepository.refresh(null) }
}
}
package de.rki.coronawarnapp.vaccination.core.execution.worker
import android.content.Context
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
import de.rki.coronawarnapp.task.TaskController
import de.rki.coronawarnapp.task.TaskFactory
import de.rki.coronawarnapp.task.TaskRequest
import de.rki.coronawarnapp.task.TaskState
import de.rki.coronawarnapp.task.common.DefaultTaskRequest
import de.rki.coronawarnapp.task.submitBlocking
import de.rki.coronawarnapp.vaccination.core.execution.task.VaccinationUpdateTask
import io.kotest.matchers.shouldBe
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockkStatic
import io.mockk.slot
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class VaccinationUpdateWorkerTest : BaseTest() {
@MockK lateinit var context: Context
@MockK(relaxed = true) lateinit var workerParams: WorkerParameters
@MockK lateinit var taskController: TaskController
@MockK lateinit var taskState: TaskState
private val taskRequestSlot = slot<TaskRequest>()
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
mockkStatic("de.rki.coronawarnapp.task.TaskControllerExtensionsKt")
coEvery { taskController.submitBlocking(capture(taskRequestSlot)) } answers { taskState }
taskState.apply {
every { isSuccessful } returns true
every { error } returns null
}
}
private fun createWorker() = VaccinationUpdateWorker(
context = context,
workerParams = workerParams,
taskController = taskController,
)
@Test
fun `sideeffect free`() {
createWorker()
}
@Test
fun `task is run blockingly`() = runBlockingTest {
val worker = createWorker()
worker.doWork() shouldBe ListenableWorker.Result.success()
taskRequestSlot.captured shouldBe DefaultTaskRequest(
id = taskRequestSlot.captured.id,
type = VaccinationUpdateTask::class,
arguments = VaccinationUpdateTask.Arguments,
errorHandling = TaskFactory.Config.ErrorHandling.SILENT,
originTag = "VaccinationUpdateWorker"
)
}
@Test
fun `task errors are rethrown `() = runBlockingTest {
taskState.apply {
every { isSuccessful } returns false
every { error } returns Exception()
}
val worker = createWorker()
worker.doWork() shouldBe ListenableWorker.Result.retry()
coVerify {
taskController.submitBlocking(any())
}
}
}
package testhelpers.gms
import com.google.common.util.concurrent.ListenableFuture
import io.mockk.every
import io.mockk.mockk
import java.util.concurrent.Executor
object MockListenableFuture {
fun <T> forResult(result: T) = mockk<ListenableFuture<T>>().apply {
every { isDone } returns true
every { get() } returns result
every { get(any(), any()) } returns result
every { isCancelled } returns false
every { cancel(any()) } returns true
every { addListener(any(), any()) } answers {
val listener = arg<Runnable>(0)
val executor = arg<Executor>(1)
listener.run()
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment