From 99d8ab2f93e76f856a5cb6922be2edc30f0d6fd5 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Thu, 29 Jan 2026 22:56:21 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(notification):=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=99=80=20=EC=95=8C=EB=A6=BC=20=EA=B5=AC=EB=8F=85=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 실행 시 서버로부터 사용자의 축제 알림 구독 목록을 가져와 로컬 상태와 동기화하는 기능을 추가했습니다. 이를 통해 다른 기기에서 알림 설정을 변경했거나, 앱을 재설치했을 때도 정확한 알림 수신 동의 상태가 반영되도록 개선했습니다. - **`FestivalNotificationService.kt` 수정:** - `GET /festivals/notifications/{deviceId}` API를 호출하는 `getFestivalNotification` 함수를 추가하여 특정 기기에 등록된 모든 축제 알림 목록을 조회하는 기능을 구현했습니다. - **`FestivalNotificationDataSource.kt` & `FestivalNotificationDataSourceImpl.kt` 수정:** - `FestivalNotificationService`의 변경 사항을 반영하여 `getFestivalNotification` 함수를 인터페이스와 구현체에 추가했습니다. - **`FestivalNotificationRepository.kt` & `FestivalNotificationRepositoryImpl.kt` 수정:** - `syncFestivalNotificationIsAllow` 함수를 새로 추가했습니다. - 이 함수는 서버에서 현재 기기의 알림 구독 목록을 가져온 뒤, 현재 선택된 축제에 대한 구독 여부를 확인하여 로컬 데이터(SharedPreferences)를 갱신합니다. - 구독 중인 경우 `festivalNotificationId`를 로컬에 저장하고, 아닌 경우 삭제합니다. - `saveFestivalNotification`, `deleteFestivalNotification` 등 기존 로직의 예외 처리와 가독성을 개선했습니다. - **`SettingViewModel.kt` 수정:** - `init` 블록에서 `festivalNotificationRepository.syncFestivalNotificationIsAllow()`를 호출하여, 설정 화면 진입 시 알림 동의 상태를 서버와 동기화하도록 로직을 추가했습니다. --- .../FestivalNotificationDataSource.kt | 3 + .../FestivalNotificationDataSourceImpl.kt | 6 ++ .../FestivalNotificationRepositoryImpl.kt | 88 +++++++++++++------ .../service/FestivalNotificationService.kt | 7 ++ .../FestivalNotificationRepository.kt | 2 + .../presentation/setting/SettingViewModel.kt | 10 +++ 6 files changed, 90 insertions(+), 26 deletions(-) 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/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 { From b8cb8dff46065dc84e2de4c4f8708ff683a9e89a Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Thu, 29 Jan 2026 22:57:21 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(Setting):=20FestivalNotificationReposi?= =?UTF-8?q?tory=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기기 변경 또는 앱 재설치 시에도 축제 알림 설정 상태가 유지되도록, 앱 시작 시 서버와 알림 설정 상태를 동기화하는 기능을 추가했습니다. - **`SettingViewModel.kt` 수정:** - ViewModel이 생성될 때(`init` 블록) `festivalNotificationRepository.syncFestivalNotificationIsAllow()`를 호출하여 서버의 알림 등록 상태를 가져와 로컬 데이터와 동기화합니다. - 동기화된 상태를 바탕으로 알림 허용 여부 UI(`uiState`)를 갱신합니다. - **`FestivalNotificationRepositoryTest.kt` 추가:** - `FestivalNotificationRepository`의 동기화 로직에 대한 단위 테스트를 작성했습니다. - **주요 테스트 시나리오:** - 서버에 알림이 등록되어 있을 때, 로컬 DB에 알림 ID와 허용 상태(`true`)를 저장하는지 확인합니다. - 서버에 알림이 등록되어 있지 않을 때, 로컬 DB의 알림 ID를 삭제하고 허용 상태(`false`)를 저장하는지 확인합니다. - 알림 ID 저장 및 삭제 시, 서버 API 호출 성공 여부에 따라 로컬 데이터가 올바르게 처리되는지 검증합니다. - **`RegisteredFestivalNotificationResponse.kt` 추가:** - 서버에서 등록된 축제 알림 목록을 받아오기 위한 새로운 데이터 모델 클래스를 정의했습니다. --- .../RegisteredFestivalNotificationResponse.kt | 16 ++ .../FestivalNotificationRepositoryTest.kt | 202 ++++++++++++++++++ .../festabook/setting/SettingViewModelTest.kt | 11 + 3 files changed, 229 insertions(+) create mode 100644 app/src/main/java/com/daedan/festabook/data/model/response/festival/RegisteredFestivalNotificationResponse.kt create mode 100644 app/src/test/java/com/daedan/festabook/setting/FestivalNotificationRepositoryTest.kt 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/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(),