diff --git a/core/common/src/main/java/com/acon/acon/core/common/utils/DateExtensions.kt b/core/common/src/main/java/com/acon/acon/core/common/utils/DateExtensions.kt new file mode 100644 index 00000000..7c852e68 --- /dev/null +++ b/core/common/src/main/java/com/acon/acon/core/common/utils/DateExtensions.kt @@ -0,0 +1,31 @@ +package com.acon.acon.core.common.utils + +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +private val yyyyMMddFormatter by lazy { + DateTimeFormatter.ofPattern("yyyyMMdd") +} + +/** + * [LocalDate]를 `yyyyMMdd` 형식으로 변환 + */ +fun LocalDate.toyyyyMMdd(): String { + return format(yyyyMMddFormatter) +} + +/** + * yyyyMMdd을 [LocalDate]로 변환. + * 파싱 실패 시 null 반환 + * ``` + * "20250915".toLocalDate() // == LocalDate.of(2025, 9, 15) + * ``` + */ +fun String.toLocalDate(): LocalDate? { + return try { + LocalDate.parse(this, yyyyMMddFormatter) + } catch (_: DateTimeParseException) { + null + } +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/acon/core/data/api/remote/ProfileApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/ProfileApi.kt index f3071489..39742a08 100644 --- a/core/data/src/main/kotlin/com/acon/core/data/api/remote/ProfileApi.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/ProfileApi.kt @@ -4,8 +4,10 @@ import com.acon.core.data.dto.request.profile.UpdateProfileRequest import com.acon.core.data.dto.response.profile.ProfileResponse import com.acon.core.data.dto.response.profile.SavedSpotResponse import com.acon.core.data.dto.response.profile.SavedSpotsResponse +import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.PATCH +import retrofit2.http.Query interface ProfileApi { @@ -13,10 +15,10 @@ interface ProfileApi { suspend fun getProfile() : ProfileResponse @PATCH("/api/v1/members/me") - suspend fun updateProfile(updateProfileRequest: UpdateProfileRequest) + suspend fun updateProfile(@Body updateProfileRequest: UpdateProfileRequest) @GET("/api/v1/nickname/validate") - suspend fun validateNickname(nickname: String) + suspend fun validateNickname(@Query("nickname") nickname: String) @GET("/api/v1/saved-spots") suspend fun getSavedSpots() : SavedSpotsResponse diff --git a/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/AconAppNoAuthApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/AconAppNoAuthApi.kt index 15df9e71..c5ba974c 100644 --- a/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/AconAppNoAuthApi.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/AconAppNoAuthApi.kt @@ -1,7 +1,11 @@ package com.acon.core.data.api.remote.noauth +import com.acon.core.data.dto.request.GetPresignedUrlRequest +import com.acon.core.data.dto.response.PresignedUrlResponse import com.acon.core.data.dto.response.app.ShouldUpdateResponse +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.POST import retrofit2.http.Query interface AconAppNoAuthApi { @@ -10,4 +14,9 @@ interface AconAppNoAuthApi { @Query("version") currentVersion: String, @Query("platform") platform: String = "android" ): ShouldUpdateResponse + + @POST("/api/v1/images/presigned-url") + suspend fun getPresignedUrl( + @Body request: GetPresignedUrlRequest + ): PresignedUrlResponse } \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/FileUploadApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/FileUploadApi.kt new file mode 100644 index 00000000..6832bc82 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/FileUploadApi.kt @@ -0,0 +1,14 @@ +package com.acon.core.data.api.remote.noauth + +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.PUT +import retrofit2.http.Url + +interface FileUploadApi { + @PUT + suspend fun uploadFile( + @Url url: String, + @Body body: RequestBody + ) +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/AconAppRemoteDataSource.kt b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/AconAppRemoteDataSource.kt index 96cba5b4..74b562f6 100644 --- a/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/AconAppRemoteDataSource.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/AconAppRemoteDataSource.kt @@ -2,12 +2,25 @@ package com.acon.core.data.datasource.remote import com.acon.core.data.dto.response.app.ShouldUpdateResponse import com.acon.core.data.api.remote.noauth.AconAppNoAuthApi +import com.acon.core.data.api.remote.noauth.FileUploadApi +import com.acon.core.data.dto.request.GetPresignedUrlRequest +import com.acon.core.data.dto.response.PresignedUrlResponse +import okhttp3.RequestBody import javax.inject.Inject class AconAppRemoteDataSource @Inject constructor( - private val aconAppNoAuthApi: AconAppNoAuthApi + private val aconAppNoAuthApi: AconAppNoAuthApi, + private val fileUploadApi: FileUploadApi, ) { suspend fun fetchShouldUpdateApp(currentVersion: String): ShouldUpdateResponse { return aconAppNoAuthApi.fetchShouldUpdateApp(currentVersion) } + + suspend fun getPresignedUrl(request: GetPresignedUrlRequest): PresignedUrlResponse { + return aconAppNoAuthApi.getPresignedUrl(request) + } + + suspend fun uploadFile(presignedUrl: String, body: RequestBody) { + fileUploadApi.uploadFile(presignedUrl, body) + } } \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/acon/core/data/di/ApiModule.kt b/core/data/src/main/kotlin/com/acon/core/data/di/ApiModule.kt index 5b197c68..c5f552ed 100644 --- a/core/data/src/main/kotlin/com/acon/core/data/di/ApiModule.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/di/ApiModule.kt @@ -14,6 +14,7 @@ import com.acon.core.data.api.remote.auth.SpotAuthApi import com.acon.core.data.api.remote.noauth.SpotNoAuthApi import com.acon.core.data.api.remote.auth.UploadAuthApi import com.acon.core.data.api.remote.auth.UserAuthApi +import com.acon.core.data.api.remote.noauth.FileUploadApi import com.acon.core.data.api.remote.noauth.UserNoAuthApi import dagger.Module import dagger.Provides @@ -113,4 +114,12 @@ internal object ApiModule { ): AconAppNoAuthApi { return retrofit.create(AconAppNoAuthApi::class.java) } + + @Singleton + @Provides + fun providesFileUploadApi( + @NoAuth retrofit: Retrofit + ): FileUploadApi { + return retrofit.create(FileUploadApi::class.java) + } } \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/acon/core/data/dto/request/GetPresignedUrlRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/GetPresignedUrlRequest.kt new file mode 100644 index 00000000..68c31cdd --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/GetPresignedUrlRequest.kt @@ -0,0 +1,11 @@ +package com.acon.core.data.dto.request + +import com.acon.acon.core.model.type.ImageType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GetPresignedUrlRequest( + @SerialName("imageType") val imageType: ImageType, + @SerialName("originalFileName") val fileName: String +) diff --git a/core/data/src/main/kotlin/com/acon/core/data/dto/request/profile/UpdateProfileRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/profile/UpdateProfileRequest.kt index aff29846..f78b8d66 100644 --- a/core/data/src/main/kotlin/com/acon/core/data/dto/request/profile/UpdateProfileRequest.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/profile/UpdateProfileRequest.kt @@ -3,18 +3,21 @@ package com.acon.core.data.dto.request.profile import com.acon.acon.core.model.model.profile.BirthDateStatus import com.acon.acon.core.model.model.profile.Profile import com.acon.acon.core.model.model.profile.ProfileImageStatus +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class UpdateProfileRequest( - val nickname: String, - val birthDate: String?, - val image: String? + @SerialName("nickname") val nickname: String, + @SerialName("birthDate") val birthDate: String?, + @SerialName("profileImage") val image: String? ) fun Profile.toUpdateProfileRequest() : UpdateProfileRequest { val requestNickname = nickname val requestBirthDate: String? = when(birthDate) { is BirthDateStatus.Specified -> with((birthDate as BirthDateStatus.Specified).date) { - "$year.$month.$dayOfMonth" + "$year.${monthValue.toString().padStart(2, '0')}.$dayOfMonth" } BirthDateStatus.NotSpecified -> null } diff --git a/core/data/src/main/kotlin/com/acon/core/data/dto/response/PresignedUrlResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/PresignedUrlResponse.kt new file mode 100644 index 00000000..6d9d5db9 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/PresignedUrlResponse.kt @@ -0,0 +1,10 @@ +package com.acon.core.data.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PresignedUrlResponse( + @SerialName("fileUrl") val fileUrl: String, + @SerialName("preSignedUrl") val presignedUrl: String +) diff --git a/core/data/src/main/kotlin/com/acon/core/data/repository/ProfileRepositoryImpl.kt b/core/data/src/main/kotlin/com/acon/core/data/repository/ProfileRepositoryImpl.kt index 92785639..24dd0c14 100644 --- a/core/data/src/main/kotlin/com/acon/core/data/repository/ProfileRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/repository/ProfileRepositoryImpl.kt @@ -1,23 +1,35 @@ package com.acon.core.data.repository +import android.content.Context +import androidx.core.net.toUri import com.acon.acon.core.model.model.profile.Profile +import com.acon.acon.core.model.model.profile.ProfileImageStatus import com.acon.acon.core.model.model.profile.SavedSpot +import com.acon.acon.core.model.type.ImageType import com.acon.acon.domain.error.profile.UpdateProfileError import com.acon.acon.domain.error.profile.ValidateNicknameError import com.acon.acon.domain.repository.ProfileRepository +import com.acon.core.data.api.remote.noauth.FileUploadApi import com.acon.core.data.datasource.local.ProfileLocalDataSource +import com.acon.core.data.datasource.remote.AconAppRemoteDataSource import com.acon.core.data.datasource.remote.ProfileRemoteDataSource +import com.acon.core.data.dto.request.GetPresignedUrlRequest import com.acon.core.data.dto.request.profile.toUpdateProfileRequest import com.acon.core.data.error.runCatchingWith +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody import javax.inject.Inject class ProfileRepositoryImpl @Inject constructor( private val profileRemoteDataSource: ProfileRemoteDataSource, - private val profileLocalDataSource: ProfileLocalDataSource + private val profileLocalDataSource: ProfileLocalDataSource, + private val aconAppRemoteDataSource: AconAppRemoteDataSource, + @ApplicationContext private val context: Context ) : ProfileRepository { override fun getProfile(): Flow> { @@ -45,9 +57,34 @@ class ProfileRepositoryImpl @Inject constructor( override suspend fun updateProfile(newProfile: Profile): Result { return runCatchingWith(UpdateProfileError()) { - profileRemoteDataSource.updateProfile(newProfile.toUpdateProfileRequest()) + val profileToUpdate: Profile - profileLocalDataSource.cacheProfile(newProfile) + val imageStatus = newProfile.image + if (imageStatus is ProfileImageStatus.Custom) { + if (imageStatus.url.startsWith("content://")) { + val presignedUrlResponse = aconAppRemoteDataSource.getPresignedUrl(GetPresignedUrlRequest( + imageType = ImageType.PROFILE, + fileName = imageStatus.url + )) + val inputStream = context.contentResolver.openInputStream(imageStatus.url.toUri()) + val requestBody = inputStream?.readBytes()?.toRequestBody("image/jpeg".toMediaTypeOrNull()) + requestBody?.let { + aconAppRemoteDataSource.uploadFile(presignedUrlResponse.presignedUrl, it) + } + + profileToUpdate = newProfile.copy( + image = ProfileImageStatus.Custom(presignedUrlResponse.fileUrl) + ) + } else { + profileToUpdate = newProfile + } + } else { + profileToUpdate = newProfile + } + + profileRemoteDataSource.updateProfile(profileToUpdate.toUpdateProfileRequest()) + + profileLocalDataSource.cacheProfile(profileToUpdate) Unit } diff --git a/core/data/src/test/java/com/acon/core/data/repository/ProfileRepositoryTest.kt b/core/data/src/test/java/com/acon/core/data/repository/ProfileRepositoryTest.kt index 2a4625cb..89088c20 100644 --- a/core/data/src/test/java/com/acon/core/data/repository/ProfileRepositoryTest.kt +++ b/core/data/src/test/java/com/acon/core/data/repository/ProfileRepositoryTest.kt @@ -4,7 +4,7 @@ import com.acon.acon.core.model.model.profile.BirthDateStatus import com.acon.acon.core.model.model.profile.Profile import com.acon.acon.core.model.model.profile.ProfileImageStatus import com.acon.acon.core.model.model.profile.SavedSpot -import com.acon.acon.domain.error.UNSPECIFIED_SERVER_ERROR_CODE +import com.acon.acon.core.model.model.profile.SpotThumbnailStatus import com.acon.acon.domain.error.profile.UpdateProfileError import com.acon.acon.domain.error.profile.ValidateNicknameError import com.acon.acon.domain.repository.ProfileRepository @@ -12,6 +12,7 @@ import com.acon.core.data.assertValidErrorMapping import com.acon.core.data.createErrorStream import com.acon.core.data.createFakeRemoteError import com.acon.core.data.datasource.local.ProfileLocalDataSource +import com.acon.core.data.datasource.remote.AconAppRemoteDataSource import com.acon.core.data.datasource.remote.ProfileRemoteDataSource import com.acon.core.data.dto.response.profile.ProfileResponse import com.acon.core.data.dto.response.profile.SavedSpotResponse @@ -20,7 +21,9 @@ import io.mockk.coVerify import io.mockk.impl.annotations.MockK import io.mockk.junit5.MockKExtension import io.mockk.just +import io.mockk.mockk import io.mockk.runs +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest @@ -42,11 +45,23 @@ class ProfileRepositoryTest { @MockK private lateinit var profileLocalDataSource: ProfileLocalDataSource + @MockK + private lateinit var aconAppRemoteDataSource: AconAppRemoteDataSource + private lateinit var profileRepository: ProfileRepository + private val sampleNewProfile get() = Profile( + nickname = "New nickname", + birthDate = BirthDateStatus.Specified(LocalDate.of(2000, 1, 1)), + image = ProfileImageStatus.Default + ) + @BeforeEach fun setUp() { - profileRepository = ProfileRepositoryImpl(profileRemoteDataSource, profileLocalDataSource) + profileRepository = ProfileRepositoryImpl( + profileRemoteDataSource, profileLocalDataSource, aconAppRemoteDataSource, + mockk(relaxed = true) + ) } @Test @@ -150,7 +165,7 @@ class ProfileRepositoryTest { @Test fun `updateProfile()은 서버에 프로필 저장을 성공할 경우, 로컬 캐싱을 업데이트하고 Result(Unit)을 반환한다`() = runTest { // Given - val sampleNewProfile = getSampleNewProfile() + val sampleNewProfile = sampleNewProfile val expectedResult = Result.success(Unit) coEvery { profileRemoteDataSource.updateProfile(any()) } just runs coEvery { profileLocalDataSource.cacheProfile(sampleNewProfile) } just runs @@ -166,7 +181,7 @@ class ProfileRepositoryTest { @Test fun `updateProfile()은 서버에 프로필 저장을 실패할 경우, 로컬 캐싱 값을 업데이트하지 않는다`() = runTest { // Given - val sampleNewProfile = getSampleNewProfile() + val sampleNewProfile = sampleNewProfile coEvery { profileRemoteDataSource.updateProfile(any()) } throws Exception() @@ -184,7 +199,7 @@ class ProfileRepositoryTest { expectedErrorClass: KClass ) = runTest { // Given - val sampleNewProfile = getSampleNewProfile() + val sampleNewProfile = sampleNewProfile val fakeRemoteError = createFakeRemoteError(errorCode) coEvery { profileRemoteDataSource.updateProfile(any()) } throws fakeRemoteError @@ -196,12 +211,6 @@ class ProfileRepositoryTest { assertValidErrorMapping(actualResult, expectedErrorClass) } - private fun getSampleNewProfile() = Profile( - nickname = "New nickname", - birthDate = BirthDateStatus.Specified(LocalDate.of(2000, 1, 1)), - image = ProfileImageStatus.Default - ) - @Test fun `validateNickname()은 서버로부터 유효성 검사 성공 시 Result(Unit)을 반환한다`() = runTest { // Given @@ -235,6 +244,24 @@ class ProfileRepositoryTest { assertValidErrorMapping(result, expectedErrorClass) } + @Test + fun `getSavedSpots()는 로컬에 저장된 캐시 값이 있을 경우, 서버 API를 호출하지 않고 캐시 값을 반환한다`() = runTest { + // Given + val sampleCachedSavedSpots = listOf( + SavedSpot(1, "Spot1", SpotThumbnailStatus.Empty), + SavedSpot(2, "Spot2", SpotThumbnailStatus.Exist("sample url1")), + SavedSpot(3, "Spot3", SpotThumbnailStatus.Exist("sample url2")) + ) + coEvery { profileLocalDataSource.getSavedSpots() } returns flowOf(sampleCachedSavedSpots) + val expectedResult = Result.success(sampleCachedSavedSpots) + + // When + val actualResult = profileRepository.getSavedSpots().first() + + // Then + coVerify(exactly = 0) { profileRemoteDataSource.getSavedSpots() } + assertEquals(expectedResult, actualResult) + } @Test fun `getSavedSpots()는 서버로부터 저장한 장소 응답받기를 성공하면, 모델로 변환하고 Result Wrapping하여 반환한다`() = runTest { // Given @@ -243,12 +270,15 @@ class ProfileRepositoryTest { SavedSpotResponse(2, "Spot2", "sample url"), SavedSpotResponse(3, "Spot3", "sample url") ) + coEvery { profileLocalDataSource.getSavedSpots() } returns flowOf(null) + coEvery { profileLocalDataSource.cacheSavedSpots(any()) } just runs + coEvery { profileRemoteDataSource.getSavedSpots() } returns sampleSavedSpotsResponse val sampleSavedSpots = sampleSavedSpotsResponse.map { it.toSavedSpot() } val expectedResult = Result.success(sampleSavedSpots) // When - val actualResult = profileRepository.getSavedSpots() + val actualResult = profileRepository.getSavedSpots().first() // Then assertEquals(expectedResult, actualResult) @@ -258,11 +288,12 @@ class ProfileRepositoryTest { fun `getSavedSpots()는 서버로부터 저장한 장소 응답받기를 실패하면 발생한 예외를 Result wrapping하여 그대로 전파한다`() = runTest { // Given val fakeException = Exception() + coEvery { profileLocalDataSource.getSavedSpots() } returns flowOf(null) coEvery { profileRemoteDataSource.getSavedSpots() } throws fakeException val expectedResult = Result.failure>(fakeException) // When - val actualResult = profileRepository.getSavedSpots() + val actualResult = profileRepository.getSavedSpots().first() // Then assertEquals(expectedResult, actualResult) @@ -271,15 +302,16 @@ class ProfileRepositoryTest { companion object { @JvmStatic fun updateProfileErrorScenarios() = createErrorStream( - UNSPECIFIED_SERVER_ERROR_CODE to UpdateProfileError.AlreadyExistNickname::class, - UNSPECIFIED_SERVER_ERROR_CODE to UpdateProfileError.InvalidNicknameFormat::class, - UNSPECIFIED_SERVER_ERROR_CODE to UpdateProfileError.InvalidBirthDateFormat::class + 40901 to UpdateProfileError.AlreadyExistNickname::class, + 40051 to UpdateProfileError.InvalidNicknameFormat::class, + 40053 to UpdateProfileError.InvalidBirthDateFormat::class, + 40052 to UpdateProfileError.InvalidBucketImagePath::class ) @JvmStatic fun validateNicknameErrorScenarios() = createErrorStream( - UNSPECIFIED_SERVER_ERROR_CODE to ValidateNicknameError.InvalidFormat::class, - UNSPECIFIED_SERVER_ERROR_CODE to ValidateNicknameError.AlreadyExist::class + 40051 to ValidateNicknameError.InvalidFormat::class, + 40901 to ValidateNicknameError.AlreadyExist::class ) } } \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/type/ImageType.kt b/core/model/src/main/java/com/acon/acon/core/model/type/ImageType.kt new file mode 100644 index 00000000..32892b6e --- /dev/null +++ b/core/model/src/main/java/com/acon/acon/core/model/type/ImageType.kt @@ -0,0 +1,5 @@ +package com.acon.acon.core.model.type + +enum class ImageType { + PROFILE, SPOT, MENUBOARD +} \ No newline at end of file diff --git a/domain/src/main/java/com/acon/acon/domain/error/profile/UpdateProfileError.kt b/domain/src/main/java/com/acon/acon/domain/error/profile/UpdateProfileError.kt index f62b183a..725a57bc 100644 --- a/domain/src/main/java/com/acon/acon/domain/error/profile/UpdateProfileError.kt +++ b/domain/src/main/java/com/acon/acon/domain/error/profile/UpdateProfileError.kt @@ -1,25 +1,36 @@ package com.acon.acon.domain.error.profile import com.acon.acon.domain.error.RootError -import com.acon.acon.domain.error.UNSPECIFIED_SERVER_ERROR_CODE open class UpdateProfileError : RootError() { class AlreadyExistNickname : UpdateProfileError() { - override val code: Int = UNSPECIFIED_SERVER_ERROR_CODE + override val code: Int = 40901 } class InvalidNicknameFormat : UpdateProfileError() { - override val code: Int = UNSPECIFIED_SERVER_ERROR_CODE + override val code: Int = 40051 } class InvalidBirthDateFormat : UpdateProfileError() { - override val code: Int = UNSPECIFIED_SERVER_ERROR_CODE + override val code: Int = 40053 + } + class InvalidBucketImagePath : UpdateProfileError() { + override val code: Int = 40052 + } + class InvalidImageType: UpdateProfileError() { + override val code: Int = 40045 + } + class InternalServerError: UpdateProfileError() { + override val code: Int = 50005 } final override fun createErrorInstances(): Array { return arrayOf( AlreadyExistNickname(), InvalidNicknameFormat(), - InvalidBirthDateFormat() + InvalidBirthDateFormat(), + InvalidBucketImagePath(), + InvalidImageType(), + InternalServerError() ) } } \ No newline at end of file diff --git a/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateBirthDateError.kt b/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateBirthDateError.kt index f4767d10..862a39a5 100644 --- a/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateBirthDateError.kt +++ b/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateBirthDateError.kt @@ -1,14 +1,13 @@ package com.acon.acon.domain.error.profile import com.acon.acon.domain.error.RootError -import com.acon.acon.domain.error.UNSPECIFIED_SERVER_ERROR_CODE open class ValidateBirthDateError : RootError() { class InputIsFuture : ValidateBirthDateError() class InputIsTooPast : ValidateBirthDateError() class InvalidFormat : ValidateBirthDateError() { - override val code = UNSPECIFIED_SERVER_ERROR_CODE + override val code = 40053 } override fun createErrorInstances(): Array { diff --git a/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateNicknameError.kt b/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateNicknameError.kt index 8ed47b6b..9dde90f2 100644 --- a/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateNicknameError.kt +++ b/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateNicknameError.kt @@ -1,17 +1,16 @@ package com.acon.acon.domain.error.profile import com.acon.acon.domain.error.RootError -import com.acon.acon.domain.error.UNSPECIFIED_SERVER_ERROR_CODE open class ValidateNicknameError : RootError() { class EmptyInput : ValidateNicknameError() class InputLengthExceeded : ValidateNicknameError() class InvalidFormat : ValidateNicknameError() { - override val code: Int = UNSPECIFIED_SERVER_ERROR_CODE + override val code: Int = 40051 } class AlreadyExist : ValidateNicknameError() { - override val code: Int = UNSPECIFIED_SERVER_ERROR_CODE + override val code: Int = 40901 } final override fun createErrorInstances(): Array { diff --git a/domain/src/main/java/com/acon/acon/domain/usecase/ValidateBirthDateUseCase.kt b/domain/src/main/java/com/acon/acon/domain/usecase/ValidateBirthDateUseCase.kt index 4cdcde4b..4b9b45cf 100644 --- a/domain/src/main/java/com/acon/acon/domain/usecase/ValidateBirthDateUseCase.kt +++ b/domain/src/main/java/com/acon/acon/domain/usecase/ValidateBirthDateUseCase.kt @@ -2,8 +2,9 @@ package com.acon.acon.domain.usecase import com.acon.acon.domain.error.profile.ValidateBirthDateError import java.time.LocalDate +import javax.inject.Inject -class ValidateBirthDateUseCase() { +class ValidateBirthDateUseCase @Inject constructor() { private val pastThreshold = LocalDate.of(1900, 1, 1) diff --git a/domain/src/main/java/com/acon/acon/domain/usecase/ValidateNicknameUseCase.kt b/domain/src/main/java/com/acon/acon/domain/usecase/ValidateNicknameUseCase.kt index a1c59813..d3469d19 100644 --- a/domain/src/main/java/com/acon/acon/domain/usecase/ValidateNicknameUseCase.kt +++ b/domain/src/main/java/com/acon/acon/domain/usecase/ValidateNicknameUseCase.kt @@ -15,7 +15,7 @@ class ValidateNicknameUseCase @Inject constructor( suspend operator fun invoke(nickname: String) : Result { return when { nickname.isEmpty() -> Result.failure(ValidateNicknameError.EmptyInput()) - nickname.length > 14 -> Result.failure(ValidateNicknameError.InputLengthExceeded()) + nickname.length > MAX_NICKNAME_LENGTH -> Result.failure(ValidateNicknameError.InputLengthExceeded()) nickname.containsInvalidCharacters() -> Result.failure(ValidateNicknameError.InvalidFormat()) else -> profileRepository.validateNickname(nickname) } @@ -24,4 +24,8 @@ class ValidateNicknameUseCase @Inject constructor( private fun String.containsInvalidCharacters(): Boolean { return nicknameValidationRegex.containsMatchIn(this) } + + companion object { + const val MAX_NICKNAME_LENGTH = 14 + } } \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/update/status/BirthDateValidationStatus.kt b/feature/profile/src/main/java/com/acon/feature/profile/update/status/BirthDateValidationStatus.kt new file mode 100644 index 00000000..0efc0be0 --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/update/status/BirthDateValidationStatus.kt @@ -0,0 +1,7 @@ +package com.acon.feature.profile.update.status + +sealed interface BirthDateValidationStatus { + data object Idle: BirthDateValidationStatus + data object Valid: BirthDateValidationStatus + data object Invalid: BirthDateValidationStatus +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/update/status/NicknameValidationStatus.kt b/feature/profile/src/main/java/com/acon/feature/profile/update/status/NicknameValidationStatus.kt new file mode 100644 index 00000000..45989447 --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/update/status/NicknameValidationStatus.kt @@ -0,0 +1,10 @@ +package com.acon.feature.profile.update.status + +sealed interface NicknameValidationStatus { + data object Idle: NicknameValidationStatus + data object Available: NicknameValidationStatus + data object AlreadyExist: NicknameValidationStatus + data object Empty: NicknameValidationStatus + data object InvalidFormat: NicknameValidationStatus + data object Loading: NicknameValidationStatus +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/update/status/ProfileImageInputStatus.kt b/feature/profile/src/main/java/com/acon/feature/profile/update/status/ProfileImageInputStatus.kt new file mode 100644 index 00000000..30345a66 --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/update/status/ProfileImageInputStatus.kt @@ -0,0 +1,6 @@ +package com.acon.feature.profile.update.status + +sealed interface ProfileImageInputStatus { + data object Changed: ProfileImageInputStatus + data object NotChanged: ProfileImageInputStatus +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/update/viewmodel/ProfileUpdateViewModel.kt b/feature/profile/src/main/java/com/acon/feature/profile/update/viewmodel/ProfileUpdateViewModel.kt new file mode 100644 index 00000000..276fc982 --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/update/viewmodel/ProfileUpdateViewModel.kt @@ -0,0 +1,248 @@ +package com.acon.feature.profile.update.viewmodel + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.input.TextFieldValue +import androidx.core.text.isDigitsOnly +import com.acon.acon.core.common.utils.toLocalDate +import com.acon.acon.core.common.utils.toyyyyMMdd +import com.acon.acon.core.model.model.profile.BirthDateStatus +import com.acon.acon.core.model.model.profile.Profile +import com.acon.acon.core.model.model.profile.ProfileImageStatus +import com.acon.acon.core.ui.base.BaseContainerHost +import com.acon.acon.domain.error.profile.ValidateNicknameError +import com.acon.acon.domain.repository.ProfileRepository +import com.acon.acon.domain.usecase.ValidateBirthDateUseCase +import com.acon.acon.domain.usecase.ValidateNicknameUseCase +import com.acon.acon.domain.usecase.ValidateNicknameUseCase.Companion.MAX_NICKNAME_LENGTH +import com.acon.feature.profile.update.status.BirthDateValidationStatus +import com.acon.feature.profile.update.status.NicknameValidationStatus +import com.acon.feature.profile.update.status.ProfileImageInputStatus +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.viewmodel.container +import javax.inject.Inject + +@HiltViewModel +class ProfileUpdateViewModel @Inject constructor( + private val profileRepository: ProfileRepository, + private val validateNicknameUseCase: ValidateNicknameUseCase, + private val validateBirthDateUseCase: ValidateBirthDateUseCase +) : BaseContainerHost() { + + private var nicknameValidationJob: Job? = null + + override val container: Container = + container(ProfileUpdateState()) { + profileRepository.getProfile().collect { profileResult -> + profileResult.onSuccess { profile -> + reduce { + ProfileUpdateState( + nicknameInput = TextFieldValue(profile.nickname), + birthDateInput = TextFieldValue( + when (val birthDateStatus = profile.birthDate) { + is BirthDateStatus.Specified -> birthDateStatus.date.toyyyyMMdd() + is BirthDateStatus.NotSpecified -> "" + } + ), + profileImageUriInput = when(val imageStatus = profile.image) { + is ProfileImageStatus.Custom -> imageStatus.url + is ProfileImageStatus.Default -> null + } + ) + } + } + } + } + + fun onNicknameInputChanged(input: TextFieldValue): Job { + nicknameValidationJob?.cancel() + nicknameValidationJob = intent { + if (input.text.length > MAX_NICKNAME_LENGTH) { + return@intent + } + + val isJustSelection = input.text == state.nicknameInput.text + if (isJustSelection) { + reduce { state.copy(nicknameInput = input) } + return@intent + } + + reduce { + state.copy( + nicknameInput = input, + shouldShowExitModal = true, + nicknameValidationStatus = NicknameValidationStatus.Loading, + ) + } + + delay(DEBOUNCE_MILLIS) + validateNicknameUseCase(input.text) + .onSuccess { + reduce { state.copy(nicknameValidationStatus = NicknameValidationStatus.Available) } + } + .onFailure { e -> + reduce { + state.copy( + nicknameValidationStatus = when (e) { + is ValidateNicknameError.EmptyInput -> NicknameValidationStatus.Empty + is ValidateNicknameError.AlreadyExist -> NicknameValidationStatus.AlreadyExist + is ValidateNicknameError.InvalidFormat -> NicknameValidationStatus.InvalidFormat + else -> NicknameValidationStatus.Idle + } + ) + } + } + } + return nicknameValidationJob!! + } + + fun onBirthDateInputChanged(input: TextFieldValue) = intent { + if (input.text.length > 8) return@intent + if (input.text.any { it.isDigit().not() }) return@intent + + val isJustSelection = input.text == state.birthDateInput.text + + if (isJustSelection) { + reduce { + state.copy(birthDateInput = input) + } + } + else { + reduce { + state.copy( + birthDateInput = input, + shouldShowExitModal = true, + ) + } + if (input.text.isEmpty()) { + reduce { + state.copy(birthDateValidationStatus = BirthDateValidationStatus.Valid) + } + } else if (input.text.length in 1 until 8) { + reduce { + state.copy(birthDateValidationStatus = BirthDateValidationStatus.Idle) + } + } else if (input.text.length == 8) { + val localDate = input.text.toLocalDate() + if (localDate == null) + reduce { + state.copy(birthDateValidationStatus = BirthDateValidationStatus.Invalid) + } + else { + validateBirthDateUseCase(localDate).onSuccess { + reduce { + state.copy(birthDateValidationStatus = BirthDateValidationStatus.Valid) + } + }.onFailure { + reduce { + state.copy(birthDateValidationStatus = BirthDateValidationStatus.Invalid) + } + } + } + } + } + } + + fun onDefaultProfileImageSelected() = intent { + reduce { + state.copy( + profileImageUriInput = null, + profileImageInputStatus = ProfileImageInputStatus.Changed, + shouldShowExitModal = true + ) + } + } + + fun onProfileImageSelected(imageUri: String) = intent { + reduce { + state.copy( + profileImageUriInput = imageUri, + profileImageInputStatus = ProfileImageInputStatus.Changed, + shouldShowExitModal = true, + ) + } + } + + fun onBack() = intent { + if (state.shouldShowExitModal) { + reduce { + state.copy(showExitModal = true) + } + } else { + postSideEffect(ProfileUpdateSideEffect.NavigateBack) + } + } + + fun onProfileImageClicked() = intent { + reduce { + state.copy(showImageSelectModal = true) + } + } + + fun onDismissImageSelectModal() = intent { + reduce { + state.copy(showImageSelectModal = false) + } + } + + fun onDismissExitModal() = intent { + reduce { + state.copy(showExitModal = false) + } + } + + fun onBackConfirmed() = intent { + reduce { + state.copy(showExitModal = false) + } + postSideEffect(ProfileUpdateSideEffect.NavigateBack) + } + + fun onSave() = intent { + profileRepository.updateProfile(Profile( + nickname = state.nicknameInput.text, + birthDate = state.birthDateInput.text.toLocalDate()?.let { date -> + BirthDateStatus.Specified(date) + } ?: BirthDateStatus.NotSpecified, + image = state.profileImageUriInput?.let { ProfileImageStatus.Custom(it) } ?: ProfileImageStatus.Default + )).onSuccess { + postSideEffect(ProfileUpdateSideEffect.NavigateBack) + }.onFailure { _ -> + postSideEffect(ProfileUpdateSideEffect.ShowSaveFailedMessage) + } + } + + companion object { + private const val DEBOUNCE_MILLIS = 200L + } +} + +@Immutable +data class ProfileUpdateState( + val nicknameInput: TextFieldValue = TextFieldValue(""), + val birthDateInput: TextFieldValue = TextFieldValue(""), + val profileImageUriInput: String? = null, + val nicknameValidationStatus: NicknameValidationStatus = NicknameValidationStatus.Idle, + val birthDateValidationStatus: BirthDateValidationStatus = BirthDateValidationStatus.Valid, + val profileImageInputStatus: ProfileImageInputStatus = ProfileImageInputStatus.NotChanged, + val showImageSelectModal: Boolean = false, + val showExitModal: Boolean = false, + val shouldShowExitModal: Boolean = false, +) { + val isSaveEnabled: Boolean + get() = ( + nicknameValidationStatus is NicknameValidationStatus.Available && + birthDateValidationStatus is BirthDateValidationStatus.Valid) || ( + profileImageInputStatus is ProfileImageInputStatus.Changed && + nicknameValidationStatus is NicknameValidationStatus.Idle && + birthDateValidationStatus is BirthDateValidationStatus.Valid + ) +} + + +sealed interface ProfileUpdateSideEffect { + data object NavigateBack: ProfileUpdateSideEffect + data object ShowSaveFailedMessage: ProfileUpdateSideEffect +} \ No newline at end of file diff --git a/feature/profile/src/test/kotlin/com/acon/feature/profile/update/viewmodel/ProfileUpdateViewModelTest.kt b/feature/profile/src/test/kotlin/com/acon/feature/profile/update/viewmodel/ProfileUpdateViewModelTest.kt new file mode 100644 index 00000000..be417f94 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/acon/feature/profile/update/viewmodel/ProfileUpdateViewModelTest.kt @@ -0,0 +1,814 @@ +package com.acon.feature.profile.update.viewmodel + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.acon.acon.core.model.model.profile.BirthDateStatus +import com.acon.acon.core.model.model.profile.Profile +import com.acon.acon.core.model.model.profile.ProfileImageStatus +import com.acon.acon.domain.error.profile.ValidateBirthDateError +import com.acon.acon.domain.error.profile.ValidateNicknameError +import com.acon.acon.domain.repository.ProfileRepository +import com.acon.acon.domain.usecase.ValidateBirthDateUseCase +import com.acon.acon.domain.usecase.ValidateNicknameUseCase +import com.acon.feature.profile.update.status.BirthDateValidationStatus +import com.acon.feature.profile.update.status.NicknameValidationStatus +import com.acon.feature.profile.update.status.ProfileImageInputStatus +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.data.forAll +import io.kotest.data.headers +import io.kotest.data.row +import io.kotest.data.table +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.orbitmvi.orbit.test.test +import java.time.LocalDate + +class ProfileUpdateViewModelTest : BehaviorSpec({ + + isolationMode = IsolationMode.InstancePerLeaf + + lateinit var profileRepository: ProfileRepository + lateinit var validateNicknameUseCase: ValidateNicknameUseCase + lateinit var validateBirthDateUseCase: ValidateBirthDateUseCase + lateinit var viewModel: ProfileUpdateViewModel + + fun ProfileUpdateViewModel.getState() = container.stateFlow.value + + context("초기 상태 업데이트 - container onCreate()") { + profileRepository = mockk() + validateNicknameUseCase = mockk() + validateBirthDateUseCase = mockk() + viewModel = ProfileUpdateViewModel( + profileRepository, + validateNicknameUseCase, + validateBirthDateUseCase + ) + + Given("유저의 프로필 불러오기를") { + When("성공하면") { + Then("입력 상태에 닉네임을 반영한다") { + val sampleProfile = Profile( + nickname = "Sample Nickname", + birthDate = BirthDateStatus.NotSpecified, + image = ProfileImageStatus.Default + ) + coEvery { profileRepository.getProfile() } returns flowOf( + Result.success( + sampleProfile + ) + ) + + val expectedNicknameInput = TextFieldValue(sampleProfile.nickname) + runTest { + viewModel.test(this) { + runOnCreate() + + val state = awaitState() + state.nicknameInput shouldBe expectedNicknameInput + } + } + } + + And("생년월일이 지정되어 있으면") { + val sampleProfile = Profile( + nickname = "Sample Nickname", + birthDate = BirthDateStatus.Specified(LocalDate.of(1999, 12, 25)), + image = ProfileImageStatus.Default + ) + coEvery { profileRepository.getProfile() } returns flowOf( + Result.success( + sampleProfile + ) + ) + Then(" 생년월일 입력 상태를 8자리 문자열로 설정한다") { + val expectedBirthDateInput = TextFieldValue("19991225") + runTest { + viewModel.test(this) { + runOnCreate() + + val state = awaitState() + state.birthDateInput shouldBe expectedBirthDateInput + } + } + } + } + And("생년월일이 지정되어 있지 않으면") { + val sampleProfile = Profile( + nickname = "Sample Nickname", + birthDate = BirthDateStatus.NotSpecified, + image = ProfileImageStatus.Default + ) + coEvery { profileRepository.getProfile() } returns flowOf( + Result.success( + sampleProfile + ) + ) + Then("생년월일 입력 상태를 빈 문자열로 설정한다") { + val expectedBirthDateInput = TextFieldValue("") + runTest { + viewModel.test(this) { + runOnCreate() + + val state = awaitState() + state.birthDateInput shouldBe expectedBirthDateInput + } + } + } + } + + And("프로필 이미지가 지정되어 있으면") { + val sampleProfile = Profile( + nickname = "Sample Nickname", + birthDate = BirthDateStatus.NotSpecified, + image = ProfileImageStatus.Custom("Sample Url") + ) + coEvery { profileRepository.getProfile() } returns flowOf( + Result.success( + sampleProfile + ) + ) + val expectedProfileImageUriInput = "Sample Url" + Then("프로필 이미지 입력 상태를 uri 형식의 문자열로 설정한다") { + runTest { + viewModel.test(this) { + runOnCreate() + + val state = awaitState() + state.profileImageUriInput shouldBe expectedProfileImageUriInput + } + } + } + } + And("프로필 이미지가 기본 이미지면") { + val sampleProfile = Profile( + nickname = "Sample Nickname", + birthDate = BirthDateStatus.NotSpecified, + image = ProfileImageStatus.Default + ) + coEvery { profileRepository.getProfile() } returns flowOf( + Result.success( + sampleProfile + ) + ) + val expectedProfileImageUriInput = null + Then("프로필 이미지 입력 상태를 null로 설정한다") { + runTest { + viewModel.test(this) { + runOnCreate() + + val state = awaitState() + state.profileImageUriInput shouldBe expectedProfileImageUriInput + } + } + } + } + } + } + } + + Given("State의 isSaveEnabled는") { + viewModel = ProfileUpdateViewModel(mockk(), mockk(), mockk()) + When("닉네임, 생년월일의 유효성 상태와 프로필 이미지 입력 상태가 주어졌을 때") { + table( + headers("테스트 설명", "닉네임 유효성 상태", "생년월일 유효성 상태", "프로필 이미지 입력 상태", "기대 결과"), + row( + "닉네임, 생년월일이 유효하고, 생일 입력이 변한 상태", + NicknameValidationStatus.Available, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.Changed, + true + ), + row( + "닉네임, 생년월일이 유효하고, 생일 입력이 변하지 않은 상태", + NicknameValidationStatus.Available, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.NotChanged, + true + ), + row( + "닉네임 초기 상태, 생년월일 유효, 프로필 이미지 변한 상태", + NicknameValidationStatus.Idle, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.Changed, + true + ), + row( + "생년월일 입력 중 상태", + NicknameValidationStatus.Available, + BirthDateValidationStatus.Idle, + ProfileImageInputStatus.Changed, + false + ), + row( + "모두 초기상태", + NicknameValidationStatus.Idle, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.NotChanged, + false + ), + row( + "생년월일 유효하지 않음", + NicknameValidationStatus.Available, + BirthDateValidationStatus.Invalid, + ProfileImageInputStatus.Changed, + false + ), + row( + "닉네임 로딩 중", + NicknameValidationStatus.Loading, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.Changed, + false + ), + row( + "닉네임 형식 오류", + NicknameValidationStatus.InvalidFormat, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.Changed, + false + ), + row( + "닉네임 중복", + NicknameValidationStatus.AlreadyExist, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.Changed, + false + ), + row( + "닉네임 비어있음", + NicknameValidationStatus.Empty, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.Changed, + false + ), + row( + "닉네임 초기 상태, 프로필 이미지 안 변한 상태", + NicknameValidationStatus.Idle, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.NotChanged, + false + ), + row( + "닉네임, 생년월일 유효하지 않고, 프로필 이미지 변한 상태", + NicknameValidationStatus.Empty, + BirthDateValidationStatus.Invalid, + ProfileImageInputStatus.Changed, + false + ), + row( + "닉네임, 생년월일 유효하지 않고, 프로필 이미지 안 변한 상태", + NicknameValidationStatus.Empty, + BirthDateValidationStatus.Invalid, + ProfileImageInputStatus.NotChanged, + false + ) + ).forAll { testDescription, nicknameStatus, birthDateStatus, imageInputState, expected -> + Then("$testDescription -> 저장 버튼 활성화 상태는 $expected") { + runTest { + viewModel.test( + this, ProfileUpdateState( + nicknameValidationStatus = nicknameStatus, + birthDateValidationStatus = birthDateStatus, + profileImageInputStatus = imageInputState + ) + ) { + viewModel.getState().isSaveEnabled shouldBe expected + } + } + } + } + } + } + + context("입력 처리") { + profileRepository = mockk() + validateNicknameUseCase = mockk() + validateBirthDateUseCase = mockk() + viewModel = ProfileUpdateViewModel( + profileRepository, + validateNicknameUseCase, + validateBirthDateUseCase + ) + + Given("onNicknameInputChanged()는") { + When("Text Value 변화로 발생한 호출일 경우") { + + val sampleNewTextFieldValue = TextFieldValue("acon123") + And("닉네임 유효성 검사 수행 전") { + Then("프로필 상태에 새 닉네임 입력을 반영한다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + nicknameInput = TextFieldValue("acon12") + ) + ) { + viewModel.onNicknameInputChanged(sampleNewTextFieldValue) + + val state = awaitState() + state.nicknameInput shouldBe sampleNewTextFieldValue + } + } + } + + Then("닉네임 유효성 상태는 '로딩'이 된다") { + val sampleTextFieldValue = TextFieldValue("acon123") + + runTest { + viewModel.test(this) { + viewModel.onNicknameInputChanged(sampleTextFieldValue) + + val state = awaitState() + + state.nicknameValidationStatus shouldBe NicknameValidationStatus.Loading + } + } + } + + And("입력이 14글자를 초과하면") { + val sampleNewTextFieldValue = TextFieldValue("이것은14글자를초과하는닉네임") + val expectedProfileUpdateState = viewModel.getState() + Then("해당 입력 전체를 무시한다") { + runTest { + viewModel.test(this) { + viewModel.onNicknameInputChanged(sampleNewTextFieldValue).join() + + viewModel.getState() shouldBe expectedProfileUpdateState + } + } + } + } + } + + And("닉네임 유효성 검사를 수행하여") { + And("통과하면") { + coEvery { validateNicknameUseCase(any()) } returns Result.success(Unit) + + Then("닉네임 유효성 상태를 '사용 가능'으로 설정한다") { + runTest { + viewModel.test(this) { + viewModel.onNicknameInputChanged(sampleNewTextFieldValue).join() + + val state = viewModel.getState() + + coVerify(exactly = 1) { + validateNicknameUseCase( + sampleNewTextFieldValue.text + ) + } + state.nicknameValidationStatus shouldBe NicknameValidationStatus.Available + + cancelAndIgnoreRemainingItems() + } + } + } + } + And("빈 입력 에러를 반환받으면") { + coEvery { validateNicknameUseCase(any()) } returns Result.failure( + ValidateNicknameError.EmptyInput() + ) + + Then("닉네임 유효성 상태를 `빈 입력`으로 설정한다") { + runTest { + viewModel.test(this) { + viewModel.onNicknameInputChanged(sampleNewTextFieldValue).join() + + val state = viewModel.getState() + + coVerify(exactly = 1) { + validateNicknameUseCase( + sampleNewTextFieldValue.text + ) + } + state.nicknameValidationStatus shouldBe NicknameValidationStatus.Empty + + cancelAndIgnoreRemainingItems() + } + } + } + } + And("중복된 닉네임 에러를 반환받으면") { + coEvery { validateNicknameUseCase(any()) } returns Result.failure( + ValidateNicknameError.AlreadyExist() + ) + + Then("닉네임 유효성 상태를 `중복`으로 설정한다") { + runTest { + viewModel.test(this) { + viewModel.onNicknameInputChanged(sampleNewTextFieldValue).join() + + val state = viewModel.getState() + + coVerify(exactly = 1) { + validateNicknameUseCase( + sampleNewTextFieldValue.text + ) + } + state.nicknameValidationStatus shouldBe NicknameValidationStatus.AlreadyExist + + cancelAndIgnoreRemainingItems() + } + } + } + } + And("잘못된 닉네임 형식 에러를 반환받으면") { + coEvery { validateNicknameUseCase(any()) } returns Result.failure( + ValidateNicknameError.InvalidFormat() + ) + + Then("닉네임 유효성 상태를 `잘못된 형식`으로 설정한다") { + runTest { + viewModel.test(this) { + viewModel.onNicknameInputChanged(sampleNewTextFieldValue).join() + + val state = viewModel.getState() + + coVerify(exactly = 1) { + validateNicknameUseCase( + sampleNewTextFieldValue.text + ) + } + state.nicknameValidationStatus shouldBe NicknameValidationStatus.InvalidFormat + + cancelAndIgnoreRemainingItems() + } + } + + } + } + } + } + + When("Text Selection으로 발생한 호출일 경우") { + val originalNicknameTextFieldValue = TextFieldValue("acon123") + val selectedTextRange = TextRange(4, 4) + val expectedNicknameTextFieldValue = + originalNicknameTextFieldValue.copy(selection = selectedTextRange) + + Then("nicknameInput 상태만 업데이트하고 그 외에는 수행하지 않는다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + nicknameInput = originalNicknameTextFieldValue + ) + ) { + viewModel.onNicknameInputChanged(expectedNicknameTextFieldValue).join() + coVerify(exactly = 0) { validateBirthDateUseCase(any()) } + + expectState { + ProfileUpdateState( + nicknameInput = expectedNicknameTextFieldValue + ) + } + } + } + } + } + } + + Given("onBirthDateInputChanged()는") { + And("입력에 숫자가 아닌 것이 포함되어 있으면") { + val sampleNewTextFieldValue = TextFieldValue("2025.") + val expectedProfileUpdateState = viewModel.getState() + Then("해당 입력 전체를 무시한다") { + runTest { + viewModel.test(this) { + viewModel.onBirthDateInputChanged(sampleNewTextFieldValue).join() + + viewModel.getState() shouldBe expectedProfileUpdateState + } + } + } + } + When("Text Value 변화로 발생한 호출일 경우") { + Then("생년월일 입력 상태를 업데이트한다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + birthDateInput = TextFieldValue("19990429") + ) + ) { + val sampleBirthDateInput = TextFieldValue("1999042") + viewModel.onBirthDateInputChanged(sampleBirthDateInput) + + val state = awaitState() + + state.birthDateInput shouldBe sampleBirthDateInput + + cancelAndIgnoreRemainingItems() + } + } + } + And("입력이 8자리로 완성되지 않았다면") { + Then("생년월일 입력 유효성 상태는 IDLE이다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + birthDateInput = TextFieldValue("1999") + ) + ) { + val sampleBirthDateInput = TextFieldValue("19990") + viewModel.onBirthDateInputChanged(sampleBirthDateInput).join() + + val state = viewModel.getState() + + state.birthDateValidationStatus shouldBe BirthDateValidationStatus.Idle + + cancelAndIgnoreRemainingItems() + } + } + } + } + And("올바른 생년월일로 입력이 완료되면") { + coEvery { validateBirthDateUseCase(any()) } returns Result.success(Unit) + Then("생년월일 입력 유효성 상태는 '유효함'이다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + birthDateInput = TextFieldValue("1999042") + ) + ) { + val sampleBirthDateInput = TextFieldValue("19990429") + viewModel.onBirthDateInputChanged(sampleBirthDateInput).join() + + val state = viewModel.getState() + + state.birthDateValidationStatus shouldBe BirthDateValidationStatus.Valid + + cancelAndIgnoreRemainingItems() + } + } + } + } + And("올바르지 않은 생년월일로 입력이 완료되면") { + Then("생년월일 입력 유효성 상태는 '유효하지 않음'이다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + birthDateInput = TextFieldValue("1999043") + ) + ) { + val sampleBirthDateInput = TextFieldValue("19990439") + viewModel.onBirthDateInputChanged(sampleBirthDateInput).join() + + val state = viewModel.getState() + + state.birthDateValidationStatus shouldBe BirthDateValidationStatus.Invalid + + cancelAndIgnoreRemainingItems() + } + } + } + } + And("이번 입력으로 인해 Text가 비어졌으면") { + Then("생년월일 입력 유효성 상태는 '유효함'이다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + birthDateInput = TextFieldValue("1") + ) + ) { + val sampleBirthDateInput = TextFieldValue("") + viewModel.onBirthDateInputChanged(sampleBirthDateInput).join() + + val state = viewModel.getState() + + state.birthDateValidationStatus shouldBe BirthDateValidationStatus.Valid + + cancelAndIgnoreRemainingItems() + } + } + } + } + } + When("Text Selection으로 발생한 호출일 경우") { + val originalBirthDateInput = TextFieldValue("19990429") + val selectedTextRange = TextRange(4, 4) + val expectedBirthDateInput = + originalBirthDateInput.copy(selection = selectedTextRange) + + Then("birthDateInput 상태만 업데이트하고 그 외에는 수행하지 않는다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + birthDateInput = originalBirthDateInput + ) + ) { + viewModel.onBirthDateInputChanged(expectedBirthDateInput).join() + coVerify(exactly = 0) { validateBirthDateUseCase(any()) } + + val state = viewModel.getState() + state shouldBe ProfileUpdateState(birthDateInput = expectedBirthDateInput) + + cancelAndIgnoreRemainingItems() + } + } + } + } + } + + Given("onDefaultProfileImageSelected()는") { + runTest { + viewModel.test(this) { + viewModel.onDefaultProfileImageSelected() + + Then("선택된 프로필 이미지 uri를 null로 설정한다") { + val state = awaitState() + + state.profileImageUriInput shouldBe null + } + Then("프로필 이미지 입력 상태를 '변경됨'으로 설정한다") { + val state = awaitState() + + state.profileImageInputStatus shouldBe ProfileImageInputStatus.Changed + } + } + } + } + + Given("onProfileImageSelected()는") { + runTest { + viewModel.test(this) { + val sampleUri = "content://..." + viewModel.onProfileImageSelected(sampleUri) + + Then("선택된 프로필 이미지 uri를 넘겨받은 매개변수로 설정한다") { + val state = awaitState() + + state.profileImageUriInput shouldBe sampleUri + } + Then("프로필 이미지 입력 상태를 '변경됨'으로 설정한다") { + val state = awaitState() + + state.profileImageInputStatus shouldBe ProfileImageInputStatus.Changed + } + } + } + } + } + + context("프로필 저장") { + profileRepository = mockk() + validateNicknameUseCase = mockk() + validateBirthDateUseCase = mockk() + viewModel = ProfileUpdateViewModel( + profileRepository, + validateNicknameUseCase, + validateBirthDateUseCase + ) + + Given("onSave()는") { + When("프로필 저장 API를 호출할 때") { + val profileSlot = slot() + Then("상태에 저장된 입력 값을 바탕으로 API를 호출한다") { + coEvery { profileRepository.updateProfile(capture(profileSlot)) } returns Result.success( + Unit + ) + runTest { + viewModel.test(this, ProfileUpdateState( + nicknameInput = TextFieldValue("acon123"), + birthDateInput = TextFieldValue("20021225"), + profileImageUriInput = null + )) { + viewModel.onSave().join() + + val capturedProfile = profileSlot.captured + capturedProfile.nickname shouldBe "acon123" + capturedProfile.birthDate shouldBe + BirthDateStatus.Specified(LocalDate.of(2002,12,25)) + capturedProfile.image shouldBe ProfileImageStatus.Default + + coVerify(exactly = 1) { profileRepository.updateProfile(any()) } + + cancelAndIgnoreRemainingItems() + } + } + } + And("성공하면") { + coEvery { profileRepository.updateProfile(any()) } returns Result.success( + Unit + ) + + Then("이전 화면으로 전환 SideEffect를 보낸다") { + runTest { + viewModel.test(this) { + viewModel.onSave() + + expectSideEffect(ProfileUpdateSideEffect.NavigateBack) + } + } + } + } + And("실패하면") { + coEvery { profileRepository.updateProfile(any()) } returns Result.failure( + Exception("") + ) + Then("저장 실패 메시지 출력 SideEffect를 보낸다") { + runTest { + viewModel.test(this) { + viewModel.onSave() + + expectSideEffect(ProfileUpdateSideEffect.ShowSaveFailedMessage) + } + } + } + } + } + } + } + + context("뒤로 가기") { + profileRepository = mockk() + validateNicknameUseCase = mockk(relaxed = true) + validateBirthDateUseCase = mockk(relaxed = true) + viewModel = ProfileUpdateViewModel( + profileRepository, + validateNicknameUseCase, + validateBirthDateUseCase + ) + runTest { + viewModel.test(this) { + Given("닉네임 입력(Text Value 변화)이 발생한 경우") { + val sampleTextFieldValue = TextFieldValue("acon123") + When("나중에 뒤로가기 할 때") { + Then("모달을 보여줘야 하는 상태로 설정한다") { + viewModel.onNicknameInputChanged(sampleTextFieldValue).join() + + val state = viewModel.getState() + + state.shouldShowExitModal shouldBe true + } + } + } + Given("생일 입력(Text Value 변화)이 발생한 경우") { + val sampleTextFieldValue = TextFieldValue("20") + When("나중에 뒤로가기 할 때") { + Then("모달을 보여줘야 하는 상태로 설정한다") { + viewModel.onBirthDateInputChanged(sampleTextFieldValue).join() + + val state = viewModel.getState() + + state.shouldShowExitModal shouldBe true + } + } + } + Given("프로필 이미지 변경이 발생한 경우") { + When("나중에 뒤로가기 할 때") { + Then("모달을 보여줘야 하는 상태로 설정한다") { + viewModel.onDefaultProfileImageSelected().join() + + viewModel.getState().shouldShowExitModal shouldBe true + + viewModel.intent { + reduce { state.copy(shouldShowExitModal = false) } + }.join() + awaitState() + + viewModel.onProfileImageSelected("").join() + + viewModel.getState().shouldShowExitModal shouldBe true + } + } + } + + Given("뒤로가기 이벤트 발생 시") { + When("모달을 보여줘야 하는 상태이면") { + viewModel.intent { + reduce { + state.copy(shouldShowExitModal = true) + } + }.join() + awaitState() + Then("모달 출력 상태를 true로 설정한다") { + viewModel.onBack() + + expectState { + ProfileUpdateState( + showExitModal = true, + shouldShowExitModal = true, + ) + } + } + } + When("모달을 보여줘야 하는 상태가 아니면") { + viewModel.intent { + reduce { + state.copy(shouldShowExitModal = false) + } + }.join() + Then("뒤로가기 SideEffect를 보낸다") { + viewModel.onBack() + + expectSideEffect(ProfileUpdateSideEffect.NavigateBack) + } + } + } + } + } + } +}) \ No newline at end of file