diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSource.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSource.kt index dde36796..afae53b8 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSource.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSource.kt @@ -2,6 +2,7 @@ package com.daedan.festabook.data.datasource.remote.festival import com.daedan.festabook.data.datasource.remote.ApiResult import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse +import com.daedan.festabook.data.model.response.festival.RegisteredFestivalNotificationResponse interface FestivalNotificationDataSource { suspend fun saveFestivalNotification( @@ -10,4 +11,6 @@ interface FestivalNotificationDataSource { ): ApiResult suspend fun deleteFestivalNotification(festivalNotificationId: Long): ApiResult + + suspend fun getFestivalNotification(deviceId: Long): ApiResult> } diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSourceImpl.kt index 64bfcb73..d8e93e1b 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSourceImpl.kt @@ -3,6 +3,7 @@ package com.daedan.festabook.data.datasource.remote.festival import com.daedan.festabook.data.datasource.remote.ApiResult import com.daedan.festabook.data.model.request.FestivalNotificationRequest import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse +import com.daedan.festabook.data.model.response.festival.RegisteredFestivalNotificationResponse import com.daedan.festabook.data.service.FestivalNotificationService import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -28,4 +29,9 @@ class FestivalNotificationDataSourceImpl( ApiResult.toApiResult { festivalNotificationService.deleteFestivalNotification(festivalNotificationId) } + + override suspend fun getFestivalNotification(deviceId: Long): ApiResult> = + ApiResult.toApiResult { + festivalNotificationService.getFestivalNotification(deviceId) + } } diff --git a/app/src/main/java/com/daedan/festabook/data/model/response/festival/RegisteredFestivalNotificationResponse.kt b/app/src/main/java/com/daedan/festabook/data/model/response/festival/RegisteredFestivalNotificationResponse.kt new file mode 100644 index 00000000..5e903d2a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/data/model/response/festival/RegisteredFestivalNotificationResponse.kt @@ -0,0 +1,16 @@ +package com.daedan.festabook.data.model.response.festival + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RegisteredFestivalNotificationResponse( + @SerialName("festivalNotificationId") + val festivalNotificationId: Long, + @SerialName("festivalId") + val festivalId: Long, + @SerialName("universityName") + val universityName: String, + @SerialName("festivalName") + val festivalName: String, +) diff --git a/app/src/main/java/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt index 4f842fa7..e161e7c4 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt @@ -9,7 +9,6 @@ import com.daedan.festabook.domain.repository.FestivalNotificationRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject -import timber.log.Timber @ContributesBinding(AppScope::class) @Inject @@ -20,43 +19,73 @@ class FestivalNotificationRepositoryImpl( private val festivalLocalDataSource: FestivalLocalDataSource, ) : FestivalNotificationRepository { override suspend fun saveFestivalNotification(): Result { - val deviceId = deviceLocalDataSource.getDeviceId() - if (deviceId == null) { - Timber.e("${::FestivalNotificationRepositoryImpl.name}: DeviceId가 없습니다.") - return Result.failure(IllegalStateException()) - } - val festivalId = festivalLocalDataSource.getFestivalId() + val deviceId = + deviceLocalDataSource.getDeviceId() ?: return Result.failure( + IllegalArgumentException(NO_DEVICE_ID_EXCEPTION), + ) + val festivalId = + festivalLocalDataSource.getFestivalId() ?: return Result.failure( + IllegalArgumentException(NO_FESTIVAL_ID_EXCEPTION), + ) - val result = - festivalId?.let { - festivalNotificationDataSource - .saveFestivalNotification( - festivalId = it, - deviceId = deviceId, - ).toResult() - } - ?: throw IllegalArgumentException("${::FestivalNotificationRepositoryImpl.javaClass.simpleName}festivalId가 null 입니다.") - return result - .mapCatching { + return festivalNotificationDataSource + .saveFestivalNotification( + festivalId = festivalId, + deviceId = deviceId, + ).toResult() + .mapCatching { response -> festivalNotificationLocalDataSource.saveFestivalNotificationId( festivalId, - it.festivalNotificationId, + response.festivalNotificationId, ) } } override suspend fun deleteFestivalNotification(): Result { val festivalId = - festivalLocalDataSource.getFestivalId() ?: return Result.failure( - IllegalStateException(), - ) + festivalLocalDataSource.getFestivalId() + ?: return Result.failure(IllegalStateException(NO_FESTIVAL_ID_EXCEPTION)) val festivalNotificationId = festivalNotificationLocalDataSource.getFestivalNotificationId(festivalId) - val response = - festivalNotificationDataSource.deleteFestivalNotification(festivalNotificationId) - festivalNotificationLocalDataSource.deleteFestivalNotificationId(festivalId) + return festivalNotificationDataSource + .deleteFestivalNotification(festivalNotificationId) + .toResult() + .mapCatching { + festivalNotificationLocalDataSource.deleteFestivalNotificationId(festivalId) + } + } + + override suspend fun syncFestivalNotificationIsAllow(): Result { + val deviceId = + deviceLocalDataSource.getDeviceId() ?: return Result.failure( + IllegalArgumentException(NO_DEVICE_ID_EXCEPTION), + ) + val festivalId = + festivalLocalDataSource.getFestivalId() ?: return Result.failure( + IllegalArgumentException(NO_FESTIVAL_ID_EXCEPTION), + ) - return response.toResult() + return festivalNotificationDataSource + .getFestivalNotification(deviceId) + .toResult() + .mapCatching { response -> + val notificationId = + response.find { it.festivalId == festivalId }?.festivalNotificationId + val isSubscribed = notificationId != null + festivalNotificationLocalDataSource.saveFestivalNotificationIsAllowed( + festivalId, + isSubscribed, + ) + if (isSubscribed) { + festivalNotificationLocalDataSource.saveFestivalNotificationId( + festivalId, + notificationId, + ) + } else { + festivalNotificationLocalDataSource.deleteFestivalNotificationId(festivalId) + } + isSubscribed + } } override fun getFestivalNotificationIsAllow(): Boolean { @@ -72,4 +101,11 @@ class FestivalNotificationRepositoryImpl( ) } } + + companion object { + private val NO_FESTIVAL_ID_EXCEPTION = + "${::FestivalNotificationRepositoryImpl.name}: FestivalId가 없습니다." + private val NO_DEVICE_ID_EXCEPTION = + "${::FestivalNotificationRepositoryImpl.name}: DeviceId가 없습니다." + } } diff --git a/app/src/main/java/com/daedan/festabook/data/service/FestivalNotificationService.kt b/app/src/main/java/com/daedan/festabook/data/service/FestivalNotificationService.kt index e17ac8e4..09688a4f 100644 --- a/app/src/main/java/com/daedan/festabook/data/service/FestivalNotificationService.kt +++ b/app/src/main/java/com/daedan/festabook/data/service/FestivalNotificationService.kt @@ -2,9 +2,11 @@ package com.daedan.festabook.data.service import com.daedan.festabook.data.model.request.FestivalNotificationRequest import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse +import com.daedan.festabook.data.model.response.festival.RegisteredFestivalNotificationResponse import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE +import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path @@ -19,4 +21,9 @@ interface FestivalNotificationService { suspend fun deleteFestivalNotification( @Path("festivalNotificationId") id: Long, ): Response + + @GET("festivals/notifications/{deviceId}") + suspend fun getFestivalNotification( + @Path("deviceId") id: Long, + ): Response> } diff --git a/app/src/main/java/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt b/app/src/main/java/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt index 4d885acd..70487c9c 100644 --- a/app/src/main/java/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt +++ b/app/src/main/java/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt @@ -5,6 +5,8 @@ interface FestivalNotificationRepository { suspend fun deleteFestivalNotification(): Result + suspend fun syncFestivalNotificationIsAllow(): Result + fun getFestivalNotificationIsAllow(): Boolean fun setFestivalNotificationIsAllow(isAllowed: Boolean) diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt index c383ed77..dd9e7fdb 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt @@ -44,6 +44,16 @@ class SettingViewModel( val success = _success.asSharedFlow() + init { + viewModelScope.launch { + festivalNotificationRepository + .syncFestivalNotificationIsAllow() + .onSuccess { + _isAllowed.emit(it) + } + } + } + fun notificationAllowClick() { if (!_isAllowed.value) { viewModelScope.launch { diff --git a/app/src/test/java/com/daedan/festabook/setting/FestivalNotificationRepositoryTest.kt b/app/src/test/java/com/daedan/festabook/setting/FestivalNotificationRepositoryTest.kt new file mode 100644 index 00000000..86a28c96 --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/setting/FestivalNotificationRepositoryTest.kt @@ -0,0 +1,202 @@ +package com.daedan.festabook.setting + +import com.daedan.festabook.data.datasource.local.DeviceLocalDataSource +import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource +import com.daedan.festabook.data.datasource.local.FestivalNotificationLocalDataSource +import com.daedan.festabook.data.datasource.remote.ApiResult +import com.daedan.festabook.data.datasource.remote.festival.FestivalNotificationDataSource +import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse +import com.daedan.festabook.data.model.response.festival.RegisteredFestivalNotificationResponse +import com.daedan.festabook.data.repository.FestivalNotificationRepositoryImpl +import com.daedan.festabook.domain.repository.FestivalNotificationRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FestivalNotificationRepositoryTest { + private lateinit var festivalNotificationRepository: FestivalNotificationRepository + private val festivalNotificationDataSource: FestivalNotificationDataSource = + mockk( + relaxed = true, + ) + private val deviceLocalDataSource: DeviceLocalDataSource = + mockk( + relaxed = true, + ) + private val festivalNotificationLocalDataSource: FestivalNotificationLocalDataSource = + mockk( + relaxed = true, + ) + private val festivalLocalDataSource: FestivalLocalDataSource = + mockk( + relaxed = true, + ) + + @BeforeEach + fun setup() { + coEvery { + deviceLocalDataSource.getDeviceId() + } returns 1 + coEvery { + festivalLocalDataSource.getFestivalId() + } returns 1 + + festivalNotificationRepository = + FestivalNotificationRepositoryImpl( + festivalNotificationDataSource, + deviceLocalDataSource, + festivalNotificationLocalDataSource, + festivalLocalDataSource, + ) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Notification ID를 저장할 수 있다 `() = + runTest { + // given + coEvery { + festivalNotificationDataSource.saveFestivalNotification(any(), any()) + } returns + ApiResult.Success( + FestivalNotificationResponse(10), + ) + + // when + festivalNotificationRepository.saveFestivalNotification() + + // then + coVerify(exactly = 1) { + festivalNotificationDataSource.saveFestivalNotification(1, 1) + festivalNotificationLocalDataSource.saveFestivalNotificationId(1, 10) + } + } + + @Test + fun `Notification ID를 삭제할 수 있다`() = + runTest { + // given + coEvery { + festivalNotificationLocalDataSource.getFestivalNotificationId(1) + } returns 10 + coEvery { + festivalNotificationDataSource.deleteFestivalNotification(10) + } returns ApiResult.Success(Unit) + + // when + festivalNotificationRepository.deleteFestivalNotification() + + // then + coVerify(exactly = 1) { + festivalNotificationDataSource.deleteFestivalNotification(10) + festivalNotificationLocalDataSource.deleteFestivalNotificationId(1) + } + } + + @Test + fun `서버에서 Notification ID 삭제에 실패하면 로컬에 ID를 삭제하지 않는다`() = + runTest { + // given + coEvery { + festivalNotificationLocalDataSource.getFestivalNotificationId(1) + } returns 10 + coEvery { + festivalNotificationDataSource.deleteFestivalNotification(10) + } returns ApiResult.ServerError(500, "", "") + + // when + festivalNotificationRepository.deleteFestivalNotification() + + // then + coVerify(exactly = 0) { + festivalNotificationLocalDataSource.deleteFestivalNotificationId(1) + } + } + + @Test + fun `서버에서 Notification ID 저장에 실패하면 로컬에 ID를 저장하지 않는다`() = + runTest { + // given + coEvery { + festivalNotificationDataSource.saveFestivalNotification(any(), any()) + } returns + ApiResult.ServerError(500, "", "") + + // when + festivalNotificationRepository.saveFestivalNotification() + + // then + coVerify(exactly = 0) { + festivalNotificationLocalDataSource.saveFestivalNotificationId(1, 10) + } + } + + @Test + fun `서버에 알람이 등록되어있지 않다면 로컬에 Notification 정보를 삭제할 수 있다`() = + runTest { + // given + coEvery { + festivalNotificationLocalDataSource.getFestivalNotificationId(1) + } returns 1 + + coEvery { + festivalNotificationDataSource.getFestivalNotification(1) + } returns ApiResult.Success(listOf()) + + // when + val result = festivalNotificationRepository.syncFestivalNotificationIsAllow() + + // then + assertThat(result.getOrNull()).isFalse() + coVerify(exactly = 1) { + festivalNotificationLocalDataSource.saveFestivalNotificationIsAllowed(1, false) + festivalNotificationLocalDataSource.deleteFestivalNotificationId(1) + } + } + + @Test + fun `서버에 알람이 등록되어 있다면 로컬에 Notification 정보를 저장할 수 있다`() = + runTest { + // given + coEvery { + festivalNotificationLocalDataSource.getFestivalNotificationId(1) + } returns -1 + + coEvery { + festivalNotificationDataSource.getFestivalNotification(1) + } returns + ApiResult.Success( + listOf( + RegisteredFestivalNotificationResponse( + festivalId = 1, + festivalNotificationId = 10, + universityName = "", + festivalName = "", + ), + ), + ) + + // when + val result = festivalNotificationRepository.syncFestivalNotificationIsAllow() + + // then + assertThat(result.getOrNull()).isTrue() + coVerify(exactly = 1) { + festivalNotificationLocalDataSource.saveFestivalNotificationIsAllowed(1, true) + festivalNotificationLocalDataSource.saveFestivalNotificationId(1, 10) + } + } +} diff --git a/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt b/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt index fdec631a..5d11e79e 100644 --- a/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt @@ -31,6 +31,8 @@ class SettingViewModelTest { fun setUp() { Dispatchers.setMain(testDispatcher) festivalNotificationRepository = mockk(relaxed = true) + coEvery { festivalNotificationRepository.syncFestivalNotificationIsAllow() } returns + Result.success(false) settingViewModel = SettingViewModel(festivalNotificationRepository) } @@ -44,6 +46,9 @@ class SettingViewModelTest { runTest { // given coEvery { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns false + coEvery { festivalNotificationRepository.syncFestivalNotificationIsAllow() } returns + Result.success(false) + settingViewModel = SettingViewModel(festivalNotificationRepository) // 먼저 생성 val event = observeEvent(settingViewModel.permissionCheckEvent) @@ -62,9 +67,13 @@ class SettingViewModelTest { runTest { // given coEvery { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns true + coEvery { festivalNotificationRepository.syncFestivalNotificationIsAllow() } returns + Result.success(true) // when settingViewModel = SettingViewModel(festivalNotificationRepository) + advanceUntilIdle() + settingViewModel.notificationAllowClick() advanceUntilIdle() @@ -82,6 +91,8 @@ class SettingViewModelTest { runTest { // given coEvery { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns true + coEvery { festivalNotificationRepository.syncFestivalNotificationIsAllow() } returns + Result.success(true) coEvery { festivalNotificationRepository.deleteFestivalNotification() } returns Result.failure( Throwable(),