diff --git a/example/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/SubscribersRestClientTest.kt b/example/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/SubscribersRestClientTest.kt new file mode 100644 index 0000000000..d91a21a063 --- /dev/null +++ b/example/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/SubscribersRestClientTest.kt @@ -0,0 +1,153 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient.SubscribersResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test + +@RunWith(MockitoJUnitRunner::class) +class SubscribersRestClientTest { + @Mock + private lateinit var dispatcher: Dispatcher + + @Mock + private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + + @Mock + private lateinit var site: SiteModel + + @Mock + private lateinit var requestQueue: RequestQueue + + @Mock + private lateinit var accessToken: AccessToken + + @Mock + private lateinit var userAgent: UserAgent + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: SubscribersRestClient + private val siteId = 12L + private val quantity = 30 + private val currentDateValue = "2022-10-10" + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = SubscribersRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, userAgent + ) + } + + @Test + fun `returns subscribers per day success response`() = test { testSuccessResponse(DAYS) } + + @Test + fun `returns subscribers per day error response`() = test { testErrorResponse(DAYS) } + + @Test + fun `returns subscribers per week success response`() = test { testSuccessResponse(WEEKS) } + + @Test + fun `returns subscribers per week error response`() = test { testErrorResponse(WEEKS) } + + @Test + fun `returns subscribers per month success response`() = test { testSuccessResponse(MONTHS) } + + @Test + fun `returns subscribers per month error response`() = test { testErrorResponse(MONTHS) } + + @Test + fun `returns subscribers per year success response`() = test { testSuccessResponse(YEARS) } + + @Test + fun `returns subscribers per year error response`() = test { testErrorResponse(YEARS) } + + private suspend fun testSuccessResponse(granularity: StatsGranularity) { + val response = mock() + initSubscribersResponse(response) + + val responseModel = restClient.fetchSubscribers(site, granularity, quantity, currentDateValue, false) + + assertThat(responseModel.response).isNotNull() + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/subscribers/") + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf("quantity" to quantity.toString(), "unit" to granularity.toString(), "date" to currentDateValue) + ) + } + + private suspend fun testErrorResponse(period: StatsGranularity) { + val errorMessage = "message" + initSubscribersResponse( + error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR, errorMessage, VolleyError(errorMessage))) + ) + + val responseModel = restClient.fetchSubscribers(site, period, quantity, currentDateValue, false) + + assertThat(responseModel.error).isNotNull() + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initSubscribersResponse( + data: SubscribersResponse? = null, + error: WPComGsonNetworkError? = null + ) = initResponse(SubscribersResponse::class.java, data ?: mock(), error) + + private suspend fun initResponse( + clazz: Class, + data: T, + error: WPComGsonNetworkError? = null, + cachingEnabled: Boolean = false + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(clazz), + eq(cachingEnabled), + any(), + eq(false) + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/example/src/test/java/org/wordpress/android/fluxc/persistance/stats/SubscribersSqlUtilsTest.kt b/example/src/test/java/org/wordpress/android/fluxc/persistance/stats/SubscribersSqlUtilsTest.kt new file mode 100644 index 0000000000..d7ccc40764 --- /dev/null +++ b/example/src/test/java/org/wordpress/android/fluxc/persistance/stats/SubscribersSqlUtilsTest.kt @@ -0,0 +1,76 @@ +package org.wordpress.android.fluxc.persistance.stats + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient.SubscribersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.persistence.StatsRequestSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.SUBSCRIBERS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.DAY +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.MONTH +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.WEEK +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.YEAR +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.SubscribersSqlUtils +import org.wordpress.android.fluxc.store.stats.subscribers.SUBSCRIBERS_RESPONSE +import java.util.Date +import kotlin.test.assertEquals + +private val DATE = Date(0) +private const val DATE_VALUE = "2024-04-22" + +@RunWith(MockitoJUnitRunner::class) +class SubscribersSqlUtilsTest { + @Mock + lateinit var statsSqlUtils: StatsSqlUtils + + @Mock + lateinit var site: SiteModel + + @Mock + lateinit var statsUtils: StatsUtils + + @Mock + lateinit var statsRequestSqlUtils: StatsRequestSqlUtils + private lateinit var timeStatsSqlUtils: SubscribersSqlUtils + private val mappedTypes = mapOf(DAY to DAYS, WEEK to WEEKS, MONTH to MONTHS, YEAR to YEARS) + + @Before + fun setUp() { + timeStatsSqlUtils = SubscribersSqlUtils(statsSqlUtils, statsUtils, statsRequestSqlUtils) + whenever(statsUtils.getFormattedDate(eq(DATE), isNull())).thenReturn(DATE_VALUE) + } + + @Test + fun `returns data from stats utils`() { + mappedTypes.forEach { (statsType, dbGranularity) -> + whenever(statsSqlUtils.select(site, SUBSCRIBERS, statsType, SubscribersResponse::class.java, DATE_VALUE)) + .thenReturn(SUBSCRIBERS_RESPONSE) + + val result = timeStatsSqlUtils.select(site, dbGranularity, DATE) + + assertEquals(result, SUBSCRIBERS_RESPONSE) + } + } + + @Test + fun `inserts data to stats utils`() { + mappedTypes.forEach { (statsType, dbGranularity) -> + timeStatsSqlUtils.insert(site, SUBSCRIBERS_RESPONSE, dbGranularity, DATE) + + verify(statsSqlUtils).insert(site, SUBSCRIBERS, statsType, SUBSCRIBERS_RESPONSE, true, DATE_VALUE) + } + } +} diff --git a/example/src/test/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersFixtures.kt b/example/src/test/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersFixtures.kt new file mode 100644 index 0000000000..2aa7db5228 --- /dev/null +++ b/example/src/test/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersFixtures.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc.store.stats.subscribers + +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersModel +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersModel.PeriodData +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient.SubscribersResponse +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE + +val SUBSCRIBERS_RESPONSE = SubscribersResponse( + "2024-04-22", + "day", + listOf("period", "subscribers"), + listOf(listOf("2024-04-21", "10")) +) +val SUBSCRIBERS_MODEL = SubscribersModel("2018-04-22", listOf(PeriodData("2024-04-22", 10))) +val INVALID_DATA_ERROR = StatsError(INVALID_RESPONSE, "Subscribers: Required data 'period' or 'dates' missing") diff --git a/example/src/test/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersStoreTest.kt b/example/src/test/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersStoreTest.kt new file mode 100644 index 0000000000..b868409aef --- /dev/null +++ b/example/src/test/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersStoreTest.kt @@ -0,0 +1,152 @@ +package org.wordpress.android.fluxc.store.stats.subscribers + +import org.assertj.core.api.Assertions +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersMapper +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient.SubscribersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.SubscribersSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.fluxc.utils.CurrentTimeProvider +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private const val QUANTITY = 8 +private val LIMIT_MODE = LimitMode.Top(QUANTITY) +private const val FORMATTED_DATE = "2024-04-22" + +@RunWith(MockitoJUnitRunner::class) +class SubscribersStoreTest { + @Mock + lateinit var site: SiteModel + + @Mock + lateinit var restClient: SubscribersRestClient + + @Mock + lateinit var sqlUtils: SubscribersSqlUtils + + @Mock + lateinit var statsUtils: StatsUtils + + @Mock + lateinit var currentTimeProvider: CurrentTimeProvider + + @Mock + lateinit var mapper: SubscribersMapper + + @Mock + lateinit var appLogWrapper: AppLogWrapper + private lateinit var store: SubscribersStore + + @Before + fun setUp() { + store = SubscribersStore( + restClient, + sqlUtils, + mapper, + statsUtils, + currentTimeProvider, + initCoroutineEngine(), + appLogWrapper + ) + val currentDate = Date(0) + whenever(currentTimeProvider.currentDate()).thenReturn(currentDate) + val timeZone = "GMT" + whenever(site.timezone).thenReturn(timeZone) + whenever(statsUtils.getFormattedDate(eq(currentDate), any())).thenReturn(FORMATTED_DATE) + } + + @Test + fun `returns data per site`() = test { + val fetchSubscribersPayload = FetchStatsPayload(SUBSCRIBERS_RESPONSE) + val forced = true + whenever(restClient.fetchSubscribers(site, DAYS, QUANTITY, FORMATTED_DATE, forced)) + .thenReturn(fetchSubscribersPayload) + whenever(mapper.map(SUBSCRIBERS_RESPONSE, LIMIT_MODE)).thenReturn(SUBSCRIBERS_MODEL) + + val responseModel = store.fetchSubscribers(site, DAYS, LIMIT_MODE, forced) + + Assertions.assertThat(responseModel.model).isEqualTo(SUBSCRIBERS_MODEL) + verify(sqlUtils).insert(site, SUBSCRIBERS_RESPONSE, DAYS, FORMATTED_DATE, QUANTITY) + } + + @Test + fun `returns cached data per site`() = test { + whenever(sqlUtils.hasFreshRequest(site, DAYS, FORMATTED_DATE, QUANTITY)).thenReturn(true) + whenever(sqlUtils.select(site, DAYS, FORMATTED_DATE)).thenReturn(SUBSCRIBERS_RESPONSE) + val model = mock() + whenever(mapper.map(SUBSCRIBERS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val forced = false + val responseModel = store.fetchSubscribers(site, DAYS, LIMIT_MODE, forced) + + Assertions.assertThat(responseModel.model).isEqualTo(model) + Assertions.assertThat(responseModel.cached).isTrue() + verify(sqlUtils, never()).insert(any(), any(), any(), any(), isNull()) + } + + @Test + fun `returns error when invalid data`() = test { + val forced = true + val fetchInsightsPayload = FetchStatsPayload(SUBSCRIBERS_RESPONSE) + whenever(restClient.fetchSubscribers(site, DAYS, QUANTITY, FORMATTED_DATE, forced)) + .thenReturn(fetchInsightsPayload) + val emptyModel = SubscribersModel("", emptyList()) + whenever(mapper.map(SUBSCRIBERS_RESPONSE, LIMIT_MODE)).thenReturn(emptyModel) + + val responseModel = store.fetchSubscribers(site, DAYS, LIMIT_MODE, forced) + + Assertions.assertThat(responseModel.error.type).isEqualTo(INVALID_DATA_ERROR.type) + Assertions.assertThat(responseModel.error.message).isEqualTo(INVALID_DATA_ERROR.message) + } + + @Test + fun `returns error when data call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchSubscribers(site, DAYS, QUANTITY, FORMATTED_DATE, forced)).thenReturn(errorPayload) + + val responseModel = store.fetchSubscribers(site, DAYS, LIMIT_MODE, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns data from db`() { + whenever(sqlUtils.select(site, DAYS, FORMATTED_DATE)).thenReturn(SUBSCRIBERS_RESPONSE) + val model = mock() + whenever(mapper.map(SUBSCRIBERS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val result = store.getSubscribers(site, DAYS, LIMIT_MODE) + + Assertions.assertThat(result).isEqualTo(model) + } +} diff --git a/fluxc-processor/src/main/resources/wp-com-endpoints.txt b/fluxc-processor/src/main/resources/wp-com-endpoints.txt index a7c15a5434..1dc2542332 100644 --- a/fluxc-processor/src/main/resources/wp-com-endpoints.txt +++ b/fluxc-processor/src/main/resources/wp-com-endpoints.txt @@ -137,6 +137,7 @@ /sites/$site/stats/post/$post_ID /sites/$site/stats/streak /sites/$site/stats/file-downloads +/sites/$site/stats/subscribers /sites/$site/post/$post_ID/diffs /sites/$site/page/$post_ID/diffs diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/SubscribersMapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/SubscribersMapper.kt new file mode 100644 index 0000000000..3b311a6e35 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/SubscribersMapper.kt @@ -0,0 +1,34 @@ +package org.wordpress.android.fluxc.model.stats.subscribers + +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient +import org.wordpress.android.util.AppLog +import javax.inject.Inject + +class SubscribersMapper @Inject constructor() { + fun map(response: SubscribersRestClient.SubscribersResponse, cacheMode: LimitMode): SubscribersModel { + val periodIndex = response.fields?.indexOf("period") + val subscribersIndex = response.fields?.indexOf("subscribers") + val dataPerPeriod = response.data?.mapNotNull { periodData -> + periodData?.let { + val period = periodIndex?.let { periodData[it] as String } + if (!period.isNullOrBlank()) { + val subscribers = subscribersIndex?.let { periodData[it] as? Double } ?: 0 + SubscribersModel.PeriodData(period, subscribers.toLong()) + } else { + null + } + } + }?.let { + if (cacheMode is LimitMode.Top) { + it.take(cacheMode.limit) + } else { + it + } + } + if (response.data == null || response.date == null || dataPerPeriod == null) { + AppLog.e(AppLog.T.STATS, "SubscribersResponse: data, date & dataPerPeriod fields should never be null") + } + return SubscribersModel(response.date ?: "", dataPerPeriod ?: listOf()) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/SubscribersModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/SubscribersModel.kt new file mode 100644 index 0000000000..3ff03fd1bb --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/SubscribersModel.kt @@ -0,0 +1,5 @@ +package org.wordpress.android.fluxc.model.stats.subscribers + +data class SubscribersModel(val period: String, val dates: List) { + data class PeriodData(val period: String, val subscribers: Long) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/SubscribersRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/SubscribersRestClient.kt new file mode 100644 index 0000000000..8b3460ffdc --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/SubscribersRestClient.kt @@ -0,0 +1,62 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class SubscribersRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchSubscribers( + site: SiteModel, + granularity: StatsGranularity, + quantity: Int, + date: String, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.subscribers.urlV1_1 + + val params = mapOf("unit" to granularity.toString(), "quantity" to quantity.toString(), "date" to date) + + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + SubscribersResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> FetchStatsPayload(response.data) + is Error -> FetchStatsPayload(response.error.toStatsError()) + } + } + + data class SubscribersResponse( + @SerializedName("date") val date: String?, + @SerializedName("unit") val unit: String?, + @SerializedName("fields") val fields: List?, + @SerializedName("data") val data: List?>? + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StatsSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StatsSqlUtils.kt index 77f4866b0b..f97d281c71 100644 --- a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StatsSqlUtils.kt +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StatsSqlUtils.kt @@ -16,8 +16,7 @@ import javax.inject.Singleton const val DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZ" @Singleton -class StatsSqlUtils -@Inject constructor() { +class StatsSqlUtils @Inject constructor() { private val gson: Gson by lazy { val builder = GsonBuilder() builder.setDateFormat(DATE_FORMAT) @@ -168,6 +167,7 @@ class StatsSqlUtils VIDEO_PLAYS, PUBLICIZE_INSIGHTS, POSTING_ACTIVITY, - FILE_DOWNLOADS + FILE_DOWNLOADS, + SUBSCRIBERS } } diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/TimeStatsSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/TimeStatsSqlUtils.kt index a4c08434b6..ab6d1ef49a 100644 --- a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/TimeStatsSqlUtils.kt +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/TimeStatsSqlUtils.kt @@ -1,6 +1,7 @@ package org.wordpress.android.fluxc.persistence import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient.SubscribersResponse import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.AuthorsRestClient.AuthorsResponse import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ClicksRestClient.ClicksResponse import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.CountryViewsRestClient.CountryViewsResponse @@ -24,6 +25,7 @@ import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.FILE_DOWN import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.POSTS_AND_PAGES_VIEWS import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.REFERRERS import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.SEARCH_TERMS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.SUBSCRIBERS import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.VIDEO_PLAYS import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.VISITS_AND_VIEWS import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType @@ -158,6 +160,18 @@ open class TimeStatsSqlUtils( VisitsAndViewsResponse::class.java ) + class SubscribersSqlUtils @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsUtils: StatsUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : TimeStatsSqlUtils( + statsSqlUtils, + statsUtils, + statsRequestSqlUtils, + SUBSCRIBERS, + SubscribersResponse::class.java + ) + class CountryViewsSqlUtils @Inject constructor( statsSqlUtils: StatsSqlUtils, diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/StatsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/StatsStore.kt index 9fbe8e7e8d..3e25b01ae2 100644 --- a/fluxc/src/main/java/org/wordpress/android/fluxc/store/StatsStore.kt +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/StatsStore.kt @@ -219,6 +219,10 @@ class StatsStore } } + suspend fun getSubscriberTypes() = coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "getSubscriberTypes") { + return@withDefaultContext SubscriberType.values().toList() + } + suspend fun getPostDetailTypes(): List = coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "getPostDetailTypes") { return@withDefaultContext PostDetailType.values().toList() @@ -268,6 +272,8 @@ class StatsStore FILE_DOWNLOADS } + enum class SubscriberType : StatsType { SUBSCRIBERS } + enum class PostDetailType : StatsType { POST_HEADER, POST_OVERVIEW, diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersStore.kt new file mode 100644 index 0000000000..e3901dda48 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersStore.kt @@ -0,0 +1,114 @@ +package org.wordpress.android.fluxc.store.stats.subscribers + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.LimitMode.Top +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersMapper +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.SubscribersSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.fluxc.utils.CurrentTimeProvider +import org.wordpress.android.fluxc.utils.SiteUtils +import org.wordpress.android.util.AppLog.T.STATS +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SubscribersStore @Inject constructor( + private val restClient: SubscribersRestClient, + private val sqlUtils: SubscribersSqlUtils, + private val subscribersMapper: SubscribersMapper, + private val statsUtils: StatsUtils, + private val currentTimeProvider: CurrentTimeProvider, + private val coroutineEngine: CoroutineEngine, + private val appLogWrapper: AppLogWrapper +) { + suspend fun fetchSubscribers( + site: SiteModel, + granularity: StatsGranularity, + limitMode: Top, + forced: Boolean = false + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchSubscribers") { + val dateWithTimeZone = statsUtils.getFormattedDate( + currentTimeProvider.currentDate(), + SiteUtils.getNormalizedTimezone(site.timezone) + ) + logProgress(granularity, "Site timezone: ${site.timezone}") + logProgress(granularity, "Fetching for date with applied timezone: $dateWithTimeZone") + if (!forced && sqlUtils.hasFreshRequest(site, granularity, dateWithTimeZone, limitMode.limit)) { + logProgress(granularity, "Loading cached data") + return@withDefaultContext OnStatsFetched( + getSubscribers(site, granularity, limitMode, dateWithTimeZone), + cached = true + ) + } + val payload = restClient.fetchSubscribers(site, granularity, limitMode.limit, dateWithTimeZone, forced) + return@withDefaultContext when { + payload.isError -> { + logProgress(granularity, "Error fetching data: ${payload.error}") + OnStatsFetched(payload.error) + } + + payload.response != null -> { + logProgress(granularity, "Data fetched correctly") + sqlUtils.insert(site, payload.response, granularity, dateWithTimeZone, limitMode.limit) + val subscribersResponse = subscribersMapper.map(payload.response, limitMode) + if (subscribersResponse.period.isBlank() || subscribersResponse.dates.isEmpty()) { + logProgress(granularity, "Invalid response") + OnStatsFetched( + StatsError(INVALID_RESPONSE, "Subscribers: Required data 'period' or 'dates' missing") + ) + } else { + logProgress(granularity, "Valid response returned for period: ${subscribersResponse.period}") + logProgress(granularity, "Last data item for: ${subscribersResponse.dates.lastOrNull()?.period}") + OnStatsFetched(subscribersResponse) + } + } + + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + private fun logProgress(granularity: StatsGranularity, message: String) { + appLogWrapper.d(STATS, "fetchSubscribers for $granularity: $message") + } + + fun getSubscribers( + site: SiteModel, + granularity: StatsGranularity, + limitMode: LimitMode + ): SubscribersModel? { + val dateWithTimeZone = statsUtils.getFormattedDate( + currentTimeProvider.currentDate(), + SiteUtils.getNormalizedTimezone(site.timezone) + ) + return getSubscribers(site, granularity, limitMode, dateWithTimeZone) + } + + fun getSubscribers( + site: SiteModel, + granularity: StatsGranularity, + limitMode: Top, + date: Date + ): SubscribersModel? { + val dateWithTimeZone = statsUtils.getFormattedDate(date, SiteUtils.getNormalizedTimezone(site.timezone)) + return getSubscribers(site, granularity, limitMode, dateWithTimeZone) + } + + private fun getSubscribers( + site: SiteModel, + granularity: StatsGranularity, + limitMode: LimitMode, + dateWithTimeZone: String + ) = coroutineEngine.run(STATS, this, "getSubscribers") { + sqlUtils.select(site, granularity, dateWithTimeZone)?.let { subscribersMapper.map(it, limitMode) } + } +}