Skip to content

Commit

Permalink
For mozilla-mobile#12258 - Retry deleting Pocket profile if initially…
Browse files Browse the repository at this point in the history
… failed

It's important to ensure the profile is deleted when the sponsored stories
feature is disabled but since this involves a network call which may fail we
need to support retrying a previous failed request until profile deletion is
successful or the sponsored stories functionality is started again.
  • Loading branch information
Mugurell committed May 31, 2022
1 parent 588c2c4 commit 7342567
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ class PocketStoriesService(
}

GlobalDependencyProvider.SponsoredStories.initialize(useCases)
spocsRefreshscheduler.stopProfileDeletion(context)
spocsRefreshscheduler.schedulePeriodicRefreshes(context)
}

Expand All @@ -114,7 +115,6 @@ class PocketStoriesService(
*/
fun stopPeriodicSponsoredStoriesRefresh() {
spocsRefreshscheduler.stopPeriodicRefreshes(context)
GlobalDependencyProvider.SponsoredStories.reset()
}

/**
Expand All @@ -126,10 +126,18 @@ class PocketStoriesService(

/**
* Delete all stored user data used for downloading personalized sponsored stories.
* This returns immediately but will handle the profile deletion in background.
*/
suspend fun deleteProfile(): Boolean {
stopPeriodicSponsoredStoriesRefresh()
return spocsUseCases?.deleteProfile?.invoke() ?: false
fun deleteProfile() {
val useCases = spocsUseCases
if (useCases == null) {
logger.warn("Cannot delete sponsored stories profile. Service has incomplete setup")
return
}

GlobalDependencyProvider.SponsoredStories.initialize(useCases)
spocsRefreshscheduler.stopPeriodicRefreshes(context)
spocsRefreshscheduler.scheduleProfileDeletion(context)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package mozilla.components.service.pocket.update

import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mozilla.components.service.pocket.GlobalDependencyProvider

/**
* WorkManager Worker used for deleting the profile used for downloading Pocket sponsored stories.
*/
internal class DeleteSpocsProfileWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {

override suspend fun doWork(): Result {
return withContext(Dispatchers.IO) {
if (GlobalDependencyProvider.SponsoredStories.useCases?.deleteProfile?.invoke() == true) {
Result.success()
} else {
Result.retry()
}
}
}

internal companion object {
const val DELETE_SPOCS_PROFILE_WORK_TAG =
"mozilla.components.feature.pocket.spocs.profile.delete.work.tag"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequest
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import mozilla.components.service.pocket.PocketStoriesConfig
import mozilla.components.service.pocket.logger
import mozilla.components.service.pocket.update.DeleteSpocsProfileWorker.Companion.DELETE_SPOCS_PROFILE_WORK_TAG
import mozilla.components.service.pocket.update.RefreshSpocsWorker.Companion.REFRESH_SPOCS_WORK_TAG
import mozilla.components.support.base.worker.Frequency

Expand All @@ -26,7 +30,7 @@ internal class SpocsRefreshScheduler(
internal fun schedulePeriodicRefreshes(context: Context) {
logger.info("Scheduling sponsored stories background refresh")

val refreshWork = createPeriodicWorkerRequest(
val refreshWork = createPeriodicRefreshWorkerRequest(
frequency = pocketStoriesConfig.sponsoredStoriesRefreshFrequency
)

Expand All @@ -39,8 +43,34 @@ internal class SpocsRefreshScheduler(
.cancelAllWorkByTag(REFRESH_SPOCS_WORK_TAG)
}

internal fun scheduleProfileDeletion(context: Context) {
logger.info("Scheduling sponsored stories profile deletion")

val deleteProfileWork = createOneTimeProfileDeletionWorkerRequest()

getWorkManager(context)
.enqueueUniqueWork(DELETE_SPOCS_PROFILE_WORK_TAG, ExistingWorkPolicy.KEEP, deleteProfileWork)
}

internal fun stopProfileDeletion(context: Context) {
getWorkManager(context)
.cancelAllWorkByTag(DELETE_SPOCS_PROFILE_WORK_TAG)
}

@VisibleForTesting
internal fun createOneTimeProfileDeletionWorkerRequest(): OneTimeWorkRequest {
val constraints = getWorkerConstrains()

return OneTimeWorkRequestBuilder<DeleteSpocsProfileWorker>()
.apply {
setConstraints(constraints)
addTag(DELETE_SPOCS_PROFILE_WORK_TAG)
}
.build()
}

@VisibleForTesting
internal fun createPeriodicWorkerRequest(
internal fun createPeriodicRefreshWorkerRequest(
frequency: Frequency
): PeriodicWorkRequest {
val constraints = getWorkerConstrains()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
import mozilla.components.service.pocket.helpers.assertConstructorsVisibility
import mozilla.components.service.pocket.spocs.SpocsUseCases
import mozilla.components.service.pocket.spocs.SpocsUseCases.DeleteProfile
import mozilla.components.service.pocket.spocs.SpocsUseCases.GetSponsoredStories
import mozilla.components.service.pocket.spocs.SpocsUseCases.RecordImpression
import mozilla.components.service.pocket.stories.PocketStoriesUseCases
Expand All @@ -23,16 +22,13 @@ import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.testContext
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.never
import org.mockito.Mockito.spy
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import java.util.UUID
import kotlin.reflect.KVisibility
Expand Down Expand Up @@ -77,7 +73,7 @@ class PocketStoriesServiceTest {
}

@Test
fun `GIVEN PocketStoriesService is initialized with a valid profile WHEN called to start periodic refreshes THEN persist dependencies and schedule stories refresh`() {
fun `GIVEN PocketStoriesService is initialized with a valid profile WHEN called to start periodic refreshes THEN persist dependencies, cancel profile deletion and schedule stories refresh`() {
val client: Client = mock()
val profileId = UUID.randomUUID()
val appId = "test"
Expand All @@ -97,6 +93,7 @@ class PocketStoriesServiceTest {
service.startPeriodicSponsoredStoriesRefresh()

assertNotNull(GlobalDependencyProvider.SponsoredStories.useCases)
verify(service.spocsRefreshscheduler).stopProfileDeletion(any())
verify(service.spocsRefreshscheduler).schedulePeriodicRefreshes(any())
}

Expand All @@ -119,7 +116,7 @@ class PocketStoriesServiceTest {
}

@Test
fun `GIVEN PocketStoriesService WHEN called to stop periodic refreshes THEN stop refreshing stories and clear dependencies`() {
fun `GIVEN PocketStoriesService WHEN called to stop periodic refreshes THEN stop refreshing stories`() {
// Mock periodic refreshes were started previously and profile details were set.
// Now they will have to be cleaned.
GlobalDependencyProvider.SponsoredStories.initialize(mock())
Expand All @@ -128,7 +125,6 @@ class PocketStoriesServiceTest {
service.stopPeriodicSponsoredStoriesRefresh()

verify(service.spocsRefreshscheduler).stopPeriodicRefreshes(any())
assertNull(GlobalDependencyProvider.SponsoredStories.useCases)
}

@Test
Expand Down Expand Up @@ -168,17 +164,46 @@ class PocketStoriesServiceTest {
}

@Test
fun `GIVEN PocketStoriesService WHEN deleteProfile THEN delegate to spocs useCases`() = runTest {
val mockedService = spy(service)
val noProfileResponse = mockedService.deleteProfile()
assertFalse(noProfileResponse)

val deleteProfileUseCase: DeleteProfile = mock()
doReturn(deleteProfileUseCase).`when`(spocsUseCases).deleteProfile
doReturn(true).`when`(deleteProfileUseCase).invoke()
val existingProfileResponse = mockedService.deleteProfile()
assertTrue(existingProfileResponse)
verify(mockedService, times(2)).stopPeriodicSponsoredStoriesRefresh()
fun `GIVEN PocketStoriesService is initialized with a valid profile WHEN called to delete profile THEN persist dependencies, cancel stories refresh and schedule profile deletion`() {
val client: Client = mock()
val profileId = UUID.randomUUID()
val appId = "test"
val service = PocketStoriesService(
context = testContext,
pocketStoriesConfig = PocketStoriesConfig(
client = client,
profile = Profile(
profileId = profileId,
appId = appId
)
)
).apply {
spocsRefreshscheduler = mock()
}

service.deleteProfile()

assertNotNull(GlobalDependencyProvider.SponsoredStories.useCases)
verify(service.spocsRefreshscheduler).stopPeriodicRefreshes(any())
verify(service.spocsRefreshscheduler).scheduleProfileDeletion(any())
}

@Test
fun `GIVEN PocketStoriesService is initialized with an invalid profile WHEN called to delete profile THEN don't schedule profile deletion and don't persist dependencies`() {
val service = PocketStoriesService(
context = testContext,
pocketStoriesConfig = PocketStoriesConfig(
client = mock(),
profile = null
)
).apply {
spocsRefreshscheduler = mock()
}

service.deleteProfile()

verify(service.spocsRefreshscheduler, never()).scheduleProfileDeletion(any())
assertNull(GlobalDependencyProvider.SponsoredStories.useCases)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package mozilla.components.service.pocket.update

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.work.ListenableWorker.Result
import androidx.work.await
import androidx.work.testing.TestListenableWorkerBuilder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import mozilla.components.service.pocket.GlobalDependencyProvider
import mozilla.components.service.pocket.helpers.assertClassVisibility
import mozilla.components.service.pocket.spocs.SpocsUseCases
import mozilla.components.service.pocket.spocs.SpocsUseCases.DeleteProfile
import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.testContext
import mozilla.components.support.test.rule.MainCoroutineRule
import mozilla.components.support.test.rule.runTestOnMain
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.doReturn
import kotlin.reflect.KVisibility.INTERNAL

@ExperimentalCoroutinesApi // for runTestOnMain
@RunWith(AndroidJUnit4::class)
class DeleteSpocsProfileWorkerTest {
@get:Rule
val mainCoroutineRule = MainCoroutineRule()

@Test
fun `GIVEN a DeleteSpocsProfileWorker THEN its visibility is internal`() {
assertClassVisibility(RefreshSpocsWorker::class, INTERNAL)
}

@Test
fun `GIVEN a DeleteSpocsProfileWorker WHEN profile deletion is successful THEN return success`() = runTestOnMain {
val useCases: SpocsUseCases = mock()
val deleteProfileUseCase: DeleteProfile = mock()
doReturn(true).`when`(deleteProfileUseCase).invoke()
doReturn(deleteProfileUseCase).`when`(useCases).deleteProfile
GlobalDependencyProvider.SponsoredStories.initialize(useCases)
val worker = TestListenableWorkerBuilder<DeleteSpocsProfileWorker>(testContext).build()

val result = worker.startWork().await()

assertEquals(Result.success(), result)
}

@Test
fun `GIVEN a DeleteSpocsProfileWorker WHEN profile deletion fails THEN work should be retried`() = runTestOnMain {
val useCases: SpocsUseCases = mock()
val deleteProfileUseCase: DeleteProfile = mock()
doReturn(false).`when`(deleteProfileUseCase).invoke()
doReturn(deleteProfileUseCase).`when`(useCases).deleteProfile
GlobalDependencyProvider.SponsoredStories.initialize(useCases)
val worker = TestListenableWorkerBuilder<DeleteSpocsProfileWorker>(testContext).build()

val result = worker.startWork().await()

assertEquals(Result.retry(), result)
}
}
Loading

0 comments on commit 7342567

Please sign in to comment.