From 14a86568e6cae7d732012386f08785642d9d1305 Mon Sep 17 00:00:00 2001 From: Aidan Follestad Date: Sat, 1 Dec 2018 19:58:09 -0800 Subject: [PATCH] Unit tests for CheckStatusManager --- .../engine/statuscheck/CheckStatusManager.kt | 38 +-- .../nocknock/engine/statuscheck/JobInfo.kt | 25 -- .../nocknock/engine/CheckStatusManagerTest.kt | 291 ++++++++++++++++++ .../afollestad/nocknock/engine/TestUtil.kt | 58 ++++ .../nocknock/utilities/UtilitiesModule.kt | 16 + .../utilities/providers/BundleProvider.kt | 39 +++ .../utilities/providers/JobInfoProvider.kt | 53 ++++ 7 files changed, 474 insertions(+), 46 deletions(-) delete mode 100644 engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/JobInfo.kt create mode 100644 engine/src/test/java/com/afollestad/nocknock/engine/CheckStatusManagerTest.kt create mode 100644 engine/src/test/java/com/afollestad/nocknock/engine/TestUtil.kt create mode 100644 utilities/src/main/java/com/afollestad/nocknock/utilities/providers/BundleProvider.kt create mode 100644 utilities/src/main/java/com/afollestad/nocknock/utilities/providers/JobInfoProvider.kt diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusManager.kt b/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusManager.kt index 11e21a4..39e22fd 100644 --- a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusManager.kt +++ b/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusManager.kt @@ -5,17 +5,16 @@ */ package com.afollestad.nocknock.engine.statuscheck -import android.app.Application -import android.app.job.JobInfo.NETWORK_TYPE_ANY import android.app.job.JobScheduler import android.app.job.JobScheduler.RESULT_SUCCESS -import android.os.PersistableBundle import com.afollestad.nocknock.data.ServerModel import com.afollestad.nocknock.data.ServerStatus.ERROR import com.afollestad.nocknock.data.ServerStatus.OK import com.afollestad.nocknock.engine.R import com.afollestad.nocknock.engine.db.ServerModelStore import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_SITE_ID +import com.afollestad.nocknock.utilities.providers.BundleProvider +import com.afollestad.nocknock.utilities.providers.JobInfoProvider import com.afollestad.nocknock.utilities.providers.StringProvider import okhttp3.OkHttpClient import okhttp3.Request @@ -38,7 +37,7 @@ interface CheckStatusManager { fun scheduleCheck( site: ServerModel, rightNow: Boolean = false, - cancelPrevious: Boolean = false, + cancelPrevious: Boolean = rightNow, fromFinishingJob: Boolean = false ) @@ -48,10 +47,11 @@ interface CheckStatusManager { } class RealCheckStatusManager @Inject constructor( - private val app: Application, private val jobScheduler: JobScheduler, private val okHttpClient: OkHttpClient, private val stringProvider: StringProvider, + private val bundleProvider: BundleProvider, + private val jobInfoProvider: JobInfoProvider, private val siteStore: ServerModelStore ) : CheckStatusManager { @@ -90,25 +90,21 @@ class RealCheckStatusManager @Inject constructor( } log("Requesting a check job for site to be scheduled: $site") - val extras = PersistableBundle().apply { + val extras = bundleProvider.createPersistable { putInt(KEY_SITE_ID, site.id) } + val jobInfo = jobInfoProvider.createCheckJob( + id = site.id, + onlyUnmeteredNetwork = false, + delayMs = if (rightNow) { + 1 + } else { + site.checkInterval + }, + extras = extras, + target = CheckStatusJob::class.java + ) - // Note: we don't use the periodic feature of JobScheduler because it requires a - // minimum of 15 minutes between each execution which may not be what's requested by the - // user of the app. - val jobInfo = jobInfo(app, site.id, CheckStatusJob::class.java) { - setRequiredNetworkType(NETWORK_TYPE_ANY) - if (rightNow) { - log(">> Job for site ${site.id} should be executed now") - setMinimumLatency(1) - } else { - log(">> Job for site ${site.id} should be in ${site.checkInterval}ms") - setMinimumLatency(site.checkInterval) - } - setExtras(extras) - setPersisted(true) - } val dispatchResult = jobScheduler.schedule(jobInfo) if (dispatchResult != RESULT_SUCCESS) { log("Failed to schedule a check job for site: ${site.id}") diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/JobInfo.kt b/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/JobInfo.kt deleted file mode 100644 index e13cac2..0000000 --- a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/JobInfo.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Licensed under Apache-2.0 - * - * Designed and developed by Aidan Follestad (@afollestad) - */ -package com.afollestad.nocknock.engine.statuscheck - -import android.app.job.JobInfo -import android.app.job.JobService -import android.content.ComponentName -import android.content.Context - -typealias JobInfoBuilder = JobInfo.Builder - -fun jobInfo( - context: Context, - id: Int, - target: Class, - exec: JobInfoBuilder.() -> JobInfoBuilder -): JobInfo { - val component = ComponentName(context, target) - val builder = JobInfo.Builder(id, component) - exec(builder) - return builder.build() -} diff --git a/engine/src/test/java/com/afollestad/nocknock/engine/CheckStatusManagerTest.kt b/engine/src/test/java/com/afollestad/nocknock/engine/CheckStatusManagerTest.kt new file mode 100644 index 0000000..d931a50 --- /dev/null +++ b/engine/src/test/java/com/afollestad/nocknock/engine/CheckStatusManagerTest.kt @@ -0,0 +1,291 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.engine + +import android.app.job.JobInfo +import android.app.job.JobScheduler +import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.data.ServerStatus.ERROR +import com.afollestad.nocknock.data.ServerStatus.OK +import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE +import com.afollestad.nocknock.engine.db.ServerModelStore +import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_SITE_ID +import com.afollestad.nocknock.engine.statuscheck.RealCheckStatusManager +import com.afollestad.nocknock.utilities.providers.StringProvider +import com.google.common.truth.Truth.assertThat +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.runBlocking +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Protocol.HTTP_2 +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import org.junit.Test +import java.net.SocketTimeoutException + +class CheckStatusManagerTest { + + private val timeoutError = "Oh no, a timeout" + + private val jobScheduler = mock() + private val okHttpClient = mock() + private val stringProvider = mock { + on { get(R.string.timeout) } doReturn timeoutError + } + private val bundleProvider = testBundleProvider() + private val jobInfoProvider = testJobInfoProvider() + private val store = mock() + + private val manager = RealCheckStatusManager( + jobScheduler, + okHttpClient, + stringProvider, + bundleProvider, + jobInfoProvider, + store + ) + + @Test fun ensureScheduledChecks_noEnabledSites() = runBlocking { + val model1 = fakeModel().copy(disabled = true) + whenever(store.get()).doReturn(listOf(model1)) + + manager.ensureScheduledChecks() + + verifyNoMoreInteractions(jobScheduler) + } + + @Test fun ensureScheduledChecks_sitesAlreadyHaveJobs() = runBlocking { + val model1 = fakeModel() + val job1 = fakeJob(model1.id) + whenever(store.get()).doReturn(listOf(model1)) + whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1)) + + manager.ensureScheduledChecks() + + verify(jobScheduler, never()).schedule(any()) + } + + @Test fun ensureScheduledChecks() = runBlocking { + val model1 = fakeModel() + whenever(store.get()).doReturn(listOf(model1)) + whenever(jobScheduler.allPendingJobs).doReturn(listOf()) + + manager.ensureScheduledChecks() + + val jobCaptor = argumentCaptor() + verify(jobScheduler).schedule(jobCaptor.capture()) + val jobInfo = jobCaptor.allValues.single() + assertThat(jobInfo.id).isEqualTo(model1.id) + assertThat(jobInfo.extras.getInt(KEY_SITE_ID)).isEqualTo(model1.id) + } + + @Test fun scheduleCheck_rightNow() { + val model1 = fakeModel() + whenever(jobScheduler.allPendingJobs).doReturn(listOf()) + + manager.scheduleCheck( + site = model1, + rightNow = true + ) + + val jobCaptor = argumentCaptor() + verify(jobScheduler).schedule(jobCaptor.capture()) + verify(jobScheduler).cancel(model1.id) + + val jobInfo = jobCaptor.allValues.single() + assertThat(jobInfo.id).isEqualTo(model1.id) + assertThat(jobInfo.extras.getInt(KEY_SITE_ID)).isEqualTo(model1.id) + } + + @Test(expected = IllegalStateException::class) + fun scheduleCheck_notFromFinishingJob_haveExistingJob() { + val model1 = fakeModel() + val job1 = fakeJob(model1.id) + whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1)) + + manager.scheduleCheck( + site = model1, + fromFinishingJob = false + ) + } + + @Test fun scheduleCheck_fromFinishingJob_haveExistingJob() { + val model1 = fakeModel() + val job1 = fakeJob(model1.id) + whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1)) + + manager.scheduleCheck( + site = model1, + fromFinishingJob = true + ) + + val jobCaptor = argumentCaptor() + verify(jobScheduler).schedule(jobCaptor.capture()) + verify(jobScheduler, never()).cancel(model1.id) + + val jobInfo = jobCaptor.allValues.single() + assertThat(jobInfo.id).isEqualTo(model1.id) + assertThat(jobInfo.extras.getInt(KEY_SITE_ID)).isEqualTo(model1.id) + } + + @Test fun scheduleCheck() { + val model1 = fakeModel() + whenever(jobScheduler.allPendingJobs).doReturn(listOf()) + + manager.scheduleCheck( + site = model1, + fromFinishingJob = true + ) + + val jobCaptor = argumentCaptor() + verify(jobScheduler).schedule(jobCaptor.capture()) + verify(jobScheduler, never()).cancel(model1.id) + + val jobInfo = jobCaptor.allValues.single() + assertThat(jobInfo.id).isEqualTo(model1.id) + assertThat(jobInfo.extras.getInt(KEY_SITE_ID)).isEqualTo(model1.id) + } + + @Test fun cancelCheck() { + val model1 = fakeModel() + manager.cancelCheck(model1) + verify(jobScheduler).cancel(model1.id) + } + + @Test fun performCheck_httpNotSuccess() = runBlocking { + val response = fakeResponse(500, "Internal Server Error", "Hello World") + val call = mock { + on { execute() } doReturn response + } + whenever(okHttpClient.newCall(any())).doReturn(call) + + val model1 = fakeModel() + val result = manager.performCheck(model1) + + assertThat(result.model).isEqualTo( + model1.copy( + status = ERROR, + reason = "Response 500 - Hello World" + ) + ) + } + + @Test fun performCheck_socketTimeout() = runBlocking { + val error = SocketTimeoutException("Oh no!") + val call = mock { + on { execute() } doAnswer { throw error } + } + whenever(okHttpClient.newCall(any())).doReturn(call) + + val model1 = fakeModel() + val result = manager.performCheck(model1) + + assertThat(result.model).isEqualTo( + model1.copy( + status = ERROR, + reason = timeoutError + ) + ) + } + + @Test fun performCheck_exception() = runBlocking { + val error = Exception("Oh no!") + val call = mock { + on { execute() } doAnswer { throw error } + } + whenever(okHttpClient.newCall(any())).doReturn(call) + + val model1 = fakeModel() + val result = manager.performCheck(model1) + + assertThat(result.model).isEqualTo( + model1.copy( + status = ERROR, + reason = "Oh no!" + ) + ) + } + + @Test fun performCheck_success() = runBlocking { + val response = fakeResponse(200, "OK", "Hello World") + val call = mock { + on { execute() } doReturn response + } + whenever(okHttpClient.newCall(any())).doReturn(call) + + val model1 = fakeModel() + val result = manager.performCheck(model1) + + assertThat(result.model).isEqualTo( + model1.copy( + status = OK, + reason = null + ) + ) + } + + @Test fun performCheck_401_butStillSuccess() = runBlocking { + val response = fakeResponse(401, "Unauthorized", "Hello World") + val call = mock { + on { execute() } doReturn response + } + whenever(okHttpClient.newCall(any())).doReturn(call) + + val model1 = fakeModel() + val result = manager.performCheck(model1) + + assertThat(result.model).isEqualTo( + model1.copy( + status = OK, + reason = null + ) + ) + } + + private fun fakeResponse( + code: Int, + message: String, + body: String? + ): Response { + val responseBody = if (body != null) { + ResponseBody.create(null, body) + } else { + null + } + val request = Request.Builder() + .url("https://placeholder.com") + .build() + return Response.Builder() + .protocol(HTTP_2) + .request(request) + .message(message) + .code(code) + .body(responseBody) + .build() + } + + private fun fakeModel() = ServerModel( + id = 1, + name = "Wakanda Forever", + url = "https://www.wakanda.gov", + validationMode = STATUS_CODE + ) + + private fun fakeJob(id: Int): JobInfo { + return mock { + on { this.id } doReturn id + } + } +} diff --git a/engine/src/test/java/com/afollestad/nocknock/engine/TestUtil.kt b/engine/src/test/java/com/afollestad/nocknock/engine/TestUtil.kt new file mode 100644 index 0000000..e71219d --- /dev/null +++ b/engine/src/test/java/com/afollestad/nocknock/engine/TestUtil.kt @@ -0,0 +1,58 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.engine + +import android.app.job.JobInfo +import android.content.ComponentName +import android.os.PersistableBundle +import com.afollestad.nocknock.utilities.providers.BundleProvider +import com.afollestad.nocknock.utilities.providers.IBundle +import com.afollestad.nocknock.utilities.providers.IBundler +import com.afollestad.nocknock.utilities.providers.JobInfoProvider +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever + +fun testBundleProvider(): BundleProvider { + val provider = mock() + whenever(provider.createPersistable(any())).doAnswer { + val realBundle = mock() + val creator = it.getArgument(0) + creator(object : IBundle { + override fun putInt( + key: String, + value: Int + ) { + whenever(realBundle.getInt(key)).doReturn(value) + } + }) + return@doAnswer realBundle + } + return provider +} + +fun testJobInfoProvider(): JobInfoProvider { + val provider = mock() + whenever(provider.createCheckJob(any(), any(), any(), any(), any())).doAnswer { inv -> + val jobInfo = mock() + val id = inv.getArgument(0) + val delay = inv.getArgument(2) + val extras = inv.getArgument(3) + val target = inv.getArgument>(4) + val component = mock() + whenever(component.className).doReturn(target.name) + + whenever(jobInfo.id).doReturn(id) + whenever(jobInfo.minLatencyMillis).doReturn(delay) + whenever(jobInfo.extras).doReturn(extras) + whenever(jobInfo.service).doReturn(component) + + return@doAnswer jobInfo + } + return provider +} diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/UtilitiesModule.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/UtilitiesModule.kt index 6e7defe..03e77b9 100644 --- a/utilities/src/main/java/com/afollestad/nocknock/utilities/UtilitiesModule.kt +++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/UtilitiesModule.kt @@ -6,11 +6,15 @@ package com.afollestad.nocknock.utilities import com.afollestad.nocknock.utilities.providers.BitmapProvider +import com.afollestad.nocknock.utilities.providers.BundleProvider import com.afollestad.nocknock.utilities.providers.IntentProvider +import com.afollestad.nocknock.utilities.providers.JobInfoProvider import com.afollestad.nocknock.utilities.providers.NotificationChannelProvider import com.afollestad.nocknock.utilities.providers.NotificationProvider import com.afollestad.nocknock.utilities.providers.RealBitmapProvider +import com.afollestad.nocknock.utilities.providers.RealBundleProvider import com.afollestad.nocknock.utilities.providers.RealIntentProvider +import com.afollestad.nocknock.utilities.providers.RealJobInfoProvider import com.afollestad.nocknock.utilities.providers.RealNotificationChannelProvider import com.afollestad.nocknock.utilities.providers.RealNotificationProvider import com.afollestad.nocknock.utilities.providers.RealSdkProvider @@ -60,4 +64,16 @@ abstract class UtilitiesModule { abstract fun provideNotificationProvider( notificationProvider: RealNotificationProvider ): NotificationProvider + + @Binds + @Singleton + abstract fun provideBundleProvider( + bundleProvider: RealBundleProvider + ): BundleProvider + + @Binds + @Singleton + abstract fun provideJobInfoProvider( + jobInfoProvider: RealJobInfoProvider + ): JobInfoProvider } diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/BundleProvider.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/BundleProvider.kt new file mode 100644 index 0000000..1185723 --- /dev/null +++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/BundleProvider.kt @@ -0,0 +1,39 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.utilities.providers + +import android.os.PersistableBundle +import javax.inject.Inject + +interface IBundle { + fun putInt( + key: String, + value: Int + ) +} + +typealias IBundler = IBundle.() -> Unit + +/** @author Aidan Follestad (@afollestad) */ +interface BundleProvider { + + fun createPersistable(builder: IBundle.() -> Unit): PersistableBundle +} + +/** @author Aidan Follestad (@afollestad) */ +class RealBundleProvider @Inject constructor() : BundleProvider { + + override fun createPersistable(bundler: IBundler): PersistableBundle { + val realBundle = PersistableBundle() + bundler(object : IBundle { + override fun putInt( + key: String, + value: Int + ) = realBundle.putInt(key, value) + }) + return realBundle + } +} diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/JobInfoProvider.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/JobInfoProvider.kt new file mode 100644 index 0000000..4b188de --- /dev/null +++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/JobInfoProvider.kt @@ -0,0 +1,53 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.utilities.providers + +import android.app.Application +import android.app.job.JobInfo +import android.app.job.JobInfo.NETWORK_TYPE_ANY +import android.app.job.JobInfo.NETWORK_TYPE_UNMETERED +import android.content.ComponentName +import android.os.PersistableBundle +import javax.inject.Inject + +interface JobInfoProvider { + + fun createCheckJob( + id: Int, + onlyUnmeteredNetwork: Boolean, + delayMs: Long, + extras: PersistableBundle, + target: Class<*> + ): JobInfo +} + +class RealJobInfoProvider @Inject constructor( + private val app: Application +) : JobInfoProvider { + + // Note: we don't use the periodic feature of JobScheduler because it requires a + // minimum of 15 minutes between each execution which may not be what's requested by the + // user of the app. + override fun createCheckJob( + id: Int, + onlyUnmeteredNetwork: Boolean, + delayMs: Long, + extras: PersistableBundle, + target: Class<*> + ): JobInfo { + val component = ComponentName(app, target) + val networkType = if (onlyUnmeteredNetwork) { + NETWORK_TYPE_UNMETERED + } else { + NETWORK_TYPE_ANY + } + return JobInfo.Builder(id, component) + .setRequiredNetworkType(networkType) + .setMinimumLatency(delayMs) + .setExtras(extras) + .build() + } +}