diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepository.kt new file mode 100644 index 000000000000..0018631bdb8f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepository.kt @@ -0,0 +1,257 @@ +package org.wordpress.android.ui.newstats.todaysstat + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.modules.IO_THREAD +import org.wordpress.android.networking.restapi.WpComApiClientProvider +import org.wordpress.android.util.AppLog +import rs.wordpress.api.kotlin.WpComApiClient +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.StatsVisitsDataValue +import uniffi.wp_api.StatsVisitsParams +import uniffi.wp_api.StatsVisitsUnit +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import javax.inject.Inject +import javax.inject.Named + +private const val HOURLY_QUANTITY = 24u +private const val DAILY_QUANTITY = 1u + +// Daily aggregates response field indexes +// Response fields order: period, views, visitors, likes, reblogs, comments, posts +@Suppress("unused") private const val INDEX_PERIOD = 0 +private const val INDEX_VIEWS = 1 +private const val INDEX_VISITORS = 2 +private const val INDEX_LIKES = 3 +@Suppress("unused") private const val INDEX_REBLOGS = 4 +private const val INDEX_COMMENTS = 5 +@Suppress("unused") private const val INDEX_POSTS = 6 + +/** + * Repository for fetching stats data using the wordpress-rs API. + * Handles hourly visits/views data for the Today's Stats card chart. + */ +class StatsRepository @Inject constructor( + private val wpComApiClientProvider: WpComApiClientProvider, + private val appLogWrapper: AppLogWrapper, + @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, +) { + /** + * Access token for API authentication. + * Marked as @Volatile to ensure visibility across threads since this repository is accessed + * from multiple coroutine contexts (main thread initialization, IO dispatcher for API calls). + */ + @Volatile + private var accessToken: String? = null + + private val wpComApiClient: WpComApiClient by lazy { + check(accessToken != null) { "Repository not initialized" } + wpComApiClientProvider.getWpComApiClient(accessToken!!) + } + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) + + fun init(accessToken: String) { + this.accessToken = accessToken + } + + /** + * Fetches today's aggregated stats (views, visitors, likes, comments). + * + * @param siteId The WordPress.com site ID + * @return Today's aggregated stats or error + */ + suspend fun fetchTodayAggregates(siteId: Long): TodayAggregatesResult = withContext(ioDispatcher) { + if (accessToken == null) { + appLogWrapper.e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") + return@withContext TodayAggregatesResult.Error("Repository not initialized") + } + + val calendar = Calendar.getInstance() + val dateString = dateFormat.format(calendar.time) + + val params = StatsVisitsParams( + unit = StatsVisitsUnit.DAY, + quantity = DAILY_QUANTITY, + endDate = dateString, + ) + + val result = wpComApiClient.request { requestBuilder -> + requestBuilder.statsVisits().getStatsVisits( + wpComSiteId = siteId.toULong(), + params = params + ) + } + + when (result) { + is WpRequestResult.Success -> { + val response = result.response.data + val row = response.data.firstOrNull() + val aggregates = row?.let { parseDailyAggregates(it) } + if (aggregates != null) { + TodayAggregatesResult.Success(aggregates) + } else { + TodayAggregatesResult.Error("No data available") + } + } + + is WpRequestResult.WpError -> { + appLogWrapper.e(AppLog.T.STATS, "API Error fetching today aggregates: ${result.errorMessage}") + TodayAggregatesResult.Error(result.errorMessage) + } + + else -> { + appLogWrapper.e(AppLog.T.STATS, "Unknown error fetching today aggregates") + TodayAggregatesResult.Error("Unknown error") + } + } + } + + /** + * Fetches hourly views data for the specified date. + * + * @param siteId The WordPress.com site ID + * @param offsetDays Number of days to offset from today (0 = today, 1 = yesterday, etc.) + * @return List of hourly views data points, or empty list if fetch fails + */ + suspend fun fetchHourlyViews( + siteId: Long, + offsetDays: Int = 0 + ): HourlyViewsResult = withContext(ioDispatcher) { + if (accessToken == null) { + appLogWrapper.e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") + return@withContext HourlyViewsResult.Error("Repository not initialized") + } + + val calendar = Calendar.getInstance() + // The API's endDate is exclusive for hourly queries, so we need to add 1 day to get + // the target day's hours. Formula: 1 (for exclusive end) - offsetDays (0=today, 1=yesterday) + // Examples: offsetDays=0 → tomorrow's date → fetches today's hours + // offsetDays=1 → today's date → fetches yesterday's hours + calendar.add(Calendar.DAY_OF_YEAR, 1 - offsetDays) + val dateString = dateFormat.format(calendar.time) + + val params = StatsVisitsParams( + unit = StatsVisitsUnit.HOUR, + quantity = HOURLY_QUANTITY, + endDate = dateString, + ) + + val result = wpComApiClient.request { requestBuilder -> + requestBuilder.statsVisits().getStatsVisits( + wpComSiteId = siteId.toULong(), + params = params + ) + } + + when (result) { + is WpRequestResult.Success -> { + val response = result.response.data + val dataPoints = response.data.mapNotNull { row -> + parseHourlyDataRow(row) + } + HourlyViewsResult.Success(dataPoints) + } + + is WpRequestResult.WpError -> { + appLogWrapper.e(AppLog.T.STATS, "API Error fetching hourly views: ${result.errorMessage}") + HourlyViewsResult.Error(result.errorMessage) + } + + else -> { + appLogWrapper.e(AppLog.T.STATS, "Unknown error fetching hourly views") + HourlyViewsResult.Error("Unknown error") + } + } + } + + @Suppress("TooGenericExceptionCaught", "ReturnCount") + private fun parseHourlyDataRow(row: Any?): HourlyViewsDataPoint? { + return try { + val rowList = row as? List<*> ?: return null + val periodValue = rowList.getOrNull(0) + val viewsValue = rowList.getOrNull(1) + + // Extract values from wrapper types + val period = when (periodValue) { + is StatsVisitsDataValue.String -> periodValue.v1 + else -> return null + } + + val views = when (viewsValue) { + is StatsVisitsDataValue.Number -> viewsValue.v1.toLong() + else -> 0L + } + + HourlyViewsDataPoint(period = period, views = views) + } catch (e: Exception) { + appLogWrapper.w(AppLog.T.STATS, "Failed to parse stats row: ${e.message}") + null + } + } + + @Suppress("TooGenericExceptionCaught") + private fun parseDailyAggregates(row: Any?): TodayAggregates? { + return try { + val rowList = row as? List<*> ?: return null + val viewsValue = rowList.getOrNull(INDEX_VIEWS) + val visitorsValue = rowList.getOrNull(INDEX_VISITORS) + val likesValue = rowList.getOrNull(INDEX_LIKES) + val commentsValue = rowList.getOrNull(INDEX_COMMENTS) + + TodayAggregates( + views = extractLongValue(viewsValue), + visitors = extractLongValue(visitorsValue), + likes = extractLongValue(likesValue), + comments = extractLongValue(commentsValue) + ) + } catch (e: Exception) { + appLogWrapper.w(AppLog.T.STATS, "Failed to parse daily aggregates: ${e.message}") + null + } + } + + private fun extractLongValue(value: Any?): Long { + return when (value) { + is StatsVisitsDataValue.Number -> value.v1.toLong() + else -> 0L + } + } +} + +/** + * Result wrapper for hourly views fetch operation. + */ +sealed class HourlyViewsResult { + data class Success(val dataPoints: List) : HourlyViewsResult() + data class Error(val message: String) : HourlyViewsResult() +} + +/** + * Raw data point from the stats API. + */ +data class HourlyViewsDataPoint( + val period: String, + val views: Long +) + +/** + * Result wrapper for today's aggregated stats fetch operation. + */ +sealed class TodayAggregatesResult { + data class Success(val aggregates: TodayAggregates) : TodayAggregatesResult() + data class Error(val message: String) : TodayAggregatesResult() +} + +/** + * Today's aggregated stats data. + */ +data class TodayAggregates( + val views: Long, + val visitors: Long, + val likes: Long, + val comments: Long +) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt index f96eb93d834b..7242e6d2e1b0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt @@ -376,10 +376,10 @@ private fun StatsChart(chartData: ChartData) { @Composable private fun MetricsRow( - views: Int, - visitors: Int, - likes: Int, - comments: Int + views: Long, + visitors: Long, + likes: Long, + comments: Long ) { Row( modifier = Modifier.fillMaxWidth(), @@ -457,7 +457,7 @@ private fun SecondaryMetricItem( } } -private fun formatStatValue(value: Int): String { +private fun formatStatValue(value: Long): String { return when { value >= MILLION -> String.format(Locale.getDefault(), "%.1fM", value / MILLION.toDouble()) value >= THOUSAND -> String.format(Locale.getDefault(), "%.1fK", value / THOUSAND.toDouble()) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt index 2c138efd4044..06fb7d1dd293 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt @@ -7,10 +7,10 @@ sealed class TodaysStatsCardUiState { data object Loading : TodaysStatsCardUiState() data class Loaded( - val views: Int, - val visitors: Int, - val likes: Int, - val comments: Int, + val views: Long, + val visitors: Long, + val likes: Long, + val comments: Long, val chartData: ChartData, val onCardClick: () -> Unit ) : TodaysStatsCardUiState() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt index 1ae5fef71daf..477335394f58 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt @@ -9,25 +9,20 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.model.stats.LimitMode -import org.wordpress.android.fluxc.network.utils.StatsGranularity -import org.wordpress.android.fluxc.store.stats.insights.TodayInsightsStore -import org.wordpress.android.fluxc.store.stats.time.VisitsAndViewsStore +import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.viewmodel.ResourceProvider import java.text.SimpleDateFormat -import java.util.Calendar import java.util.Locale import javax.inject.Inject -private const val HOURLY_DATA_POINTS = 24 private const val PREVIOUS_PERIOD_OFFSET_DAYS = 1 @HiltViewModel class TodaysStatsViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, - private val todayInsightsStore: TodayInsightsStore, - private val visitsAndViewsStore: VisitsAndViewsStore, + private val accountStore: AccountStore, + private val statsRepository: StatsRepository, private val resourceProvider: ResourceProvider ) : ViewModel() { private val _uiState = MutableStateFlow(TodaysStatsCardUiState.Loading) @@ -58,6 +53,16 @@ class TodaysStatsViewModel @Inject constructor( return } + val accessToken = accountStore.accessToken + if (accessToken.isNullOrEmpty()) { + _uiState.value = TodaysStatsCardUiState.Error( + message = resourceProvider.getString(R.string.stats_todays_stats_failed_to_load), + onRetry = { loadData(forced = true) } + ) + return + } + + statsRepository.init(accessToken) _uiState.value = TodaysStatsCardUiState.Loading viewModelScope.launch { @@ -65,7 +70,7 @@ class TodaysStatsViewModel @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") + @Suppress("TooGenericExceptionCaught", "UnusedParameter") private suspend fun loadDataInternal(forced: Boolean) { val site = selectedSiteRepository.getSelectedSite() if (site == null) { @@ -77,8 +82,8 @@ class TodaysStatsViewModel @Inject constructor( } try { - val todayStats = fetchTodayStats(site, forced) - val chartData = fetchChartData(site, forced) + val todayStats = fetchTodayStats(site) + val chartData = fetchChartData(site) if (todayStats != null) { _uiState.value = TodaysStatsCardUiState.Loaded( @@ -103,25 +108,24 @@ class TodaysStatsViewModel @Inject constructor( } } - private suspend fun fetchTodayStats(site: SiteModel, forced: Boolean): TodayStatsData? { - val response = todayInsightsStore.fetchTodayInsights(site, forced) - return if (response.isError) { - null - } else { - response.model?.let { model -> + private suspend fun fetchTodayStats(site: SiteModel): TodayStatsData? { + val result = statsRepository.fetchTodayAggregates(site.siteId) + return when (result) { + is TodayAggregatesResult.Success -> { TodayStatsData( - views = model.views, - visitors = model.visitors, - likes = model.likes, - comments = model.comments + views = result.aggregates.views, + visitors = result.aggregates.visitors, + likes = result.aggregates.likes, + comments = result.aggregates.comments ) } + is TodayAggregatesResult.Error -> null } } - private suspend fun fetchChartData(site: SiteModel, forced: Boolean): ChartData { - val currentPeriodData = fetchHourlyData(site, forced, offsetDays = 0) - val previousPeriodData = fetchHourlyData(site, forced, offsetDays = PREVIOUS_PERIOD_OFFSET_DAYS) + private suspend fun fetchChartData(site: SiteModel): ChartData { + val currentPeriodData = fetchHourlyData(site, offsetDays = 0) + val previousPeriodData = fetchHourlyData(site, offsetDays = PREVIOUS_PERIOD_OFFSET_DAYS) return ChartData( currentPeriod = currentPeriodData, @@ -131,32 +135,23 @@ class TodaysStatsViewModel @Inject constructor( private suspend fun fetchHourlyData( site: SiteModel, - forced: Boolean, offsetDays: Int ): List { - val calendar = Calendar.getInstance() - if (offsetDays > 0) { - calendar.add(Calendar.DAY_OF_YEAR, -offsetDays) - } - - val response = visitsAndViewsStore.fetchVisits( - site = site, - granularity = StatsGranularity.HOURS, - limitMode = LimitMode.Top(HOURLY_DATA_POINTS), - date = calendar.time, - forced = forced + val result = statsRepository.fetchHourlyViews( + siteId = site.siteId, + offsetDays = offsetDays ) - val model = response.model - if (response.isError || model == null) { - return emptyList() - } - - return model.dates.map { periodData -> - ViewsDataPoint( - label = formatHourlyLabel(periodData.period), - views = periodData.views - ) + return when (result) { + is HourlyViewsResult.Success -> { + result.dataPoints.map { dataPoint -> + ViewsDataPoint( + label = formatHourlyLabel(dataPoint.period), + views = dataPoint.views + ) + } + } + is HourlyViewsResult.Error -> emptyList() } } @@ -190,9 +185,9 @@ class TodaysStatsViewModel @Inject constructor( } private data class TodayStatsData( - val views: Int, - val visitors: Int, - val likes: Int, - val comments: Int + val views: Long, + val visitors: Long, + val likes: Long, + val comments: Long ) } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepositoryTest.kt new file mode 100644 index 000000000000..84886538c241 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepositoryTest.kt @@ -0,0 +1,406 @@ +package org.wordpress.android.ui.newstats.todaysstat + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.networking.restapi.WpComApiClientProvider +import org.wordpress.android.util.AppLog +import rs.wordpress.api.kotlin.WpComApiClient +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.StatsVisitsDataValue +import uniffi.wp_api.WpErrorCode + +@ExperimentalCoroutinesApi +class StatsRepositoryTest : BaseUnitTest() { + @Mock + lateinit var wpComApiClientProvider: WpComApiClientProvider + + @Mock + lateinit var wpComApiClient: WpComApiClient + + @Mock + lateinit var appLogWrapper: AppLogWrapper + + private lateinit var repository: StatsRepository + + @Before + fun setUp() { + whenever(wpComApiClientProvider.getWpComApiClient(TEST_ACCESS_TOKEN)) + .thenReturn(wpComApiClient) + + repository = StatsRepository( + wpComApiClientProvider = wpComApiClientProvider, + appLogWrapper = appLogWrapper, + ioDispatcher = testDispatcher() + ) + } + + // region init tests + @Test + fun `init sets access token`() { + repository.init(TEST_ACCESS_TOKEN) + // If we get here without exception, the test passes + } + // endregion + + // region fetchTodayAggregates tests + @Test + fun `fetchTodayAggregates returns error when not initialized`() = runTest { + // Given - repository not initialized + + // When + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(TodayAggregatesResult.Error::class.java) + assertThat((result as TodayAggregatesResult.Error).message).isEqualTo("Repository not initialized") + verify(appLogWrapper).e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") + } + + @Test + fun `fetchTodayAggregates returns success when API returns valid data`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + val mockResponse = createMockDailyAggregatesResponse( + views = TEST_VIEWS, + visitors = TEST_VISITORS, + likes = TEST_LIKES, + comments = TEST_COMMENTS + ) + setupApiClientToReturnSuccess(mockResponse) + + // When + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(TodayAggregatesResult.Success::class.java) + val success = result as TodayAggregatesResult.Success + assertThat(success.aggregates.views).isEqualTo(TEST_VIEWS) + assertThat(success.aggregates.visitors).isEqualTo(TEST_VISITORS) + assertThat(success.aggregates.likes).isEqualTo(TEST_LIKES) + assertThat(success.aggregates.comments).isEqualTo(TEST_COMMENTS) + } + + @Test + fun `fetchTodayAggregates returns error when API returns WpError`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + setupApiClientToReturnWpError(API_ERROR_MESSAGE) + + // When + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(TodayAggregatesResult.Error::class.java) + assertThat((result as TodayAggregatesResult.Error).message).isEqualTo(API_ERROR_MESSAGE) + } + + @Test + fun `fetchTodayAggregates returns error when API returns UnknownError`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + setupApiClientToReturnUnknownError() + + // When + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(TodayAggregatesResult.Error::class.java) + assertThat((result as TodayAggregatesResult.Error).message).isEqualTo("Unknown error") + } + + @Test + fun `fetchTodayAggregates returns error when API returns empty data`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + val mockResponse = createMockEmptyResponse() + setupApiClientToReturnSuccess(mockResponse) + + // When + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(TodayAggregatesResult.Error::class.java) + assertThat((result as TodayAggregatesResult.Error).message).isEqualTo("No data available") + } + + @Test + fun `fetchTodayAggregates returns success with zero values when data has non-numeric values`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + val mockResponse = createMockDailyAggregatesResponseWithStringValues() + setupApiClientToReturnSuccess(mockResponse) + + // When + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(TodayAggregatesResult.Success::class.java) + val success = result as TodayAggregatesResult.Success + assertThat(success.aggregates.views).isEqualTo(0L) + assertThat(success.aggregates.visitors).isEqualTo(0L) + assertThat(success.aggregates.likes).isEqualTo(0L) + assertThat(success.aggregates.comments).isEqualTo(0L) + } + // endregion + + // region fetchHourlyViews tests + @Test + fun `fetchHourlyViews returns error when not initialized`() = runTest { + // Given - repository not initialized + + // When + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(HourlyViewsResult.Error::class.java) + assertThat((result as HourlyViewsResult.Error).message).isEqualTo("Repository not initialized") + verify(appLogWrapper).e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") + } + + @Test + fun `fetchHourlyViews returns success when API returns valid data`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + val mockResponse = createMockHourlyViewsResponse( + listOf( + HourlyDataPoint(TEST_PERIOD_1, TEST_HOURLY_VIEWS_1), + HourlyDataPoint(TEST_PERIOD_2, TEST_HOURLY_VIEWS_2) + ) + ) + setupApiClientToReturnSuccess(mockResponse) + + // When + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(HourlyViewsResult.Success::class.java) + val success = result as HourlyViewsResult.Success + assertThat(success.dataPoints).hasSize(2) + assertThat(success.dataPoints[0].period).isEqualTo(TEST_PERIOD_1) + assertThat(success.dataPoints[0].views).isEqualTo(TEST_HOURLY_VIEWS_1) + assertThat(success.dataPoints[1].period).isEqualTo(TEST_PERIOD_2) + assertThat(success.dataPoints[1].views).isEqualTo(TEST_HOURLY_VIEWS_2) + } + + @Test + fun `fetchHourlyViews returns success with empty list when API returns empty data`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + val mockResponse = createMockEmptyResponse() + setupApiClientToReturnSuccess(mockResponse) + + // When + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(HourlyViewsResult.Success::class.java) + assertThat((result as HourlyViewsResult.Success).dataPoints).isEmpty() + } + + @Test + fun `fetchHourlyViews returns error when API returns WpError`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + setupApiClientToReturnWpError(API_ERROR_MESSAGE) + + // When + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(HourlyViewsResult.Error::class.java) + assertThat((result as HourlyViewsResult.Error).message).isEqualTo(API_ERROR_MESSAGE) + } + + @Test + fun `fetchHourlyViews returns error when API returns UnknownError`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + setupApiClientToReturnUnknownError() + + // When + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(HourlyViewsResult.Error::class.java) + assertThat((result as HourlyViewsResult.Error).message).isEqualTo("Unknown error") + } + + @Test + fun `fetchHourlyViews with offsetDays parameter works correctly`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + val mockResponse = createMockHourlyViewsResponse( + listOf(HourlyDataPoint(TEST_PERIOD_1, TEST_HOURLY_VIEWS_1)) + ) + setupApiClientToReturnSuccess(mockResponse) + + // When - fetch yesterday's data + val result = repository.fetchHourlyViews(TEST_SITE_ID, offsetDays = 1) + + // Then + assertThat(result).isInstanceOf(HourlyViewsResult.Success::class.java) + assertThat((result as HourlyViewsResult.Success).dataPoints).hasSize(1) + } + + @Test + fun `fetchHourlyViews returns zero views when views value is not a number`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + val mockResponse = createMockHourlyViewsResponseWithNonNumericViews() + setupApiClientToReturnSuccess(mockResponse) + + // When + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(HourlyViewsResult.Success::class.java) + val success = result as HourlyViewsResult.Success + assertThat(success.dataPoints).hasSize(1) + assertThat(success.dataPoints[0].views).isEqualTo(0L) + } + // endregion + + // region Helper methods + @Suppress("UNCHECKED_CAST") + private suspend fun setupApiClientToReturnSuccess(response: MockStatsResponse) { + val mockHeaderMap = mock() + val responseObject = uniffi.wp_api.StatsVisitsRequestGetStatsVisitsResponse( + data = response.toStatsVisitsResponse(), + headerMap = mockHeaderMap + ) + + val successResponse = WpRequestResult.Success(responseObject) + + whenever( + wpComApiClient.request(any()) + ).thenReturn(successResponse as WpRequestResult) + } + + @Suppress("UNCHECKED_CAST") + private suspend fun setupApiClientToReturnWpError(errorMessage: String) { + val errorResponse = WpRequestResult.WpError( + errorCode = WpErrorCode.Forbidden(), + errorMessage = errorMessage, + statusCode = 403.toUShort(), + response = "" + ) + whenever( + wpComApiClient.request(any()) + ).thenReturn(errorResponse) + } + + @Suppress("UNCHECKED_CAST") + private suspend fun setupApiClientToReturnUnknownError() { + val errorResponse = WpRequestResult.UnknownError( + statusCode = 500.toUShort(), + response = "Internal Server Error" + ) + whenever( + wpComApiClient.request(any()) + ).thenReturn(errorResponse) + } + + private fun createMockDailyAggregatesResponse( + views: Long, + visitors: Long, + likes: Long, + comments: Long + ): MockStatsResponse { + // Response fields order: period, views, visitors, likes, reblogs, comments, posts + val row = listOf( + StatsVisitsDataValue.String("2024-01-16"), + StatsVisitsDataValue.Number(views.toULong()), + StatsVisitsDataValue.Number(visitors.toULong()), + StatsVisitsDataValue.Number(likes.toULong()), + StatsVisitsDataValue.Number(0.toULong()), // reblogs + StatsVisitsDataValue.Number(comments.toULong()), + StatsVisitsDataValue.Number(0.toULong()) // posts + ) + return MockStatsResponse(listOf(row)) + } + + private fun createMockDailyAggregatesResponseWithStringValues(): MockStatsResponse { + // Row with period but string values for metrics (should return 0 for all) + val row = listOf( + StatsVisitsDataValue.String("2024-01-16"), + StatsVisitsDataValue.String("not a number"), // views + StatsVisitsDataValue.String("not a number"), // visitors + StatsVisitsDataValue.String("not a number"), // likes + StatsVisitsDataValue.String("not a number"), // reblogs + StatsVisitsDataValue.String("not a number"), // comments + StatsVisitsDataValue.String("not a number") // posts + ) + return MockStatsResponse(listOf(row)) + } + + private fun createMockHourlyViewsResponse(dataPoints: List): MockStatsResponse { + val rows = dataPoints.map { dataPoint -> + listOf( + StatsVisitsDataValue.String(dataPoint.period), + StatsVisitsDataValue.Number(dataPoint.views.toULong()) + ) + } + return MockStatsResponse(rows) + } + + private fun createMockHourlyViewsResponseWithNonNumericViews(): MockStatsResponse { + val row = listOf( + StatsVisitsDataValue.String(TEST_PERIOD_1), + StatsVisitsDataValue.String("not a number") // views as string instead of number + ) + return MockStatsResponse(listOf(row)) + } + + private fun createMockEmptyResponse(): MockStatsResponse { + return MockStatsResponse(emptyList()) + } + + private data class HourlyDataPoint(val period: String, val views: Long) + + private data class MockStatsResponse(val data: List>) { + fun toStatsVisitsResponse(): uniffi.wp_api.StatsVisitsResponse { + return uniffi.wp_api.StatsVisitsResponse( + date = "2024-01-16", + unit = "day", + fields = listOf("period", "views", "visitors", "likes", "reblogs", "comments", "posts"), + data = data + ) + } + } + // endregion + + companion object { + private const val TEST_ACCESS_TOKEN = "test_access_token" + private const val TEST_SITE_ID = 123L + private const val TEST_VIEWS = 500L + private const val TEST_VISITORS = 100L + private const val TEST_LIKES = 50L + private const val TEST_COMMENTS = 25L + private const val TEST_PERIOD_1 = "2024-01-16 14:00:00" + private const val TEST_PERIOD_2 = "2024-01-16 15:00:00" + private const val TEST_HOURLY_VIEWS_1 = 100L + private const val TEST_HOURLY_VIEWS_2 = 150L + private const val API_ERROR_MESSAGE = "API Error" + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt index 00f28d51e6f5..6a8e4a933d53 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt @@ -12,16 +12,8 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.model.stats.LimitMode -import org.wordpress.android.fluxc.model.stats.VisitsModel -import org.wordpress.android.fluxc.model.stats.time.VisitsAndViewsModel -import org.wordpress.android.fluxc.network.utils.StatsGranularity -import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched -import org.wordpress.android.fluxc.store.StatsStore.StatsError -import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType +import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.R -import org.wordpress.android.fluxc.store.stats.insights.TodayInsightsStore -import org.wordpress.android.fluxc.store.stats.time.VisitsAndViewsStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.viewmodel.ResourceProvider @@ -31,10 +23,10 @@ class TodaysStatsViewModelTest : BaseUnitTest() { private lateinit var selectedSiteRepository: SelectedSiteRepository @Mock - private lateinit var todayInsightsStore: TodayInsightsStore + private lateinit var accountStore: AccountStore @Mock - private lateinit var visitsAndViewsStore: VisitsAndViewsStore + private lateinit var statsRepository: StatsRepository @Mock private lateinit var resourceProvider: ResourceProvider @@ -50,6 +42,7 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Before fun setUp() { whenever(selectedSiteRepository.getSelectedSite()).thenReturn(testSite) + whenever(accountStore.accessToken).thenReturn(TEST_ACCESS_TOKEN) whenever(resourceProvider.getString(R.string.stats_todays_stats_no_site_selected)) .thenReturn(NO_SITE_SELECTED_ERROR) whenever(resourceProvider.getString(R.string.stats_todays_stats_failed_to_load)) @@ -61,8 +54,8 @@ class TodaysStatsViewModelTest : BaseUnitTest() { private fun initViewModel() { viewModel = TodaysStatsViewModel( selectedSiteRepository, - todayInsightsStore, - visitsAndViewsStore, + accountStore, + statsRepository, resourceProvider ) } @@ -81,21 +74,17 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Test fun `when data loads successfully, then loaded state is emitted with correct values`() = test { - val visitsModel = VisitsModel( - period = "2024-01-16", + val aggregates = TodayAggregates( views = TEST_VIEWS, visitors = TEST_VISITORS, likes = TEST_LIKES, - reblogs = 0, - comments = TEST_COMMENTS, - posts = 0 + comments = TEST_COMMENTS ) - val visitsAndViewsModel = createVisitsAndViewsModel() - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) - .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -111,13 +100,11 @@ class TodaysStatsViewModelTest : BaseUnitTest() { } @Test - fun `when today insights fetch fails, then error state is emitted`() = test { - val error = StatsError(StatsErrorType.GENERIC_ERROR, "Network error") - - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) - .thenReturn(OnStatsFetched(error)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(createVisitsAndViewsModel())) + fun `when today aggregates fetch fails, then error state is emitted`() = test { + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Error("Network error")) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -128,28 +115,13 @@ class TodaysStatsViewModelTest : BaseUnitTest() { } @Test - fun `when today insights returns null model, then error state is emitted`() = test { - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) - .thenReturn(OnStatsFetched(model = null)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(createVisitsAndViewsModel())) - - initViewModel() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertThat(state).isInstanceOf(TodaysStatsCardUiState.Error::class.java) - } - - @Test - fun `when visits and views fetch fails, then chart data is empty but state is loaded`() = test { - val visitsModel = createVisitsModel() - val error = StatsError(StatsErrorType.GENERIC_ERROR, "Network error") + fun `when hourly views fetch fails, then chart data is empty but state is loaded`() = test { + val aggregates = createTodayAggregates() - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) - .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(error)) + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(HourlyViewsResult.Error("Network error")) initViewModel() advanceUntilIdle() @@ -163,14 +135,13 @@ class TodaysStatsViewModelTest : BaseUnitTest() { } @Test - fun `when loadData is called with forced true, then stores are called with forced true`() = test { - val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() + fun `when loadData is called with forced true, then repository is called`() = test { + val aggregates = createTodayAggregates() - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) - .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -178,18 +149,18 @@ class TodaysStatsViewModelTest : BaseUnitTest() { viewModel.loadData(forced = true) advanceUntilIdle() - verify(todayInsightsStore).fetchTodayInsights(eq(testSite), eq(true)) + // Called twice: once during init, once during loadData(forced = true) + verify(statsRepository, times(2)).fetchTodayAggregates(eq(TEST_SITE_ID)) } @Test fun `when onRetry is called, then loadData is called with forced true`() = test { - val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() + val aggregates = createTodayAggregates() - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) - .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -197,18 +168,18 @@ class TodaysStatsViewModelTest : BaseUnitTest() { viewModel.onRetry() advanceUntilIdle() - verify(todayInsightsStore).fetchTodayInsights(eq(testSite), eq(true)) + // Called twice: once during init, once during onRetry + verify(statsRepository, times(2)).fetchTodayAggregates(eq(TEST_SITE_ID)) } @Test fun `when data loads, then chart data contains current and previous period data`() = test { - val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() + val aggregates = createTodayAggregates() - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) - .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -222,32 +193,26 @@ class TodaysStatsViewModelTest : BaseUnitTest() { } @Test - fun `when fetch visits is called, then hourly granularity is used for both periods`() = test { - val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() + fun `when fetch hourly views is called, then repository is called for both periods`() = test { + val aggregates = createTodayAggregates() - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) - .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() - // fetchVisits is called twice: once for current period, once for previous period - verify(visitsAndViewsStore, times(2)).fetchVisits( - site = eq(testSite), - granularity = eq(StatsGranularity.HOURS), - limitMode = any(), - date = any(), - forced = any(), - applySiteTimezone = any() - ) + // fetchHourlyViews is called twice: once for current period (offsetDays=0), + // once for previous period (offsetDays=1) + verify(statsRepository).fetchHourlyViews(eq(TEST_SITE_ID), eq(0)) + verify(statsRepository).fetchHourlyViews(eq(TEST_SITE_ID), eq(1)) } @Test fun `when exception is thrown during fetch, then error state is emitted with exception message`() = test { - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenThrow(RuntimeException("Test exception")) initViewModel() @@ -260,7 +225,7 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Test fun `when exception with null message is thrown, then error state has unknown error message`() = test { - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenThrow(RuntimeException()) initViewModel() @@ -273,13 +238,12 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Test fun `when loadData is called again, then state transitions through loading`() = test { - val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() + val aggregates = createTodayAggregates() - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) - .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -305,10 +269,10 @@ class TodaysStatsViewModelTest : BaseUnitTest() { // Now set up for successful reload whenever(selectedSiteRepository.getSelectedSite()).thenReturn(testSite) - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) - .thenReturn(OnStatsFetched(createVisitsModel())) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(createVisitsAndViewsModel())) + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(createTodayAggregates())) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) errorState.onRetry() advanceUntilIdle() @@ -318,13 +282,12 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Test fun `when refresh is called, then isRefreshing becomes true then false`() = test { - val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() + val aggregates = createTodayAggregates() - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) - .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -340,14 +303,13 @@ class TodaysStatsViewModelTest : BaseUnitTest() { } @Test - fun `when refresh is called, then data is fetched with forced true`() = test { - val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() + fun `when refresh is called, then data is fetched`() = test { + val aggregates = createTodayAggregates() - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) - .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -355,21 +317,18 @@ class TodaysStatsViewModelTest : BaseUnitTest() { viewModel.refresh() advanceUntilIdle() - // Verify that fetchTodayInsights was called with forced = true during refresh - // (called twice: once during init, once during refresh) - verify(todayInsightsStore, times(2)).fetchTodayInsights(eq(testSite), any()) - verify(todayInsightsStore).fetchTodayInsights(eq(testSite), eq(true)) + // Called twice: once during init, once during refresh + verify(statsRepository, times(2)).fetchTodayAggregates(eq(TEST_SITE_ID)) } @Test fun `when refresh is called, then state remains loaded without showing loading state`() = test { - val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() + val aggregates = createTodayAggregates() - whenever(todayInsightsStore.fetchTodayInsights(any(), any())) - .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -384,46 +343,175 @@ class TodaysStatsViewModelTest : BaseUnitTest() { assertThat(viewModel.uiState.value).isInstanceOf(TodaysStatsCardUiState.Loaded::class.java) } - private fun createVisitsModel() = VisitsModel( - period = "2024-01-16", + @Test + fun `when access token is null, then error state is emitted`() = test { + whenever(accountStore.accessToken).thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(TodaysStatsCardUiState.Error::class.java) + assertThat((state as TodaysStatsCardUiState.Error).message).isEqualTo(FAILED_TO_LOAD_ERROR) + } + + @Test + fun `when access token is empty, then error state is emitted`() = test { + whenever(accountStore.accessToken).thenReturn("") + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(TodaysStatsCardUiState.Error::class.java) + assertThat((state as TodaysStatsCardUiState.Error).message).isEqualTo(FAILED_TO_LOAD_ERROR) + } + + @Test + fun `when loadData is called, then repository is initialized with access token`() = test { + val aggregates = createTodayAggregates() + + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) + + initViewModel() + advanceUntilIdle() + + verify(statsRepository).init(eq(TEST_ACCESS_TOKEN)) + } + + @Test + fun `when chart data has labels, then they are formatted correctly`() = test { + val aggregates = createTodayAggregates() + + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as TodaysStatsCardUiState.Loaded + // Labels should be formatted as "2pm", "3pm" from "2024-01-16 14:00:00", "2024-01-16 15:00:00" + assertThat(state.chartData.currentPeriod).isNotEmpty() + assertThat(state.chartData.currentPeriod[0].label).isNotEmpty() + } + + @Test + fun `when only current period hourly fetch fails, then current period is empty`() = test { + val aggregates = createTodayAggregates() + + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + // Current period (offsetDays=0) fails + whenever(statsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(0))) + .thenReturn(HourlyViewsResult.Error("Network error")) + // Previous period (offsetDays=1) succeeds + whenever(statsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(1))) + .thenReturn(createHourlyViewsResult()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as TodaysStatsCardUiState.Loaded + assertThat(state.chartData.currentPeriod).isEmpty() + assertThat(state.chartData.previousPeriod).hasSize(2) + } + + @Test + fun `when only previous period hourly fetch fails, then previous period is empty`() = test { + val aggregates = createTodayAggregates() + + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + // Current period (offsetDays=0) succeeds + whenever(statsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(0))) + .thenReturn(createHourlyViewsResult()) + // Previous period (offsetDays=1) fails + whenever(statsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(1))) + .thenReturn(HourlyViewsResult.Error("Network error")) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as TodaysStatsCardUiState.Loaded + assertThat(state.chartData.currentPeriod).hasSize(2) + assertThat(state.chartData.previousPeriod).isEmpty() + } + + @Test + fun `when loaded state is shown, then onCardClick callback can be invoked`() = test { + val aggregates = createTodayAggregates() + + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as TodaysStatsCardUiState.Loaded + // Verify onCardClick callback can be invoked without error + state.onCardClick() + // If we reached here, the callback is present and invocable + } + + @Test + fun `when data loads with zero values, then loaded state shows zeros`() = test { + val aggregates = TodayAggregates( + views = 0L, + visitors = 0L, + likes = 0L, + comments = 0L + ) + + whenever(statsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(statsRepository.fetchHourlyViews(any(), any())) + .thenReturn(HourlyViewsResult.Success(emptyList())) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as TodaysStatsCardUiState.Loaded + assertThat(state.views).isEqualTo(0L) + assertThat(state.visitors).isEqualTo(0L) + assertThat(state.likes).isEqualTo(0L) + assertThat(state.comments).isEqualTo(0L) + assertThat(state.chartData.currentPeriod).isEmpty() + } + + private fun createTodayAggregates() = TodayAggregates( views = TEST_VIEWS, visitors = TEST_VISITORS, likes = TEST_LIKES, - reblogs = 0, - comments = TEST_COMMENTS, - posts = 0 + comments = TEST_COMMENTS ) - private fun createVisitsAndViewsModel() = VisitsAndViewsModel( - period = "hour", - dates = listOf( - VisitsAndViewsModel.PeriodData( + private fun createHourlyViewsResult() = HourlyViewsResult.Success( + listOf( + HourlyViewsDataPoint( period = "2024-01-16 14:00:00", - views = 100L, - visitors = 50L, - likes = 10L, - reblogs = 0L, - comments = 5L, - posts = 0L + views = 100L ), - VisitsAndViewsModel.PeriodData( + HourlyViewsDataPoint( period = "2024-01-16 15:00:00", - views = 150L, - visitors = 75L, - likes = 15L, - reblogs = 0L, - comments = 8L, - posts = 0L + views = 150L ) ) ) companion object { private const val TEST_SITE_ID = 123L - private const val TEST_VIEWS = 500 - private const val TEST_VISITORS = 100 - private const val TEST_LIKES = 50 - private const val TEST_COMMENTS = 25 + private const val TEST_ACCESS_TOKEN = "test_access_token" + private const val TEST_VIEWS = 500L + private const val TEST_VISITORS = 100L + private const val TEST_LIKES = 50L + private const val TEST_COMMENTS = 25L private const val NO_SITE_SELECTED_ERROR = "No site selected" private const val FAILED_TO_LOAD_ERROR = "Failed to load stats" private const val UNKNOWN_ERROR = "Unknown error" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index be8df5d7eb9a..f1cedc757348 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,7 +102,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = 'trunk-21200d63735de310067d88c7dfb6080a2200f18e' +wordpress-rs = 'trunk-8d78ff73bc58961adb1059ea895eb946ff29bccb' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.2'